Skip to content

Commit a79e056

Browse files
committed
Media editor: restore original image in the crop panel
Add a "Restore original image" button to the Crop sidebar tab. It appears when the edited attachment exposes media_details.original_attachment (the lineage root from the backend primitive). Clicking loads the original into the cropper as a dirty preview: - Save with no further crop repoints the block at the original attachment — no /edit request, no new attachment. - Save after cropping the restored original runs /edit against the original's id, creating a fresh child off the root. - Cancel discards the restore via the existing discard-confirm. The canvas takes an optional srcOverride so the cropper can show the original; swapping the source resets the cropper's dirty baseline, so a bare restore is tracked by a separate isOriginalRestored flag OR'd into hasChanges. The original's natural dimensions come from a cached getEntityRecord on its id (the field itself carries only id + url). Based on #81803 (the original_attachment lineage primitive).
1 parent 40b51f8 commit a79e056

4 files changed

Lines changed: 176 additions & 24 deletions

File tree

‎packages/media-editor/src/components/media-editor-canvas/index.tsx‎

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ export interface MediaEditorCanvasProps {
1414
onGestureStart?: () => void;
1515
/** Fires when a canvas cropper gesture ends. */
1616
onGestureEnd?: () => void;
17+
/**
18+
* When set, load this image into the cropper instead of the media's own
19+
* `source_url` — used by "Restore original image" to preview the lineage
20+
* root before saving. Swapping the source resets the cropper baseline, so
21+
* the restore counts as clean until the user re-crops.
22+
*/
23+
srcOverride?: {
24+
url: string;
25+
width: number;
26+
height: number;
27+
};
1728
}
1829

