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 @@
+
+
+
+
+
+ {{ __('Crop Image') }}
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
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 @@