From f8bc33ca53b877ec120509d01594c7ad4f7e2ff7 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Mon, 9 Feb 2026 17:05:18 -0500 Subject: [PATCH 01/28] PoC --- package-lock.json | 7 + package.json | 1 + .../components/assets/Editor/CropEditor.vue | 143 ++++++++++++++++++ .../js/components/assets/Editor/Editor.vue | 108 +++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 resources/js/components/assets/Editor/CropEditor.vue diff --git a/package-lock.json b/package-lock.json index 001c7435d6..1cc75c7679 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "clsx": "^2.1.1", "codemirror": "5.65.12", "cookies-js": "^1.2.2", + "cropperjs": "^1.6.2", "cva": "^1.0.0-beta.3", "floating-vue": "^5.2.2", "fuzzysort": "^3.1.0", @@ -4068,6 +4069,12 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cropperjs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz", + "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index 5ee5f6ea33..04333222d0 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "clsx": "^2.1.1", "codemirror": "5.65.12", "cookies-js": "^1.2.2", + "cropperjs": "^1.6.2", "cva": "^1.0.0-beta.3", "floating-vue": "^5.2.2", "fuzzysort": "^3.1.0", diff --git a/resources/js/components/assets/Editor/CropEditor.vue b/resources/js/components/assets/Editor/CropEditor.vue new file mode 100644 index 0000000000..041b416cd2 --- /dev/null +++ b/resources/js/components/assets/Editor/CropEditor.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/resources/js/components/assets/Editor/Editor.vue b/resources/js/components/assets/Editor/Editor.vue index 12ea14c97d..7e219358e0 100644 --- a/resources/js/components/assets/Editor/Editor.vue +++ b/resources/js/components/assets/Editor/Editor.vue @@ -39,6 +39,7 @@ v-slot="{ actions }" > + @@ -167,6 +168,13 @@ @closed="closeFocalPointEditor" /> + + + + - - diff --git a/resources/js/components/assets/Editor/Editor.vue b/resources/js/components/assets/Editor/Editor.vue index 7e219358e0..7b02ccda9d 100644 --- a/resources/js/components/assets/Editor/Editor.vue +++ b/resources/js/components/assets/Editor/Editor.vue @@ -39,7 +39,7 @@ v-slot="{ actions }" > - + @@ -169,22 +169,14 @@ /> - - + + @@ -402,6 +404,12 @@ export default { this.showFocalPointEditor = false; }, + selectFocalPoint(point) { + point = point === '50-50-1' ? null : point; + this.values['focus'] = point; + this.$dirty.add(this.publishContainer); + }, + openCropEditor() { this.showCropEditor = true; }, @@ -413,7 +421,7 @@ export default { handleCropped(blob) { this.croppedBlob = blob; // Close crop editor first, then show confirmation - this.showCropEditor = false; + this.closeCropEditor(); this.$nextTick(() => { this.showCropConfirmation = true; }); @@ -478,17 +486,6 @@ export default { } }, - cancelCropUpload() { - this.croppedBlob = null; - this.showCropConfirmation = false; - }, - - selectFocalPoint(point) { - point = point === '50-50-1' ? null : point; - this.values['focus'] = point; - this.$dirty.add(this.publishContainer); - }, - updateValues(values) { let updated = { ...event, focus: values.focus }; diff --git a/resources/svg/icons/crop.svg b/resources/svg/icons/crop.svg new file mode 100644 index 0000000000..0f364af507 --- /dev/null +++ b/resources/svg/icons/crop.svg @@ -0,0 +1 @@ + \ No newline at end of file From bfed514ea5b5d336931bdbe948be401a3cbab56e Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Mon, 9 Feb 2026 17:42:53 -0500 Subject: [PATCH 04/28] Fill the crop box --- .../components/assets/Editor/CropEditor.vue | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/resources/js/components/assets/Editor/CropEditor.vue b/resources/js/components/assets/Editor/CropEditor.vue index 975f0603d6..7d633dd1a9 100644 --- a/resources/js/components/assets/Editor/CropEditor.vue +++ b/resources/js/components/assets/Editor/CropEditor.vue @@ -141,7 +141,7 @@ export default { aspectRatio: NaN, viewMode: 1, dragMode: 'move', - autoCropArea: 0.8, + autoCropArea: 1, restore: false, guides: true, center: true, @@ -167,6 +167,8 @@ export default { this.baseRatio = ratio; this.isFlipped = false; this.applyCurrentRatio(); + // Expand crop box to fill available space + this.expandCropBoxToFill(); } }, @@ -176,6 +178,8 @@ export default { // Toggle the flipped state this.isFlipped = !this.isFlipped; this.applyCurrentRatio(); + // Expand crop box to fill available space after flipping + this.expandCropBoxToFill(); }, applyCurrentRatio() { @@ -195,6 +199,44 @@ export default { this.cropper.setAspectRatio(ratioToApply); }, + expandCropBoxToFill() { + if (!this.cropper) return; + + const canvasData = this.cropper.getCanvasData(); + const containerData = this.cropper.getContainerData(); + + // Calculate the maximum crop box size that fits within the canvas + // while maintaining the aspect ratio + let cropWidth = canvasData.width; + let cropHeight = canvasData.height; + + if (this.baseRatio !== null) { + const ratioToApply = this.isFlipped ? 1 / this.baseRatio : this.baseRatio; + + // Calculate dimensions that fit within canvas while maintaining ratio + if (canvasData.width / canvasData.height > ratioToApply) { + // Canvas is wider than ratio, fit to height + cropWidth = canvasData.height * ratioToApply; + cropHeight = canvasData.height; + } else { + // Canvas is taller than ratio, fit to width + cropWidth = canvasData.width; + cropHeight = canvasData.width / ratioToApply; + } + } + + // Center the crop box + const left = canvasData.left + (canvasData.width - cropWidth) / 2; + const top = canvasData.top + (canvasData.height - cropHeight) / 2; + + this.cropper.setCropBoxData({ + left, + top, + width: cropWidth, + height: cropHeight, + }); + }, + crop() { if (!this.cropper) return; @@ -221,11 +263,18 @@ export default { reset() { if (this.cropper) { - this.cropper.reset(); this.selectedRatio = null; this.baseRatio = null; this.isFlipped = false; this.cropper.setAspectRatio(NaN); + // Reset to full canvas (image) bounds + const canvasData = this.cropper.getCanvasData(); + this.cropper.setCropBoxData({ + left: canvasData.left, + top: canvasData.top, + width: canvasData.width, + height: canvasData.height, + }); } }, From 9386fcadd9168a4494d6376ce8eb258be6c6027c Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Mon, 9 Feb 2026 22:44:48 -0500 Subject: [PATCH 05/28] fix background leak --- resources/js/components/assets/Editor/CropEditor.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/resources/js/components/assets/Editor/CropEditor.vue b/resources/js/components/assets/Editor/CropEditor.vue index 7d633dd1a9..8e31ca62d2 100644 --- a/resources/js/components/assets/Editor/CropEditor.vue +++ b/resources/js/components/assets/Editor/CropEditor.vue @@ -8,8 +8,8 @@ -
-
+
+
Crop
@@ -289,3 +289,9 @@ export default { }, }; + + From 49e32fd193011b91d95af40d7cbf040d097612cc Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 10 Feb 2026 07:52:44 -0500 Subject: [PATCH 06/28] Preserves the crop box's aspect ratio while maintaining the original image's resolution --- .../js/components/assets/Editor/CropEditor.vue | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/resources/js/components/assets/Editor/CropEditor.vue b/resources/js/components/assets/Editor/CropEditor.vue index 8e31ca62d2..01433c00f0 100644 --- a/resources/js/components/assets/Editor/CropEditor.vue +++ b/resources/js/components/assets/Editor/CropEditor.vue @@ -240,9 +240,22 @@ export default { crop() { if (!this.cropper) return; + // Get crop box data in natural image coordinates + const cropBoxData = this.cropper.getCropBoxData(); + const imageData = this.cropper.getImageData(); + + // Calculate the crop dimensions in natural image coordinates + // Scale from display coordinates to natural coordinates + const scaleX = imageData.naturalWidth / imageData.width; + const scaleY = imageData.naturalHeight / imageData.height; + + const naturalCropWidth = cropBoxData.width * scaleX; + const naturalCropHeight = cropBoxData.height * scaleY; + + // Use the calculated dimensions to preserve aspect ratio const canvas = this.cropper.getCroppedCanvas({ - width: this.cropper.getImageData().naturalWidth, - height: this.cropper.getImageData().naturalHeight, + width: naturalCropWidth, + height: naturalCropHeight, }); if (!canvas) { From bbba1f2b175d51576eb73fb2b2772027a9dcf562 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 10 Feb 2026 07:59:44 -0500 Subject: [PATCH 07/28] Export cropped image as the original MIME type --- .../components/assets/Editor/CropEditor.vue | 54 ++++++++++++++++++- .../js/components/assets/Editor/Editor.vue | 23 +++++++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/resources/js/components/assets/Editor/CropEditor.vue b/resources/js/components/assets/Editor/CropEditor.vue index 01433c00f0..7c0ecb9983 100644 --- a/resources/js/components/assets/Editor/CropEditor.vue +++ b/resources/js/components/assets/Editor/CropEditor.vue @@ -83,6 +83,7 @@ export default { selectedRatio: null, baseRatio: null, isFlipped: false, + imageMimeType: 'image/png', // Default to PNG to preserve transparency aspectRatios: [ { label: '16:9', value: 16 / 9 }, { label: '4:3', value: 4 / 3 }, @@ -137,6 +138,7 @@ export default { }, createCropper(imageElement) { + this.detectImageFormat(imageElement); this.cropper = new Cropper(imageElement, { aspectRatio: NaN, viewMode: 1, @@ -156,6 +158,50 @@ export default { }); }, + detectImageFormat(imageElement) { + // Try to detect format from URL extension first + const url = this.image.toLowerCase(); + const urlWithoutQuery = url.split('?')[0]; // Remove query parameters + + if (urlWithoutQuery.endsWith('.jpg') || urlWithoutQuery.endsWith('.jpeg')) { + this.imageMimeType = 'image/jpeg'; + return; + } + if (urlWithoutQuery.endsWith('.png')) { + this.imageMimeType = 'image/png'; + return; + } + if (urlWithoutQuery.endsWith('.webp')) { + this.imageMimeType = 'image/webp'; + return; + } + if (urlWithoutQuery.endsWith('.gif')) { + this.imageMimeType = 'image/gif'; + return; + } + + // Fallback: try to detect from image element's natural format + // Check if image has transparency by sampling multiple pixels + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = Math.min(imageElement.naturalWidth, 100); + canvas.height = Math.min(imageElement.naturalHeight, 100); + ctx.drawImage(imageElement, 0, 0, canvas.width, canvas.height); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + // Check if any pixel has transparency + let hasTransparency = false; + for (let i = 3; i < imageData.data.length; i += 4) { + if (imageData.data[i] < 255) { + hasTransparency = true; + break; + } + } + + // If image has transparency, use PNG; otherwise default to JPEG + this.imageMimeType = hasTransparency ? 'image/png' : 'image/jpeg'; + }, + setAspectRatio(ratio) { if (!this.cropper) return; @@ -263,15 +309,19 @@ export default { return; } + // Determine quality based on format (PNG doesn't use quality parameter) + const mimeType = this.imageMimeType; + const quality = mimeType === 'image/jpeg' || mimeType === 'image/webp' ? 0.95 : undefined; + canvas.toBlob((blob) => { if (!blob) { this.$toast.error(__('Failed to create cropped image')); return; } - this.$emit('cropped', blob); + this.$emit('cropped', { blob, mimeType }); this.close(); - }, 'image/jpeg', 0.95); + }, mimeType, quality); }, reset() { diff --git a/resources/js/components/assets/Editor/Editor.vue b/resources/js/components/assets/Editor/Editor.vue index 7b02ccda9d..a5b067f139 100644 --- a/resources/js/components/assets/Editor/Editor.vue +++ b/resources/js/components/assets/Editor/Editor.vue @@ -270,6 +270,7 @@ export default { actions: [], closingWithChanges: false, croppedBlob: null, + croppedMimeType: null, showCropConfirmation: false, uploadingCrop: false, }; @@ -418,8 +419,9 @@ export default { this.showCropEditor = false; }, - handleCropped(blob) { + handleCropped({ blob, mimeType }) { this.croppedBlob = blob; + this.croppedMimeType = mimeType; // Close crop editor first, then show confirmation this.closeCropEditor(); this.$nextTick(() => { @@ -438,9 +440,25 @@ export default { // Extract folder from path (dirname) const pathParts = assetPath.split('/'); - const filename = pathParts.pop(); + let filename = pathParts.pop(); const folder = pathParts.length > 0 ? pathParts.join('/') : '/'; + // Update filename extension to match the blob's MIME type + if (this.croppedMimeType) { + const extensionMap = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + }; + const newExtension = extensionMap[this.croppedMimeType]; + if (newExtension) { + // Remove old extension and add new one + const nameWithoutExt = filename.replace(/\.[^/.]+$/, ''); + filename = nameWithoutExt + newExtension; + } + } + // Create FormData const formData = new FormData(); formData.append('file', this.croppedBlob, filename); @@ -474,6 +492,7 @@ export default { } this.croppedBlob = null; + this.croppedMimeType = null; this.showCropConfirmation = false; } catch (error) { if (error.response && error.response.data) { From b598f7d875a82dff5b2eb465aa0adc2113520fb8 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 10 Feb 2026 08:00:54 -0500 Subject: [PATCH 08/28] Translatable string --- resources/js/components/assets/Editor/Editor.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/assets/Editor/Editor.vue b/resources/js/components/assets/Editor/Editor.vue index a5b067f139..dcc2f01047 100644 --- a/resources/js/components/assets/Editor/Editor.vue +++ b/resources/js/components/assets/Editor/Editor.vue @@ -180,7 +180,7 @@ Date: Tue, 10 Feb 2026 08:01:39 -0500 Subject: [PATCH 09/28] cleanup --- resources/js/components/assets/Editor/CropEditor.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/js/components/assets/Editor/CropEditor.vue b/resources/js/components/assets/Editor/CropEditor.vue index 7c0ecb9983..f6ed53a99d 100644 --- a/resources/js/components/assets/Editor/CropEditor.vue +++ b/resources/js/components/assets/Editor/CropEditor.vue @@ -249,7 +249,6 @@ export default { if (!this.cropper) return; const canvasData = this.cropper.getCanvasData(); - const containerData = this.cropper.getContainerData(); // Calculate the maximum crop box size that fits within the canvas // while maintaining the aspect ratio From cf6e43c5cb3b706a153e7c40baa344f51d6bbc07 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 10 Feb 2026 08:03:19 -0500 Subject: [PATCH 10/28] Prevent unintended upload on cancel --- .../js/components/assets/Editor/Editor.vue | 9 ++++- .../components/ui/Modal/ConfirmationModal.vue | 34 +++++++++++++------ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/resources/js/components/assets/Editor/Editor.vue b/resources/js/components/assets/Editor/Editor.vue index dcc2f01047..9edbf6f3fe 100644 --- a/resources/js/components/assets/Editor/Editor.vue +++ b/resources/js/components/assets/Editor/Editor.vue @@ -186,7 +186,8 @@ :danger="false" :busy="uploadingCrop" @confirm="uploadCroppedImage(true)" - @cancel="uploadCroppedImage(false)" + @cancel-clicked="uploadCroppedImage(false)" + @cancel="handleCropConfirmationDismissed" /> import { computed, onBeforeUnmount, onMounted, ref, useSlots } from 'vue'; -import { Modal, ModalClose, Button, Icon } from '@/components/ui'; +import { Modal, Button, Icon } from '@/components/ui'; const emit = defineEmits([ 'update:open', 'opened', 'confirm', - 'cancel' + 'cancel', + 'cancel-clicked' ]); const props = defineProps({ @@ -48,6 +49,8 @@ const props = defineProps({ }, }); +const cancelButtonClicked = ref(false); + function updateModalOpen(open) { if (! open && props.busy) { return; @@ -55,7 +58,18 @@ function updateModalOpen(open) { emit('update:open', open); - if (! open) emit('cancel'); + if (! open) { + if (cancelButtonClicked.value) { + emit('cancel-clicked'); + cancelButtonClicked.value = false; + } + emit('cancel'); + } +} + +function handleCancelClick() { + cancelButtonClicked.value = true; + updateModalOpen(false); } function submit() { @@ -97,13 +111,13 @@ const shouldCloseOnSubmit = computed(() => {