Skip to content
Closed
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 @@ -44,6 +44,7 @@ export default function MediaEditorCanvas( {
controller={ controller }
aspectRatio={ aspectRatio }
freeformCrop={ freeformCrop }
showGrid="interactive"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like

/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ export default function MediaEditorCropPanel( {
onFreeformChange,
aspectRatioPresets,
}: MediaEditorCropPanelProps ) {
const { state, setZoom } = useCropper();
const { state, setZoom, notifyInteractionStart, notifyInteractionEnd } =
useCropper();
const aspectRatioOptions = [
...DEFAULT_ASPECT_RATIOS.filter( ( preset ) => preset.value <= 0 ),
...( aspectRatioPresets ??
Expand All @@ -93,26 +94,32 @@ export default function MediaEditorCropPanel( {

return (
<Stack direction="column" gap="md">
<RangeControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'Zoom' ) }
min={ MIN_ZOOM }
max={ MAX_ZOOM }
step={ 0.1 }
value={ state.zoom }
onChange={ ( value ) =>
setZoom( typeof value === 'number' ? value : MIN_ZOOM )
}
renderTooltipContent={ ( value ) => {
const zoom = typeof value === 'number' ? value : MIN_ZOOM;
return sprintf(
/* translators: %d: zoom level as a percentage. */
__( '%d%%' ),
Math.round( zoom * 100 )
);
} }
/>
<div
onPointerDown={ notifyInteractionStart }
onPointerUp={ notifyInteractionEnd }
>
<RangeControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'Zoom' ) }
min={ MIN_ZOOM }
max={ MAX_ZOOM }
step={ 0.1 }
value={ state.zoom }
onChange={ ( value ) =>
setZoom( typeof value === 'number' ? value : MIN_ZOOM )
}
renderTooltipContent={ ( value ) => {
const zoom =
typeof value === 'number' ? value : MIN_ZOOM;
return sprintf(
/* translators: %d: zoom level as a percentage. */
__( '%d%%' ),
Math.round( zoom * 100 )
);
} }
/>
</div>
<SelectControl
__next40pxDefaultSize
__nextHasNoMarginBottom
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,16 @@ export interface MediaEditorToolbarProps {
export default function MediaEditorToolbar( {
onReset,
}: MediaEditorToolbarProps ) {
const { state, setRotation, setFlip, snapRotate90, reset, isDirty } =
useCropper();
const {
state,
setRotation,
setFlip,
snapRotate90,
reset,
isDirty,
notifyInteractionStart,
notifyInteractionEnd,
} = useCropper();

const handleReset = () => {
reset();
Expand Down Expand Up @@ -117,7 +125,11 @@ export default function MediaEditorToolbar( {
} )
}
/>
<div className="media-editor-toolbar__rotation-slider">
<div
className="media-editor-toolbar__rotation-slider"
onPointerDown={ notifyInteractionStart }
onPointerUp={ notifyInteractionEnd }
>
<RangeControl
__next40pxDefaultSize
__nextHasNoMarginBottom
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const DOUBLE_TAP_TIME = 300;
const DOUBLE_TAP_DISTANCE = 30;
/** Duration of the zoom animation state (ms). */
const ZOOM_ANIMATION_DURATION = 200;
/** Debounce delay for detecting keyboard pan gesture end (ms). */
const KEYBOARD_GESTURE_DEBOUNCE_MS = 200;

/**
* Get the natural image dimensions from cropper state, falling back to 1x1.
Expand Down Expand Up @@ -181,6 +183,12 @@ export class InteractionController {
/** Whether a wheel gesture is currently active. */
private wheelGestureActive = false;

/** Timer for keyboard pan gesture debounce. */
private keyboardGestureTimer: ReturnType< typeof setTimeout > | undefined;

/** Whether a keyboard pan gesture is currently active. */
private keyboardGestureActive = false;

/** Current requestAnimationFrame ID. */
private rafId = 0;

Expand Down Expand Up @@ -771,6 +779,26 @@ export class InteractionController {
handleKeyDown( e: KeyboardEvent ): void {
const currentState = this.options.getState();

// Arrow keys trigger a keyboard pan gesture — show the grid for the
// duration, using a debounce to detect when the key is released
// (key-repeat fires every ~30–50 ms, so 200 ms reliably detects release).
if (
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight'
) {
if ( ! this.keyboardGestureActive ) {
this.keyboardGestureActive = true;
this.options.onGestureStart?.();
}
clearTimeout( this.keyboardGestureTimer );
this.keyboardGestureTimer = setTimeout( () => {
this.keyboardGestureActive = false;
this.options.onGestureEnd?.();
}, KEYBOARD_GESTURE_DEBOUNCE_MS );
}

switch ( e.key ) {
case 'ArrowUp': {
e.preventDefault();
Expand Down Expand Up @@ -878,6 +906,7 @@ export class InteractionController {
cancelAnimationFrame( this.rafId );
clearTimeout( this.zoomTimer );
clearTimeout( this.wheelGestureTimer );
clearTimeout( this.keyboardGestureTimer );
this.touchCleanup?.();
this.touchCleanup = null;
this.pointerCleanup?.();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ $handle-touch-target-size: 44px;
position: absolute;
pointer-events: none;
overflow: hidden;
transition: opacity 0.2s ease;
}

&__grid-line {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ import './cropper.scss';
/** Threshold for comparing normalized crop rect values. */
const CROP_RECT_EPSILON = 1e-6;

/** How long to wait after the last interaction before fading the grid out. */
const GRID_FADE_DELAY_MS = 200;

// Largest rect of the given pixel aspect ratio that fits inside the visual
// bounds, centered in [0,1] × [0,1] normalized space. Returns a full-frame
// rect (1×1) if `aspectRatio` is unset or non-positive.
Expand Down Expand Up @@ -81,8 +84,14 @@ export interface CropperProps {
controller: UseCropperStateReturn;
/** Stencil component for the crop area. Defaults to RectangleStencil. */
stencil?: React.ComponentType< StencilProps >;
/** Show the rule-of-thirds grid overlay. */
showGrid?: boolean;
/**
* Controls the rule-of-thirds grid overlay.
* - `false` (default): grid is never shown.
* - `true`: grid is always shown.
* - `'interactive'`: grid fades in when any cropper value changes and fades
* out automatically after the user stops interacting.
*/
showGrid?: boolean | 'interactive';
/** Show the dimming overlay outside the crop area. */
showDimming?: boolean;
/** Minimum zoom level. */
Expand Down Expand Up @@ -132,7 +141,7 @@ export interface CropperProps {
* @param root0.src Image source URL.
* @param root0.controller The full state/setter object from `useCropperState`.
* @param root0.stencil Custom stencil component.
* @param root0.showGrid Show rule-of-thirds grid overlay.
* @param root0.showGrid Grid overlay mode: false | true | 'interactive'.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also like this flexibility.

* @param root0.showDimming Show dimming overlay outside crop.
* @param root0.minZoom Minimum zoom level.
* @param root0.maxZoom Maximum zoom level.
Expand Down Expand Up @@ -273,6 +282,48 @@ function CropperInner(
return getCropBounds( state, elementSize, visualSize, canvasSize );
}, [ state, elementSize, visualSize, canvasSize ] );

// Interactive grid: visible while the user is interacting, fades out
// after GRID_FADE_DELAY_MS of inactivity.
const [ gridVisible, setGridVisible ] = useState( false );
const gridFadeTimerRef = useRef< ReturnType< typeof setTimeout > >();
const gridInteractionReadyRef = useRef( false );

// Defer readiness until after image load and its dependent effects settle,
// so automatic initialisation changes don't trigger the interactive grid.
useEffect( () => {
gridInteractionReadyRef.current = false;
if ( ! state.image ) {
return;
}
const id = setTimeout( () => {
gridInteractionReadyRef.current = true;
}, 0 );
return () => clearTimeout( id );
}, [ state.image ] );

// Show the grid at the start of a canvas gesture (pan, zoom, rotate,
// stencil resize) and schedule the fade-out when the gesture ends.
// These are defined before useInteraction() so they can be passed as
// its options — they also forward to the external onGestureStart/End props.
const handleGestureStart = useCallback( () => {
if ( showGrid === 'interactive' && gridInteractionReadyRef.current ) {
clearTimeout( gridFadeTimerRef.current );
setGridVisible( true );
}
onGestureStart?.();
}, [ showGrid, onGestureStart ] );

const handleGestureEnd = useCallback( () => {
if ( showGrid === 'interactive' ) {
clearTimeout( gridFadeTimerRef.current );
gridFadeTimerRef.current = setTimeout(
() => setGridVisible( false ),
GRID_FADE_DELAY_MS
);
}
onGestureEnd?.();
}, [ showGrid, onGestureEnd ] );

// Use the interaction hook for mouse, touch, and keyboard events.
const { handlers, onWheelNative, isDragging, isZooming } = useInteraction(
state,
Expand All @@ -282,8 +333,8 @@ function CropperInner(
{
minZoom,
maxZoom,
onGestureStart,
onGestureEnd,
onGestureStart: handleGestureStart,
onGestureEnd: handleGestureEnd,
}
);

Expand Down Expand Up @@ -351,6 +402,22 @@ function CropperInner(
};
}, [] );

// Register the grid handlers with the controller so that external controls
// (e.g. the fine-rotation and zoom sliders) can trigger the grid via
// controller.notifyInteractionStart/End without needing to know about
// the Cropper's internal grid state.
useEffect( () => {
controller.registerInteractionListener( {
onStart: handleGestureStart,
onEnd: handleGestureEnd,
} );
return () => controller.registerInteractionListener( null );
}, [ controller, handleGestureStart, handleGestureEnd ] );

useEffect( () => {
return () => clearTimeout( gridFadeTimerRef.current );
}, [] );
Comment on lines +417 to +419
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this required? It's cleaned up in the useEffect above. Or just a fallback case?


/**
* Handle Escape on a resize handle — return focus to the canvas so
* arrow keys pan the image rather than resize.
Expand All @@ -365,12 +432,12 @@ function CropperInner(
const handleResizeEnd = useCallback( () => {
setSettling( true );
settleCrop();
onGestureEnd?.();
handleGestureEnd();
clearTimeout( settleTimerRef.current );
settleTimerRef.current = setTimeout( () => {
setSettling( false );
}, 200 );
}, [ settleCrop, onGestureEnd ] );
}, [ settleCrop, handleGestureEnd ] );

const imageTransition =
settling || isZooming ? 'transform 150ms linear' : undefined;
Expand Down Expand Up @@ -465,7 +532,7 @@ function CropperInner(
containerSize={ canvasSize }
imageSize={ visualSize }
onCropChange={ handleCropChange }
onResizeStart={ onGestureStart }
onResizeStart={ handleGestureStart }
onResizeEnd={ handleResizeEnd }
onEscape={ handleEscape }
aspectRatio={ aspectRatio }
Expand All @@ -475,11 +542,14 @@ function CropperInner(
/>

{ /* Rule-of-thirds grid */ }
{ showGrid && (
{ ( showGrid === true || showGrid === 'interactive' ) && (
<GridOverlay
cropRect={ state.cropRect }
containerSize={ canvasSize }
imageSize={ visualSize }
opacity={
showGrid === 'interactive' && ! gridVisible ? 0 : 1
}
/>
) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface GridOverlayProps {
containerSize: Size;
/** The rendered image dimensions in pixels within the container. */
imageSize: Size;
/** Opacity of the grid overlay (0–1). Defaults to 1. */
opacity?: number;
}

/**
Expand All @@ -25,12 +27,14 @@ interface GridOverlayProps {
* @param props.cropRect The crop rectangle in normalized coordinates.
* @param props.containerSize The container element dimensions in pixels.
* @param props.imageSize The rendered image dimensions in pixels.
* @param props.opacity Opacity of the overlay (0–1).
* @return The grid overlay element.
*/
export function GridOverlay( {
cropRect,
containerSize,
imageSize,
opacity = 1,
}: GridOverlayProps ) {
if ( containerSize.width === 0 || containerSize.height === 0 ) {
return null;
Expand All @@ -54,6 +58,7 @@ export function GridOverlay( {
top,
width,
height,
opacity,
} }
>
{ /* Horizontal lines at 1/3 and 2/3 */ }
Expand Down
Loading
Loading