1930
/**
@@ -33,6 +44,7 @@ export default function MediaEditorCanvas( {
3344
isPlacementActive = false,
3445
onGestureStart,
3546
onGestureEnd,
47+
srcOverride,
3648
}: MediaEditorCanvasProps ) {
3749
const { media } = useMediaEditorContext();
3850
const controller = useMediaEditor();
@@ -66,28 +78,41 @@ export default function MediaEditorCanvas( {
6678
onGestureEnd?.();
6779
}, [ endGesture, onGestureEnd ] );
6880

69-
const mediaUrl = media?.source_url;
7081
const mediaType = getMediaTypeFromMimeType( media?.mime_type );
71-
const mediaWidth = Number( media?.media_details?.width );
72-
const mediaHeight = Number( media?.media_details?.height );
82+
83+
// When a restore is active, the cropper shows the lineage root instead of
84+
// the media's own file. `loadUrl`/`loadWidth`/`loadHeight` are whichever
85+
// source is currently in play.
86+
const loadUrl = srcOverride?.url ?? media?.source_url;
87+
const loadWidth = srcOverride
88+
? srcOverride.width
89+
: Number( media?.media_details?.width );
90+
const loadHeight = srcOverride
91+
? srcOverride.height
92+
: Number( media?.media_details?.height );
7393

7494
useEffect( () => {
7595
if (
76-
cropperImage ||
77-
! mediaUrl ||
78-
! Number.isFinite( mediaWidth ) ||
79-
! Number.isFinite( mediaHeight ) ||
80-
mediaWidth <= 0 ||
81-
mediaHeight <= 0
96+
! loadUrl ||
97+
! Number.isFinite( loadWidth ) ||
98+
! Number.isFinite( loadHeight ) ||
99+
loadWidth <= 0 ||
100+
loadHeight <= 0
82101
) {
83102
return;
84103
}
104+
// Idempotent: skip when the cropper already holds this source. When the
105+
// source changes (initial load, or a restore swapping in the original)
106+
// this re-runs and `setImage` refreshes the clean baseline.
107+
if ( cropperImage?.src === loadUrl ) {
108+
return;
109+
}
85110
setImage( {
86-
src: mediaUrl,
87-
naturalWidth: mediaWidth,
88-
naturalHeight: mediaHeight,
111+
src: loadUrl,
112+
naturalWidth: loadWidth,
113+
naturalHeight: loadHeight,
89114
} );
90-
}, [ cropperImage, mediaUrl, mediaWidth, mediaHeight, setImage ] );
115+
}, [ cropperImage, loadUrl, loadWidth, loadHeight, setImage ] );
91116

92117
const isImage = mediaType.type === 'image';
93118

@@ -96,14 +121,14 @@ export default function MediaEditorCanvas( {
96121
// cropper's own `<img>`, so this adds no network cost. The cropper stays
97122
// framework-pure — load/error handling lives here in the wrapper layer.
98123
useEffect( () => {
99-
if ( ! mediaUrl || ! isImage ) {
124+
if ( ! loadUrl || ! isImage ) {
100125
return;
101126
}
102127
setStatus( 'loading' );
103128
const probe = new window.Image();
104129
probe.onload = () => setStatus( 'loaded' );
105130
probe.onerror = () => setStatus( 'error' );
106-
probe.src = mediaUrl;
131+
probe.src = loadUrl;
107132
// Cached images may already be complete before listeners attach.
108133
if ( probe.complete ) {
109134
setStatus( probe.naturalWidth > 0 ? 'loaded' : 'error' );
@@ -112,9 +137,9 @@ export default function MediaEditorCanvas( {
112137
probe.onload = null;
113138
probe.onerror = null;
114139
};
115-
}, [ mediaUrl, isImage ] );
140+
}, [ loadUrl, isImage ] );
116141

117-
if ( ! mediaUrl || ! isImage ) {
142+
if ( ! loadUrl || ! isImage ) {
118143
return null;
119144
}
120145

@@ -151,7 +176,7 @@ export default function MediaEditorCanvas( {
151176
} ) }
152177
>
153178
<Cropper
154-
src={ mediaUrl }
179+
src={ loadUrl }
155180
controller={ controller }
156181
aspectRatio={ aspectRatio }
157182
freeformCrop

‎packages/media-editor/src/components/media-editor-crop-panel/index.tsx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SelectControl } from '@wordpress/components';
1+
import { Button, SelectControl } from '@wordpress/components';
22
import { Stack, VisuallyHidden } from '@wordpress/ui';
33
import { __ } from '@wordpress/i18n';
44
import { CROP_CONTROL_ATTR } from '../../hooks/use-crop-gesture-handlers';
@@ -22,6 +22,18 @@ export interface MediaEditorCropPanelProps {
2222
* them.
2323
*/
2424
showTransformControls?: boolean;
25+
/**
26+
* When `true`, the media has a lineage root to restore to, so the
27+
* "Restore original image" button is shown. Requires `onRestoreOriginal`.
28+
*/
29+
canRestoreOriginal?: boolean;
30+
/**
31+
* When `true`, the original has already been loaded into the cropper this
32+
* session, so the button is disabled.
33+
*/
34+
isOriginalRestored?: boolean;
35+
/** Load the lineage root into the cropper as a dirty preview. */
36+
onRestoreOriginal?: () => void;
2537
}
2638

