Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 ] ).toBe(
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 ] ).toBe(
Comment thread
ramonjd marked this conversation as resolved.
Outdated
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