Skip to content

Commit e5f999f

Browse files
committed
Media editor: Add a session boundary above the cropper
The media editor controller now represents the editing session rather than extending CropperController. The cropper becomes a geometry-only sub-controller at `session.cropper`, passed to `<Cropper>`. - `setSourceImage` replaces `setImage` on the session. A different image resets geometry and crop options, the baseline and history. The cropper's `setImage` calls it. - `hasOutputEdits` replaces `isCropperDirty` for deciding whether saving produces new image output. The save hook reads the session. See #79175.
1 parent 2e7b30c commit e5f999f

12 files changed

Lines changed: 249 additions & 142 deletions

File tree

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ export default function MediaEditorCanvas( {
4242
disabled = false,
4343
}: MediaEditorCanvasProps ) {
4444
const { media } = useMediaEditorContext();
45-
const controller = useMediaEditor();
46-
const { aspectRatioValue } = controller.cropOptions;
47-
const cropperImage = controller.state.image;
48-
const { beginGesture, endGesture, setImage } = controller;
45+
const session = useMediaEditor();
46+
const { aspectRatioValue } = session.cropOptions;
47+
const cropperImage = session.cropper.state.image;
48+
const { beginGesture, endGesture, setSourceImage } = session;
4949

5050
// Tracks whether the image pixels have actually loaded. The cropper's
5151
// geometry is driven by the known media dimensions, so its handles and
@@ -89,12 +89,12 @@ export default function MediaEditorCanvas( {
8989
) {
9090
return;
9191
}
92-
setImage( {
92+
setSourceImage( {
9393
src: mediaUrl,
9494
naturalWidth: mediaWidth,
9595
naturalHeight: mediaHeight,
9696
} );
97-
}, [ cropperImage, mediaUrl, mediaWidth, mediaHeight, setImage ] );
97+
}, [ cropperImage, mediaUrl, mediaWidth, mediaHeight, setSourceImage ] );
9898

9999
const isImage = mediaType.type === 'image';
100100