2739
/**
@@ -39,6 +51,9 @@ export default function MediaEditorCropPanel( {
3951
onAspectRatioChange,
4052
aspectRatioOptions,
4153
showTransformControls = false,
54+
canRestoreOriginal = false,
55+
isOriginalRestored = false,
56+
onRestoreOriginal,
4257
}: MediaEditorCropPanelProps ) {
4358
return (
4459
// Tag the whole panel as a crop-control region so the modal's
@@ -62,6 +77,16 @@ export default function MediaEditorCropPanel( {
6277
value: preset.value.toString(),
6378
} ) ) }
6479
/>
80+
{ canRestoreOriginal && onRestoreOriginal && (
81+
<Button
82+
variant="secondary"
83+
onClick={ onRestoreOriginal }
84+
disabled={ isOriginalRestored }
85+
accessibleWhenDisabled
86+
>
87+
{ __( 'Restore original image' ) }
88+
</Button>
89+
) }
6590
</Stack>
6691
);
6792
}

‎packages/media-editor/src/components/media-editor/index.tsx‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,8 +447,6 @@ function MediaEditorContent( {
447447
[ id ]
448448
);
449449

450-
const hasChanges = cropper.isCropperDirty || hasEdits;
451-
452450
const { clearEntityRecordEdits, editEntityRecord, invalidateResolution } =
453451
useDispatch( coreStore );
454452
const { removeAllNotices } = useDispatch( noticesStore );
@@ -457,6 +455,11 @@ function MediaEditorContent( {
457455
const [ isPlacementActive, setIsPlacementActive ] = useState( false );
458456
const [ isCanvasGestureActive, setIsCanvasGestureActive ] =
459457
useState( false );
458+
// Whether the user has loaded the lineage root into the cropper this
459+
// session. Stays a distinct flag (not derived from the cropper) so a bare
460+
// restore counts as a change even though swapping the source resets the
461+
// cropper's own dirty baseline.
462+
const [ isOriginalRestored, setIsOriginalRestored ] = useState( false );
460463
const placementControlTimerRef =
461464
useRef< ReturnType< typeof setTimeout > >();
462465

@@ -484,6 +487,7 @@ function MediaEditorContent( {
484487
useEffect( () => {
485488
setIsPlacementActive( false );
486489
setIsCanvasGestureActive( false );
490+
setIsOriginalRestored( false );
487491
}, [ id ] );
488492

489493
// Bust the cached `_embed` resolution each time the editor mounts (or the
@@ -498,6 +502,65 @@ function MediaEditorContent( {
498502
] );
499503
}, [ id, invalidateResolution ] );
500504

505+
// Restore-original: the lineage root the edited attachment descends from,
506+
// exposed by the server on `media_details.original_attachment`. Fetch its
507+
// record for the natural dimensions the cropper needs to seed itself (the
508+
// field itself carries only the id and url).
509+
const originalAttachment = media?.media_details?.original_attachment;
510+
const originalId: number | undefined = originalAttachment?.attachment_id;
511+
const originalRecord = useSelect(
512+
( select ) =>
513+
originalId
514+
? ( select( coreStore ).getEntityRecord(
515+
'postType',
516+
'attachment',
517+
originalId
518+
) as Media | undefined )
519+
: undefined,
520+
[ originalId ]
521+
);
522+
const originalWidth = Number( originalRecord?.media_details?.width );
523+
const originalHeight = Number( originalRecord?.media_details?.height );
524+
const originalSource =
525+
originalAttachment &&
526+
originalRecord &&
527+
Number.isFinite( originalWidth ) &&
528+
originalWidth > 0 &&
529+
Number.isFinite( originalHeight ) &&
530+
originalHeight > 0
531+
? {
532+
id: originalAttachment.attachment_id as number,
533+
url: originalAttachment.source_url as string,
534+
width: originalWidth,
535+
height: originalHeight,
536+
media: originalRecord,
537+
}
538+
: undefined;
539+
const canRestoreOriginal = !! originalSource;
540+
const restoredSource =
541+
isOriginalRestored && originalSource
542+
? {
543+
id: originalSource.id,
544+
url: originalSource.url,
545+
media: originalSource.media,
546+
}
547+
: undefined;
548+
const canvasSrcOverride =
549+
isOriginalRestored && originalSource
550+
? {
551+
url: originalSource.url,
552+
width: originalSource.width,
553+
height: originalSource.height,
554+
}
555+
: undefined;
556+
const handleRestoreOriginal = useCallback( () => {
557+
setIsOriginalRestored( true );
558+
}, [] );
559+
560+
// A bare restore has no cropper diff, so OR the flag in explicitly.
561+
const hasChanges =
562+
cropper.isCropperDirty || hasEdits || isOriginalRestored;
563+
501564
const mediaType = getMediaTypeFromMimeType( media?.mime_type ).type;
502565
const isImage = !! media && mediaType === 'image';
503566
const {
@@ -514,6 +577,7 @@ function MediaEditorContent( {
514577
isImage,
515578
media,
516579
onSaved,
580+
restoredSource,
517581
} );
518582

519583
const tabs = useMemo< EditorTab[] >( () => {
@@ -548,6 +612,9 @@ function MediaEditorContent( {
548612
onAspectRatioChange={ setAspectRatioValue }
549613
aspectRatioOptions={ aspectRatioOptions }
550614
showTransformControls={ isPanelLayout }
615+
canRestoreOriginal={ canRestoreOriginal }
616+
isOriginalRestored={ isOriginalRestored }
617+
onRestoreOriginal={ handleRestoreOriginal }
551618
/>
552619
</Stack>
553620
),
@@ -560,6 +627,9 @@ function MediaEditorContent( {
560627
setAspectRatioValue,
561628
aspectRatioOptions,
562629
isPanelLayout,
630+
canRestoreOriginal,
631+
isOriginalRestored,
632+
handleRestoreOriginal,
563633
] );
564634

565635
// Control the active tab from state here so the selection survives the
@@ -576,6 +646,7 @@ function MediaEditorContent( {
576646
const discardAndClose = () => {
577647
removeAllNotices( 'snackbar', MEDIA_EDITOR_NOTICES_CONTEXT );
578648
clearEntityRecordEdits( 'postType', 'attachment', id );
649+
setIsOriginalRestored( false );
579650
onClose?.();
580651
};
581652

@@ -684,6 +755,9 @@ function MediaEditorContent( {
684755
onGestureEnd={
685756
handleCanvasGestureEnd
686757
}
758+
srcOverride={
759+
canvasSrcOverride
760+
}
687761
/>
688762
) : (
689763
<MediaPreview />

‎packages/media-editor/src/components/media-editor/use-save-media-editor.ts‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ interface UseSaveMediaEditorArgs {
4242
isImage: boolean;
4343
media?: Media | null;
4444
onSaved?: ( result: MediaEditorSaveResult ) => void;
45+
/**
46+
* When the user has restored the lineage root, the save targets that
47+
* original attachment instead of the currently-edited one:
48+
* - with no fresh crop, the block is repointed at the original (no `/edit`);
49+
* - with a fresh crop, `/edit` runs against the original's id and url.
50+
*/
51+
restoredSource?: {
52+
id: number;
53+
url?: string;
54+
media: Media;
55+
};
4556
}
4657

