Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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 ]
);
Expand Down
Loading