@@ -159,7 +159,7 @@ export default function MediaEditorCanvas( {
159159
>
160160
<Cropper
161161
src={ mediaUrl }
162-
controller={ controller }
162+
controller={ session.cropper }
163163
aspectRatio={ aspectRatio }
164164
freeformCrop
165165
showGrid="interactive"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export default function MediaEditorFineRotation( {
2828
onPlacementControlInteraction,
2929
disabled = false,
3030
}: MediaEditorFineRotationProps ) {
31-
const { state, setRotation } = useMediaEditor();
31+
const { state, setRotation } = useMediaEditor().cropper;
3232
// `commitOnKeyUp: false` lets rapid arrow-key adjustments coalesce
3333
// into one undo entry via the gesture idle window. Pointer-up still
3434
// closes pointer drags immediately.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export default function MediaEditorImageControls( {
8181
zoomFactor = DEFAULT_ZOOM_FACTOR,
8282
disabled = false,
8383
}: MediaEditorImageControlsProps ) {
84-
const { state, setFlip, snapRotate90, setZoom } = useMediaEditor();
84+
const { state, setFlip, snapRotate90, setZoom } = useMediaEditor().cropper;
8585
const { aspectRatioValue, setAspectRatioValue, aspectRatioOptions } =
8686
useCropOptions( { aspectRatioPresets } );
8787
const hasAspectRatioControl = ! withLabels && showAspectRatioControl;

‎packages/media-editor/src/components/media-editor-image-controls/test/index.jsdom.test.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ function setup(
2020
}
2121

2222
function CurrentState() {
23-
const { state, cropOptions } = useMediaEditor();
23+
const {
24+
cropper: { state },
25+
cropOptions,
26+
} = useMediaEditor();
2427

2528
return (
2629
<>

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ function HistoryActions() {
294294
const { isImage, isUndoRedoDisabled, onReset, isWide, activePanel } =
295295
useMediaEditorFrameContext();
296296
const {
297-
reset,
297+
cropper: { reset },
298298
isDirty,
299299
hasUndo,
300300
hasRedo,
@@ -435,7 +435,7 @@ function MediaEditorContent( {
435435
noticesPortalElement,
436436
shouldCloseOnEsc = false,
437437
}: MediaEditorProps ) {
438-
const cropper = useMediaEditor();
438+
const session = useMediaEditor();
439439
// Width decides whether the settings panel docks beside the canvas or
440440
// takes the whole body. It docks from `small` (600px): the modal is the
441441
// viewport less a 16px margin either side, so a 320px panel and the
@@ -516,7 +516,7 @@ function MediaEditorContent( {
516516
[ id ]
517517
);
518518

519-
const hasChanges = cropper.isCropperDirty || hasEdits;
519+
const hasChanges = session.hasOutputEdits || hasEdits;
520520

521521
const { clearEntityRecordEdits, editEntityRecord, invalidateResolution } =
522522
useDispatch( coreStore );
@@ -602,7 +602,7 @@ function MediaEditorContent( {
602602
resetCropOptions,
603603
} = useCropOptions( { aspectRatioPresets } );
604604
const { isSaving, save: saveMediaEditor } = useSaveMediaEditor( {
605-
cropper,
605+
session,
606606
id,
607607
isImage,
608608
media,
@@ -656,9 +656,9 @@ function MediaEditorContent( {
656656
return;
657657
}
658658
if ( isRedoShortcut ) {
659-
cropper.redo();
659+
session.redo();
660660
} else {
661-
cropper.undo();
661+
session.undo();
662662
}
663663
}
664664
}

‎packages/media-editor/src/components/media-editor/use-crop-options.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function useCropOptions( {
5050
}: UseCropOptionsArgs = {} ): UseCropOptionsReturn {
5151
const controller = useMediaEditor();
5252
const { aspectRatioValue } = controller.cropOptions;
53-
const cropperImage = controller.state.image;
53+
const cropperImage = controller.cropper.state.image;
5454

5555
const aspectRatioOptions = useMemo(
5656
() => getAspectRatioOptions( aspectRatioPresets ),

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

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export interface MediaEditorSaveResult {
3737
}
3838

3939
interface UseSaveMediaEditorArgs {
40-
cropper: MediaEditorController;
40+
session: MediaEditorController;
4141
id: number;
4242
isImage: boolean;
4343
media?: Media | null;
@@ -49,13 +49,14 @@ interface UseSaveMediaEditorReturn {
4949
save: () => Promise< void >;
5050
}
5151

52-
function getCropModifiers( cropper: MediaEditorController ): Modifier[] {
53-
if ( ! cropper.isCropperDirty || ! cropper.state.image ) {
52+
function getCropModifiers( session: MediaEditorController ): Modifier[] {
53+
const { state } = session.cropper;
54+
if ( ! session.hasOutputEdits || ! state.image ) {
5455
return [];
5556
}
56-
return buildModifiers( cropper.state, {
57-
width: cropper.state.image.naturalWidth,
58-
height: cropper.state.image.naturalHeight,
57+
return buildModifiers( state, {
58+
width: state.image.naturalWidth,
59+
height: state.image.naturalHeight,
5960
} );
6061
}
6162

@@ -80,7 +81,7 @@ function getMetadataEdits(
8081
}
8182

8283
export function useSaveMediaEditor( {
83-
cropper,
84+
session,
8485
id,
8586
isImage,
8687
media,
@@ -100,7 +101,7 @@ export function useSaveMediaEditor( {
100101
setIsSaving( true );
101102
try {
102103
let saved: Media | null | undefined;
103-
const modifiers = getCropModifiers( cropper );
104+
const modifiers = getCropModifiers( session );
104105
const previous =
105106
modifiers.length > 0 && media
106107
? {
@@ -154,7 +155,7 @@ export function useSaveMediaEditor( {
154155

155156
if ( next && next.id ) {
156157
if ( next.id === id ) {
157-
cropper.reset();
158+
session.cropper.reset();
158159
}
159160
onSaved?.( {
160161
id: next.id,
@@ -192,7 +193,6 @@ export function useSaveMediaEditor( {
192193
}, [
193194
clearEntityRecordEdits,
194195
createErrorNotice,
195-
cropper,
196196
id,
197197
isImage,
198198
media,
@@ -201,6 +201,7 @@ export function useSaveMediaEditor( {
201201
registry,
202202
removeAllNotices,
203203
saveEditedEntityRecord,
204+
session,
204205
] );
205206

206207
return { isSaving, save };

‎packages/media-editor/src/image-editor/docs/architecture.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
| React cropper | `src/image-editor/react/` | DOM measurement, interaction hooks, overlays, stencil rendering, and the pure `useCropperReducer` hook. |
99
| Media editor state | `src/state/` | Composite state for the media editor: cropper state, crop options, undo/redo, and gesture boundaries. |
1010

11-
The cropper layer does not own editor history. History belongs to the composite media editor controller because sidebar controls and cropper gestures need one shared undo stack.
11+
The cropper layer does not own editor history. History belongs to the media editor session because sidebar controls and cropper gestures need one shared undo stack.
1212

1313
## Coordinate Spaces
1414

@@ -37,6 +37,10 @@ The render path uses focused calculations for CSS transforms, stencil placement,
3737

3838
The core reducer is framework-agnostic and serializable. React and media-editor layers add DOM measurement, gestures, undo/redo, save behavior, and UI state.
3939

40+
### Session
41+
42+
`useMediaEditorState()` returns the media editor session. The session owns the source image, every editing slice, the shared undo/redo history, gesture boundaries, and whether the edits change the saved image (`hasOutputEdits`). The cropper is one participant: `session.cropper` is a geometry-only `CropperController` that a `<Cropper>` takes via its `controller` prop, and its `isDirty` covers geometry alone. Replacing the source image (`setSourceImage`, which the cropper's `setImage` also calls) starts a fresh session with default geometry and crop options, a clean baseline, and no history. Save code asks the session, not the cropper, whether image output needs saving.
43+
4044
### Viewport
4145

4246
Viewport pan/zoom lets the user inspect or follow the crop area without changing the crop output. It is not part of export and should not create undo entries.
@@ -52,4 +56,4 @@ These APIs let code inside `@wordpress/media-editor` compose the cropper. They a
5256
| Crop data for REST requests | `getSourceRegion()` and `getSourceRegionPercent()`. |
5357
| Canvas export | `exportCroppedImage()` or `applyToCanvas()`. |
5458
| Shared cropper state across internal components | `CropperProvider` / `useCropper()`. |
55-
| Media editor history | `useMediaEditorState()` composite controller. |
59+
| Media editor history | `useMediaEditorState()` session. |

‎packages/media-editor/src/image-editor/docs/recipes.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,29 +76,29 @@ function CropperPanel( { src }: { src: string } ) {
7676
}
7777
```
7878

79-
## Use the Media Editor Composite Controller
79+
## Use the Media Editor Session
8080

81-
Use the media editor controller when cropper state must share history with controls such as aspect ratio.
81+
Use the media editor session when cropper state must share history with controls such as aspect ratio. The session owns history and gesture boundaries; pass its `cropper` controller to `<Cropper>`.
8282

8383
```tsx
8484
import { resolveAspectRatio, useMediaEditor } from '../../state';
8585
import { Cropper } from '../image-editor';
8686

8787
function MediaEditorCanvas( { src }: { src: string } ) {
88-
const controller = useMediaEditor();
88+
const session = useMediaEditor();
8989
const aspectRatio = resolveAspectRatio(
90-
controller.cropOptions.aspectRatioValue,
91-
controller.state.image
90+
session.cropOptions.aspectRatioValue,
91+
session.cropper.state.image
9292
);
9393

9494
return (
9595
<Cropper
9696
src={ src }
97-
controller={ controller }
97+
controller={ session.cropper }
9898
aspectRatio={ aspectRatio }
9999
freeformCrop
100-
onGestureStart={ controller.beginGesture }
101-
onGestureEnd={ controller.endGesture }
100+
onGestureStart={ session.beginGesture }
101+
onGestureEnd={ session.endGesture }
102102
/>
103103
);
104104
}

‎packages/media-editor/src/state/media-editor-state-provider.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ interface MediaEditorStateProviderProps {
2020
}
2121

2222
/**
23-
* Provider that vends the composite media-editor controller via
24-
* context. Wrap the media editor in this so every consumer
25-
* (canvas, toolbar, sidebar) reads from the same store.
23+
* Provider that vends the media editor session via context. Wrap
24+
* the media editor in this so every consumer (canvas, toolbar,
25+
* sidebar) reads from the same store.
2626
*
2727
* @param props
2828
* @param props.initialCropperState
@@ -47,10 +47,10 @@ export function MediaEditorStateProvider( {
4747
}
4848

4949
/**
50-
* Consume the composite media-editor controller. Throws if used
50+
* Consume the media editor session. Throws if used
5151
* outside a `<MediaEditorStateProvider>`.
5252
*
53-
* @return The composite controller.
53+
* @return The media editor session.
5454
*/
5555
export function useMediaEditor(): MediaEditorController {
5656
const context = useContext( MediaEditorStateContext );

0 commit comments

Comments
 (0)