{
+ dispatch( { type: 'ROTATE_90_DEG' } );
+ } }
+ />
+ );
+}
diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js
index 21b9f4bca7fc3..783eb7f00a245 100644
--- a/packages/block-editor/src/private-apis.js
+++ b/packages/block-editor/src/private-apis.js
@@ -50,6 +50,7 @@ import useBlockDisplayTitle from './components/block-title/use-block-display-tit
import TabbedSidebar from './components/tabbed-sidebar';
import CommentIconSlotFill from './components/collab/block-comment-icon-slot';
import CommentIconToolbarSlotFill from './components/collab/block-comment-icon-toolbar-slot';
+import ImageEditor from './components/image-editor/v2';
/**
* Private @wordpress/block-editor APIs.
*/
@@ -97,4 +98,5 @@ lock( privateApis, {
sectionRootClientIdKey,
CommentIconSlotFill,
CommentIconToolbarSlotFill,
+ ImageEditor,
} );
diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js
index c3783b50e0006..8ee954509d412 100644
--- a/packages/block-library/src/image/image.js
+++ b/packages/block-library/src/image/image.js
@@ -57,7 +57,11 @@ import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
import { MIN_SIZE, ALLOWED_MEDIA_TYPES } from './constants';
import { evalAspectRatio } from './utils';
-const { DimensionsTool, ResolutionTool } = unlock( blockEditorPrivateApis );
+const {
+ DimensionsTool,
+ ResolutionTool,
+ ImageEditor: ImageEditorV2,
+} = unlock( blockEditorPrivateApis );
const scaleOptions = [
{
@@ -950,21 +954,43 @@ export default function Image( {
if ( canEditImage && isEditingImage ) {
img = (
-
- setAttributes( imageAttributes )
- }
- onFinishEditing={ () => {
- setIsEditingImage( false );
- } }
- borderProps={ isRounded ? undefined : borderProps }
- />
+ { window.__experimentalImageCropper ? (
+ {
+ getSettings().mediaUpload( {
+ filesList: [ imageBlob ],
+ onFileChange: ( [ media ] ) => {
+ onSelectImage( media );
+ },
+ allowedTypes: ALLOWED_MEDIA_TYPES,
+ onError: onUploadError,
+ } );
+ setIsEditingImage( false );
+ } }
+ onCancel={ () => {
+ setIsEditingImage( false );
+ } }
+ />
+ ) : (
+
+ setAttributes( imageAttributes )
+ }
+ onFinishEditing={ () => {
+ setIsEditingImage( false );
+ } }
+ borderProps={ isRounded ? undefined : borderProps }
+ />
+ ) }
);
} else if ( ! isResizable || parentLayoutType === 'grid' ) {
diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx
new file mode 100644
index 0000000000000..b92131b2e465b
--- /dev/null
+++ b/packages/components/src/image-cropper/component.tsx
@@ -0,0 +1,351 @@
+/**
+ * External dependencies
+ */
+import type { RefObject, ReactNode, MouseEvent, TouchEvent } from 'react';
+import { useAnimate } from 'framer-motion';
+/**
+ * WordPress dependencies
+ */
+import {
+ forwardRef,
+ useContext,
+ useRef,
+ useEffect,
+ useState,
+} from '@wordpress/element';
+import { useMergeRefs } from '@wordpress/compose';
+/**
+ * Internal dependencies
+ */
+import {
+ Resizable,
+ Draggable,
+ MaxWidthWrapper,
+ Container,
+ ContainWindow,
+ Img,
+ BackgroundImg,
+} from './styles';
+import { ImageCropperContext } from './context';
+import { useImageCropper } from './hook';
+import type { Position } from './types';
+
+const RESIZING_THRESHOLDS: [ number, number ] = [ 10, 10 ]; // 10px.
+
+function CropWindow( { children }: { children: ReactNode } ) {
+ const {
+ state: {
+ transforms: { scale },
+ cropper,
+ isResizing,
+ isAspectRatioLocked,
+ },
+ refs: { cropperWindowRef },
+ dispatch,
+ } = useContext( ImageCropperContext );
+ const [ resizableScope, animate ] = useAnimate();
+ const initialMousePositionRef = useRef< Position >( { x: 0, y: 0 } );
+
+ useEffect( () => {
+ if ( resizableScope.current ) {
+ animate( [ resizableScope.current ], {
+ '--wp-cropper-window-x': '0px',
+ '--wp-cropper-window-y': '0px',
+ width: `${ cropper.width }px`,
+ height: `${ cropper.height }px`,
+ } );
+ }
+ }, [ resizableScope, animate, cropper.width, cropper.height ] );
+
+ return (
+ {
+ if ( event.type === 'mousedown' ) {
+ const mouseEvent = event as MouseEvent;
+ initialMousePositionRef.current = {
+ x: mouseEvent.clientX,
+ y: mouseEvent.clientY,
+ };
+ } else if ( event.type === 'touchstart' ) {
+ const touch = ( event as TouchEvent ).touches[ 0 ];
+ initialMousePositionRef.current = {
+ x: touch.clientX,
+ y: touch.clientY,
+ };
+ }
+ } }
+ onResize={ ( _event, direction, _element, delta ) => {
+ if ( delta.width === 0 && delta.height === 0 ) {
+ if ( Math.abs( scale.x ) === 1 ) {
+ return;
+ }
+ // let x = 0;
+ // let y = 0;
+ // if ( event.type === 'mousemove' ) {
+ // const mouseEvent = event as unknown as MouseEvent;
+ // x =
+ // mouseEvent.clientX -
+ // initialMousePositionRef.current.x;
+ // y =
+ // mouseEvent.clientY -
+ // initialMousePositionRef.current.y;
+ // } else if ( event.type === 'touchmove' ) {
+ // const touch = ( event as unknown as TouchEvent )
+ // .touches[ 0 ];
+ // x = touch.clientX - initialMousePositionRef.current.x;
+ // y = touch.clientY - initialMousePositionRef.current.y;
+ // }
+ }
+ if ( ! isResizing ) {
+ if (
+ Math.abs( delta.width ) >= RESIZING_THRESHOLDS[ 0 ] ||
+ Math.abs( delta.height ) >= RESIZING_THRESHOLDS[ 1 ]
+ ) {
+ dispatch( { type: 'RESIZE_START' } );
+ }
+ } else {
+ const x =
+ ( [ 'left', 'topLeft', 'bottomLeft' ].includes(
+ direction
+ )
+ ? -delta.width
+ : delta.width ) / 2;
+ const y =
+ ( [ 'top', 'topLeft', 'topRight' ].includes( direction )
+ ? -delta.height
+ : delta.height ) / 2;
+ animate( [ resizableScope.current ], {
+ '--wp-cropper-window-x': `${ x }px`,
+ '--wp-cropper-window-y': `${ y }px`,
+ width: `${ cropper.width + delta.width }px`,
+ height: `${ cropper.height + delta.height }px`,
+ } ).complete();
+ }
+ } }
+ onResizeStop={ ( _event, direction, _element, delta ) => {
+ dispatch( { type: 'RESIZE_WINDOW', direction, delta } );
+ } }
+ ref={ resizableScope }
+ >
+ }>
+ { children }
+
+
+ );
+}
+
+const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => {
+ const {
+ state: {
+ image,
+ transforms: { rotate, scale, translate },
+ cropper,
+ isAxisSwapped,
+ isResizing,
+ isDragging,
+ isZooming,
+ },
+ originalWidth,
+ originalHeight,
+ src,
+ refs: { imageRef },
+ dispatch,
+ } = useContext( ImageCropperContext );
+
+ const maxWidthWrapperRef = useRef< HTMLDivElement >( null! );
+ useEffect( () => {
+ const maxWidthWrapper = maxWidthWrapperRef.current;
+
+ const resizeObserver = new ResizeObserver( ( [ entry ] ) => {
+ const [ { inlineSize } ] = entry.contentBoxSize;
+ const originalInlineSize = isAxisSwapped
+ ? originalHeight
+ : originalWidth;
+
+ dispatch( {
+ type: 'RESIZE_CONTAINER',
+ width:
+ inlineSize < originalInlineSize
+ ? inlineSize
+ : originalInlineSize,
+ } );
+ } );
+
+ resizeObserver.observe( maxWidthWrapper );
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [ dispatch, originalWidth, originalHeight, isAxisSwapped ] );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+} );
+
+function ImageCropperProvider( {
+ src,
+ width,
+ height,
+ children,
+}: {
+ src: string;
+ width: number;
+ height: number;
+ children: ReactNode;
+} ) {
+ const context = useImageCropper( { src, width, height } );
+ return (
+
+ { children }
+
+ );
+}
+
+function ImageCropperAutoSizer( {
+ src,
+ width,
+ height,
+ children,
+}: {
+ src: string;
+ width?: number;
+ height?: number;
+ children: ReactNode;
+} ) {
+ const [ size, setSize ] = useState< { width: number; height: number } >( {
+ width: width || 0,
+ height: height || 0,
+ } );
+
+ useEffect( () => {
+ const image = new Image();
+ image.src = src;
+ if ( width ) {
+ image.width = width;
+ }
+ if ( height ) {
+ image.height = height;
+ }
+
+ function onLoadImage() {
+ setSize( ( currentSize ) => {
+ if (
+ image.width === currentSize.width &&
+ image.height === currentSize.height
+ ) {
+ return currentSize;
+ }
+ return { width: image.width, height: image.height };
+ } );
+ }
+
+ if ( image.complete ) {
+ onLoadImage();
+ }
+
+ image.addEventListener( 'load', onLoadImage, false );
+
+ return () => {
+ image.removeEventListener( 'load', onLoadImage );
+ };
+ }, [ src, width, height ] );
+
+ if ( ! size.width || ! size.height ) {
+ return null;
+ }
+
+ return (
+
+ { children }
+
+ );
+}
+
+const ImageCropper = Object.assign( Cropper, {
+ Provider: ImageCropperAutoSizer,
+} );
+
+export { ImageCropper };
diff --git a/packages/components/src/image-cropper/context.ts b/packages/components/src/image-cropper/context.ts
new file mode 100644
index 0000000000000..d42f0544f6cb4
--- /dev/null
+++ b/packages/components/src/image-cropper/context.ts
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { createContext } from '@wordpress/element';
+/**
+ * Internal dependencies
+ */
+import type { useImageCropper } from './hook';
+
+export const ImageCropperContext = createContext<
+ ReturnType< typeof useImageCropper >
+>( null! );
diff --git a/packages/components/src/image-cropper/hook.ts b/packages/components/src/image-cropper/hook.ts
new file mode 100644
index 0000000000000..a92bd120c98f9
--- /dev/null
+++ b/packages/components/src/image-cropper/hook.ts
@@ -0,0 +1,169 @@
+/**
+ * External dependencies
+ */
+import {
+ createUseGesture,
+ dragAction,
+ pinchAction,
+ wheelAction,
+} from '@use-gesture/react';
+/**
+ * WordPress dependencies
+ */
+import { useRef, useMemo, useReducer, useCallback } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { imageCropperReducer, createInitialState } from './reducer';
+import type { State } from './reducer';
+
+const useGesture = createUseGesture( [ dragAction, pinchAction, wheelAction ] );
+
+export const useImageCropper = ( {
+ src,
+ width,
+ height,
+}: {
+ src: string;
+ width: number;
+ height: number;
+} ) => {
+ const imageRef = useRef< HTMLImageElement >( null! );
+ const cropperWindowRef = useRef< HTMLElement >( null! );
+
+ const [ state, dispatch ] = useReducer(
+ imageCropperReducer,
+ { width, height },
+ createInitialState
+ );
+
+ useGesture(
+ {
+ onPinch: ( {
+ origin: [ originX, originY ],
+ offset: [ scale ],
+ movement: [ deltaScale ],
+ memo,
+ first,
+ } ) => {
+ if ( first ) {
+ const {
+ width: imageWidth,
+ height: imageHeight,
+ x,
+ y,
+ } = imageRef.current.getBoundingClientRect();
+ // Save the initial position and distances from the origin.
+ memo = {
+ initial: state.transforms.translate,
+ distances: {
+ x: originX - ( x + imageWidth / 2 ),
+ y: originY - ( y + imageHeight / 2 ),
+ },
+ };
+ }
+ dispatch( {
+ type: 'ZOOM',
+ scale,
+ // Calculate the new position based on the scale from the origin.
+ position: {
+ x:
+ memo.initial.x -
+ ( deltaScale - 1 ) * memo.distances.x,
+ y:
+ memo.initial.y -
+ ( deltaScale - 1 ) * memo.distances.y,
+ },
+ } );
+ return memo;
+ },
+ onPinchEnd: () => {
+ dispatch( { type: 'ZOOM_END' } );
+ },
+ onDrag: ( { offset: [ x, y ] } ) => {
+ dispatch( { type: 'MOVE', x, y } );
+ },
+ onDragEnd: () => {
+ dispatch( { type: 'MOVE_END' } );
+ },
+ },
+ {
+ target: cropperWindowRef,
+ pinch: {
+ scaleBounds: { min: 1, max: 10 },
+ from: () => [ Math.abs( state.transforms.scale.x ), 0 ],
+ },
+ drag: {
+ from: () => [
+ state.transforms.translate.x,
+ state.transforms.translate.y,
+ ],
+ },
+ }
+ );
+
+ const getImageBlob = useCallback( async ( cropperState: State ) => {
+ const image = imageRef.current;
+ const { naturalWidth, naturalHeight } = image;
+ const scaleFactor = naturalWidth / cropperState.image.width;
+ const offscreenCanvas = new OffscreenCanvas(
+ cropperState.cropper.width * scaleFactor,
+ cropperState.cropper.height * scaleFactor
+ );
+ const ctx = offscreenCanvas.getContext( '2d' )!;
+ ctx.translate(
+ cropperState.transforms.translate.x * scaleFactor +
+ offscreenCanvas.width / 2,
+ cropperState.transforms.translate.y * scaleFactor +
+ offscreenCanvas.height / 2
+ );
+ ctx.rotate( cropperState.transforms.rotate );
+ ctx.scale(
+ cropperState.transforms.scale.x,
+ cropperState.transforms.scale.y
+ );
+ const isAxisSwapped = cropperState.isAxisSwapped;
+ const imageDimensions = {
+ width: isAxisSwapped
+ ? cropperState.image.height
+ : cropperState.image.width,
+ height: isAxisSwapped
+ ? cropperState.image.width
+ : cropperState.image.height,
+ };
+ ctx.translate(
+ -( ( imageDimensions.width - cropperState.cropper.width ) / 2 ) *
+ scaleFactor,
+ -( ( imageDimensions.height - cropperState.cropper.height ) / 2 ) *
+ scaleFactor
+ );
+ const imageOffset = {
+ x: isAxisSwapped ? ( naturalHeight - naturalWidth ) / 2 : 0,
+ y: isAxisSwapped ? ( naturalWidth - naturalHeight ) / 2 : 0,
+ };
+ ctx.drawImage(
+ image,
+ -offscreenCanvas.width / 2 + imageOffset.x,
+ -offscreenCanvas.height / 2 + imageOffset.y
+ );
+ const blob = await offscreenCanvas.convertToBlob();
+ return blob;
+ }, [] );
+
+ return useMemo(
+ () => ( {
+ state,
+ src,
+ originalWidth: width,
+ originalHeight: height,
+ refs: {
+ imageRef,
+ cropperWindowRef,
+ },
+ dispatch,
+ getImageBlob,
+ } ),
+ [ state, src, width, height, getImageBlob ]
+ );
+};
diff --git a/packages/components/src/image-cropper/index.ts b/packages/components/src/image-cropper/index.ts
new file mode 100644
index 0000000000000..58475cb129cba
--- /dev/null
+++ b/packages/components/src/image-cropper/index.ts
@@ -0,0 +1,3 @@
+export { ImageCropper } from './component';
+export { useImageCropper } from './hook';
+export { ImageCropperContext } from './context';
diff --git a/packages/components/src/image-cropper/math.ts b/packages/components/src/image-cropper/math.ts
new file mode 100644
index 0000000000000..7d78586d95a6f
--- /dev/null
+++ b/packages/components/src/image-cropper/math.ts
@@ -0,0 +1,133 @@
+/**
+ * External dependencies
+ */
+import memize from 'memize';
+/**
+ * Internal dependencies
+ */
+import type { Position } from './types';
+
+/**
+ * The conversion factor from degrees to radians.
+ */
+export const DEGREE_TO_RADIAN = Math.PI / 180;
+
+/**
+ * 90 degrees in radians.
+ */
+export const PI_OVER_TWO = Math.PI / 2;
+
+/**
+ * Rotate a point around a center point by a given degree.
+ * @param point The point to rotate.
+ * @param radian The radian to rotate by.
+ * @param center The optional center point to rotate around. If not provided then the origin is used.
+ */
+export function rotatePoint(
+ point: Position,
+ radian: number,
+ center: Position = { x: 0, y: 0 }
+): Position {
+ const cos = Math.cos( radian );
+ const sin = Math.sin( radian );
+ const dx = point.x - center.x;
+ const dy = point.y - center.y;
+ return {
+ x: center.x + dx * cos - dy * sin,
+ y: center.y + dx * sin + dy * cos,
+ };
+}
+
+/**
+ * Convert degree to radian.
+ * @param degree The degree to convert.
+ */
+export function degreeToRadian( degree: number ): number {
+ return degree * DEGREE_TO_RADIAN;
+}
+
+export const calculateRotatedBounds = memize(
+ (
+ radian: number,
+ imageWidth: number,
+ imageHeight: number,
+ cropperWidth: number,
+ cropperHeight: number
+ ) => {
+ // Calculate half dimensions of the image and cropper.
+ const halfImageWidth = imageWidth / 2;
+ const halfImageHeight = imageHeight / 2;
+ const halfCropperWidth = cropperWidth / 2;
+ const halfCropperHeight = cropperHeight / 2;
+
+ // Calculate absolute values of sin and cos for the rotation angle.
+ // This works for all angles due to the periodicity of sine and cosine.
+ const sin = Math.abs( Math.sin( radian ) );
+ const cos = Math.abs( Math.cos( radian ) );
+
+ // Calculate the dimensions of the rotated rectangle's bounding box.
+ // This formula works for all angles because it considers the maximum extent
+ // of the rotated rectangle in each direction.
+ const rotatedWidth = halfCropperWidth * cos + halfCropperHeight * sin;
+ const rotatedHeight = halfCropperHeight * cos + halfCropperWidth * sin;
+
+ // Calculate the boundaries of the area where the cropper can move.
+ // These boundaries ensure the cropper stays within the image.
+ const minX = -halfImageWidth + rotatedWidth;
+ const maxX = halfImageWidth - rotatedWidth;
+ const minY = -halfImageHeight + rotatedHeight;
+ const maxY = halfImageHeight - rotatedHeight;
+
+ return {
+ minX,
+ maxX,
+ minY,
+ maxY,
+ };
+ },
+ { maxSize: 1 }
+);
+
+export const getMinScale = memize(
+ (
+ radian: number,
+ imageWidth: number,
+ imageHeight: number,
+ cropperWidth: number,
+ cropperHeight: number,
+ imageX: number,
+ imageY: number
+ ) => {
+ // Calculate the boundaries of the area where the cropper can move.
+ // These boundaries ensure the cropper stays within the image.
+ const { minX, maxX, minY, maxY } = calculateRotatedBounds(
+ radian,
+ imageWidth,
+ imageHeight,
+ cropperWidth,
+ cropperHeight
+ );
+
+ // Rotate the image center to align with the rotated coordinate system.
+ const rotatedPoint = rotatePoint( { x: imageX, y: imageY }, -radian );
+
+ // Calculate the maximum distances the cropper can move from the current position.
+ const maxDistanceX = Math.max(
+ minX - rotatedPoint.x,
+ rotatedPoint.x - maxX,
+ 0
+ );
+ const maxDistanceY = Math.max(
+ minY - rotatedPoint.y,
+ rotatedPoint.y - maxY,
+ 0
+ );
+
+ // Calculate the minimum scales that fit the cropper within the image.
+ const widthScale = ( maxDistanceX * 2 + imageWidth ) / imageWidth;
+ const heightScale = ( maxDistanceY * 2 + imageHeight ) / imageHeight;
+
+ return Math.max( widthScale, heightScale );
+ },
+ { maxSize: 1 }
+);
diff --git a/packages/components/src/image-cropper/reducer.ts b/packages/components/src/image-cropper/reducer.ts
new file mode 100644
index 0000000000000..3959868a4db80
--- /dev/null
+++ b/packages/components/src/image-cropper/reducer.ts
@@ -0,0 +1,529 @@
+/**
+ * Internal dependencies
+ */
+import type { ResizeDirection, Position } from './types';
+import {
+ rotatePoint,
+ degreeToRadian,
+ calculateRotatedBounds,
+ getMinScale,
+ PI_OVER_TWO,
+} from './math';
+
+export type State = {
+ /** The image dimensions. */
+ image: {
+ /** The width of the image. */
+ width: number;
+ /** The height of the image. */
+ height: number;
+ };
+ /** The image transforms. */
+ transforms: {
+ /** The rotation angle of the image in radians. */
+ rotate: number;
+ /** The image scale. */
+ scale: { x: number; y: number };
+ /** Position of the image relative to the container in pixels. */
+ translate: { x: number; y: number };
+ };
+ /** The cropper window dimensions. */
+ cropper: {
+ /** The width of the cropper window. */
+ width: number;
+ /** The height of the cropper window */
+ height: number;
+ };
+ /** The tilt angle in degrees from -45 to 45 for UI state. */
+ tilt: number;
+ /** Whether the image axis is swapped from 90 degree turns. */
+ isAxisSwapped: boolean;
+ /** Whether the cropper window aspect ratio is locked. */
+ isAspectRatioLocked: boolean;
+ /** Whether the cropper window is resizing. */
+ isResizing: boolean;
+ /** Whether the image is dragging/moving. */
+ isDragging: boolean;
+ /** Whether the image is zooming/pinching. */
+ isZooming: boolean;
+};
+
+/** Zoom in/out to a scale. */
+type ZoomAction = {
+ /** Zoom type action. */
+ type: 'ZOOM';
+ /** Zoom scale. */
+ scale: number;
+ /** Zoom position. */
+ position: Position;
+};
+
+/** End zooming. */
+type ZoomEndAction = {
+ /** Zoom end type action. */
+ type: 'ZOOM_END';
+};
+
+/** Flip the image horizontally. */
+type FlipAction = {
+ /** Flip type action. */
+ type: 'FLIP';
+};
+
+/** Set the image tilt to an angle. */
+type SetTiltAction = {
+ /** Rotate type action. */
+ type: 'SET_TILT';
+ /** Angle in degrees from -45 to 45. */
+ tilt: number;
+};
+
+/** Rotate the image 90-degree clockwise or counter-clockwise. */
+type Rotate90DegAction = {
+ /** Rotate clockwise type action. */
+ type: 'ROTATE_90_DEG';
+ /** Whether to rotate counter-clockwise instead. */
+ isCounterClockwise?: boolean;
+};
+
+/** Move the image to a position. */
+type MoveAction = {
+ /** Move type action. */
+ type: 'MOVE';
+ /** Move x position. */
+ x: number;
+ /** Move y position. */
+ y: number;
+};
+
+/** End moving the image. */
+type MoveEndAction = {
+ /** Move end type action. */
+ type: 'MOVE_END';
+};
+
+/** Start resizing the cropper window. */
+type ResizeStartAction = {
+ /** Resize start type action. */
+ type: 'RESIZE_START';
+};
+
+/** Resize the cropper window by a delta size in a direction. */
+type ResizeWindowAction = {
+ /** Resize window type action. */
+ type: 'RESIZE_WINDOW';
+ /** Resize direction. */
+ direction: ResizeDirection;
+ /** Change in size. */
+ delta: {
+ /** Change in width. */
+ width: number;
+ /** Change in height. */
+ height: number;
+ };
+};
+
+/** Resize the container and image to a new width. */
+type ResizeContainerAction = {
+ /** Resize container type action. */
+ type: 'RESIZE_CONTAINER';
+ /** New width of the container. */
+ width: number;
+};
+
+/** Reset the state to the initial state. */
+type ResetAction = {
+ /** Reset type action. */
+ type: 'RESET';
+};
+
+/** Lock the aspect ratio of the cropper window. */
+type lockAspectRatioAction = {
+ /** Lock aspect ratio type action. */
+ type: 'LOCK_ASPECT_RATIO';
+ /** Aspect ratio to lock. */
+ aspectRatio: number;
+};
+
+/** Unlock the aspect ratio of the cropper window. */
+type unlockAspectRatioAction = {
+ /** Unlock aspect ratio type action. */
+ type: 'UNLOCK_ASPECT_RATIO';
+};
+
+/** All possible actions. */
+type Action =
+ | ZoomAction
+ | ZoomEndAction
+ | FlipAction
+ | SetTiltAction
+ | Rotate90DegAction
+ | MoveAction
+ | MoveEndAction
+ | ResizeStartAction
+ | ResizeWindowAction
+ | ResizeContainerAction
+ | ResetAction
+ | lockAspectRatioAction
+ | unlockAspectRatioAction;
+
+function createInitialState( {
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+} ): State {
+ return {
+ image: {
+ width,
+ height,
+ },
+ transforms: {
+ rotate: 0,
+ scale: { x: 1, y: 1 },
+ translate: { x: 0, y: 0 },
+ },
+ cropper: {
+ width,
+ height,
+ },
+ tilt: 0,
+ isAxisSwapped: false,
+ isAspectRatioLocked: false,
+ isResizing: false,
+ isDragging: false,
+ isZooming: false,
+ };
+}
+
+function imageCropperReducer( state: State, action: Action ): State {
+ const {
+ image,
+ transforms: { rotate, scale, translate },
+ cropper,
+ isAxisSwapped,
+ } = state;
+ switch ( action.type ) {
+ case 'ZOOM': {
+ const minScale = getMinScale(
+ rotate,
+ image.width,
+ image.height,
+ cropper.width,
+ cropper.height,
+ translate.x,
+ translate.y
+ );
+ const nextScale = Math.min(
+ Math.max( action.scale, minScale ),
+ 10
+ );
+
+ return {
+ ...state,
+ transforms: {
+ ...state.transforms,
+ translate: action.position,
+ scale: {
+ x: nextScale * Math.sign( scale.x ),
+ y: nextScale * Math.sign( scale.y ),
+ },
+ },
+ isZooming: true,
+ };
+ }
+ case 'ZOOM_END': {
+ return {
+ ...state,
+ isZooming: false,
+ };
+ }
+ case 'FLIP': {
+ return {
+ ...state,
+ transforms: {
+ ...state.transforms,
+ rotate: rotate + Math.PI,
+ scale: {
+ x: isAxisSwapped ? -scale.x : scale.x,
+ y: isAxisSwapped ? scale.y : -scale.y,
+ },
+ },
+ };
+ }
+ case 'SET_TILT': {
+ const radian = degreeToRadian( state.tilt );
+ const nextRadian = degreeToRadian( action.tilt );
+ const nextRotate = rotate - radian + nextRadian;
+ const absScale = Math.abs( scale.x );
+ const scaledWidth = image.width * absScale;
+ const scaledHeight = image.height * absScale;
+
+ // Calculate the translation of the image center after the rotation.
+ // This is needed to rotate from the center of the cropper rather than the
+ // center of the image.
+ const rotatedPosition = rotatePoint(
+ translate,
+ nextRotate - rotate
+ );
+
+ // Calculate the minimum scale to fit the image within the cropper.
+ // TODO: Optimize the performance?
+ const minScale =
+ getMinScale(
+ nextRotate,
+ scaledWidth,
+ scaledHeight,
+ cropper.width,
+ cropper.height,
+ rotatedPosition.x,
+ rotatedPosition.y
+ ) * absScale;
+ const nextScale = Math.min( Math.max( absScale, minScale ), 10 );
+
+ return {
+ ...state,
+ transforms: {
+ ...state.transforms,
+ rotate: nextRotate,
+ translate: rotatedPosition,
+ scale: {
+ x: nextScale * Math.sign( scale.x ),
+ y: nextScale * Math.sign( scale.y ),
+ },
+ },
+ tilt: action.tilt,
+ };
+ }
+ case 'ROTATE_90_DEG': {
+ const angle = action.isCounterClockwise
+ ? -PI_OVER_TWO
+ : PI_OVER_TWO;
+ const rotatedPosition = rotatePoint( translate, angle );
+ return {
+ ...state,
+ transforms: {
+ ...state.transforms,
+ translate: rotatedPosition,
+ rotate: rotate + angle,
+ },
+ cropper: {
+ ...state.cropper,
+ width: cropper.height,
+ height: cropper.width,
+ },
+ isAxisSwapped: ! isAxisSwapped,
+ };
+ }
+ case 'MOVE': {
+ const absScale = Math.abs( scale.x );
+
+ // Calculate the boundaries of the area where the cropper can move.
+ // These boundaries ensure the cropper stays within the image.
+ const { minX, maxX, minY, maxY } = calculateRotatedBounds(
+ rotate,
+ image.width * absScale,
+ image.height * absScale,
+ cropper.width,
+ cropper.height
+ );
+
+ // Rotate the action point to align with the non-rotated coordinate system.
+ const rotatedPoint = rotatePoint(
+ { x: action.x, y: action.y },
+ -rotate
+ );
+
+ // Constrain the rotated point to within the calculated boundaries.
+ // This ensures the cropper doesn't move outside the image.
+ const boundPoint = {
+ x: Math.min( Math.max( rotatedPoint.x, minX ), maxX ),
+ y: Math.min( Math.max( rotatedPoint.y, minY ), maxY ),
+ };
+
+ // Rotate the constrained point back to the original coordinate system.
+ const nextPosition = rotatePoint( boundPoint, rotate );
+
+ return {
+ ...state,
+ transforms: {
+ ...state.transforms,
+ translate: nextPosition,
+ },
+ isDragging: true,
+ };
+ }
+ case 'MOVE_END': {
+ return {
+ ...state,
+ isDragging: false,
+ };
+ }
+ case 'RESIZE_START': {
+ return {
+ ...state,
+ isResizing: true,
+ };
+ }
+ case 'RESIZE_WINDOW': {
+ const { direction, delta } = action;
+
+ // Calculate the new size of the cropper.
+ const newSize = {
+ width: cropper.width + delta.width,
+ height: cropper.height + delta.height,
+ };
+
+ // Determine the actual dimensions of the image, considering rotations.
+ const imageDimensions = {
+ width: isAxisSwapped ? image.height : image.width,
+ height: isAxisSwapped ? image.width : image.height,
+ };
+
+ // Calculate the scale of the image to fit within the new size.
+ const widthScale = imageDimensions.width / newSize.width;
+ const heightScale = imageDimensions.height / newSize.height;
+ const windowScale = Math.min( widthScale, heightScale );
+ const absScale = Math.abs( scale.x );
+ const nextScale = absScale * windowScale;
+
+ const scaledSize = {
+ width: imageDimensions.width,
+ height: imageDimensions.height,
+ };
+ const translated = { x: 0, y: 0 };
+ // Adjust scaled size and translation based on which dimension is limiting.
+ // We do this instead of multiplying by windowScale to account for floating point errors.
+ if ( widthScale === windowScale ) {
+ scaledSize.height = newSize.height * windowScale;
+ translated.y =
+ imageDimensions.height / 2 - scaledSize.height / 2;
+ } else {
+ scaledSize.width = newSize.width * windowScale;
+ translated.x = imageDimensions.width / 2 - scaledSize.width / 2;
+ }
+
+ // Calculate the delta for the image in each direction.
+ const deltaX = [ 'left', 'bottomLeft', 'topLeft' ].includes(
+ direction
+ )
+ ? delta.width
+ : -delta.width;
+ const deltaY = [ 'top', 'topLeft', 'topRight' ].includes(
+ direction
+ )
+ ? delta.height
+ : -delta.height;
+
+ return {
+ ...state,
+ transforms: {
+ ...state.transforms,
+ translate: {
+ x: ( translate.x + deltaX / 2 ) * windowScale,
+ y: ( translate.y + deltaY / 2 ) * windowScale,
+ },
+ scale: {
+ x: nextScale * Math.sign( scale.x ),
+ y: nextScale * Math.sign( scale.y ),
+ },
+ },
+ cropper: {
+ ...state.cropper,
+ width: scaledSize.width,
+ height: scaledSize.height,
+ },
+ isResizing: false,
+ };
+ }
+ case 'LOCK_ASPECT_RATIO': {
+ // Calculate the size of the cropper based on the aspect ratio.
+ const largerDimension = Math.max( image.width, image.height );
+ const cropperSize =
+ action.aspectRatio > 1
+ ? {
+ width: largerDimension,
+ height: largerDimension / action.aspectRatio,
+ }
+ : {
+ width: largerDimension * action.aspectRatio,
+ height: largerDimension,
+ };
+
+ const minScale = getMinScale(
+ rotate,
+ image.width,
+ image.height,
+ cropperSize.width,
+ cropperSize.height,
+ translate.x,
+ translate.y
+ );
+ const absScale = Math.abs( scale.x );
+ const nextScale = Math.min( Math.max( absScale, minScale ), 10 );
+
+ return {
+ ...state,
+ transforms: {
+ ...state.transforms,
+ scale: {
+ x: nextScale * Math.sign( scale.x ),
+ y: nextScale * Math.sign( scale.y ),
+ },
+ },
+ cropper: {
+ ...state.cropper,
+ ...cropperSize,
+ },
+ isAspectRatioLocked: true,
+ };
+ }
+ case 'UNLOCK_ASPECT_RATIO': {
+ return {
+ ...state,
+ isAspectRatioLocked: false,
+ };
+ }
+ case 'RESIZE_CONTAINER': {
+ const imageInlineSize = isAxisSwapped ? image.height : image.width;
+ const ratio = action.width / imageInlineSize;
+
+ if ( ratio === 1 ) {
+ return state;
+ }
+
+ return {
+ ...state,
+ image: {
+ ...state.image,
+ width: image.width * ratio,
+ height: image.height * ratio,
+ },
+ cropper: {
+ ...state.cropper,
+ width: cropper.width * ratio,
+ height: cropper.height * ratio,
+ },
+ transforms: {
+ ...state.transforms,
+ translate: {
+ x: translate.x * ratio,
+ y: translate.y * ratio,
+ },
+ },
+ };
+ }
+ case 'RESET': {
+ return createInitialState( {
+ width: image.width,
+ height: image.height,
+ } );
+ }
+ default: {
+ throw new Error( 'Unknown action' );
+ }
+ }
+}
+
+export { createInitialState, imageCropperReducer };
diff --git a/packages/components/src/image-cropper/stories/index.story.tsx b/packages/components/src/image-cropper/stories/index.story.tsx
new file mode 100644
index 0000000000000..6688a2b4a1367
--- /dev/null
+++ b/packages/components/src/image-cropper/stories/index.story.tsx
@@ -0,0 +1,234 @@
+/**
+ * External dependencies
+ */
+import type { Meta, StoryObj } from '@storybook/react';
+import type { ComponentProps } from 'react';
+/**
+ * WordPress dependencies
+ */
+import { useState, useContext } from '@wordpress/element';
+import { aspectRatio } from '@wordpress/icons';
+/**
+ * Internal dependencies
+ */
+import { ImageCropper, ImageCropperContext } from '../';
+import { Button, RangeControl, Flex, FlexItem, DropdownMenu } from '../../';
+
+const meta: Meta< typeof ImageCropper.Provider > = {
+ component: ImageCropper.Provider,
+ title: 'Components/ImageCropper',
+ argTypes: {
+ src: { control: { type: 'text' } },
+ width: { control: { type: 'number' } },
+ height: { control: { type: 'number' } },
+ },
+ parameters: {
+ controls: { expanded: true },
+ },
+};
+export default meta;
+
+function Controls() {
+ const { state, dispatch } = useContext( ImageCropperContext );
+ return (
+
+
+ {
+ dispatch( {
+ type: 'SET_TILT',
+ tilt: Number( value ),
+ } );
+ } }
+ />
+
+
+
+
+
+ ( {
+ title: control.title,
+ role: 'menuitemradio',
+ icon: aspectRatio,
+ isActive: state.isAspectRatioLocked
+ ? state.cropper.width / state.cropper.height ===
+ control.value
+ : 0 === control.value,
+ onClick: () => {
+ if ( control.value === 0 ) {
+ dispatch( {
+ type: 'UNLOCK_ASPECT_RATIO',
+ } );
+ } else {
+ dispatch( {
+ type: 'LOCK_ASPECT_RATIO',
+ aspectRatio: control.value,
+ } );
+ }
+ },
+ } ) ) }
+ />
+
+ );
+}
+
+function Apply( {
+ previewUrl,
+ setPreviewUrl,
+}: {
+ previewUrl: string;
+ setPreviewUrl: ( url: string ) => void;
+} ) {
+ const { state, dispatch, getImageBlob } = useContext( ImageCropperContext );
+
+ return (
+
+
+
+
+ );
+}
+
+function Preview( { previewUrl }: { previewUrl: string } ) {
+ const { state } = useContext( ImageCropperContext );
+ return previewUrl ? (
+
+
+
+ ) : null;
+}
+
+function StateLogger() {
+ const { state } = useContext( ImageCropperContext );
+ return (
+
+ { JSON.stringify( state, null, 2 ) }
+
+ );
+}
+
+export const Inline: StoryObj< typeof ImageCropper.Provider > = (
+ args: ComponentProps< typeof ImageCropper.Provider >
+) => {
+ const [ previewUrl, setPreviewUrl ] = useState< string >( '' );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+Inline.args = {
+ src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Hydrochoeris_hydrochaeris_in_Brazil_in_Petr%C3%B3polis%2C_Rio_de_Janeiro%2C_Brazil_09.jpg/1200px-Hydrochoeris_hydrochaeris_in_Brazil_in_Petr%C3%B3polis%2C_Rio_de_Janeiro%2C_Brazil_09.jpg',
+ width: 300,
+ height: 200,
+};
+
+export const Framed: StoryObj< typeof ImageCropper.Provider > = (
+ args: ComponentProps< typeof ImageCropper.Provider >
+) => {
+ const [ previewUrl, setPreviewUrl ] = useState< string >( '' );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+Framed.args = {
+ src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Hydrochoeris_hydrochaeris_in_Brazil_in_Petr%C3%B3polis%2C_Rio_de_Janeiro%2C_Brazil_09.jpg/1200px-Hydrochoeris_hydrochaeris_in_Brazil_in_Petr%C3%B3polis%2C_Rio_de_Janeiro%2C_Brazil_09.jpg',
+ width: 300,
+ height: 200,
+};
diff --git a/packages/components/src/image-cropper/styles.tsx b/packages/components/src/image-cropper/styles.tsx
new file mode 100644
index 0000000000000..9f9bf1a5904f5
--- /dev/null
+++ b/packages/components/src/image-cropper/styles.tsx
@@ -0,0 +1,153 @@
+/**
+ * External dependencies
+ */
+import styled from '@emotion/styled';
+import { motion } from 'framer-motion';
+import type { ComponentProps } from 'react';
+/**
+ * WordPress dependencies
+ */
+import { forwardRef } from '@wordpress/element';
+/**
+ * Internal dependencies
+ */
+import ResizableBox from '../resizable-box';
+
+export const Draggable = styled.div`
+ position: absolute;
+ inset: 0;
+ cursor: move;
+ touch-action: none;
+ overscroll-behavior: none;
+`;
+
+const MotionResizable = motion(
+ forwardRef< HTMLDivElement, ComponentProps< typeof ResizableBox > >(
+ ( props, ref ) => {
+ const updateRef = ( element: HTMLDivElement | null ) => {
+ if ( typeof ref === 'function' ) {
+ ref( element );
+ } else if ( ref ) {
+ ref.current = element;
+ }
+ };
+
+ return (
+ {
+ updateRef(
+ resizable?.resizable as HTMLDivElement | null
+ );
+ } }
+ />
+ );
+ }
+ )
+);
+
+export const Resizable = styled( MotionResizable )`
+ /* --wp-cropper-window-x: 0px;
+ --wp-cropper-window-y: 0px; */
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform-origin: center center;
+ translate: calc( var( --wp-cropper-window-x ) - 50% )
+ calc( var( --wp-cropper-window-y ) - 50% );
+ will-change: translate;
+ contain: layout size style;
+
+ &:active {
+ &::after,
+ &::before,
+ ${ Draggable }::after, ${ Draggable }::before {
+ content: ' ';
+ position: absolute;
+ display: block;
+ width: 1px;
+ height: 100%;
+ overflow: hidden;
+ left: 33.33%;
+ background: rgba( 255, 255, 255, 0.33 );
+ }
+
+ &::before {
+ right: 33.33%;
+ left: auto;
+ }
+
+ ${ Draggable }::before {
+ left: auto;
+ width: 100%;
+ height: 1px;
+ top: 33.33%;
+ }
+
+ ${ Draggable }::after {
+ left: auto;
+ width: 100%;
+ height: 1px;
+ bottom: 33.33%;
+ top: auto;
+ }
+ }
+`;
+
+export const MaxWidthWrapper = styled.div`
+ position: relative;
+ max-width: 100%;
+ min-width: 0;
+`;
+
+export const Container = styled( motion.div )`
+ position: relative;
+ display: flex;
+ box-sizing: border-box;
+`;
+
+export const ContainWindow = styled.div`
+ position: absolute;
+ inset: 0;
+ contain: strict;
+ overflow: hidden;
+`;
+
+export const Img = styled( motion.img )`
+ // Using a "namespace" ID to increase CSS specificity for this component.
+ #components-image-cropper & {
+ position: absolute;
+ pointer-events: none;
+ top: 50%;
+ left: 50%;
+ transform-origin: center center;
+ rotate: var( --wp-cropper-angle );
+ scale: var( --wp-cropper-scale-x ) var( --wp-cropper-scale-y );
+ translate: calc(
+ var( --wp-cropper-image-x ) - var( --wp-cropper-window-x ) - 50%
+ )
+ calc(
+ var( --wp-cropper-image-y ) - var( --wp-cropper-window-y ) - 50%
+ );
+ will-change: rotate, scale, translate;
+ contain: strict;
+ max-width: none;
+ }
+`;
+
+export const BackgroundImg = styled( Img, {
+ shouldForwardProp: ( propName: string ) =>
+ propName !== 'isResizing' && propName !== 'isDragging',
+} )< {
+ isResizing: boolean;
+ isDragging: boolean;
+} >`
+ filter: ${ ( props ) =>
+ props.isResizing || props.isDragging ? 'none' : 'blur( 5px )' };
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+
+ ${ Container }:hover & {
+ opacity: 0.5;
+ }
+`;
diff --git a/packages/components/src/image-cropper/types.ts b/packages/components/src/image-cropper/types.ts
new file mode 100644
index 0000000000000..26f6ce1ef8516
--- /dev/null
+++ b/packages/components/src/image-cropper/types.ts
@@ -0,0 +1,9 @@
+/**
+ * External dependencies
+ */
+export type { ResizeDirection } from 're-resizable';
+
+export type Position = {
+ x: number;
+ y: number;
+};
diff --git a/packages/components/src/private-apis.ts b/packages/components/src/private-apis.ts
index f5a9ee90519c2..cef49ec130f7e 100644
--- a/packages/components/src/private-apis.ts
+++ b/packages/components/src/private-apis.ts
@@ -9,6 +9,7 @@ import { Tabs } from './tabs';
import { kebabCase } from './utils/strings';
import { lock } from './lock-unlock';
import Badge from './badge';
+import { ImageCropper, ImageCropperContext } from './image-cropper';
export const privateApis = {};
lock( privateApis, {
@@ -19,4 +20,6 @@ lock( privateApis, {
Menu,
kebabCase,
Badge,
+ ImageCropper,
+ ImageCropperContext,
} );
diff --git a/packages/components/src/resizable-box/index.tsx b/packages/components/src/resizable-box/index.tsx
index 3bf3d36aa0d5c..561e025c5f529 100644
--- a/packages/components/src/resizable-box/index.tsx
+++ b/packages/components/src/resizable-box/index.tsx
@@ -88,7 +88,7 @@ const HANDLE_STYLES = {
};
type ResizableBoxProps = ResizableProps & {
- children: ReactNode;
+ children?: ReactNode;
showHandle?: boolean;
__experimentalShowTooltip?: boolean;
__experimentalTooltipProps?: Parameters< typeof ResizeTooltip >[ 0 ];