4758
interface UseSaveMediaEditorReturn {
@@ -85,6 +96,7 @@ export function useSaveMediaEditor( {
8596
isImage,
8697
media,
8798
onSaved,
99+
restoredSource,
88100
}: UseSaveMediaEditorArgs ): UseSaveMediaEditorReturn {
89101
const registry = useRegistry();
90102
const {
@@ -101,8 +113,16 @@ export function useSaveMediaEditor( {
101113
try {
102114
let saved: Media | null | undefined;
103115
const modifiers = getCropModifiers( cropper );
116+
117+
// A restore retargets the save at the lineage root; without one the
118+
// current attachment is both source and target as before.
119+
const targetId = restoredSource?.id ?? id;
120+
const targetUrl = restoredSource?.url ?? media?.source_url;
121+
122+
// Both a fresh crop and a bare restore swap the block's image, so
123+
// both offer an Undo back to the current attachment.
104124
const previous =
105-
modifiers.length > 0 && media
125+
( modifiers.length > 0 || restoredSource ) && media
106126
? {
107127
id,
108128
url: media.source_url,
@@ -120,10 +140,10 @@ export function useSaveMediaEditor( {
120140
const metadataEdits = getMetadataEdits( pendingEdits, media );
121141

122142
saved = ( await apiFetch( {
123-
path: `/wp/v2/media/${ id }/edit`,
143+
path: `/wp/v2/media/${ targetId }/edit`,
124144
method: 'POST',
125145
data: {
126-
src: media?.source_url,
146+
src: targetUrl,
127147
modifiers,
128148
...metadataEdits,
129149
},
@@ -138,6 +158,13 @@ export function useSaveMediaEditor( {
138158
true
139159
);
140160
}
161+
} else if ( restoredSource ) {
162+
// Restore with no fresh crop: repoint the block at the original
163+
// attachment, which already exists — no `/edit` request. Drop
164+
// staged metadata edits, which were made against the cropped
165+
// version the user is discarding.
166+
clearEntityRecordEdits( 'postType', 'attachment', id );
167+
saved = restoredSource.media;
141168
} else {
142169
saved = ( await saveEditedEntityRecord(
143170
'postType',
@@ -200,6 +227,7 @@ export function useSaveMediaEditor( {
200227
receiveEntityRecords,
201228
registry,
202229
removeAllNotices,
230+
restoredSource,
203231
saveEditedEntityRecord,
204232
] );
205233

0 commit comments

Comments
 (0)