diff --git a/packages/media-editor/src/components/media-editor-canvas/index.tsx b/packages/media-editor/src/components/media-editor-canvas/index.tsx index 27001d396a8b78..18106bc57e313c 100644 --- a/packages/media-editor/src/components/media-editor-canvas/index.tsx +++ b/packages/media-editor/src/components/media-editor-canvas/index.tsx @@ -44,6 +44,7 @@ export default function MediaEditorCanvas( { controller={ controller } aspectRatio={ aspectRatio } freeformCrop={ freeformCrop } + showGrid="interactive" /> ); diff --git a/packages/media-editor/src/components/media-editor-crop-panel/index.tsx b/packages/media-editor/src/components/media-editor-crop-panel/index.tsx index 817e7029092003..a0daa0821e4349 100644 --- a/packages/media-editor/src/components/media-editor-crop-panel/index.tsx +++ b/packages/media-editor/src/components/media-editor-crop-panel/index.tsx @@ -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 ?? @@ -93,26 +94,32 @@ export default function MediaEditorCropPanel( { return ( - - 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 ) - ); - } } - /> +
+ + 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 ) + ); + } } + /> +
{ reset(); @@ -117,7 +125,11 @@ export default function MediaEditorToolbar( { } ) } /> -
+
| undefined; + + /** Whether a keyboard pan gesture is currently active. */ + private keyboardGestureActive = false; + /** Current requestAnimationFrame ID. */ private rafId = 0; @@ -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(); @@ -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?.(); diff --git a/packages/media-editor/src/image-editor/react/components/cropper.scss b/packages/media-editor/src/image-editor/react/components/cropper.scss index 52e8a83d07cb26..5ff65967b26d81 100644 --- a/packages/media-editor/src/image-editor/react/components/cropper.scss +++ b/packages/media-editor/src/image-editor/react/components/cropper.scss @@ -58,6 +58,7 @@ $handle-touch-target-size: 44px; position: absolute; pointer-events: none; overflow: hidden; + transition: opacity 0.2s ease; } &__grid-line { diff --git a/packages/media-editor/src/image-editor/react/components/cropper.tsx b/packages/media-editor/src/image-editor/react/components/cropper.tsx index 4ec92dc5c7bae5..6152b2768e65eb 100644 --- a/packages/media-editor/src/image-editor/react/components/cropper.tsx +++ b/packages/media-editor/src/image-editor/react/components/cropper.tsx @@ -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. @@ -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. */ @@ -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'. * @param root0.showDimming Show dimming overlay outside crop. * @param root0.minZoom Minimum zoom level. * @param root0.maxZoom Maximum zoom level. @@ -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, @@ -282,8 +333,8 @@ function CropperInner( { minZoom, maxZoom, - onGestureStart, - onGestureEnd, + onGestureStart: handleGestureStart, + onGestureEnd: handleGestureEnd, } ); @@ -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 ); + }, [] ); + /** * Handle Escape on a resize handle — return focus to the canvas so * arrow keys pan the image rather than resize. @@ -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; @@ -465,7 +532,7 @@ function CropperInner( containerSize={ canvasSize } imageSize={ visualSize } onCropChange={ handleCropChange } - onResizeStart={ onGestureStart } + onResizeStart={ handleGestureStart } onResizeEnd={ handleResizeEnd } onEscape={ handleEscape } aspectRatio={ aspectRatio } @@ -475,11 +542,14 @@ function CropperInner( /> { /* Rule-of-thirds grid */ } - { showGrid && ( + { ( showGrid === true || showGrid === 'interactive' ) && ( ) } diff --git a/packages/media-editor/src/image-editor/react/components/overlays/grid-overlay.tsx b/packages/media-editor/src/image-editor/react/components/overlays/grid-overlay.tsx index ebbad5b3e703f3..8d9955c8158143 100644 --- a/packages/media-editor/src/image-editor/react/components/overlays/grid-overlay.tsx +++ b/packages/media-editor/src/image-editor/react/components/overlays/grid-overlay.tsx @@ -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; } /** @@ -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; @@ -54,6 +58,7 @@ export function GridOverlay( { top, width, height, + opacity, } } > { /* Horizontal lines at 1/3 and 2/3 */ } 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 a992b5ef7dc8f7..972c89dff02e0f 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 @@ -71,6 +71,22 @@ export interface UseCropperStateReturn { * try/catch if you need to recover. */ getCroppedImage: ( mimeType?: string, quality?: number ) => Promise< Blob >; + /** + * Notify the interactive grid that a user interaction has started + * (e.g. pointerdown on a slider). Call the matching `notifyInteractionEnd` + * when the interaction completes. + */ + notifyInteractionStart: () => void; + /** Notify the interactive grid that the current interaction has ended. */ + notifyInteractionEnd: () => void; + /** + * Register the Cropper's grid show/hide handlers so external controls + * can trigger them via `notifyInteractionStart`/`notifyInteractionEnd`. + * Used by the Cropper component only. + */ + registerInteractionListener: ( + listener: { onStart: () => void; onEnd: () => void } | null + ) => void; } /** @@ -201,6 +217,28 @@ export function useCropperState( const isDirty = isStateDirty( state, initialRef.current ); + // Lightweight pub/sub for the interactive grid: external controls (sliders) + // call notifyInteractionStart/End; the Cropper registers its handlers here. + const interactionListenerRef = useRef< { + onStart: () => void; + onEnd: () => void; + } | null >( null ); + + const registerInteractionListener = useCallback( + ( listener: { onStart: () => void; onEnd: () => void } | null ) => { + interactionListenerRef.current = listener; + }, + [] + ); + + const notifyInteractionStart = useCallback( () => { + interactionListenerRef.current?.onStart(); + }, [] ); + + const notifyInteractionEnd = useCallback( () => { + interactionListenerRef.current?.onEnd(); + }, [] ); + const getCroppedImage = useCallback( ( mimeType?: string, quality?: number ): Promise< Blob > => { if ( ! state.image ) { @@ -233,6 +271,9 @@ export function useCropperState( reset, isDirty, getCroppedImage, + notifyInteractionStart, + notifyInteractionEnd, + registerInteractionListener, }; return controller; } diff --git a/packages/media-editor/src/image-editor/stories/rectangle-crop.story.tsx b/packages/media-editor/src/image-editor/stories/rectangle-crop.story.tsx index 4f5840457ebab0..48977da8e318c6 100644 --- a/packages/media-editor/src/image-editor/stories/rectangle-crop.story.tsx +++ b/packages/media-editor/src/image-editor/stories/rectangle-crop.story.tsx @@ -173,6 +173,9 @@ const WithControlsComponent = () => { const [ aspectRatioValue, setAspectRatioValue ] = useState( '0' ); const [ freeformCrop, setFreeformCrop ] = useState( false ); + const [ gridMode, setGridMode ] = useState< 'off' | 'on' | 'interactive' >( + 'interactive' + ); const { src, isCustom, handleFileChange, resetToSample } = useUploadableImage(); const fileInputRef = useRef< HTMLInputElement >( null ); @@ -409,6 +412,28 @@ const WithControlsComponent = () => { onChange={ setFreeformCrop } /> + + + setGridMode( + value as 'off' | 'on' | 'interactive' + ) + } + options={ [ + { label: 'Grid: off', value: 'off' }, + { label: 'Grid: always on', value: 'on' }, + { + label: 'Grid: interactive', + value: 'interactive', + }, + ] } + /> +