From e1bf02a67fad4a479e7b7a22f6fbf49867eb860a Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 18 May 2026 13:56:45 +1000 Subject: [PATCH 1/2] Media Editor: Anchor cursorless zoom at the crop center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cropper state controller's `setZoom` previously dispatched plain `SET_ZOOM`, leaving pan untouched. With no focal-point correction, `enforceContainment` translated the image whichever way is shortest to restore image-covers-crop — which, when zooming out from an off-center position, snaps toward the nearest corner of the viewport. Every cursorless caller (the zoom slider, `+`/`-` keyboard shortcuts, programmatic toolbar buttons) suffered from this. Pointer-driven paths don't have the problem: wheel, pinch, and double-tap each compute a focal point and dispatch `SET_ZOOM_AT_POINT` with explicit pan correction. Make `setZoom` default to anchoring at the crop center. The math mirrors the wheel handler's focal-point math but uses the crop-rect center as the focal point. `setZoomAtPoint` still exists for callers that want an explicit focal point. No interaction-controller wiring needed — the keyboard handler's existing `setZoom` call gets the new behavior for free. --- .../core/interaction-controller.ts | 6 ++- .../react/hooks/use-cropper-state.ts | 54 +++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/media-editor/src/image-editor/core/interaction-controller.ts b/packages/media-editor/src/image-editor/core/interaction-controller.ts index d9cc5c9658272b..4092edaacb269f 100644 --- a/packages/media-editor/src/image-editor/core/interaction-controller.ts +++ b/packages/media-editor/src/image-editor/core/interaction-controller.ts @@ -62,7 +62,11 @@ export interface InteractionStatus { export interface CropperInteractionActions { /** Set the image pan offset in normalized coordinates. */ setPan: ( pan: NormalizedPoint ) => void; - /** Set the zoom level. */ + /** + * Set the zoom level. Cursorless surfaces (slider, keyboard `+`/`-`) + * rely on this anchoring at the crop center; pointer-driven zoom + * paths use `setZoomAtPoint` with an explicit focal point instead. + */ setZoom: ( zoom: number ) => void; /** Set zoom and pan together for focal-point zoom. */ setZoomAtPoint: ( zoom: number, pan: NormalizedPoint ) => void; diff --git a/packages/media-editor/src/image-editor/react/hooks/use-cropper-state.ts b/packages/media-editor/src/image-editor/react/hooks/use-cropper-state.ts index 92901a3e3feda2..da3643920c5502 100644 --- a/packages/media-editor/src/image-editor/react/hooks/use-cropper-state.ts +++ b/packages/media-editor/src/image-editor/react/hooks/use-cropper-state.ts @@ -19,8 +19,9 @@ import type { NormalizedRect, Flip, } from '../../core/types'; -import { DEFAULT_STATE } from '../../core/constants'; +import { DEFAULT_STATE, MIN_ZOOM, MAX_ZOOM } from '../../core/constants'; import { exportCroppedImage } from '../../core/export/canvas-renderer'; +import { restrictPanZoom } from '../../core/containment'; import { cropperReducer, enforceContainment, @@ -46,7 +47,18 @@ export interface UseCropperStateReturn { * `setCropRect` for the crop rectangle. */ setPan: ( pan: NormalizedPoint ) => void; - /** Set the zoom level. Clamped to [1, 10]. */ + /** + * Set the zoom level (clamped to [1, 10]), anchored at the crop + * center. + * + * Pointer-driven zoom (wheel, pinch, double-tap) anchors at the + * cursor via `setZoomAtPoint`. Cursorless surfaces — the slider, the + * `+`/`-` keys, programmatic toolbar buttons — have no natural + * focal point, so they default to the crop center. Otherwise + * `enforceContainment` would translate the image whichever way is + * shortest, which snaps the framed content toward the nearest + * viewport corner when zooming out from an off-center position. + */ setZoom: ( zoom: number ) => void; /** Set zoom and pan together so focal-point zoom remains atomic. */ setZoomAtPoint: ( zoom: number, pan: NormalizedPoint ) => void; @@ -309,7 +321,43 @@ export function useCropperState( const setZoom = useCallback( ( zoom: number ) => { - dispatch( { type: 'SET_ZOOM', payload: zoom } ); + const s = stateRef.current; + const clampedZoom = Math.min( + MAX_ZOOM, + Math.max( MIN_ZOOM, zoom ) + ); + if ( clampedZoom === s.zoom ) { + return; + } + // Crop center expressed in the same coord system as `state.pan`: + // container-center-relative, normalized by image rendered size. + // `cropRect` is image-top-left-origin (0–1), so subtract 0.5 + // to recenter. + const { cropRect } = s; + const focalNormX = cropRect.x + cropRect.width / 2 - 0.5; + const focalNormY = cropRect.y + cropRect.height / 2 - 0.5; + const zoomRatio = 1 - clampedZoom / s.zoom; + const newPanX = s.pan.x + ( focalNormX - s.pan.x ) * zoomRatio; + const newPanY = s.pan.y + ( focalNormY - s.pan.y ) * zoomRatio; + const imageSize = s.image + ? { + width: s.image.naturalWidth, + height: s.image.naturalHeight, + } + : { width: 1, height: 1 }; + const { pan: clampedPan } = restrictPanZoom( + { + ...s, + zoom: clampedZoom, + pan: { x: newPanX, y: newPanY }, + }, + imageSize, + s.cropRect + ); + dispatch( { + type: 'SET_ZOOM_AT_POINT', + payload: { zoom: clampedZoom, pan: clampedPan }, + } ); }, [ dispatch ] ); From a5c79c8cd3d5f8fb8de2b3d4d403a97d6b9e45c8 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 18 May 2026 15:34:02 +1000 Subject: [PATCH 2/2] Add tests for crop-center anchoring in setZoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regression guards for the center-anchored zoom contract: - Default cropRect (full image) → crop center coincides with image center → focal-point correction degenerates to "no pan change." Catches a regression where setZoom accidentally pans on default state. - Off-center cropRect (bottom-right quadrant) → setZoom from 4→2 must move pan in +x, +y toward the crop center. Under the old plain-SET_ZOOM behavior, containment leaves pan at the origin (image still covers the small crop), so this test fails on the pre-fix code and passes after. --- .../react/hooks/test/use-cropper-state.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/media-editor/src/image-editor/react/hooks/test/use-cropper-state.ts b/packages/media-editor/src/image-editor/react/hooks/test/use-cropper-state.ts index da58a78fd067e9..39312ab443a57e 100644 --- a/packages/media-editor/src/image-editor/react/hooks/test/use-cropper-state.ts +++ b/packages/media-editor/src/image-editor/react/hooks/test/use-cropper-state.ts @@ -54,6 +54,65 @@ describe( 'useCropperState', () => { expect( result.current.state.zoom ).toBe( 3 ); } ); + it( 'leaves pan at zero on setZoom when the cropRect is centered', () => { + // Sanity: the default cropRect spans the full image, so its + // center coincides with the image center and the crop-center + // focal-point correction degenerates to "no pan change." + // Guards against regressions where setZoom accidentally pans. + const { result } = renderHook( () => useCropperState() ); + + act( () => { + result.current.setZoom( 4 ); + } ); + + expect( result.current.state.zoom ).toBe( 4 ); + expect( result.current.state.pan ).toEqual( { x: 0, y: 0 } ); + + act( () => { + result.current.setZoom( 2 ); + } ); + + expect( result.current.state.zoom ).toBe( 2 ); + expect( result.current.state.pan ).toEqual( { x: 0, y: 0 } ); + } ); + + it( 'anchors setZoom at the crop-rect center', () => { + // With an off-center crop, setZoom must shift pan toward the + // crop center as zoom decreases — otherwise `enforceContainment` + // would translate the image toward the nearest viewport corner + // (the original bug this method fixes). + const { result } = renderHook( () => useCropperState() ); + + act( () => { + result.current.setZoom( 4 ); + } ); + // Place the crop in the bottom-right quadrant — center + // at ( 0.7, 0.7 ), i.e. ( +0.2, +0.2 ) in pan coords. + act( () => { + result.current.setCropRect( { + x: 0.6, + y: 0.6, + width: 0.2, + height: 0.2, + } ); + } ); + act( () => { + result.current.setPan( { x: 0, y: 0 } ); + } ); + + expect( result.current.state.pan ).toEqual( { x: 0, y: 0 } ); + + // Zoom out — pan should move toward the focal point in +x, +y, + // not stay at the origin (which would corner-snap on containment). + act( () => { + result.current.setZoom( 2 ); + } ); + + expect( result.current.state.zoom ).toBe( 2 ); + expect( result.current.state.pan.x ).toBeGreaterThan( 0 ); + expect( result.current.state.pan.y ).toBeGreaterThan( 0 ); + } ); + it( 'should dispatch SET_ZOOM_AT_POINT via setZoomAtPoint', () => { const { result } = renderHook( () => useCropperState() );