From 1247190ed3686d4a62709cdd41bb3005da908378 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Wed, 10 Jul 2024 12:49:20 +0800 Subject: [PATCH 01/34] Add freeform image cropper component --- .../src/image-cropper/component.tsx | 161 +++++++++++++++ .../components/src/image-cropper/context.ts | 12 ++ packages/components/src/image-cropper/hook.ts | 117 +++++++++++ .../components/src/image-cropper/index.tsx | 3 + packages/components/src/image-cropper/math.ts | 50 +++++ .../components/src/image-cropper/reducer.ts | 193 ++++++++++++++++++ .../src/image-cropper/stories/index.story.tsx | 97 +++++++++ .../components/src/image-cropper/styles.ts | 45 ++++ .../components/src/image-cropper/types.ts | 9 + 9 files changed, 687 insertions(+) create mode 100644 packages/components/src/image-cropper/component.tsx create mode 100644 packages/components/src/image-cropper/context.ts create mode 100644 packages/components/src/image-cropper/hook.ts create mode 100644 packages/components/src/image-cropper/index.tsx create mode 100644 packages/components/src/image-cropper/math.ts create mode 100644 packages/components/src/image-cropper/reducer.ts create mode 100644 packages/components/src/image-cropper/stories/index.story.tsx create mode 100644 packages/components/src/image-cropper/styles.ts create mode 100644 packages/components/src/image-cropper/types.ts diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx new file mode 100644 index 00000000000000..91053372c09507 --- /dev/null +++ b/packages/components/src/image-cropper/component.tsx @@ -0,0 +1,161 @@ +/** + * External dependencies + */ +import type { RefObject, ReactNode } from 'react'; +/** + * WordPress dependencies + */ +import { useState, forwardRef, useContext } from '@wordpress/element'; +/** + * Internal dependencies + */ +import { Resizable, Draggable, Container, Img } from './styles'; +import { getRotatedScale } from './math'; +import { ImageCropperContext } from './context'; +import { useImageCropper } from './hook'; + +function CropWindow() { + const { + state: { size, offset }, + width, + height, + refs: { cropperWindowRef }, + dispatch, + } = useContext( ImageCropperContext ); + const [ element, setElement ] = useState< HTMLDivElement >(); + + return ( + { + // setMaxSize((maxSize) => { + // let maxWidth = maxSize.width; + // let maxHeight = maxSize.height; + // if (direction.toLowerCase().includes('left')) { + // maxWidth = offset.x + size.width; + // } + // if (direction.toLowerCase().includes('right')) { + // maxWidth = containerWidth - offset.x; + // } + // if (direction.startsWith('top')) { + // maxHeight = offset.y + size.height; + // } + // if (direction.startsWith('bottom')) { + // maxHeight = containerHeight - offset.y; + // } + // // Bail out updates if the states are the same. + // if (maxWidth === maxSize.width && maxHeight === maxSize.height) { + // return maxSize; + // } + // return { width: maxWidth, height: maxHeight }; + // }); + + // Set the temporaray offset on resizing. + element!.style.setProperty( + '--wp-cropper-x', + `${ offset.x }px` + ); + element!.style.setProperty( + '--wp-cropper-y', + `${ offset.y }px` + ); + } } + onResize={ ( _event, direction, _element, delta ) => { + // Set the temporaray offset on resizing. + if ( direction.toLowerCase().includes( 'left' ) ) { + element!.style.setProperty( + '--wp-cropper-x', + `${ offset.x - delta.width }px` + ); + } + if ( direction.startsWith( 'top' ) ) { + element!.style.setProperty( + '--wp-cropper-y', + `${ offset.y - delta.height }px` + ); + } + } } + onResizeStop={ ( _event, direction, _element, delta ) => { + // Remove the temporary offset. + element!.style.removeProperty( '--wp-cropper-x' ); + element!.style.removeProperty( '--wp-cropper-y' ); + // Commit the offset to state if needed. + dispatch( { type: 'RESIZE_WINDOW', direction, delta } ); + } } + ref={ ( resizable ) => { + if ( resizable ) { + setElement( resizable.resizable as HTMLDivElement ); + } + } } + > + } + /> + + ); +} + +const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { + const { + state: { angle, scale, offset, position }, + src, + width, + height, + refs: { imageRef }, + } = useContext( ImageCropperContext ); + const rotatedScale = getRotatedScale( angle, scale, width, height ); + + return ( + + + + + ); +} ); + +function ImageCropperProvider( { + src, + width, + height, + children, +}: { + src: string; + width: number; + height: number; + children: ReactNode; +} ) { + const context = useImageCropper( { src, width, height } ); + return ( + + { children } + + ); +} + +const ImageCropper = Object.assign( Cropper, { + Provider: ImageCropperProvider, +} ); + +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 00000000000000..d42f0544f6cb40 --- /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 00000000000000..b0b7d5f67e1df8 --- /dev/null +++ b/packages/components/src/image-cropper/hook.ts @@ -0,0 +1,117 @@ +/** + * External dependencies + */ +import { + createUseGesture, + dragAction, + pinchAction, + wheelAction, +} from '@use-gesture/react'; +/** + * WordPress dependencies + */ +import { useRef, useMemo, useReducer, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { degreeToRadian, getRotatedScale } from './math'; +import { imageCropperReducer, createInitialState } 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: ( { offset: [ scale ] } ) => { + dispatch( { type: 'ZOOM', scale } ); + }, + onWheel: ( { pinching, movement: [ , deltaY ] } ) => { + if ( pinching ) { + return; + } + const deltaScale = deltaY * 0.001; + dispatch( { type: 'ZOOM_BY', deltaScale } ); + }, + onDrag: ( { offset: [ x, y ] } ) => { + dispatch( { type: 'MOVE', x, y } ); + }, + }, + { + target: cropperWindowRef, + pinch: { scaleBounds: { min: 1, max: 10 } }, + wheel: { threshold: 10 }, + drag: { from: () => [ state.position.x, state.position.y ] }, + } + ); + + const getImageBlob = useCallback( async () => { + const offscreenCanvas = new OffscreenCanvas( + state.size.width, + state.size.height + ); + const ctx = offscreenCanvas.getContext( '2d' )!; + ctx.translate( + state.position.x + offscreenCanvas.width / 2, + state.position.y + offscreenCanvas.height / 2 + ); + ctx.rotate( degreeToRadian( state.angle ) ); + const rotatedScale = getRotatedScale( + state.angle, + state.scale, + width, + height + ); + ctx.scale( rotatedScale, rotatedScale ); + ctx.drawImage( + imageRef.current!, + -state.offset.x - offscreenCanvas.width / 2, + -state.offset.y - offscreenCanvas.height / 2, + width, + height + ); + const blob = await offscreenCanvas.convertToBlob(); + return blob; + }, [ + width, + height, + state.angle, + state.offset, + state.position, + state.scale, + state.size, + ] ); + + return useMemo( + () => ( { + state, + src, + width, + height, + refs: { + imageRef, + cropperWindowRef, + }, + dispatch, + getImageBlob, + } ), + [ state, src, width, height, getImageBlob ] + ); +}; diff --git a/packages/components/src/image-cropper/index.tsx b/packages/components/src/image-cropper/index.tsx new file mode 100644 index 00000000000000..58475cb129cba8 --- /dev/null +++ b/packages/components/src/image-cropper/index.tsx @@ -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 00000000000000..3e1ac3add165c0 --- /dev/null +++ b/packages/components/src/image-cropper/math.ts @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import memize from 'memize'; +/** + * Internal dependencies + */ +import type { Position } from './types'; + +export function rotatePoint( + point: Position, + center: Position, + radian: number +): 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, + }; +} + +export function degreeToRadian( degree: number ): number { + return ( degree * Math.PI ) / 180; +} + +export const getRotatedScale = memize( + ( angle: number, scale: number, width: number, height: number ) => { + const radian = degreeToRadian( angle ); + if ( radian === 0 ) { + return scale; + } + + // Calculate bounding box of the rotated image. + const sin = Math.sin( radian ); + const cos = Math.cos( radian ); + const newWidth = Math.abs( width * cos ) + Math.abs( height * sin ); + const newHeight = Math.abs( width * sin ) + Math.abs( height * cos ); + + // Calculate the scaling factor to cover the entire container. + const scaleX = newWidth / width; + const scaleY = newHeight / height; + const minScale = Math.max( scaleX, scaleY ); + + return scale * minScale; + }, + { 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 00000000000000..02fe0d80ef52c2 --- /dev/null +++ b/packages/components/src/image-cropper/reducer.ts @@ -0,0 +1,193 @@ +/** + * Internal dependencies + */ +import type { Position, Size } from './types'; +import { rotatePoint, degreeToRadian, getRotatedScale } from './math'; + +type State = { + readonly width: number; + readonly height: number; + angle: number; + scale: number; + offset: Position; + position: Position; + size: Size; +}; + +type Action = + | { type: 'ZOOM'; scale: number } + | { type: 'ZOOM_BY'; deltaScale: number } + | { type: 'ROTATE'; angle: number } + | { type: 'TRANSLATE'; offset: Position } + | { type: 'MOVE'; x: number; y: number } + | { type: 'RESIZE_WINDOW'; direction: string; delta: Size } + | { type: 'RESET' }; + +function createInitialState( { + width, + height, +}: { + width: number; + height: number; +} ) { + return { + width, + height, + angle: 0, + scale: 1, + offset: { x: 0, y: 0 }, + position: { x: 0, y: 0 }, + size: { width, height }, + }; +} + +function imageCropperReducer( state: State, action: Action ) { + const { width, height, scale, angle, size, position } = state; + + switch ( action.type ) { + case 'ZOOM': { + return { + ...state, + scale: action.scale, + }; + } + case 'ZOOM_BY': { + return { + ...state, + scale: state.scale + action.deltaScale, + }; + } + case 'ROTATE': { + return { + ...state, + angle: action.angle, + }; + } + case 'TRANSLATE': { + return { + ...state, + offset: action.offset, + }; + } + case 'MOVE': { + const windowVertices = [ + { x: size.width / 2, y: -size.height / 2 }, // top right + { x: size.width / 2, y: size.height / 2 }, // bottom right + { x: -size.width / 2, y: +size.height / 2 }, // bottom left + { x: -size.width / 2, y: -size.height / 2 }, // top left + ]; + const radian = degreeToRadian( angle ); + const rotatedScale = getRotatedScale( angle, scale, width, height ); + const scaledWidth = width * rotatedScale; + const scaledHeight = height * rotatedScale; + + let { x, y } = action; + + const minX = x - scaledWidth / 2; + const maxX = x + scaledWidth / 2; + const minY = y - scaledHeight / 2; + const maxY = y + scaledHeight / 2; + + let furthestX = 0; + let furthestY = 0; + + for ( const point of windowVertices ) { + const rotatedPoint = rotatePoint( point, { x, y }, -radian ); + + if ( rotatedPoint.x < minX ) { + furthestX = + Math.abs( rotatedPoint.x - minX ) > + Math.abs( furthestX ) + ? rotatedPoint.x - minX + : furthestX; + } + if ( rotatedPoint.x > maxX ) { + furthestX = + Math.abs( rotatedPoint.x - maxX ) > + Math.abs( furthestX ) + ? rotatedPoint.x - maxX + : furthestX; + } + if ( rotatedPoint.y < minY ) { + furthestY = + Math.abs( rotatedPoint.y - minY ) > + Math.abs( furthestY ) + ? rotatedPoint.y - minY + : furthestY; + } + if ( rotatedPoint.y > maxY ) { + furthestY = + Math.abs( rotatedPoint.y - maxY ) > + Math.abs( furthestY ) + ? rotatedPoint.y - maxY + : furthestY; + } + } + + const vectorInUnrotated = { x: furthestX, y: furthestY }; + + // Step 3: Rotate the vector back to the original coordinate system + const vector = rotatePoint( + vectorInUnrotated, + { x: 0, y: 0 }, + radian + ); + + if ( + Math.round( vector.x ) !== 0 || + Math.round( vector.y ) !== 0 + ) { + x += vector.x; + y += vector.y; + } + + return { + ...state, + position: { x, y }, + }; + } + case 'RESIZE_WINDOW': { + const { direction, delta } = action; + const deltaX = direction.toLowerCase().includes( 'left' ) + ? delta.width + : -delta.width; + const deltaY = direction.startsWith( 'top' ) + ? delta.height + : -delta.height; + const newSize = { + width: size.width + delta.width, + height: size.height + delta.height, + }; + const widthScale = width / newSize.width; + const heightScale = height / newSize.height; + const windowScale = Math.min( widthScale, heightScale ); + const scaledSize = { width, height }; + const translated = { x: 0, y: 0 }; + if ( widthScale === windowScale ) { + scaledSize.height = newSize.height * windowScale; + translated.y = height / 2 - scaledSize.height / 2; + } else { + scaledSize.width = newSize.width * windowScale; + translated.x = width / 2 - scaledSize.width / 2; + } + return { + ...state, + offset: translated, + size: scaledSize, + scale: scale * windowScale, + position: { + x: ( position.x + deltaX / 2 ) * windowScale, + y: ( position.y + deltaY / 2 ) * windowScale, + }, + }; + } + case 'RESET': { + return createInitialState( { width, 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 00000000000000..e1c7d717a5d0dc --- /dev/null +++ b/packages/components/src/image-cropper/stories/index.story.tsx @@ -0,0 +1,97 @@ +/** + * External dependencies + */ +import type { Meta, StoryObj, StoryFn } from '@storybook/react'; +/** + * WordPress dependencies + */ +import { useState, useContext } from '@wordpress/element'; +/** + * Internal dependencies + */ +import { ImageCropper, ImageCropperContext } 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 TemplateControls() { + const { + state: { angle }, + dispatch, + getImageBlob, + } = useContext( ImageCropperContext ); + const [ previewUrl, setPreviewUrl ] = useState< string >( '' ); + + return ( + <> + + dispatch( { + type: 'ROTATE', + angle: Number( event.target.value ), + } ) + } + /> + + + { previewUrl && ( +
+ preview +
+ ) } + + ); +} + +const Template: StoryFn< typeof ImageCropper.Provider > = ( { ...args } ) => { + return ( + + + + + + ); +}; + +export const Default: StoryObj< typeof ImageCropper.Provider > = Template.bind( + {} +); +Default.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: 250, + height: 200, +}; diff --git a/packages/components/src/image-cropper/styles.ts b/packages/components/src/image-cropper/styles.ts new file mode 100644 index 00000000000000..d9333041266d84 --- /dev/null +++ b/packages/components/src/image-cropper/styles.ts @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import styled from '@emotion/styled'; + +/** + * Internal dependencies + */ +import ResizableBox from '../resizable-box'; + +export const PADDING = { + x: 30, + y: 30, +}; + +export const Draggable = styled.div` + position: absolute; + inset: 0; + cursor: move; + touch-action: none; +`; + +export const Resizable = styled( ResizableBox )` + transform: translate( var( --wp-cropper-x ), var( --wp-cropper-y ) ); + box-shadow: 0 0 0 100vmax rgba( 0, 0, 0, 0.5 ); +`; + +export const Container = styled.div` + position: relative; + display: flex; + overflow: hidden; + padding: ${ PADDING.y }px ${ PADDING.x }px; + box-sizing: content-box; +`; + +export const Img = styled.img` + position: absolute; + top: ${ PADDING.x }px; + left: ${ PADDING.y }px; + pointer-events: none; + transform-origin: center center; + rotate: var( --wp-cropper-angle ); + scale: var( --wp-cropper-scale ); + translate: var( --wp-cropper-image-x ) var( --wp-cropper-image-y ); +`; diff --git a/packages/components/src/image-cropper/types.ts b/packages/components/src/image-cropper/types.ts new file mode 100644 index 00000000000000..0e9b5d9248d77f --- /dev/null +++ b/packages/components/src/image-cropper/types.ts @@ -0,0 +1,9 @@ +export type Position = { + x: number; + y: number; +}; + +export type Size = { + width: number; + height: number; +}; From 9b6907a0b13c6ad2d4db32a4fe9debdc16bba5fe Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Tue, 16 Jul 2024 16:22:42 +0800 Subject: [PATCH 02/34] Try rotating 90deg --- .../src/image-cropper/component.tsx | 144 ++++++----- packages/components/src/image-cropper/hook.ts | 46 ++-- packages/components/src/image-cropper/math.ts | 68 +++-- .../components/src/image-cropper/reducer.ts | 239 ++++++++++++------ .../src/image-cropper/stories/index.story.tsx | 34 ++- .../components/src/image-cropper/styles.ts | 2 - 6 files changed, 343 insertions(+), 190 deletions(-) diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx index 91053372c09507..122e20480e3537 100644 --- a/packages/components/src/image-cropper/component.tsx +++ b/packages/components/src/image-cropper/component.tsx @@ -1,59 +1,40 @@ /** * External dependencies */ -import type { RefObject, ReactNode } from 'react'; +import type { RefObject, ReactNode, MouseEvent, TouchEvent } from 'react'; /** * WordPress dependencies */ -import { useState, forwardRef, useContext } from '@wordpress/element'; +import { useState, forwardRef, useContext, useRef } from '@wordpress/element'; /** * Internal dependencies */ import { Resizable, Draggable, Container, Img } from './styles'; -import { getRotatedScale } from './math'; import { ImageCropperContext } from './context'; import { useImageCropper } from './hook'; +import type { Position } from './types'; + +const RESIZING_THRESHOLDS: [ number, number ] = [ 10, 10 ]; // 10px. function CropWindow() { const { - state: { size, offset }, - width, - height, + state: { size, offset, scale, isResizing }, refs: { cropperWindowRef }, dispatch, } = useContext( ImageCropperContext ); const [ element, setElement ] = useState< HTMLDivElement >(); + const initialMousePositionRef = useRef< Position >( { x: 0, y: 0 } ); return ( { - // setMaxSize((maxSize) => { - // let maxWidth = maxSize.width; - // let maxHeight = maxSize.height; - // if (direction.toLowerCase().includes('left')) { - // maxWidth = offset.x + size.width; - // } - // if (direction.toLowerCase().includes('right')) { - // maxWidth = containerWidth - offset.x; - // } - // if (direction.startsWith('top')) { - // maxHeight = offset.y + size.height; - // } - // if (direction.startsWith('bottom')) { - // maxHeight = containerHeight - offset.y; - // } - // // Bail out updates if the states are the same. - // if (maxWidth === maxSize.width && maxHeight === maxSize.height) { - // return maxSize; - // } - // return { width: maxWidth, height: maxHeight }; - // }); - - // Set the temporaray offset on resizing. + // Emulate the resizing thresholds. + grid={ isResizing ? undefined : RESIZING_THRESHOLDS } + onResizeStart={ ( event ) => { + // Set the temporary offset on resizing. element!.style.setProperty( '--wp-cropper-x', `${ offset.x }px` @@ -62,20 +43,64 @@ function CropWindow() { '--wp-cropper-y', `${ offset.y }px` ); + + 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 ) => { - // Set the temporaray offset on resizing. - if ( direction.toLowerCase().includes( 'left' ) ) { - element!.style.setProperty( - '--wp-cropper-x', - `${ offset.x - delta.width }px` - ); + if ( delta.width === 0 && delta.height === 0 ) { + if ( scale === 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 ( direction.startsWith( 'top' ) ) { - element!.style.setProperty( - '--wp-cropper-y', - `${ offset.y - delta.height }px` - ); + if ( ! isResizing ) { + if ( + Math.abs( delta.width ) >= RESIZING_THRESHOLDS[ 0 ] || + Math.abs( delta.height ) >= RESIZING_THRESHOLDS[ 1 ] + ) { + dispatch( { type: 'RESIZE_START' } ); + } + } else { + // Set the temporary offset on resizing. + if ( direction.toLowerCase().includes( 'left' ) ) { + element!.style.setProperty( + '--wp-cropper-x', + `${ offset.x - delta.width }px` + ); + } + if ( direction.startsWith( 'top' ) ) { + element!.style.setProperty( + '--wp-cropper-y', + `${ offset.y - delta.height }px` + ); + } } } } onResizeStop={ ( _event, direction, _element, delta ) => { @@ -100,21 +125,20 @@ function CropWindow() { const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { const { - state: { angle, scale, offset, position }, + state: { width, height, angle, turns, scale, offset, position }, src, - width, - height, refs: { imageRef }, } = useContext( ImageCropperContext ); - const rotatedScale = getRotatedScale( angle, scale, width, height ); + const isAxisSwapped = turns % 2 !== 0; + const degree = angle + turns * 90; return ( ( ( {}, ref ) => { } } ref={ ref } > - +
+ +
); diff --git a/packages/components/src/image-cropper/hook.ts b/packages/components/src/image-cropper/hook.ts index b0b7d5f67e1df8..1c3038d1863aa8 100644 --- a/packages/components/src/image-cropper/hook.ts +++ b/packages/components/src/image-cropper/hook.ts @@ -15,8 +15,9 @@ import { useRef, useMemo, useReducer, useCallback } from '@wordpress/element'; /** * Internal dependencies */ -import { degreeToRadian, getRotatedScale } from './math'; +import { degreeToRadian } from './math'; import { imageCropperReducer, createInitialState } from './reducer'; +import type { State } from './reducer'; const useGesture = createUseGesture( [ dragAction, pinchAction, wheelAction ] ); @@ -62,49 +63,36 @@ export const useImageCropper = ( { } ); - const getImageBlob = useCallback( async () => { + // FIXME: This doesn't work for rotated images for now. + const getImageBlob = useCallback( async ( cropperState: State ) => { const offscreenCanvas = new OffscreenCanvas( - state.size.width, - state.size.height + cropperState.size.width, + cropperState.size.height ); const ctx = offscreenCanvas.getContext( '2d' )!; ctx.translate( - state.position.x + offscreenCanvas.width / 2, - state.position.y + offscreenCanvas.height / 2 + cropperState.position.x + offscreenCanvas.width / 2, + cropperState.position.y + offscreenCanvas.height / 2 ); - ctx.rotate( degreeToRadian( state.angle ) ); - const rotatedScale = getRotatedScale( - state.angle, - state.scale, - width, - height + ctx.rotate( + degreeToRadian( cropperState.angle + cropperState.turns * 90 ) ); - ctx.scale( rotatedScale, rotatedScale ); + ctx.scale( cropperState.scale, cropperState.scale ); ctx.drawImage( imageRef.current!, - -state.offset.x - offscreenCanvas.width / 2, - -state.offset.y - offscreenCanvas.height / 2, - width, - height + -cropperState.offset.x - offscreenCanvas.width / 2, + -cropperState.offset.y - offscreenCanvas.height / 2, + cropperState.width, + cropperState.height ); const blob = await offscreenCanvas.convertToBlob(); return blob; - }, [ - width, - height, - state.angle, - state.offset, - state.position, - state.scale, - state.size, - ] ); + }, [] ); return useMemo( () => ( { state, src, - width, - height, refs: { imageRef, cropperWindowRef, @@ -112,6 +100,6 @@ export const useImageCropper = ( { dispatch, getImageBlob, } ), - [ state, src, width, height, getImageBlob ] + [ state, src, getImageBlob ] ); }; diff --git a/packages/components/src/image-cropper/math.ts b/packages/components/src/image-cropper/math.ts index 3e1ac3add165c0..93ea4f37bac82a 100644 --- a/packages/components/src/image-cropper/math.ts +++ b/packages/components/src/image-cropper/math.ts @@ -5,7 +5,7 @@ import memize from 'memize'; /** * Internal dependencies */ -import type { Position } from './types'; +import type { Position, Size } from './types'; export function rotatePoint( point: Position, @@ -26,25 +26,59 @@ export function degreeToRadian( degree: number ): number { return ( degree * Math.PI ) / 180; } -export const getRotatedScale = memize( - ( angle: number, scale: number, width: number, height: number ) => { - const radian = degreeToRadian( angle ); - if ( radian === 0 ) { - return scale; - } +export const getFurthestVector = memize( + ( + width: number, + height: number, + radian: number, + size: Size, + position: Position + ): Position => { + const windowVertices = [ + { x: size.width / 2, y: -size.height / 2 }, // top right + { x: size.width / 2, y: size.height / 2 }, // bottom right + { x: -size.width / 2, y: +size.height / 2 }, // bottom left + { x: -size.width / 2, y: -size.height / 2 }, // top left + ]; + + const minX = position.x - width / 2; + const maxX = position.x + width / 2; + const minY = position.y - height / 2; + const maxY = position.y + height / 2; - // Calculate bounding box of the rotated image. - const sin = Math.sin( radian ); - const cos = Math.cos( radian ); - const newWidth = Math.abs( width * cos ) + Math.abs( height * sin ); - const newHeight = Math.abs( width * sin ) + Math.abs( height * cos ); + let furthestX = 0; + let furthestY = 0; - // Calculate the scaling factor to cover the entire container. - const scaleX = newWidth / width; - const scaleY = newHeight / height; - const minScale = Math.max( scaleX, scaleY ); + for ( const point of windowVertices ) { + const rotatedPoint = rotatePoint( point, position, -radian ); + + if ( rotatedPoint.x < minX ) { + furthestX = + Math.abs( rotatedPoint.x - minX ) > Math.abs( furthestX ) + ? rotatedPoint.x - minX + : furthestX; + } + if ( rotatedPoint.x > maxX ) { + furthestX = + Math.abs( rotatedPoint.x - maxX ) > Math.abs( furthestX ) + ? rotatedPoint.x - maxX + : furthestX; + } + if ( rotatedPoint.y < minY ) { + furthestY = + Math.abs( rotatedPoint.y - minY ) > Math.abs( furthestY ) + ? rotatedPoint.y - minY + : furthestY; + } + if ( rotatedPoint.y > maxY ) { + furthestY = + Math.abs( rotatedPoint.y - maxY ) > Math.abs( furthestY ) + ? rotatedPoint.y - maxY + : furthestY; + } + } - return scale * minScale; + return { x: furthestX, y: furthestY }; }, { maxSize: 1 } ); diff --git a/packages/components/src/image-cropper/reducer.ts b/packages/components/src/image-cropper/reducer.ts index 02fe0d80ef52c2..08117421ae7a70 100644 --- a/packages/components/src/image-cropper/reducer.ts +++ b/packages/components/src/image-cropper/reducer.ts @@ -2,24 +2,42 @@ * Internal dependencies */ import type { Position, Size } from './types'; -import { rotatePoint, degreeToRadian, getRotatedScale } from './math'; +import { rotatePoint, degreeToRadian, getFurthestVector } from './math'; -type State = { - readonly width: number; - readonly height: number; +export type State = { + // The container/image's width. + width: number; + // The container/image's height. + height: number; + // The rotation angle between -45deg to 45deg. angle: number; + // The number of 90-degree turns. + turns: 0 | 1 | 2 | 3; + // The zoom scale. scale: number; + // Whether the image is flipped horizontally. + flipped: boolean; + // The offset position of the cropper window. offset: Position; + // The position of center of the image. position: Position; + // The size of the cropper window. size: Size; + // Whether the cropper window is resizing. + isResizing: boolean; + // Whether the image is dragging/moving. + isDragging: boolean; }; type Action = | { type: 'ZOOM'; scale: number } | { type: 'ZOOM_BY'; deltaScale: number } + | { type: 'FLIP' } | { type: 'ROTATE'; angle: number } + | { type: 'ROTATE_CLOCKWISE'; isCounterClockwise?: boolean } | { type: 'TRANSLATE'; offset: Position } | { type: 'MOVE'; x: number; y: number } + | { type: 'RESIZE_START' } | { type: 'RESIZE_WINDOW'; direction: string; delta: Size } | { type: 'RESET' }; @@ -29,38 +47,136 @@ function createInitialState( { }: { width: number; height: number; -} ) { +} ): State { return { width, height, angle: 0, + turns: 0, scale: 1, + flipped: false, offset: { x: 0, y: 0 }, position: { x: 0, y: 0 }, size: { width, height }, + isResizing: false, + isDragging: false, }; } function imageCropperReducer( state: State, action: Action ) { - const { width, height, scale, angle, size, position } = state; + const { + width, + height, + scale, + flipped, + angle, + turns, + size, + position, + offset, + } = state; + const radian = degreeToRadian( angle + turns * 90 ); switch ( action.type ) { case 'ZOOM': { + const { x, y } = getFurthestVector( + width, + height, + radian, + size, + position + ); + + const widthScale = ( Math.abs( x ) * 2 + width ) / width; + const heightScale = ( Math.abs( y ) * 2 + height ) / height; + const minScale = Math.max( widthScale, heightScale ); return { ...state, - scale: action.scale, + scale: Math.min( Math.max( action.scale, minScale ), 10 ), }; } case 'ZOOM_BY': { + const { x, y } = getFurthestVector( + width, + height, + radian, + size, + position + ); + + const widthScale = ( Math.abs( x ) * 2 + width ) / width; + const heightScale = ( Math.abs( y ) * 2 + height ) / height; + const minScale = Math.max( widthScale, heightScale ); return { ...state, - scale: state.scale + action.deltaScale, + scale: Math.min( + Math.max( state.scale + action.deltaScale, minScale ), + 10 + ), + }; + } + case 'FLIP': { + return { + ...state, + flipped: ! flipped, }; } case 'ROTATE': { + const nextRadian = degreeToRadian( action.angle + turns * 90 ); + const scaledWidth = width * scale; + const scaledHeight = height * scale; + const { x, y } = getFurthestVector( + scaledWidth, + scaledHeight, + nextRadian, + size, + position + ); + const widthScale = ( Math.abs( x ) * 2 + scaledWidth ) / width; + const heightScale = ( Math.abs( y ) * 2 + scaledHeight ) / height; + const minScale = Math.max( widthScale, heightScale ); return { ...state, angle: action.angle, + scale: Math.max( scale, minScale ), + }; + } + case 'ROTATE_CLOCKWISE': { + const isCounterClockwise = action.isCounterClockwise; + const nextTurns = ( ( turns + ( isCounterClockwise ? 3 : 1 ) ) % + 4 ) as 0 | 1 | 2 | 3; + const rotatedPosition = rotatePoint( + position, + { x: 0, y: 0 }, + ( Math.PI / 2 ) * ( isCounterClockwise ? -1 : 1 ) + ); + const isAxisSwapped = nextTurns % 2 !== 0; + // TODO: I'm sure there's a simpler way to do this... + const axisOffset = { + x: isAxisSwapped + ? ( height - width ) / 2 + : ( width - height ) / 2, + y: ( width - height ) / 2, + }; + if ( isCounterClockwise && ! isAxisSwapped ) { + axisOffset.x = -axisOffset.x; + axisOffset.y = -axisOffset.y; + } + return { + ...state, + size: { + width: size.height, + height: size.width, + }, + offset: { + x: offset.y, + y: offset.x, + }, + turns: nextTurns, + position: { + x: rotatedPosition.x + axisOffset.x, + y: rotatedPosition.y + axisOffset.y, + }, }; } case 'TRANSLATE': { @@ -70,61 +186,20 @@ function imageCropperReducer( state: State, action: Action ) { }; } case 'MOVE': { - const windowVertices = [ - { x: size.width / 2, y: -size.height / 2 }, // top right - { x: size.width / 2, y: size.height / 2 }, // bottom right - { x: -size.width / 2, y: +size.height / 2 }, // bottom left - { x: -size.width / 2, y: -size.height / 2 }, // top left - ]; - const radian = degreeToRadian( angle ); - const rotatedScale = getRotatedScale( angle, scale, width, height ); - const scaledWidth = width * rotatedScale; - const scaledHeight = height * rotatedScale; - - let { x, y } = action; - - const minX = x - scaledWidth / 2; - const maxX = x + scaledWidth / 2; - const minY = y - scaledHeight / 2; - const maxY = y + scaledHeight / 2; - - let furthestX = 0; - let furthestY = 0; - - for ( const point of windowVertices ) { - const rotatedPoint = rotatePoint( point, { x, y }, -radian ); - - if ( rotatedPoint.x < minX ) { - furthestX = - Math.abs( rotatedPoint.x - minX ) > - Math.abs( furthestX ) - ? rotatedPoint.x - minX - : furthestX; - } - if ( rotatedPoint.x > maxX ) { - furthestX = - Math.abs( rotatedPoint.x - maxX ) > - Math.abs( furthestX ) - ? rotatedPoint.x - maxX - : furthestX; - } - if ( rotatedPoint.y < minY ) { - furthestY = - Math.abs( rotatedPoint.y - minY ) > - Math.abs( furthestY ) - ? rotatedPoint.y - minY - : furthestY; - } - if ( rotatedPoint.y > maxY ) { - furthestY = - Math.abs( rotatedPoint.y - maxY ) > - Math.abs( furthestY ) - ? rotatedPoint.y - maxY - : furthestY; - } - } - - const vectorInUnrotated = { x: furthestX, y: furthestY }; + const scaledWidth = width * scale; + const scaledHeight = height * scale; + const isAxisSwapped = turns % 2 !== 0; + const axisOffset = { + x: isAxisSwapped ? ( width - height ) / 2 : 0, + y: isAxisSwapped ? ( height - width ) / 2 : 0, + }; + const vectorInUnrotated = getFurthestVector( + scaledWidth, + scaledHeight, + radian, + size, + { x: action.x + axisOffset.x, y: action.y + axisOffset.y } + ); // Step 3: Rotate the vector back to the original coordinate system const vector = rotatePoint( @@ -133,19 +208,27 @@ function imageCropperReducer( state: State, action: Action ) { radian ); + const nextPosition = { x: action.x, y: action.y }; if ( Math.round( vector.x ) !== 0 || Math.round( vector.y ) !== 0 ) { - x += vector.x; - y += vector.y; + nextPosition.x += vector.x; + nextPosition.y += vector.y; } return { ...state, - position: { x, y }, + position: nextPosition, + }; + } + case 'RESIZE_START': { + return { + ...state, + isResizing: true, }; } + // TODO: No idea how this should work for rotated(turned) images. case 'RESIZE_WINDOW': { const { direction, delta } = action; const deltaX = direction.toLowerCase().includes( 'left' ) @@ -158,17 +241,26 @@ function imageCropperReducer( state: State, action: Action ) { width: size.width + delta.width, height: size.height + delta.height, }; - const widthScale = width / newSize.width; - const heightScale = height / newSize.height; + const isAxisSwapped = turns % 2 !== 0; + const widthScale = + ( isAxisSwapped ? height : width ) / newSize.width; + const heightScale = + ( isAxisSwapped ? width : height ) / newSize.height; const windowScale = Math.min( widthScale, heightScale ); - const scaledSize = { width, height }; + const scaledSize = isAxisSwapped + ? { width: height, height: width } + : { width, height }; const translated = { x: 0, y: 0 }; if ( widthScale === windowScale ) { scaledSize.height = newSize.height * windowScale; - translated.y = height / 2 - scaledSize.height / 2; + translated.y = + ( isAxisSwapped ? width : height ) / 2 - + scaledSize.height / 2; } else { scaledSize.width = newSize.width * windowScale; - translated.x = width / 2 - scaledSize.width / 2; + translated.x = + ( isAxisSwapped ? height : width ) / 2 - + scaledSize.width / 2; } return { ...state, @@ -176,9 +268,10 @@ function imageCropperReducer( state: State, action: Action ) { size: scaledSize, scale: scale * windowScale, position: { - x: ( position.x + deltaX / 2 ) * windowScale, - y: ( position.y + deltaY / 2 ) * windowScale, + x: position.x + ( deltaX / 2 ) * windowScale, + y: position.y + ( deltaY / 2 ) * windowScale, }, + isResizing: false, }; } case 'RESET': { diff --git a/packages/components/src/image-cropper/stories/index.story.tsx b/packages/components/src/image-cropper/stories/index.story.tsx index e1c7d717a5d0dc..988d4d37191716 100644 --- a/packages/components/src/image-cropper/stories/index.story.tsx +++ b/packages/components/src/image-cropper/stories/index.story.tsx @@ -26,11 +26,7 @@ const meta: Meta< typeof ImageCropper.Provider > = { export default meta; function TemplateControls() { - const { - state: { angle }, - dispatch, - getImageBlob, - } = useContext( ImageCropperContext ); + const { state, dispatch, getImageBlob } = useContext( ImageCropperContext ); const [ previewUrl, setPreviewUrl ] = useState< string >( '' ); return ( @@ -40,17 +36,35 @@ function TemplateControls() { min={ -45 } max={ 45 } step={ 1 } - value={ angle } - onChange={ ( event ) => + value={ state.angle } + onChange={ ( event ) => { dispatch( { type: 'ROTATE', angle: Number( event.target.value ), - } ) - } + } ); + } } /> + + + + - - - + + + + + + - - { previewUrl && ( -
- preview -
- ) } + } } + > + Reset + +
); } @@ -101,9 +124,8 @@ function TemplateControls() { const Template: StoryFn< typeof ImageCropper.Provider > = ( { ...args } ) => { return ( - - + ); }; From c30fb87371347947a0765a0a54f35a86e20f7e2d Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Thu, 18 Jul 2024 11:41:00 +0800 Subject: [PATCH 08/34] Refactor state --- .../src/image-cropper/component.tsx | 55 +++-- packages/components/src/image-cropper/hook.ts | 39 +-- .../components/src/image-cropper/reducer.ts | 229 +++++++++++------- .../src/image-cropper/stories/index.story.tsx | 2 +- 4 files changed, 190 insertions(+), 135 deletions(-) diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx index e4b252bdb6ad96..8018989de93c2c 100644 --- a/packages/components/src/image-cropper/component.tsx +++ b/packages/components/src/image-cropper/component.tsx @@ -18,7 +18,12 @@ const RESIZING_THRESHOLDS: [ number, number ] = [ 10, 10 ]; // 10px. function CropWindow() { const { - state: { width, height, size, offset, scale, turns, isResizing }, + state: { + image, + transforms: { turns, scale }, + cropper, + isResizing, + }, refs: { cropperWindowRef }, dispatch, } = useContext( ImageCropperContext ); @@ -28,9 +33,12 @@ function CropWindow() { return ( ( ( {}, ref ) => { const { state: { - width, - height, - angle, - turns, - scale, - flipped, - offset, - position, + image, + transforms: { angle, turns, scale, flipped }, + cropper, }, src, refs: { imageRef }, @@ -142,15 +145,15 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { const isAxisSwapped = turns % 2 !== 0; const degree = angle + turns * 90; const imageOffset = { - top: isAxisSwapped ? ( width - height ) / 2 : 0, - left: isAxisSwapped ? ( height - width ) / 2 : 0, + top: isAxisSwapped ? ( image.width - image.height ) / 2 : 0, + left: isAxisSwapped ? ( image.height - image.width ) / 2 : 0, }; return ( ( ( {}, ref ) => { '--wp-cropper-scale-y': `${ scale * ( flipped && isAxisSwapped ? -1 : 1 ) }`, - '--wp-cropper-window-x': `${ offset.x }px`, - '--wp-cropper-window-y': `${ offset.y }px`, - '--wp-cropper-image-x': `${ position.x }px`, - '--wp-cropper-image-y': `${ position.y }px`, + '--wp-cropper-window-x': `${ cropper.x }px`, + '--wp-cropper-window-y': `${ cropper.y }px`, + '--wp-cropper-image-x': `${ image.x }px`, + '--wp-cropper-image-y': `${ image.y }px`, } } ref={ ref } >
[ state.position.x, state.position.y ] }, + drag: { from: () => [ state.image.x, state.image.y ] }, } ); // FIXME: This doesn't work for rotated images for now. const getImageBlob = useCallback( async ( cropperState: State ) => { const offscreenCanvas = new OffscreenCanvas( - cropperState.size.width, - cropperState.size.height + cropperState.cropper.width, + cropperState.cropper.height ); const ctx = offscreenCanvas.getContext( '2d' )!; ctx.translate( - cropperState.position.x + offscreenCanvas.width / 2, - cropperState.position.y + offscreenCanvas.height / 2 + cropperState.image.x + offscreenCanvas.width / 2, + cropperState.image.y + offscreenCanvas.height / 2 ); ctx.rotate( - degreeToRadian( cropperState.angle + cropperState.turns * 90 ) + degreeToRadian( + cropperState.transforms.angle + + cropperState.transforms.turns * 90 + ) ); - const isAxisSwapped = cropperState.turns % 2 !== 0; + const isAxisSwapped = cropperState.transforms.turns % 2 !== 0; ctx.scale( - cropperState.scale * - ( cropperState.flipped && ! isAxisSwapped ? -1 : 1 ), - cropperState.scale * - ( cropperState.flipped && isAxisSwapped ? -1 : 1 ) + cropperState.transforms.scale * + ( cropperState.transforms.flipped && ! isAxisSwapped ? -1 : 1 ), + cropperState.transforms.scale * + ( cropperState.transforms.flipped && isAxisSwapped ? -1 : 1 ) ); const imageOffset = { x: isAxisSwapped - ? ( cropperState.height - cropperState.width ) / 2 + ? ( cropperState.image.height - cropperState.image.width ) / 2 : 0, y: isAxisSwapped - ? ( cropperState.width - cropperState.height ) / 2 + ? ( cropperState.image.width - cropperState.image.height ) / 2 : 0, }; ctx.drawImage( imageRef.current!, - -cropperState.offset.x - offscreenCanvas.width / 2 + imageOffset.x, - -cropperState.offset.y - offscreenCanvas.height / 2 + imageOffset.y, - cropperState.width, - cropperState.height + -cropperState.cropper.x - offscreenCanvas.width / 2 + imageOffset.x, + -cropperState.cropper.y - + offscreenCanvas.height / 2 + + imageOffset.y, + cropperState.image.width, + cropperState.image.height ); const blob = await offscreenCanvas.convertToBlob(); return blob; diff --git a/packages/components/src/image-cropper/reducer.ts b/packages/components/src/image-cropper/reducer.ts index 779a05d635a871..6045ca962ca5d9 100644 --- a/packages/components/src/image-cropper/reducer.ts +++ b/packages/components/src/image-cropper/reducer.ts @@ -5,24 +5,24 @@ import type { Position, Size, ResizeDirection } from './types'; import { rotatePoint, degreeToRadian, getFurthestVector } from './math'; export type State = { - // The container/image's width. - width: number; - // The container/image's height. - height: number; - // The rotation angle between -45deg to 45deg. - angle: number; - // The number of 90-degree turns. - turns: 0 | 1 | 2 | 3; - // The zoom scale. - scale: number; - // Whether the image is flipped horizontally. - flipped: boolean; - // The offset position of the cropper window. - offset: Position; - // The position of center of the image. - position: Position; - // The size of the cropper window. - size: Size; + image: { + x: number; + y: number; + width: number; + height: number; + }; + transforms: { + angle: number; + turns: 0 | 1 | 2 | 3; + scale: number; + flipped: boolean; + }; + cropper: { + x: number; + y: number; + width: number; + height: number; + }; // Whether the cropper window is resizing. isResizing: boolean; // Whether the image is dragging/moving. @@ -49,15 +49,24 @@ function createInitialState( { height: number; } ): State { return { - width, - height, - angle: 0, - turns: 0, - scale: 1, - flipped: false, - offset: { x: 0, y: 0 }, - position: { x: 0, y: 0 }, - size: { width, height }, + image: { + width, + height, + x: 0, + y: 0, + }, + transforms: { + angle: 0, + turns: 0, + scale: 1, + flipped: false, + }, + cropper: { + width, + height, + x: 0, + y: 0, + }, isResizing: false, isDragging: false, }; @@ -65,85 +74,97 @@ function createInitialState( { function imageCropperReducer( state: State, action: Action ) { const { - width, - height, - scale, - flipped, - angle, - turns, - size, - position, - offset, + image, + transforms: { angle, turns, scale, flipped }, + cropper, } = state; const radian = degreeToRadian( angle + turns * 90 ); switch ( action.type ) { case 'ZOOM': { const { x, y } = getFurthestVector( - width, - height, + image.width, + image.height, radian, - size, - position + { width: cropper.width, height: cropper.height }, + { x: image.x, y: image.y } ); - const widthScale = ( Math.abs( x ) * 2 + width ) / width; - const heightScale = ( Math.abs( y ) * 2 + height ) / height; + const widthScale = + ( Math.abs( x ) * 2 + image.width ) / image.width; + const heightScale = + ( Math.abs( y ) * 2 + image.height ) / image.height; const minScale = Math.max( widthScale, heightScale ); return { ...state, - scale: Math.min( Math.max( action.scale, minScale ), 10 ), + transforms: { + ...state.transforms, + scale: Math.min( Math.max( action.scale, minScale ), 10 ), + }, }; } case 'ZOOM_BY': { const { x, y } = getFurthestVector( - width, - height, + image.width, + image.height, radian, - size, - position + { width: cropper.width, height: cropper.height }, + { x: image.x, y: image.y } ); - const widthScale = ( Math.abs( x ) * 2 + width ) / width; - const heightScale = ( Math.abs( y ) * 2 + height ) / height; + const widthScale = + ( Math.abs( x ) * 2 + image.width ) / image.width; + const heightScale = + ( Math.abs( y ) * 2 + image.height ) / image.height; const minScale = Math.max( widthScale, heightScale ); return { ...state, - scale: Math.min( - Math.max( state.scale + action.deltaScale, minScale ), - 10 - ), + transforms: { + ...state.transforms, + scale: Math.min( + Math.max( scale + action.deltaScale, minScale ), + 10 + ), + }, }; } case 'FLIP': { return { ...state, - flipped: ! flipped, - angle: -angle, - position: { - x: -position.x, - y: position.y, + image: { + ...state.image, + x: -image.x, + }, + transforms: { + ...state.transforms, + angle: -angle, + flipped: ! flipped, }, }; } case 'ROTATE': { const nextRadian = degreeToRadian( action.angle + turns * 90 ); - const scaledWidth = width * scale; - const scaledHeight = height * scale; + const scaledWidth = image.width * scale; + const scaledHeight = image.height * scale; const { x, y } = getFurthestVector( scaledWidth, scaledHeight, nextRadian, - size, - position + { width: cropper.width, height: cropper.height }, + { x: image.x, y: image.y } ); - const widthScale = ( Math.abs( x ) * 2 + scaledWidth ) / width; - const heightScale = ( Math.abs( y ) * 2 + scaledHeight ) / height; + const widthScale = + ( Math.abs( x ) * 2 + scaledWidth ) / image.width; + const heightScale = + ( Math.abs( y ) * 2 + scaledHeight ) / image.height; const minScale = Math.max( widthScale, heightScale ); return { ...state, - angle: action.angle, - scale: Math.max( scale, minScale ), + transforms: { + ...state.transforms, + angle: action.angle, + scale: Math.max( scale, minScale ), + }, }; } case 'ROTATE_CLOCKWISE': { @@ -151,38 +172,48 @@ function imageCropperReducer( state: State, action: Action ) { const nextTurns = ( ( turns + ( isCounterClockwise ? 3 : 1 ) ) % 4 ) as 0 | 1 | 2 | 3; const rotatedPosition = rotatePoint( - position, + { x: image.x, y: image.y }, { x: 0, y: 0 }, ( Math.PI / 2 ) * ( isCounterClockwise ? -1 : 1 ) ); return { ...state, - size: { - width: size.height, - height: size.width, + image: { + ...state.image, + x: rotatedPosition.x, + y: rotatedPosition.y, }, - offset: { - x: offset.y, - y: offset.x, + transforms: { + ...state.transforms, + turns: nextTurns, + }, + cropper: { + ...state.cropper, + width: cropper.height, + height: cropper.width, + x: cropper.y, + y: cropper.x, }, - turns: nextTurns, - position: rotatedPosition, }; } case 'TRANSLATE': { return { ...state, - offset: action.offset, + cropper: { + ...state.cropper, + x: action.offset.x, + y: action.offset.y, + }, }; } case 'MOVE': { - const scaledWidth = width * scale; - const scaledHeight = height * scale; + const scaledWidth = image.width * scale; + const scaledHeight = image.height * scale; const vectorInUnrotated = getFurthestVector( scaledWidth, scaledHeight, radian, - size, + { width: cropper.width, height: cropper.height }, { x: action.x, y: action.y } ); @@ -204,7 +235,11 @@ function imageCropperReducer( state: State, action: Action ) { return { ...state, - position: nextPosition, + image: { + ...state.image, + x: nextPosition.x, + y: nextPosition.y, + }, }; } case 'RESIZE_START': { @@ -227,13 +262,13 @@ function imageCropperReducer( state: State, action: Action ) { ? delta.height : -delta.height; const newSize = { - width: size.width + delta.width, - height: size.height + delta.height, + width: cropper.width + delta.width, + height: cropper.height + delta.height, }; const isAxisSwapped = turns % 2 !== 0; const imageDimensions = { - width: isAxisSwapped ? height : width, - height: isAxisSwapped ? width : height, + width: isAxisSwapped ? image.height : image.width, + height: isAxisSwapped ? image.width : image.height, }; const widthScale = imageDimensions.width / newSize.width; const heightScale = imageDimensions.height / newSize.height; @@ -253,18 +288,30 @@ function imageCropperReducer( state: State, action: Action ) { } return { ...state, - offset: translated, - size: scaledSize, - scale: scale * windowScale, - position: { - x: ( position.x + deltaX / 2 ) * windowScale, - y: ( position.y + deltaY / 2 ) * windowScale, + image: { + ...state.image, + x: ( image.x + deltaX / 2 ) * windowScale, + y: ( image.y + deltaY / 2 ) * windowScale, + }, + transforms: { + ...state.transforms, + scale: scale * windowScale, + }, + cropper: { + ...state.cropper, + width: scaledSize.width, + height: scaledSize.height, + x: translated.x, + y: translated.y, }, isResizing: false, }; } case 'RESET': { - return createInitialState( { width, height } ); + return createInitialState( { + width: image.width, + height: image.height, + } ); } default: { throw new Error( 'Unknown action' ); diff --git a/packages/components/src/image-cropper/stories/index.story.tsx b/packages/components/src/image-cropper/stories/index.story.tsx index 521c17dfda23f6..d4b64f9fea2fab 100644 --- a/packages/components/src/image-cropper/stories/index.story.tsx +++ b/packages/components/src/image-cropper/stories/index.story.tsx @@ -55,7 +55,7 @@ function TemplateControls() { min={ -45 } max={ 45 } step={ 1 } - value={ state.angle } + value={ state.transforms.angle } onChange={ ( value ) => { dispatch( { type: 'ROTATE', From ff9ecb6e10e1142bef797b705f88676d16c5092a Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Thu, 18 Jul 2024 11:51:22 +0800 Subject: [PATCH 09/34] Add some comments --- packages/components/src/image-cropper/hook.ts | 3 ++ .../components/src/image-cropper/reducer.ts | 50 +++++++++++++------ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/components/src/image-cropper/hook.ts b/packages/components/src/image-cropper/hook.ts index 4c28e6adecd2e8..7dd38a16839885 100644 --- a/packages/components/src/image-cropper/hook.ts +++ b/packages/components/src/image-cropper/hook.ts @@ -54,6 +54,9 @@ export const useImageCropper = ( { onDrag: ( { offset: [ x, y ] } ) => { dispatch( { type: 'MOVE', x, y } ); }, + onDragEnd: () => { + dispatch( { type: 'MOVE_END' } ); + }, }, { target: cropperWindowRef, diff --git a/packages/components/src/image-cropper/reducer.ts b/packages/components/src/image-cropper/reducer.ts index 6045ca962ca5d9..0d7643d4647504 100644 --- a/packages/components/src/image-cropper/reducer.ts +++ b/packages/components/src/image-cropper/reducer.ts @@ -1,26 +1,41 @@ /** * Internal dependencies */ -import type { Position, Size, ResizeDirection } from './types'; +import type { Size, ResizeDirection } from './types'; import { rotatePoint, degreeToRadian, getFurthestVector } from './math'; export type State = { + // The image dimensions. image: { + // The x position of the image center. x: number; + // The y position of the image center. y: number; - width: number; - height: number; + // The width of the image. This doesn't change. + readonly width: number; + // The height of the image. This doesn't change. + readonly height: number; }; + // The image transforms. transforms: { + // The angle of the image in degrees, from -45 to 45 degrees. angle: number; + // The number of 90-degree turns clockwise. turns: 0 | 1 | 2 | 3; + // The image scale. scale: number; + // Whether the image is flipped horizontally. flipped: boolean; }; + // The cropper window dimensions. cropper: { + // The x position of the cropper window center. x: number; + // The y position of the cropper window center. y: number; + // The width of the cropper window. width: number; + // The height of the cropper window height: number; }; // Whether the cropper window is resizing. @@ -30,15 +45,25 @@ export type State = { }; type Action = + // Zoom in/out to a scale. | { type: 'ZOOM'; scale: number } + // Zoom in/out by a delta scale. | { type: 'ZOOM_BY'; deltaScale: number } + // Flip the image horizontally. | { type: 'FLIP' } + // Rotate the image to an angle. | { type: 'ROTATE'; angle: number } + // Rotate the image 90-degree clockwise or counter-clockwise. | { type: 'ROTATE_CLOCKWISE'; isCounterClockwise?: boolean } - | { type: 'TRANSLATE'; offset: Position } + // Move the image to a position. | { type: 'MOVE'; x: number; y: number } + // End moving the image. + | { type: 'MOVE_END' } + // Start resizing the cropper window. | { type: 'RESIZE_START' } + // Resize the cropper window by a delta size in a direction. | { type: 'RESIZE_WINDOW'; direction: ResizeDirection; delta: Size } + // Reset the state to the initial state. | { type: 'RESET' }; function createInitialState( { @@ -196,16 +221,6 @@ function imageCropperReducer( state: State, action: Action ) { }, }; } - case 'TRANSLATE': { - return { - ...state, - cropper: { - ...state.cropper, - x: action.offset.x, - y: action.offset.y, - }, - }; - } case 'MOVE': { const scaledWidth = image.width * scale; const scaledHeight = image.height * scale; @@ -240,6 +255,13 @@ function imageCropperReducer( state: State, action: Action ) { x: nextPosition.x, y: nextPosition.y, }, + isDragging: true, + }; + } + case 'MOVE_END': { + return { + ...state, + isDragging: false, }; } case 'RESIZE_START': { From f076adac86860bef8bbbea43f5486c8ea2d5f6d7 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Sun, 28 Jul 2024 01:11:25 +0800 Subject: [PATCH 10/34] Better move algo --- .../src/image-cropper/component.tsx | 30 +++++------ packages/components/src/image-cropper/math.ts | 50 +++++++++++++++++-- .../components/src/image-cropper/reducer.ts | 48 ++++++++++-------- .../components/src/image-cropper/styles.ts | 5 ++ 4 files changed, 93 insertions(+), 40 deletions(-) diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx index 8018989de93c2c..ff5a35bb31e9d5 100644 --- a/packages/components/src/image-cropper/component.tsx +++ b/packages/components/src/image-cropper/component.tsx @@ -9,7 +9,7 @@ import { useState, forwardRef, useContext, useRef } from '@wordpress/element'; /** * Internal dependencies */ -import { Resizable, Draggable, Container, Img } from './styles'; +import { Resizable, Draggable, Container, Img, PADDING } from './styles'; import { ImageCropperContext } from './context'; import { useImageCropper } from './hook'; import type { Position } from './types'; @@ -145,8 +145,12 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { const isAxisSwapped = turns % 2 !== 0; const degree = angle + turns * 90; const imageOffset = { - top: isAxisSwapped ? ( image.width - image.height ) / 2 : 0, - left: isAxisSwapped ? ( image.height - image.width ) / 2 : 0, + top: isAxisSwapped + ? PADDING.y + ( image.width - image.height ) / 2 + : PADDING.y, + left: isAxisSwapped + ? PADDING.x + ( image.height - image.width ) / 2 + : PADDING.x, }; return ( @@ -168,17 +172,15 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { } } ref={ ref } > -
- -
+ ); diff --git a/packages/components/src/image-cropper/math.ts b/packages/components/src/image-cropper/math.ts index e0f8a5ceb02332..0cf89f037b0a97 100644 --- a/packages/components/src/image-cropper/math.ts +++ b/packages/components/src/image-cropper/math.ts @@ -12,13 +12,13 @@ const DEGREE_TO_RADIAN = Math.PI / 180; /** * Rotate a point around a center point by a given degree. * @param point The point to rotate. - * @param center The center point to rotate around. * @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, - center: Position, - radian: number + radian: number, + center: Position = { x: 0, y: 0 } ): Position { const cos = Math.cos( radian ); const sin = Math.sin( radian ); @@ -66,7 +66,7 @@ export const getFurthestVector = memize( let furthestY = 0; for ( const point of windowVertices ) { - const rotatedPoint = rotatePoint( point, position, -radian ); + const rotatedPoint = rotatePoint( point, -radian, position ); if ( rotatedPoint.x < minX && @@ -98,3 +98,45 @@ export const getFurthestVector = memize( }, { maxSize: 1 } ); + +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 } +); diff --git a/packages/components/src/image-cropper/reducer.ts b/packages/components/src/image-cropper/reducer.ts index 0d7643d4647504..c111f73bd2bf5a 100644 --- a/packages/components/src/image-cropper/reducer.ts +++ b/packages/components/src/image-cropper/reducer.ts @@ -2,7 +2,12 @@ * Internal dependencies */ import type { Size, ResizeDirection } from './types'; -import { rotatePoint, degreeToRadian, getFurthestVector } from './math'; +import { + rotatePoint, + degreeToRadian, + getFurthestVector, + calculateRotatedBounds, +} from './math'; export type State = { // The image dimensions. @@ -198,7 +203,6 @@ function imageCropperReducer( state: State, action: Action ) { 4 ) as 0 | 1 | 2 | 3; const rotatedPosition = rotatePoint( { x: image.x, y: image.y }, - { x: 0, y: 0 }, ( Math.PI / 2 ) * ( isCounterClockwise ? -1 : 1 ) ); return { @@ -222,31 +226,31 @@ function imageCropperReducer( state: State, action: Action ) { }; } case 'MOVE': { - const scaledWidth = image.width * scale; - const scaledHeight = image.height * scale; - const vectorInUnrotated = getFurthestVector( - scaledWidth, - scaledHeight, + // 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, - { width: cropper.width, height: cropper.height }, - { x: action.x, y: action.y } + image.width * scale, + image.height * scale, + cropper.width, + cropper.height ); - // Step 3: Rotate the vector back to the original coordinate system - const vector = rotatePoint( - vectorInUnrotated, - { x: 0, y: 0 }, - radian + // Rotate the action point to align with the non-rotated coordinate system. + const rotatedPoint = rotatePoint( + { x: action.x, y: action.y }, + -radian ); - const nextPosition = { x: action.x, y: action.y }; - if ( - Math.round( vector.x ) !== 0 || - Math.round( vector.y ) !== 0 - ) { - nextPosition.x += vector.x; - nextPosition.y += vector.y; - } + // 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, radian ); return { ...state, diff --git a/packages/components/src/image-cropper/styles.ts b/packages/components/src/image-cropper/styles.ts index bf26c832338791..310a98e4b631ca 100644 --- a/packages/components/src/image-cropper/styles.ts +++ b/packages/components/src/image-cropper/styles.ts @@ -26,6 +26,8 @@ export const Resizable = styled( ResizableBox )` var( --wp-cropper-window-y ) ); box-shadow: 0 0 0 100vmax rgba( 0, 0, 0, 0.5 ); + will-change: transform; + contain: layout size style; `; export const Container = styled.div` @@ -34,6 +36,7 @@ export const Container = styled.div` overflow: hidden; padding: ${ PADDING.y }px ${ PADDING.x }px; box-sizing: content-box; + contain: strict; `; export const Img = styled.img` @@ -43,4 +46,6 @@ export const Img = styled.img` rotate: var( --wp-cropper-angle ); scale: var( --wp-cropper-scale-x ) var( --wp-cropper-scale-y ); translate: var( --wp-cropper-image-x ) var( --wp-cropper-image-y ); + will-change: transform; + contain: strict; `; From 51ab644d740f6d1c44842efa9eb516535b3f8915 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Mon, 29 Jul 2024 15:38:55 +0800 Subject: [PATCH 11/34] Smooth animation --- .../src/image-cropper/component.tsx | 100 +++++++++++------- packages/components/src/image-cropper/hook.ts | 4 +- .../components/src/image-cropper/reducer.ts | 32 ++++-- .../components/src/image-cropper/styles.ts | 49 +++++++-- 4 files changed, 125 insertions(+), 60 deletions(-) diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx index ff5a35bb31e9d5..f179a37605536b 100644 --- a/packages/components/src/image-cropper/component.tsx +++ b/packages/components/src/image-cropper/component.tsx @@ -2,10 +2,17 @@ * External dependencies */ import type { RefObject, ReactNode, MouseEvent, TouchEvent } from 'react'; +import { animate } from 'framer-motion'; /** * WordPress dependencies */ -import { useState, forwardRef, useContext, useRef } from '@wordpress/element'; +import { + useState, + forwardRef, + useContext, + useRef, + useEffect, +} from '@wordpress/element'; /** * Internal dependencies */ @@ -20,16 +27,27 @@ function CropWindow() { const { state: { image, - transforms: { turns, scale }, + transforms: { rotations, scale }, cropper, isResizing, }, refs: { cropperWindowRef }, dispatch, } = useContext( ImageCropperContext ); - const [ element, setElement ] = useState< HTMLDivElement >(); + const [ element, setElement ] = useState< HTMLDivElement >( null! ); const initialMousePositionRef = useRef< Position >( { x: 0, y: 0 } ); - const isAxisSwapped = turns % 2 !== 0; + const isAxisSwapped = rotations % 2 !== 0; + + useEffect( () => { + if ( element ) { + animate( element, { + '--wp-cropper-window-x': `${ cropper.x }px`, + '--wp-cropper-window-y': `${ cropper.y }px`, + width: `${ cropper.width }px`, + height: `${ cropper.height }px`, + } ); + } + }, [ element, cropper.x, cropper.y, cropper.width, cropper.height ] ); return ( { // Set the temporary offset on resizing. - element!.style.setProperty( - '--wp-cropper-window-x', - `${ cropper.x }px` - ); - element!.style.setProperty( - '--wp-cropper-window-y', - `${ cropper.y }px` - ); + animate( element, { + '--wp-cropper-window-x': `${ cropper.x }px`, + '--wp-cropper-window-y': `${ cropper.y }px`, + } ).complete(); if ( event.type === 'mousedown' ) { const mouseEvent = event as MouseEvent; @@ -97,26 +111,28 @@ function CropWindow() { dispatch( { type: 'RESIZE_START' } ); } } else { - // Set the temporary offset on resizing. - if ( direction.toLowerCase().includes( 'left' ) ) { - element!.style.setProperty( - '--wp-cropper-window-x', - `${ cropper.x - delta.width }px` - ); + let { x, y } = cropper; + if ( + [ 'left', 'topLeft', 'bottomLeft' ].includes( + direction + ) + ) { + x -= delta.width; } - if ( direction.startsWith( 'top' ) ) { - element!.style.setProperty( - '--wp-cropper-window-y', - `${ cropper.y - delta.height }px` - ); + if ( + [ 'top', 'topLeft', 'topRight' ].includes( direction ) + ) { + y -= delta.height; } + animate( element, { + '--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 ) => { - // Remove the temporary offset. - element!.style.removeProperty( '--wp-cropper-window-x' ); - element!.style.removeProperty( '--wp-cropper-window-y' ); - // Commit the offset to state if needed. dispatch( { type: 'RESIZE_WINDOW', direction, delta } ); } } ref={ ( resizable ) => { @@ -136,14 +152,14 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { const { state: { image, - transforms: { angle, turns, scale, flipped }, - cropper, + transforms: { angle, rotations, scale, flipped }, + isDragging, }, src, refs: { imageRef }, } = useContext( ImageCropperContext ); - const isAxisSwapped = turns % 2 !== 0; - const degree = angle + turns * 90; + const isAxisSwapped = rotations % 2 !== 0; + const degree = angle + rotations * 90; const imageOffset = { top: isAxisSwapped ? PADDING.y + ( image.width - image.height ) / 2 @@ -155,18 +171,22 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { return ( Date: Mon, 29 Jul 2024 22:09:03 +0800 Subject: [PATCH 12/34] Fix aspect ratio and generated image --- packages/components/src/image-cropper/hook.ts | 32 +++++++++---------- .../src/image-cropper/stories/index.story.tsx | 11 +++++-- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/components/src/image-cropper/hook.ts b/packages/components/src/image-cropper/hook.ts index a62cc67caeecf8..0e69f4057a563c 100644 --- a/packages/components/src/image-cropper/hook.ts +++ b/packages/components/src/image-cropper/hook.ts @@ -66,16 +66,18 @@ export const useImageCropper = ( { } ); - // FIXME: This doesn't work for rotated images for now. 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, - cropperState.cropper.height + cropperState.cropper.width * scaleFactor, + cropperState.cropper.height * scaleFactor ); const ctx = offscreenCanvas.getContext( '2d' )!; ctx.translate( - cropperState.image.x + offscreenCanvas.width / 2, - cropperState.image.y + offscreenCanvas.height / 2 + cropperState.image.x * scaleFactor + offscreenCanvas.width / 2, + cropperState.image.y * scaleFactor + offscreenCanvas.height / 2 ); ctx.rotate( degreeToRadian( @@ -91,21 +93,17 @@ export const useImageCropper = ( { ( cropperState.transforms.flipped && isAxisSwapped ? -1 : 1 ) ); const imageOffset = { - x: isAxisSwapped - ? ( cropperState.image.height - cropperState.image.width ) / 2 - : 0, - y: isAxisSwapped - ? ( cropperState.image.width - cropperState.image.height ) / 2 - : 0, + x: isAxisSwapped ? ( naturalHeight - naturalWidth ) / 2 : 0, + y: isAxisSwapped ? ( naturalWidth - naturalHeight ) / 2 : 0, }; ctx.drawImage( - imageRef.current!, - -cropperState.cropper.x - offscreenCanvas.width / 2 + imageOffset.x, - -cropperState.cropper.y - + image, + -cropperState.cropper.x * scaleFactor - + offscreenCanvas.width / 2 + + imageOffset.x, + -cropperState.cropper.y * scaleFactor - offscreenCanvas.height / 2 + - imageOffset.y, - cropperState.image.width, - cropperState.image.height + imageOffset.y ); const blob = await offscreenCanvas.convertToBlob(); return blob; diff --git a/packages/components/src/image-cropper/stories/index.story.tsx b/packages/components/src/image-cropper/stories/index.story.tsx index d4b64f9fea2fab..0ce81dac0fbd9f 100644 --- a/packages/components/src/image-cropper/stories/index.story.tsx +++ b/packages/components/src/image-cropper/stories/index.story.tsx @@ -44,7 +44,14 @@ function TemplateControls() { { previewUrl && ( - preview + + preview + ) } @@ -135,6 +142,6 @@ export const Default: StoryObj< typeof ImageCropper.Provider > = Template.bind( ); Default.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: 250, + width: 300, height: 200, }; From 37cca6db74a52ddb6925923d8784c7bb704e24d0 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Tue, 30 Jul 2024 00:19:32 +0800 Subject: [PATCH 13/34] Fix flip (scale) --- .../src/image-cropper/component.tsx | 10 ++-- packages/components/src/image-cropper/hook.ts | 6 +- .../components/src/image-cropper/reducer.ts | 58 +++++++++++++------ .../components/src/image-cropper/styles.ts | 4 +- 4 files changed, 48 insertions(+), 30 deletions(-) diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx index f179a37605536b..d764bf40f37372 100644 --- a/packages/components/src/image-cropper/component.tsx +++ b/packages/components/src/image-cropper/component.tsx @@ -83,7 +83,7 @@ function CropWindow() { } } onResize={ ( _event, direction, _element, delta ) => { if ( delta.width === 0 && delta.height === 0 ) { - if ( scale === 1 ) { + if ( Math.abs( scale.x ) === 1 ) { return; } // let x = 0; @@ -152,7 +152,7 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { const { state: { image, - transforms: { angle, rotations, scale, flipped }, + transforms: { angle, rotations, scale }, isDragging, }, src, @@ -173,10 +173,8 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { Date: Tue, 30 Jul 2024 00:24:04 +0800 Subject: [PATCH 14/34] Fix pinching animation --- .../components/src/image-cropper/component.tsx | 11 +++++++++-- packages/components/src/image-cropper/hook.ts | 6 ++++++ packages/components/src/image-cropper/reducer.ts | 15 ++++++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx index d764bf40f37372..d06b6b0b999f01 100644 --- a/packages/components/src/image-cropper/component.tsx +++ b/packages/components/src/image-cropper/component.tsx @@ -154,6 +154,7 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { image, transforms: { angle, rotations, scale }, isDragging, + isZooming, }, src, refs: { imageRef }, @@ -173,8 +174,12 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { ( ( {}, ref ) => { height: `${ isAxisSwapped ? image.width : image.height }px`, '--wp-cropper-image-x': `${ image.x }px`, '--wp-cropper-image-y': `${ image.y }px`, + '--wp-cropper-scale-x': scale.x, + '--wp-cropper-scale-y': scale.y, } } ref={ ref } > diff --git a/packages/components/src/image-cropper/hook.ts b/packages/components/src/image-cropper/hook.ts index 79d616d0458b0d..b18faf98e95d5d 100644 --- a/packages/components/src/image-cropper/hook.ts +++ b/packages/components/src/image-cropper/hook.ts @@ -44,6 +44,9 @@ export const useImageCropper = ( { onPinch: ( { offset: [ scale ] } ) => { dispatch( { type: 'ZOOM', scale } ); }, + onPinchEnd: () => { + dispatch( { type: 'ZOOM_END' } ); + }, onWheel: ( { pinching, movement: [ , deltaY ] } ) => { if ( pinching ) { return; @@ -51,6 +54,9 @@ export const useImageCropper = ( { const deltaScale = deltaY * 0.001; dispatch( { type: 'ZOOM_BY', deltaScale } ); }, + onWheelEnd: () => { + dispatch( { type: 'ZOOM_END' } ); + }, onDrag: ( { offset: [ x, y ] } ) => { dispatch( { type: 'MOVE', x, y } ); }, diff --git a/packages/components/src/image-cropper/reducer.ts b/packages/components/src/image-cropper/reducer.ts index c2f2800e59ad05..18ddd9e6a62138 100644 --- a/packages/components/src/image-cropper/reducer.ts +++ b/packages/components/src/image-cropper/reducer.ts @@ -45,11 +45,15 @@ export type State = { isResizing: boolean; // Whether the image is dragging/moving. isDragging: boolean; + // Whether the image is zooming/pinching. + isZooming: boolean; }; type Action = - // Zoom in/out to a scale. + // Zoom in/out to a scale. | { type: 'ZOOM'; scale: number } + // End zooming. + | { type: 'ZOOM_END' } // Zoom in/out by a delta scale. | { type: 'ZOOM_BY'; deltaScale: number } // Flip the image horizontally. @@ -98,6 +102,7 @@ function createInitialState( { }, isResizing: false, isDragging: false, + isZooming: false, }; } @@ -139,6 +144,7 @@ function imageCropperReducer( state: State, action: Action ) { y: nextScale * Math.sign( scale.y ), }, }, + isZooming: true, }; } case 'ZOOM_BY': { @@ -168,6 +174,13 @@ function imageCropperReducer( state: State, action: Action ) { y: nextScale * Math.sign( scale.y ), }, }, + isZooming: true, + }; + } + case 'ZOOM_END': { + return { + ...state, + isZooming: false, }; } case 'FLIP': { From 3ad8c12fe78ce2baf0d822183a81199f7d7818e9 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Tue, 30 Jul 2024 01:35:44 +0800 Subject: [PATCH 15/34] Rewrite algo --- packages/components/src/image-cropper/math.ts | 107 ++++++++---------- .../components/src/image-cropper/reducer.ts | 69 ++++++----- .../components/src/image-cropper/types.ts | 5 - 3 files changed, 78 insertions(+), 103 deletions(-) diff --git a/packages/components/src/image-cropper/math.ts b/packages/components/src/image-cropper/math.ts index 0cf89f037b0a97..bf0e039122d469 100644 --- a/packages/components/src/image-cropper/math.ts +++ b/packages/components/src/image-cropper/math.ts @@ -5,7 +5,7 @@ import memize from 'memize'; /** * Internal dependencies */ -import type { Position, Size } from './types'; +import type { Position } from './types'; const DEGREE_TO_RADIAN = Math.PI / 180; @@ -38,67 +38,6 @@ export function degreeToRadian( degree: number ): number { return degree * DEGREE_TO_RADIAN; } -/** - * Get the maximum (rotated) pair of (x,y) vector for each corner of a rotated rectangle (window) - * that is the furthest from a un-rotated rectangle. - */ -export const getFurthestVector = memize( - ( - width: number, - height: number, - radian: number, - size: Size, - position: Position - ): Position => { - const windowVertices = [ - { x: size.width / 2, y: -size.height / 2 }, // top right - { x: size.width / 2, y: size.height / 2 }, // bottom right - { x: -size.width / 2, y: +size.height / 2 }, // bottom left - { x: -size.width / 2, y: -size.height / 2 }, // top left - ]; - - const minX = position.x - width / 2; - const maxX = position.x + width / 2; - const minY = position.y - height / 2; - const maxY = position.y + height / 2; - - let furthestX = 0; - let furthestY = 0; - - for ( const point of windowVertices ) { - const rotatedPoint = rotatePoint( point, -radian, position ); - - if ( - rotatedPoint.x < minX && - Math.abs( rotatedPoint.x - minX ) > Math.abs( furthestX ) - ) { - furthestX = rotatedPoint.x - minX; - } - if ( - rotatedPoint.x > maxX && - Math.abs( rotatedPoint.x - maxX ) > Math.abs( furthestX ) - ) { - furthestX = rotatedPoint.x - maxX; - } - if ( - rotatedPoint.y < minY && - Math.abs( rotatedPoint.y - minY ) > Math.abs( furthestY ) - ) { - furthestY = rotatedPoint.y - minY; - } - if ( - rotatedPoint.y > maxY && - Math.abs( rotatedPoint.y - maxY ) > Math.abs( furthestY ) - ) { - furthestY = rotatedPoint.y - maxY; - } - } - - return { x: furthestX, y: furthestY }; - }, - { maxSize: 1 } -); - export const calculateRotatedBounds = memize( ( radian: number, @@ -140,3 +79,47 @@ export const calculateRotatedBounds = memize( }, { 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 index 18ddd9e6a62138..6a71a7bc9c4b54 100644 --- a/packages/components/src/image-cropper/reducer.ts +++ b/packages/components/src/image-cropper/reducer.ts @@ -1,12 +1,12 @@ /** * Internal dependencies */ -import type { Size, ResizeDirection } from './types'; +import type { ResizeDirection } from './types'; import { rotatePoint, degreeToRadian, - getFurthestVector, calculateRotatedBounds, + getMinScale, } from './math'; export type State = { @@ -71,7 +71,11 @@ type Action = // Resize the cropper window by a delta size in a direction. | { type: 'MOVE_WINDOW'; x: number; y: number } // Resize the cropper window by a delta size in a direction. - | { type: 'RESIZE_WINDOW'; direction: ResizeDirection; delta: Size } + | { + type: 'RESIZE_WINDOW'; + direction: ResizeDirection; + delta: { width: number; height: number }; + } // Reset the state to the initial state. | { type: 'RESET' }; @@ -118,23 +122,20 @@ function imageCropperReducer( state: State, action: Action ) { switch ( action.type ) { case 'ZOOM': { - const { x, y } = getFurthestVector( + const minScale = getMinScale( + radian, image.width, image.height, - radian, - { width: cropper.width, height: cropper.height }, - { x: image.x, y: image.y } + cropper.width, + cropper.height, + image.x, + image.y ); - - const widthScale = - ( Math.abs( x ) * 2 + image.width ) / image.width; - const heightScale = - ( Math.abs( y ) * 2 + image.height ) / image.height; - const minScale = Math.max( widthScale, heightScale ); const nextScale = Math.min( Math.max( action.scale, minScale ), 10 ); + return { ...state, transforms: { @@ -148,23 +149,20 @@ function imageCropperReducer( state: State, action: Action ) { }; } case 'ZOOM_BY': { - const { x, y } = getFurthestVector( + const minScale = getMinScale( + radian, image.width, image.height, - radian, - { width: cropper.width, height: cropper.height }, - { x: image.x, y: image.y } + cropper.width, + cropper.height, + image.x, + image.y ); - - const widthScale = - ( Math.abs( x ) * 2 + image.width ) / image.width; - const heightScale = - ( Math.abs( y ) * 2 + image.height ) / image.height; - const minScale = Math.max( widthScale, heightScale ); const nextScale = Math.min( Math.max( absScale + action.deltaScale, minScale ), 10 ); + return { ...state, transforms: { @@ -205,19 +203,18 @@ function imageCropperReducer( state: State, action: Action ) { const nextRadian = degreeToRadian( action.angle + rotations * 90 ); const scaledWidth = image.width * absScale; const scaledHeight = image.height * absScale; - const { x, y } = getFurthestVector( - scaledWidth, - scaledHeight, - nextRadian, - { width: cropper.width, height: cropper.height }, - { x: image.x, y: image.y } - ); - const widthScale = - ( Math.abs( x ) * 2 + scaledWidth ) / image.width; - const heightScale = - ( Math.abs( y ) * 2 + scaledHeight ) / image.height; - const minScale = Math.max( widthScale, heightScale ); - const nextScale = Math.max( absScale, minScale ); + const minScale = + getMinScale( + nextRadian, + scaledWidth, + scaledHeight, + cropper.width, + cropper.height, + image.x, + image.y + ) * absScale; + const nextScale = Math.min( Math.max( absScale, minScale ), 10 ); + return { ...state, transforms: { diff --git a/packages/components/src/image-cropper/types.ts b/packages/components/src/image-cropper/types.ts index 476eded0a41acd..26f6ce1ef85167 100644 --- a/packages/components/src/image-cropper/types.ts +++ b/packages/components/src/image-cropper/types.ts @@ -7,8 +7,3 @@ export type Position = { x: number; y: number; }; - -export type Size = { - width: number; - height: number; -}; From 75a0dca553ee1e54b6f930e3da665fa82d65ad07 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Tue, 30 Jul 2024 03:28:45 +0800 Subject: [PATCH 16/34] motionize resizable-box --- .../src/image-cropper/component.tsx | 50 +++++++++---------- .../image-cropper/{styles.ts => styles.tsx} | 41 +++++++++++---- .../components/src/resizable-box/index.tsx | 2 +- 3 files changed, 58 insertions(+), 35 deletions(-) rename packages/components/src/image-cropper/{styles.ts => styles.tsx} (67%) diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx index d06b6b0b999f01..25a6e81d73deb4 100644 --- a/packages/components/src/image-cropper/component.tsx +++ b/packages/components/src/image-cropper/component.tsx @@ -6,22 +6,17 @@ import { animate } from 'framer-motion'; /** * WordPress dependencies */ -import { - useState, - forwardRef, - useContext, - useRef, - useEffect, -} from '@wordpress/element'; +import { forwardRef, useContext, useRef, useEffect } from '@wordpress/element'; /** * Internal dependencies */ -import { Resizable, Draggable, Container, Img, PADDING } from './styles'; +import { Resizable, Draggable, Container, Img } from './styles'; import { ImageCropperContext } from './context'; import { useImageCropper } from './hook'; import type { Position } from './types'; const RESIZING_THRESHOLDS: [ number, number ] = [ 10, 10 ]; // 10px. +const MIN_PADDING = 20; // 20px; function CropWindow() { const { @@ -34,23 +29,24 @@ function CropWindow() { refs: { cropperWindowRef }, dispatch, } = useContext( ImageCropperContext ); - const [ element, setElement ] = useState< HTMLDivElement >( null! ); + const resizableElementRef = useRef< HTMLDivElement >( null! ); const initialMousePositionRef = useRef< Position >( { x: 0, y: 0 } ); const isAxisSwapped = rotations % 2 !== 0; useEffect( () => { - if ( element ) { - animate( element, { + if ( resizableElementRef.current ) { + animate( resizableElementRef.current, { '--wp-cropper-window-x': `${ cropper.x }px`, '--wp-cropper-window-y': `${ cropper.y }px`, width: `${ cropper.width }px`, height: `${ cropper.height }px`, } ); } - }, [ element, cropper.x, cropper.y, cropper.width, cropper.height ] ); + }, [ cropper.x, cropper.y, cropper.width, cropper.height ] ); return ( { // Set the temporary offset on resizing. - animate( element, { + animate( resizableElementRef.current, { '--wp-cropper-window-x': `${ cropper.x }px`, '--wp-cropper-window-y': `${ cropper.y }px`, } ).complete(); @@ -124,7 +120,7 @@ function CropWindow() { ) { y -= delta.height; } - animate( element, { + animate( resizableElementRef.current, { '--wp-cropper-window-x': `${ x }px`, '--wp-cropper-window-y': `${ y }px`, width: `${ cropper.width + delta.width }px`, @@ -135,11 +131,7 @@ function CropWindow() { onResizeStop={ ( _event, direction, _element, delta ) => { dispatch( { type: 'RESIZE_WINDOW', direction, delta } ); } } - ref={ ( resizable ) => { - if ( resizable ) { - setElement( resizable.resizable as HTMLDivElement ); - } - } } + ref={ resizableElementRef } > } @@ -161,13 +153,19 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { } = useContext( ImageCropperContext ); const isAxisSwapped = rotations % 2 !== 0; const degree = angle + rotations * 90; + + const squareImageHorizontalOffset = ( image.width - image.height ) / 2; + const paddingY = Math.max( + MIN_PADDING + squareImageHorizontalOffset, + MIN_PADDING + ); + const paddingX = Math.max( + MIN_PADDING - squareImageHorizontalOffset, + MIN_PADDING + ); const imageOffset = { - top: isAxisSwapped - ? PADDING.y + ( image.width - image.height ) / 2 - : PADDING.y, - left: isAxisSwapped - ? PADDING.x + ( image.height - image.width ) / 2 - : PADDING.x, + top: isAxisSwapped ? paddingX + squareImageHorizontalOffset : paddingY, + left: isAxisSwapped ? paddingY - squareImageHorizontalOffset : paddingX, }; return ( @@ -194,6 +192,8 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { '--wp-cropper-image-y': `${ image.y }px`, '--wp-cropper-scale-x': scale.x, '--wp-cropper-scale-y': scale.y, + paddingBlock: `${ isAxisSwapped ? paddingX : paddingY }px`, + paddingInline: `${ isAxisSwapped ? paddingY : paddingX }px`, } } ref={ ref } > diff --git a/packages/components/src/image-cropper/styles.ts b/packages/components/src/image-cropper/styles.tsx similarity index 67% rename from packages/components/src/image-cropper/styles.ts rename to packages/components/src/image-cropper/styles.tsx index d626e087d01a2a..1d131746efab57 100644 --- a/packages/components/src/image-cropper/styles.ts +++ b/packages/components/src/image-cropper/styles.tsx @@ -3,17 +3,16 @@ */ 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 PADDING = { - x: 30, - y: 30, -}; - export const Draggable = styled.div` position: absolute; inset: 0; @@ -21,11 +20,36 @@ export const Draggable = styled.div` touch-action: none; `; -export const Resizable = styled( ResizableBox )` +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 )` translate: var( --wp-cropper-window-x ) var( --wp-cropper-window-y ); box-shadow: 0 0 0 100vmax rgba( 0, 0, 0, 0.5 ); will-change: translate; - contain: layout, size, style; + contain: layout size style; &:active { &::after, @@ -67,7 +91,6 @@ export const Container = styled( motion.div )` position: relative; display: flex; overflow: hidden; - padding: ${ PADDING.y }px ${ PADDING.x }px; box-sizing: content-box; contain: strict; `; diff --git a/packages/components/src/resizable-box/index.tsx b/packages/components/src/resizable-box/index.tsx index 1b05270ea0bf20..8a60a09dd5e418 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 ]; From b699f8d99e285f62cb7b3f489d9c8e39c205f284 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Wed, 31 Jul 2024 09:34:53 +0800 Subject: [PATCH 17/34] Fix resizing and scaling from cursor position --- .../src/image-cropper/component.tsx | 60 +++++++++- packages/components/src/image-cropper/hook.ts | 72 +++++++++--- .../components/src/image-cropper/reducer.ts | 106 ++++++++++-------- .../components/src/image-cropper/styles.tsx | 4 +- 4 files changed, 173 insertions(+), 69 deletions(-) diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx index 25a6e81d73deb4..4f663f210a7393 100644 --- a/packages/components/src/image-cropper/component.tsx +++ b/packages/components/src/image-cropper/component.tsx @@ -7,6 +7,7 @@ import { animate } from 'framer-motion'; * WordPress dependencies */ import { forwardRef, useContext, useRef, useEffect } from '@wordpress/element'; +import { useMergeRefs } from '@wordpress/compose'; /** * Internal dependencies */ @@ -149,7 +150,11 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { isZooming, }, src, + originalWidth, + originalHeight, refs: { imageRef }, + getState, + dispatch, } = useContext( ImageCropperContext ); const isAxisSwapped = rotations % 2 !== 0; const degree = angle + rotations * 90; @@ -163,11 +168,48 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { MIN_PADDING - squareImageHorizontalOffset, MIN_PADDING ); + const paddingInline = isAxisSwapped ? paddingY : paddingX; + const paddingBlock = isAxisSwapped ? paddingX : paddingY; const imageOffset = { top: isAxisSwapped ? paddingX + squareImageHorizontalOffset : paddingY, left: isAxisSwapped ? paddingY - squareImageHorizontalOffset : paddingX, }; + const containerRef = useRef< HTMLDivElement >( null! ); + useEffect( () => { + const container = containerRef.current; + const aspectRatio = originalWidth / originalHeight; + + const resizeObserver = new ResizeObserver( ( [ entry ] ) => { + const [ { inlineSize: width } ] = entry.contentBoxSize; + const _isAxisSwapped = getState().transforms.rotations % 2 !== 0; + const { width: imageWidth, height: imageHeight } = getState().image; + const imageDimensions = _isAxisSwapped + ? { + width: imageHeight, + height: imageWidth, + } + : { + width: imageWidth, + height: imageHeight, + }; + + if ( width < imageDimensions.width ) { + dispatch( { + type: 'RESIZE_CONTAINER', + width: _isAxisSwapped ? width * aspectRatio : width, + height: _isAxisSwapped ? width : width / aspectRatio, + } ); + } + } ); + + resizeObserver.observe( container ); + + return () => { + resizeObserver.disconnect(); + }; + }, [ getState, dispatch, originalWidth, originalHeight ] ); + return ( ( ( {}, ref ) => { '--wp-cropper-scale-x': scale.x, '--wp-cropper-scale-y': scale.y, } ), - ...( isDragging + ...( isDragging || isZooming ? {} : { '--wp-cropper-image-x': `${ image.x }px`, @@ -186,16 +228,22 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { } ), } } style={ { - width: `${ isAxisSwapped ? image.height : image.width }px`, - height: `${ isAxisSwapped ? image.width : image.height }px`, + width: `${ + paddingInline * 2 + + ( isAxisSwapped ? image.height : image.width ) + }px`, + height: `${ + paddingBlock * 2 + + ( isAxisSwapped ? image.width : image.height ) + }px`, '--wp-cropper-image-x': `${ image.x }px`, '--wp-cropper-image-y': `${ image.y }px`, '--wp-cropper-scale-x': scale.x, '--wp-cropper-scale-y': scale.y, - paddingBlock: `${ isAxisSwapped ? paddingX : paddingY }px`, - paddingInline: `${ isAxisSwapped ? paddingY : paddingX }px`, + paddingBlock: `${ paddingBlock }px`, + paddingInline: `${ paddingInline }px`, } } - ref={ ref } + ref={ useMergeRefs( [ containerRef, ref ] ) } > { - dispatch( { type: 'ZOOM', scale } ); - }, - onPinchEnd: () => { - dispatch( { type: 'ZOOM_END' } ); - }, - onWheel: ( { pinching, movement: [ , deltaY ] } ) => { - if ( pinching ) { - return; + onPinch: ( { + origin: [ originX, originY ], + offset: [ scale ], + movement: [ deltaScale ], + memo, + first, + } ) => { + if ( first ) { + const { + width: imageWidth, + height: imageHeight, + x, + y, + } = imageRef.current.getBoundingClientRect(); + memo = { + initial: { x: state.image.x, y: state.image.y }, + distances: { + x: originX - ( x + imageWidth / 2 ), + y: originY - ( y + imageHeight / 2 ), + }, + }; } - const deltaScale = deltaY * 0.001; - dispatch( { type: 'ZOOM_BY', deltaScale } ); + dispatch( { + type: 'ZOOM', + scale, + position: { + x: + memo.initial.x - + ( deltaScale - 1 ) * memo.distances.x, + y: + memo.initial.y - + ( deltaScale - 1 ) * memo.distances.y, + }, + } ); + return memo; }, - onWheelEnd: () => { + onPinchEnd: () => { dispatch( { type: 'ZOOM_END' } ); }, onDrag: ( { offset: [ x, y ] } ) => { @@ -66,8 +95,10 @@ export const useImageCropper = ( { }, { target: cropperWindowRef, - pinch: { scaleBounds: { min: 1, max: 10 } }, - wheel: { threshold: 10 }, + pinch: { + scaleBounds: { min: 1, max: 10 }, + from: () => [ Math.abs( state.transforms.scale.x ), 0 ], + }, drag: { from: () => [ state.image.x, state.image.y ] }, } ); @@ -113,17 +144,26 @@ export const useImageCropper = ( { return blob; }, [] ); + const stateRef = useRef< State >( state ); + useEffect( () => { + stateRef.current = state; + }, [ state ] ); + const getState = useCallback( () => stateRef.current, [] ); + return useMemo( () => ( { state, src, + originalWidth: width, + originalHeight: height, refs: { imageRef, cropperWindowRef, }, + getState, dispatch, getImageBlob, } ), - [ state, src, getImageBlob ] + [ state, src, width, height, getImageBlob, getState ] ); }; diff --git a/packages/components/src/image-cropper/reducer.ts b/packages/components/src/image-cropper/reducer.ts index 6a71a7bc9c4b54..204d30c85b9e53 100644 --- a/packages/components/src/image-cropper/reducer.ts +++ b/packages/components/src/image-cropper/reducer.ts @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import type { ResizeDirection } from './types'; +import type { ResizeDirection, Position } from './types'; import { rotatePoint, degreeToRadian, @@ -16,10 +16,10 @@ export type State = { x: number; // The y position of the image center. y: number; - // The width of the image. This doesn't change. - readonly width: number; - // The height of the image. This doesn't change. - readonly height: number; + // The width of the image. + width: number; + // The height of the image. + height: number; }; // The image transforms. transforms: { @@ -51,11 +51,9 @@ export type State = { type Action = // Zoom in/out to a scale. - | { type: 'ZOOM'; scale: number } + | { type: 'ZOOM'; scale: number; position: Position } // End zooming. | { type: 'ZOOM_END' } - // Zoom in/out by a delta scale. - | { type: 'ZOOM_BY'; deltaScale: number } // Flip the image horizontally. | { type: 'FLIP' } // Rotate the image to an angle. @@ -77,7 +75,9 @@ type Action = delta: { width: number; height: number }; } // Reset the state to the initial state. - | { type: 'RESET' }; + | { type: 'RESET' } + // Resize the container and image to a new width and height. + | { type: 'RESIZE_CONTAINER'; width: number; height: number }; function createInitialState( { width, @@ -138,33 +138,10 @@ function imageCropperReducer( state: State, action: Action ) { return { ...state, - transforms: { - ...state.transforms, - scale: { - x: nextScale * Math.sign( scale.x ), - y: nextScale * Math.sign( scale.y ), - }, + image: { + ...state.image, + ...action.position, }, - isZooming: true, - }; - } - case 'ZOOM_BY': { - const minScale = getMinScale( - radian, - image.width, - image.height, - cropper.width, - cropper.height, - image.x, - image.y - ); - const nextScale = Math.min( - Math.max( absScale + action.deltaScale, minScale ), - 10 - ); - - return { - ...state, transforms: { ...state.transforms, scale: { @@ -314,37 +291,35 @@ function imageCropperReducer( state: State, action: Action ) { }, }; } - // TODO: No idea how this should work for rotated(turned) images. case 'RESIZE_WINDOW': { const { direction, delta } = action; - const deltaX = [ 'left', 'bottomLeft', 'topLeft' ].includes( - direction - ) - ? delta.width - : -delta.width; - const deltaY = [ 'top', 'topLeft', 'topRight' ].includes( - direction - ) - ? delta.height - : -delta.height; + + // 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 isAxisSwapped = rotations % 2 !== 0; 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 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 = @@ -353,6 +328,19 @@ function imageCropperReducer( state: State, action: Action ) { 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, image: { @@ -377,6 +365,32 @@ function imageCropperReducer( state: State, action: Action ) { isResizing: false, }; } + case 'RESIZE_CONTAINER': { + if ( + action.width === image.width && + action.height === image.height + ) { + return state; + } + const ratio = action.width / image.width; + return { + ...state, + image: { + ...state.image, + width: action.width, + height: action.height, + x: image.x * ratio, + y: image.y * ratio, + }, + cropper: { + ...state.cropper, + width: cropper.width * ratio, + height: cropper.height * ratio, + x: cropper.x * ratio, + y: cropper.y * ratio, + }, + }; + } case 'RESET': { return createInitialState( { width: image.width, diff --git a/packages/components/src/image-cropper/styles.tsx b/packages/components/src/image-cropper/styles.tsx index 1d131746efab57..ec2d42c779660c 100644 --- a/packages/components/src/image-cropper/styles.tsx +++ b/packages/components/src/image-cropper/styles.tsx @@ -18,6 +18,7 @@ export const Draggable = styled.div` inset: 0; cursor: move; touch-action: none; + overscroll-behavior: none; `; const MotionResizable = motion( @@ -91,8 +92,9 @@ export const Container = styled( motion.div )` position: relative; display: flex; overflow: hidden; - box-sizing: content-box; contain: strict; + max-width: 100%; + box-sizing: border-box; `; export const Img = styled( motion.img )` From d0d996327aafc64f676b438d72ada012cc26d0ee Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Wed, 31 Jul 2024 14:47:05 +0800 Subject: [PATCH 18/34] Fix rotation from the cropper center --- packages/components/src/image-cropper/hook.ts | 2 ++ .../components/src/image-cropper/reducer.ts | 20 +++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/components/src/image-cropper/hook.ts b/packages/components/src/image-cropper/hook.ts index 947ca3fdb98e26..cf1977cdd64c02 100644 --- a/packages/components/src/image-cropper/hook.ts +++ b/packages/components/src/image-cropper/hook.ts @@ -61,6 +61,7 @@ export const useImageCropper = ( { x, y, } = imageRef.current.getBoundingClientRect(); + // Save the initial position and distances from the origin. memo = { initial: { x: state.image.x, y: state.image.y }, distances: { @@ -72,6 +73,7 @@ export const useImageCropper = ( { dispatch( { type: 'ZOOM', scale, + // Calculate the new position based on the scale from the origin. position: { x: memo.initial.x - diff --git a/packages/components/src/image-cropper/reducer.ts b/packages/components/src/image-cropper/reducer.ts index 204d30c85b9e53..e7b480b95c881a 100644 --- a/packages/components/src/image-cropper/reducer.ts +++ b/packages/components/src/image-cropper/reducer.ts @@ -180,6 +180,18 @@ function imageCropperReducer( state: State, action: Action ) { const nextRadian = degreeToRadian( action.angle + rotations * 90 ); 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 deltaRadians = nextRadian - radian; + const rotatedPosition = rotatePoint( + { x: image.x, y: image.y }, + deltaRadians + ); + + // Calculate the minimum scale to fit the image within the cropper. + // TODO: Optimize the performance? const minScale = getMinScale( nextRadian, @@ -187,13 +199,17 @@ function imageCropperReducer( state: State, action: Action ) { scaledHeight, cropper.width, cropper.height, - image.x, - image.y + rotatedPosition.x, + rotatedPosition.y ) * absScale; const nextScale = Math.min( Math.max( absScale, minScale ), 10 ); return { ...state, + image: { + ...state.image, + ...rotatedPosition, + }, transforms: { ...state.transforms, angle: action.angle, From 797ec46d0c020ce6ac94ff3f9b7665cc7351dbc6 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Thu, 1 Aug 2024 00:09:26 +0800 Subject: [PATCH 19/34] Fix resizing container --- .../src/image-cropper/component.tsx | 59 ++++++++++--------- packages/components/src/image-cropper/hook.ts | 17 +----- .../components/src/image-cropper/reducer.ts | 21 +++---- 3 files changed, 44 insertions(+), 53 deletions(-) diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx index 4f663f210a7393..ab1087f08d7a01 100644 --- a/packages/components/src/image-cropper/component.tsx +++ b/packages/components/src/image-cropper/component.tsx @@ -6,7 +6,13 @@ import { animate } from 'framer-motion'; /** * WordPress dependencies */ -import { forwardRef, useContext, useRef, useEffect } from '@wordpress/element'; +import { + forwardRef, + useContext, + useRef, + useEffect, + useMemo, +} from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; /** * Internal dependencies @@ -153,11 +159,14 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { originalWidth, originalHeight, refs: { imageRef }, - getState, dispatch, } = useContext( ImageCropperContext ); const isAxisSwapped = rotations % 2 !== 0; const degree = angle + rotations * 90; + const aspectRatio = useMemo( + () => originalWidth / originalHeight, + [ originalWidth, originalHeight ] + ); const squareImageHorizontalOffset = ( image.width - image.height ) / 2; const paddingY = Math.max( @@ -178,29 +187,20 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { const containerRef = useRef< HTMLDivElement >( null! ); useEffect( () => { const container = containerRef.current; - const aspectRatio = originalWidth / originalHeight; const resizeObserver = new ResizeObserver( ( [ entry ] ) => { - const [ { inlineSize: width } ] = entry.contentBoxSize; - const _isAxisSwapped = getState().transforms.rotations % 2 !== 0; - const { width: imageWidth, height: imageHeight } = getState().image; - const imageDimensions = _isAxisSwapped - ? { - width: imageHeight, - height: imageWidth, - } - : { - width: imageWidth, - height: imageHeight, - }; + const [ { inlineSize } ] = entry.contentBoxSize; + const originalInlineSize = isAxisSwapped + ? originalHeight + : originalWidth; - if ( width < imageDimensions.width ) { - dispatch( { - type: 'RESIZE_CONTAINER', - width: _isAxisSwapped ? width * aspectRatio : width, - height: _isAxisSwapped ? width : width / aspectRatio, - } ); - } + dispatch( { + type: 'RESIZE_CONTAINER', + width: + inlineSize < originalInlineSize + ? inlineSize + : originalInlineSize, + } ); } ); resizeObserver.observe( container ); @@ -208,7 +208,13 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { return () => { resizeObserver.disconnect(); }; - }, [ getState, dispatch, originalWidth, originalHeight ] ); + }, [ + dispatch, + originalWidth, + originalHeight, + aspectRatio, + isAxisSwapped, + ] ); return ( ( ( {}, ref ) => { style={ { width: `${ paddingInline * 2 + - ( isAxisSwapped ? image.height : image.width ) - }px`, - height: `${ - paddingBlock * 2 + - ( isAxisSwapped ? image.width : image.height ) + ( isAxisSwapped ? originalHeight : originalWidth ) }px`, + aspectRatio: '1 / 1', '--wp-cropper-image-x': `${ image.x }px`, '--wp-cropper-image-y': `${ image.y }px`, '--wp-cropper-scale-x': scale.x, diff --git a/packages/components/src/image-cropper/hook.ts b/packages/components/src/image-cropper/hook.ts index cf1977cdd64c02..7a4e0b6aeaba6c 100644 --- a/packages/components/src/image-cropper/hook.ts +++ b/packages/components/src/image-cropper/hook.ts @@ -10,13 +10,7 @@ import { /** * WordPress dependencies */ -import { - useRef, - useMemo, - useReducer, - useCallback, - useEffect, -} from '@wordpress/element'; +import { useRef, useMemo, useReducer, useCallback } from '@wordpress/element'; /** * Internal dependencies @@ -146,12 +140,6 @@ export const useImageCropper = ( { return blob; }, [] ); - const stateRef = useRef< State >( state ); - useEffect( () => { - stateRef.current = state; - }, [ state ] ); - const getState = useCallback( () => stateRef.current, [] ); - return useMemo( () => ( { state, @@ -162,10 +150,9 @@ export const useImageCropper = ( { imageRef, cropperWindowRef, }, - getState, dispatch, getImageBlob, } ), - [ state, src, width, height, getImageBlob, getState ] + [ state, src, width, height, getImageBlob ] ); }; diff --git a/packages/components/src/image-cropper/reducer.ts b/packages/components/src/image-cropper/reducer.ts index e7b480b95c881a..40448fc1ec0878 100644 --- a/packages/components/src/image-cropper/reducer.ts +++ b/packages/components/src/image-cropper/reducer.ts @@ -74,10 +74,10 @@ type Action = direction: ResizeDirection; delta: { width: number; height: number }; } + // Resize the container and image to a new width. + | { type: 'RESIZE_CONTAINER'; width: number } // Reset the state to the initial state. - | { type: 'RESET' } - // Resize the container and image to a new width and height. - | { type: 'RESIZE_CONTAINER'; width: number; height: number }; + | { type: 'RESET' }; function createInitialState( { width, @@ -382,19 +382,20 @@ function imageCropperReducer( state: State, action: Action ) { }; } case 'RESIZE_CONTAINER': { - if ( - action.width === image.width && - action.height === image.height - ) { + const isAxisSwapped = rotations % 2 !== 0; + const imageInlineSize = isAxisSwapped ? image.height : image.width; + const ratio = action.width / imageInlineSize; + + if ( ratio === 1 ) { return state; } - const ratio = action.width / image.width; + return { ...state, image: { ...state.image, - width: action.width, - height: action.height, + width: image.width * ratio, + height: image.height * ratio, x: image.x * ratio, y: image.y * ratio, }, From 2fd0c69b9827aec6926461ce5e45a150e66aaf74 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Tue, 6 Aug 2024 10:30:38 +0800 Subject: [PATCH 20/34] Rename rotate action --- packages/components/src/image-cropper/reducer.ts | 4 ++-- packages/components/src/image-cropper/stories/index.story.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/image-cropper/reducer.ts b/packages/components/src/image-cropper/reducer.ts index 40448fc1ec0878..d414f5a61685ae 100644 --- a/packages/components/src/image-cropper/reducer.ts +++ b/packages/components/src/image-cropper/reducer.ts @@ -59,7 +59,7 @@ type Action = // Rotate the image to an angle. | { type: 'ROTATE'; angle: number } // Rotate the image 90-degree clockwise or counter-clockwise. - | { type: 'ROTATE_CLOCKWISE'; isCounterClockwise?: boolean } + | { type: 'ROTATE_90_DEG'; isCounterClockwise?: boolean } // Move the image to a position. | { type: 'MOVE'; x: number; y: number } // End moving the image. @@ -220,7 +220,7 @@ function imageCropperReducer( state: State, action: Action ) { }, }; } - case 'ROTATE_CLOCKWISE': { + case 'ROTATE_90_DEG': { const isCounterClockwise = action.isCounterClockwise; const nextRotations = rotations + ( isCounterClockwise ? -1 : 1 ); const rotatedPosition = rotatePoint( diff --git a/packages/components/src/image-cropper/stories/index.story.tsx b/packages/components/src/image-cropper/stories/index.story.tsx index 0ce81dac0fbd9f..2ab9f5e4f53b7a 100644 --- a/packages/components/src/image-cropper/stories/index.story.tsx +++ b/packages/components/src/image-cropper/stories/index.story.tsx @@ -76,7 +76,7 @@ function TemplateControls() { variant="secondary" onClick={ () => { dispatch( { - type: 'ROTATE_CLOCKWISE', + type: 'ROTATE_90_DEG', isCounterClockwise: true, } ); } } @@ -86,7 +86,7 @@ function TemplateControls() { + ( { + title: control.title, + role: 'menuitemradio', + icon: aspectRatio, + isActive: state.cropper.lockAspectRatio + ? 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, + } ); + } + }, + } ) ) } + /> + + + ( { + 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 ? ( + + preview + + ) : null; +} + function StateLogger() { const { state } = useContext( ImageCropperContext ); - return
{ JSON.stringify( state, null, 2 ) }
; + return ( +
+			{ JSON.stringify( state, null, 2 ) }
+		
+ ); } -function TemplateControls() { - const { state, dispatch, getImageBlob } = useContext( ImageCropperContext ); +export const Inline: StoryObj< typeof ImageCropper.Provider > = ( + args: ComponentProps< typeof ImageCropper.Provider > +) => { const [ previewUrl, setPreviewUrl ] = useState< string >( '' ); return ( - <> + - - { previewUrl && ( - - - preview - - - ) } - - - - { - 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, - } ); - } - }, - } ) ) } - /> - - - - - + + + + ); -} +}; +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 >( '' ); -const Template: StoryFn< typeof ImageCropper.Provider > = ( { ...args } ) => { return ( - - + + + + + + + + + + ); }; - -export const Default: StoryObj< typeof ImageCropper.Provider > = Template.bind( - {} -); -Default.args = { +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 index fc037b1284b515..0004556a21b634 100644 --- a/packages/components/src/image-cropper/styles.tsx +++ b/packages/components/src/image-cropper/styles.tsx @@ -47,15 +47,14 @@ const MotionResizable = motion( ); export const Resizable = styled( MotionResizable )` - --wp-cropper-window-x: 0px; - --wp-cropper-window-y: 0px; + /* --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% ); - box-shadow: 0 0 0 100vmax rgba( 0, 0, 0, 0.5 ); will-change: translate; contain: layout size style; @@ -95,15 +94,26 @@ export const Resizable = styled( MotionResizable )` } `; +export const MaxWidthWrapper = styled.div` + position: relative; + max-width: 100%; + + min-width: 0; +`; + export const Container = styled( motion.div )` position: relative; display: flex; - overflow: hidden; - contain: strict; - max-width: 100%; box-sizing: border-box; `; +export const ContainWindow = styled.div` + position: absolute; + inset: 0; + contain: strict; + overflow: hidden; +`; + export const Img = styled( motion.img )` position: absolute; pointer-events: none; @@ -112,8 +122,27 @@ export const Img = styled( motion.img )` 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 ) - 50% ) - calc( var( --wp-cropper-image-y ) - 50% ); + 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; `; + +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; + } +`; From 15b4f3080ff588e93289bb51d7e8524a17af12e8 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Fri, 16 Aug 2024 16:13:15 +0800 Subject: [PATCH 34/34] Use ID as a namespace to increase specificity --- .../image-editor/v2/aspect-ratio-dropdown.js | 4 +-- .../src/image-cropper/component.tsx | 11 +++++++ .../components/src/image-cropper/styles.tsx | 33 +++++++++++-------- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/block-editor/src/components/image-editor/v2/aspect-ratio-dropdown.js b/packages/block-editor/src/components/image-editor/v2/aspect-ratio-dropdown.js index cf8fb2eb14ddee..8962e140b454b9 100644 --- a/packages/block-editor/src/components/image-editor/v2/aspect-ratio-dropdown.js +++ b/packages/block-editor/src/components/image-editor/v2/aspect-ratio-dropdown.js @@ -56,7 +56,7 @@ function presetRatioAsNumber( { ratio, ...rest } ) { export default function AspectRatioDropdown( { toggleProps } ) { const { - state: { image, cropper }, + state: { image, cropper, isAspectRatioLocked }, dispatch, } = useImageCropper(); const defaultAspect = image.width / image.height; @@ -89,7 +89,7 @@ export default function AspectRatioDropdown( { toggleProps } ) { } onClose(); } } - value={ aspectRatio } + value={ isAspectRatioLocked ? aspectRatio : 0 } aspectRatios={ [ // All ratios should be mirrored in AspectRatioTool in @wordpress/block-editor. { diff --git a/packages/components/src/image-cropper/component.tsx b/packages/components/src/image-cropper/component.tsx index 578c314121353e..b92131b2e465ba 100644 --- a/packages/components/src/image-cropper/component.tsx +++ b/packages/components/src/image-cropper/component.tsx @@ -190,6 +190,9 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { return ( ( ( {}, ref ) => { crossOrigin="anonymous" isResizing={ isResizing } isDragging={ isDragging } + style={ { + width: `${ image.width }px`, + height: `${ image.height }px`, + } } /> @@ -242,6 +249,10 @@ const Cropper = forwardRef< HTMLDivElement >( ( {}, ref ) => { alt="" crossOrigin="anonymous" ref={ imageRef } + style={ { + width: `${ image.width }px`, + height: `${ image.height }px`, + } } /> diff --git a/packages/components/src/image-cropper/styles.tsx b/packages/components/src/image-cropper/styles.tsx index 0004556a21b634..9f9bf1a5904f52 100644 --- a/packages/components/src/image-cropper/styles.tsx +++ b/packages/components/src/image-cropper/styles.tsx @@ -97,7 +97,6 @@ export const Resizable = styled( MotionResizable )` export const MaxWidthWrapper = styled.div` position: relative; max-width: 100%; - min-width: 0; `; @@ -115,19 +114,25 @@ export const ContainWindow = styled.div` `; export const Img = styled( motion.img )` - 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; + // 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, {