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..d877eb2207 --- /dev/null +++ b/resources/js/components/assets/Editor/CropEditor.vue @@ -0,0 +1,542 @@ + + + + + diff --git a/resources/js/components/assets/Editor/Editor.vue b/resources/js/components/assets/Editor/Editor.vue index 12ea14c97d..4f81f58ad8 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,29 @@ @closed="closeFocalPointEditor" /> + + + + import FocalPointEditor from './FocalPointEditor.vue'; +import CropEditor from './CropEditor.vue'; import PdfViewer from './PdfViewer.vue'; import { pick, flatten } from 'lodash-es'; +import { router } from '@inertiajs/vue3'; import { Dropdown, DropdownMenu, @@ -204,6 +230,7 @@ export default { DropdownItem, ItemActions, FocalPointEditor, + CropEditor, PdfViewer, PublishContainer, PublishTabs, @@ -239,11 +266,16 @@ export default { fields: null, fieldset: null, showFocalPointEditor: false, + showCropEditor: false, showCheckerboard: true, error: null, errors: {}, actions: [], closingWithChanges: false, + croppedBlob: null, + croppedMimeType: null, + showCropConfirmation: false, + uploadingCrop: false, }; }, @@ -288,6 +320,7 @@ export default { events: { 'close-child-editor': function () { this.closeFocalPointEditor(); + this.closeCropEditor(); this.closeImageEditor(); this.closeRenamer(); }, @@ -305,7 +338,7 @@ export default { const url = cp_url(`assets/${utf8btoa(this.id)}`); - this.$axios.get(url).then((response) => { + return this.$axios.get(url).then((response) => { const data = response.data.data; this.asset = data; @@ -381,6 +414,161 @@ export default { this.$dirty.add(this.publishContainer); }, + openCropEditor() { + this.showCropEditor = true; + }, + + closeCropEditor() { + this.showCropEditor = false; + }, + + handleCropped({ blob, mimeType }) { + this.croppedBlob = blob; + this.croppedMimeType = mimeType; + // Close crop editor first, then show confirmation + this.closeCropEditor(); + this.$nextTick(() => { + this.showCropConfirmation = true; + }); + }, + + handleCropConfirmationDismissed() { + // User dismissed the modal (Escape/click outside) - only clear state if no upload is in progress + // If upload is in progress, the blob should be preserved for retry on failure + if (!this.uploadingCrop) { + this.croppedBlob = null; + this.croppedMimeType = null; + } + }, + + async uploadCroppedImage(replaceOriginal) { + if (!this.croppedBlob || !this.asset) return; + + this.uploadingCrop = true; + + try { + // Extract container and folder from asset ID (format: container::path) + const [containerHandle, assetPath] = this.id.split('::'); + + // Extract folder from path (dirname) + const pathParts = assetPath.split('/'); + let filename = pathParts.pop(); + const folder = pathParts.length > 0 ? pathParts.join('/') : '/'; + + // Update filename extension only when saving as new copy + // When replacing original, keep original filename so server can find it to overwrite + const shouldUpdateExtension = !replaceOriginal && this.croppedMimeType && ( + this.croppedMimeType !== this.asset.mimeType + ); + + if (shouldUpdateExtension) { + 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(); + // Use the File object directly - it already has the correct name and MIME type + // If we need a different filename, create a new File with that name + const fileToUpload = filename !== this.croppedBlob.name + ? new File([this.croppedBlob], filename, { type: this.croppedBlob.type }) + : this.croppedBlob; + formData.append('file', fileToUpload); + formData.append('container', containerHandle); + formData.append('folder', folder); + formData.append('_token', Statamic.$config.get('csrfToken')); + + if (replaceOriginal) { + formData.append('option', 'overwrite'); + } else { + // Use timestamp option to avoid conflicts when saving as new copy + formData.append('option', 'timestamp'); + } + + const url = cp_url('assets'); + const response = await this.$axios.post(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + if (response.data && response.data.data) { + this.$toast.success(replaceOriginal ? __('Image replaced successfully') : __('Cropped image saved successfully')); + + // If replacing, reload the current asset and bust browser cache; if new copy, redirect to the new asset + if (replaceOriginal) { + // Store original URLs for cache busting + const originalPreview = this.asset?.preview; + const originalThumbnail = this.asset?.thumbnail; + + // Reload the asset and wait for it to complete + await this.load(); + + // After reload completes, add cache-busting parameter to force browser to reload images + if (this.asset) { + const timestamp = Date.now(); + + // Update preview URL with cache-busting parameter + if (this.asset.preview) { + const previewUrl = this.asset.preview.split('?')[0]; + this.asset.preview = `${previewUrl}?t=${timestamp}`; + } + + // Update thumbnail URL with cache-busting parameter + if (this.asset.thumbnail) { + const thumbnailUrl = this.asset.thumbnail.split('?')[0]; + this.asset.thumbnail = `${thumbnailUrl}?t=${timestamp}`; + } + + // Also directly update any img elements in the DOM that match the original URLs + if (originalPreview || originalThumbnail) { + document.querySelectorAll('img').forEach((img) => { + const imgSrc = img.src || img.getAttribute('src') || ''; + const imgSrcBase = imgSrc.split('?')[0]; + + if (originalPreview && imgSrcBase === originalPreview.split('?')[0]) { + img.src = `${imgSrcBase}?t=${timestamp}`; + } else if (originalThumbnail && imgSrcBase === originalThumbnail.split('?')[0]) { + img.src = `${imgSrcBase}?t=${timestamp}`; + } + }); + } + } + } else { + // Extract container and path from the new asset ID (format: container::path) + const newAssetId = response.data.data.id; + const [containerHandle, assetPath] = newAssetId.split('::'); + + // Navigate to the edit URL for the new asset + const editUrl = cp_url(`assets/browse/${containerHandle}/${assetPath}/edit`); + router.get(editUrl); + } + } + + this.croppedBlob = null; + this.croppedMimeType = null; + this.showCropConfirmation = false; + } catch (error) { + if (error.response && error.response.data) { + this.$toast.error(error.response.data.message || __('Failed to upload cropped image')); + } else { + this.$toast.error(__('Failed to upload cropped image')); + } + } finally { + this.uploadingCrop = false; + } + }, + updateValues(values) { let updated = { ...event, focus: values.focus }; diff --git a/resources/js/components/ui/Modal/ConfirmationModal.vue b/resources/js/components/ui/Modal/ConfirmationModal.vue index 5d1c12f067..1035adc857 100644 --- a/resources/js/components/ui/Modal/ConfirmationModal.vue +++ b/resources/js/components/ui/Modal/ConfirmationModal.vue @@ -1,12 +1,13 @@