Skip to content
Open
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
6 changes: 6 additions & 0 deletions packages/media-editor/src/image-editor/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import type { NormalizedRect, Flip, CropperState } from './types';
export const MIN_ZOOM = 1;
export const MAX_ZOOM = 10;

/**
* Wheel zoom sensitivity. A deltaY of 100 changes zoom by 0.25.
* This could be made configurable as a prop to the Cropper component.
*/
export const DEFAULT_WHEEL_ZOOM_SPEED = 0.0025;

/**
* Maximum free-rotation offset in degrees from the nearest 90° step.
* The rotation slider allows ±45° around the current cardinal angle.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Internal dependencies
*/
import type { CropperState, NormalizedPoint, Size } from './types';
import { MIN_ZOOM, MAX_ZOOM } from './constants';
import { DEFAULT_WHEEL_ZOOM_SPEED, MIN_ZOOM, MAX_ZOOM } from './constants';
import { restrictPanZoom } from './containment';

/** Time window for detecting a double-tap gesture (ms). */
Expand Down Expand Up @@ -84,7 +84,7 @@ export interface InteractionControllerOptions {
minZoom?: number;
/** Maximum zoom level. Defaults to MAX_ZOOM. Read lazily. */
maxZoom?: number;
/** Zoom speed multiplier for wheel events. Defaults to 0.01. Read lazily. */
/** Zoom speed multiplier for wheel events. Defaults to 0.0025. Read lazily. */
zoomSpeed?: number;
/** Pan step size in normalized coords for keyboard events. Defaults to 0.05. Read lazily. */
keyboardStep?: number;
Expand Down Expand Up @@ -200,7 +200,7 @@ export class InteractionController {

/** Read zoomSpeed lazily so option changes take effect immediately. */
private get zoomSpeed(): number {
return this.options.zoomSpeed ?? 0.01;
return this.options.zoomSpeed ?? DEFAULT_WHEEL_ZOOM_SPEED;
}
Comment thread
ramonjd marked this conversation as resolved.

/** Read keyboardStep lazily so option changes take effect immediately. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
type CropperInteractionActions,
} from '../interaction-controller';
import type { CropperState, Size } from '../types';
import { DEFAULT_STATE, MIN_ZOOM, MAX_ZOOM } from '../constants';
import {
DEFAULT_STATE,
DEFAULT_WHEEL_ZOOM_SPEED,
MIN_ZOOM,
MAX_ZOOM,
} from '../constants';

// The test environment is Node (not jsdom), so DOM globals like HTMLElement
// and Element are not available. Provide minimal stubs so that `instanceof`
Expand Down Expand Up @@ -379,8 +384,10 @@ describe( 'InteractionController', () => {
expect( actionMocks.setZoom ).toHaveBeenCalled();

const setZoomCall = actionMocks.setZoom.mock.calls[ 0 ];
// deltaY=-100, zoomSpeed=0.01, delta = 1, newZoom = 2+1 = 3.
expect( setZoomCall![ 0 ] ).toBe( 3 );
// deltaY=-100, default zoomSpeed = 0.0025, delta = 0.25.
expect( setZoomCall![ 0 ] ).toBeCloseTo(
2 + 100 * DEFAULT_WHEEL_ZOOM_SPEED
);
Comment thread
ramonjd marked this conversation as resolved.
} );

it( 'calls setZoomAtPoint on wheel with currentTarget element', () => {
Expand Down Expand Up @@ -409,7 +416,9 @@ describe( 'InteractionController', () => {
);

expect( actionMocks.setZoomAtPoint ).toHaveBeenCalled();
expect( actionMocks.setZoomAtPoint.mock.calls[ 0 ][ 0 ] ).toBe( 3 );
expect(
actionMocks.setZoomAtPoint.mock.calls[ 0 ][ 0 ]
).toBeCloseTo( 2 + 100 * DEFAULT_WHEEL_ZOOM_SPEED );
} );

it( 'clamps to maxZoom on large positive wheel', () => {
Expand All @@ -421,7 +430,7 @@ describe( 'InteractionController', () => {
);

const setZoomCall = actionMocks.setZoom.mock.calls[ 0 ];
// 9 + 5 = 14, clamped to MAX_ZOOM (10).
// 9 + 1.25 = 10.25, clamped to MAX_ZOOM (10).
expect( setZoomCall![ 0 ] ).toBe( MAX_ZOOM );
} );

Expand All @@ -434,7 +443,7 @@ describe( 'InteractionController', () => {
);

const setZoomCall = actionMocks.setZoom.mock.calls[ 0 ];
// 2 + (-5) = -3, clamped to MIN_ZOOM (1).
// 2 + (-1.25) = 0.75, clamped to MIN_ZOOM (1).
expect( setZoomCall![ 0 ] ).toBe( MIN_ZOOM );
} );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ function CropperInner(
}
return getCropBounds( state, elementSize, visualSize, canvasSize );
}, [ state, elementSize, visualSize, canvasSize ] );
const [ isResizing, setIsResizing ] = useState( false );
const isResizingRef = useRef( false );

// Use the interaction hook for mouse, touch, and keyboard events.
const {
Expand All @@ -300,11 +302,18 @@ function CropperInner(
if ( ! el ) {
return;
}
el.addEventListener( 'wheel', onWheelNative, {
const handleWheel = ( event: WheelEvent ) => {
if ( isResizingRef.current ) {
event.preventDefault();
return;
}
onWheelNative( event );
};
el.addEventListener( 'wheel', handleWheel, {
passive: false,
} );
return () => {
el.removeEventListener( 'wheel', onWheelNative );
el.removeEventListener( 'wheel', handleWheel );
};
}, [ onWheelNative ] );

Expand Down Expand Up @@ -355,7 +364,6 @@ function CropperInner(
};
}, [] );

const [ isResizing, setIsResizing ] = useState( false );
const isInteractiveGrid = showGrid === 'interactive';
const showInteractiveGrid =
isInteractiveGrid &&
Expand All @@ -370,6 +378,7 @@ function CropperInner(
}, [] );

const handleResizeStart = useCallback( () => {
isResizingRef.current = true;
setIsResizing( true );
onGestureStart?.();
}, [ onGestureStart ] );
Expand All @@ -378,6 +387,7 @@ function CropperInner(
* Handle resize end — settle the crop rect (re-center, fill height).
*/
const handleResizeEnd = useCallback( () => {
isResizingRef.current = false;
setIsResizing( false );
setSettling( true );
settleCrop();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';

/**
* Internal dependencies
Expand Down Expand Up @@ -41,10 +41,28 @@ function createController(): UseCropperStateReturn {
};
}

describe( 'Cropper grid visibility classes', () => {
describe( 'Cropper', () => {
const originalResizeObserver = globalThis.ResizeObserver;

beforeAll( () => {
if ( ! HTMLElement.prototype.setPointerCapture ) {
HTMLElement.prototype.setPointerCapture = jest.fn();
}
if ( ! HTMLElement.prototype.releasePointerCapture ) {
HTMLElement.prototype.releasePointerCapture = jest.fn();
}
if ( typeof ( globalThis as any ).PointerEvent === 'undefined' ) {
( globalThis as any ).PointerEvent = class PointerEvent extends (
MouseEvent
) {
pointerId: number;
constructor( type: string, init: PointerEventInit = {} ) {
super( type, init );
this.pointerId = init.pointerId ?? 0;
}
};
}

globalThis.ResizeObserver = class ResizeObserver {
private callback: ResizeObserverCallback;

Expand Down Expand Up @@ -137,4 +155,43 @@ describe( 'Cropper grid visibility classes', () => {
expect( canvas ).toHaveClass( GRID_INTERACTIVE_CLASS );
expect( canvas ).toHaveClass( SHOW_GRID_CLASS );
} );

it( 'ignores wheel zoom while a crop resize is active', async () => {
const controller = createController();
render(
<Cropper
src="test.jpg"
controller={ controller }
showDimming={ false }
freeformCrop
/>
);

const resizeHandle = await screen.findByRole( 'button', {
name: 'Resize top-left corner',
} );
const canvas = screen.getByRole( 'group', { name: 'Image editor' } );

fireEvent.pointerDown( resizeHandle, {
button: 0,
clientX: 100,
clientY: 100,
pointerId: 1,
} );

const wheelEvent = new WheelEvent( 'wheel', {
bubbles: true,
cancelable: true,
clientX: 300,
clientY: 200,
deltaY: -100,
} );
fireEvent( canvas, wheelEvent );

expect( wheelEvent.defaultPrevented ).toBe( true );
expect( controller.setZoom ).not.toHaveBeenCalled();
expect( controller.setZoomAtPoint ).not.toHaveBeenCalled();

fireEvent.pointerUp( resizeHandle, { pointerId: 1 } );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface UseInteractionOptions {
minZoom?: number;
/** Maximum zoom level. Defaults to MAX_ZOOM. */
maxZoom?: number;
/** Zoom speed multiplier for wheel events. Defaults to 0.01. */
/** Zoom speed multiplier for wheel events. Defaults to 0.0025. */
zoomSpeed?: number;
/** Pan step size in normalized coords for keyboard events. Defaults to 0.05. */
keyboardStep?: number;
Expand Down
Loading