@wordpress/image-cropper 1.0.1-next.6deb34194.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/LICENSE.md +788 -0
  3. package/README.md +196 -0
  4. package/build/components/image-cropper/index.js +97 -0
  5. package/build/components/image-cropper/index.js.map +7 -0
  6. package/build/constants.js +37 -0
  7. package/build/constants.js.map +7 -0
  8. package/build/index.js +52 -0
  9. package/build/index.js.map +7 -0
  10. package/build/provider/index.js +86 -0
  11. package/build/provider/index.js.map +7 -0
  12. package/build/provider/use-image-cropper.js +189 -0
  13. package/build/provider/use-image-cropper.js.map +7 -0
  14. package/build/types.js +19 -0
  15. package/build/types.js.map +7 -0
  16. package/build/utils.js +107 -0
  17. package/build/utils.js.map +7 -0
  18. package/build-module/components/image-cropper/index.js +66 -0
  19. package/build-module/components/image-cropper/index.js.map +7 -0
  20. package/build-module/constants.js +10 -0
  21. package/build-module/constants.js.map +7 -0
  22. package/build-module/index.js +12 -0
  23. package/build-module/index.js.map +7 -0
  24. package/build-module/provider/index.js +50 -0
  25. package/build-module/provider/index.js.map +7 -0
  26. package/build-module/provider/use-image-cropper.js +167 -0
  27. package/build-module/provider/use-image-cropper.js.map +7 -0
  28. package/build-module/types.js +1 -0
  29. package/build-module/types.js.map +7 -0
  30. package/build-module/utils.js +78 -0
  31. package/build-module/utils.js.map +7 -0
  32. package/build-types/components/image-cropper/index.d.ts +3 -0
  33. package/build-types/components/image-cropper/index.d.ts.map +1 -0
  34. package/build-types/constants.d.ts +4 -0
  35. package/build-types/constants.d.ts.map +1 -0
  36. package/build-types/index.d.ts +5 -0
  37. package/build-types/index.d.ts.map +1 -0
  38. package/build-types/provider/index.d.ts +7 -0
  39. package/build-types/provider/index.d.ts.map +1 -0
  40. package/build-types/provider/use-image-cropper.d.ts +15 -0
  41. package/build-types/provider/use-image-cropper.d.ts.map +1 -0
  42. package/build-types/stories/index.story.d.ts +28 -0
  43. package/build-types/stories/index.story.d.ts.map +1 -0
  44. package/build-types/types.d.ts +42 -0
  45. package/build-types/types.d.ts.map +1 -0
  46. package/build-types/utils.d.ts +60 -0
  47. package/build-types/utils.d.ts.map +1 -0
  48. package/package.json +49 -0
  49. package/src/components/image-cropper/index.tsx +84 -0
  50. package/src/constants.ts +3 -0
  51. package/src/index.ts +4 -0
  52. package/src/provider/index.tsx +57 -0
  53. package/src/provider/use-image-cropper.ts +220 -0
  54. package/src/stories/index.story.tsx +351 -0
  55. package/src/stories/style.css +17 -0
  56. package/src/types.ts +54 -0
  57. package/src/utils.ts +146 -0
  58. package/tsconfig.json +8 -0
  59. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { createContext, useContext, useMemo } from '@wordpress/element';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import useCropper from './use-image-cropper';
10
+ import type { ImageCropperContextValue } from '../types';
11
+ import { MIN_ZOOM } from '../constants';
12
+
13
+ export const ImageCropperContext = createContext< ImageCropperContextValue >( {
14
+ cropperState: {
15
+ crop: { x: 0, y: 0 },
16
+ croppedArea: { x: 0, y: 0, width: 100, height: 100 },
17
+ croppedAreaPixels: null,
18
+ zoom: MIN_ZOOM,
19
+ rotation: 0,
20
+ aspectRatio: 1,
21
+ flip: { horizontal: false, vertical: false },
22
+ mediaSize: null,
23
+ },
24
+ setCropperState: () => {},
25
+ resetState: null,
26
+ setResetState: () => {},
27
+ isDirty: false,
28
+ reset: () => {},
29
+ getCroppedImage: () => Promise.resolve( null ),
30
+ } );
31
+
32
+ export default function ImageCropperProvider( {
33
+ children,
34
+ }: {
35
+ children: React.ReactNode;
36
+ } ) {
37
+ const cropperApi = useCropper();
38
+ const contextValue = useMemo( () => {
39
+ return {
40
+ ...cropperApi,
41
+ };
42
+ }, [ cropperApi ] );
43
+
44
+ return (
45
+ <ImageCropperContext.Provider value={ contextValue }>
46
+ { children }
47
+ </ImageCropperContext.Provider>
48
+ );
49
+ }
50
+
51
+ export const useImageCropper = () => {
52
+ const context = useContext( ImageCropperContext );
53
+ if ( ! context ) {
54
+ throw new Error( 'Missing ImageCropperContext' );
55
+ }
56
+ return context;
57
+ };
@@ -0,0 +1,220 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { dequal } from 'dequal';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { useMemo, useState, useCallback } from '@wordpress/element';
10
+
11
+ /**
12
+ * Internal dependencies
13
+ */
14
+ import type { Point, ImageCropperState, CropperState } from '../types';
15
+ import { MIN_ZOOM } from '../constants';
16
+ import {
17
+ getCroppedImage as getCroppedImageUtil,
18
+ normalizeRotation,
19
+ } from '../utils';
20
+
21
+ export const DEFAULT_INITIAL_STATE: Required< ImageCropperState > = {
22
+ crop: {
23
+ x: 0,
24
+ y: 0,
25
+ width: 100,
26
+ height: 100,
27
+ },
28
+ zoom: MIN_ZOOM,
29
+ rotation: 0,
30
+ aspectRatio: 1,
31
+ flip: {
32
+ horizontal: false,
33
+ vertical: false,
34
+ },
35
+ };
36
+
37
+ const DEFAULT_CROP_MEDIA_POSITION: Point = {
38
+ x: 0,
39
+ y: 0,
40
+ };
41
+
42
+ const DEFAULT_CROPPER_STATE: CropperState = {
43
+ crop: DEFAULT_CROP_MEDIA_POSITION,
44
+ croppedArea: DEFAULT_INITIAL_STATE.crop,
45
+ croppedAreaPixels: null,
46
+ zoom: DEFAULT_INITIAL_STATE.zoom,
47
+ rotation: DEFAULT_INITIAL_STATE.rotation,
48
+ flip: DEFAULT_INITIAL_STATE.flip,
49
+ aspectRatio: DEFAULT_INITIAL_STATE.aspectRatio,
50
+ mediaSize: null,
51
+ };
52
+
53
+ export default function useCropper() {
54
+ const [ cropperState, setInternalCropperState ] = useState< CropperState >(
55
+ DEFAULT_CROPPER_STATE
56
+ );
57
+ const [ resetState, setInternalResetState ] =
58
+ useState< ImageCropperState | null >( null );
59
+
60
+ // Unified setter that supports both partial updates and function updates
61
+ const setCropperState = useCallback(
62
+ (
63
+ newState:
64
+ | Partial< CropperState >
65
+ | ( ( prev: CropperState ) => Partial< CropperState > )
66
+ ) => {
67
+ setInternalCropperState( ( prev ) => {
68
+ const updates =
69
+ typeof newState === 'function'
70
+ ? newState( prev )
71
+ : newState;
72
+
73
+ // Apply normalization to rotation if it's being updated
74
+ const normalizedUpdates = { ...updates };
75
+ if (
76
+ 'rotation' in normalizedUpdates &&
77
+ normalizedUpdates.rotation !== undefined
78
+ ) {
79
+ normalizedUpdates.rotation = normalizeRotation(
80
+ normalizedUpdates.rotation
81
+ );
82
+ }
83
+
84
+ return { ...prev, ...normalizedUpdates };
85
+ } );
86
+ },
87
+ []
88
+ );
89
+
90
+ const setResetState = useCallback(
91
+ ( newResetState: Partial< ImageCropperState > | null = null ) => {
92
+ // If null, wipe the reset state and reset the cropper state to the default state.
93
+ if ( ! newResetState ) {
94
+ setInternalResetState( null );
95
+ setCropperState( DEFAULT_CROPPER_STATE );
96
+ return;
97
+ }
98
+ if ( typeof newResetState === 'object' ) {
99
+ const initialState = {
100
+ ...DEFAULT_INITIAL_STATE,
101
+ ...newResetState,
102
+ };
103
+ setInternalResetState( initialState );
104
+ setCropperState( initialState );
105
+ }
106
+ },
107
+ [ setCropperState, setInternalResetState ]
108
+ );
109
+
110
+ /*
111
+ * Resets the cropper state.
112
+ */
113
+ const reset = useCallback( () => {
114
+ if ( resetState ) {
115
+ // Convert ImageCropperState to CropperState updates
116
+ const resetUpdates: Partial< CropperState > = {
117
+ // Reset media position to center
118
+ crop: { x: 0, y: 0 },
119
+ // Reset cropped area pixels (will be recalculated)
120
+ croppedAreaPixels: null,
121
+ };
122
+
123
+ // Set the cropped area from resetState (this is the target crop area)
124
+ if ( resetState.crop ) {
125
+ resetUpdates.croppedArea = resetState.crop;
126
+ }
127
+ if ( resetState.zoom !== undefined ) {
128
+ resetUpdates.zoom = resetState.zoom;
129
+ }
130
+ if ( resetState.rotation !== undefined ) {
131
+ resetUpdates.rotation = resetState.rotation;
132
+ }
133
+ if ( resetState.aspectRatio !== undefined ) {
134
+ resetUpdates.aspectRatio = resetState.aspectRatio;
135
+ }
136
+ if ( resetState.flip !== undefined ) {
137
+ resetUpdates.flip = resetState.flip;
138
+ }
139
+
140
+ setCropperState( resetUpdates );
141
+ } else {
142
+ setCropperState( { ...DEFAULT_CROPPER_STATE } );
143
+ }
144
+ }, [ resetState, setCropperState ] );
145
+
146
+ /*
147
+ * Returns true if the cropper state is dirty.
148
+ * Compare against reset state if available, otherwise against default state.
149
+ */
150
+ const isDirty = useMemo( () => {
151
+ if ( resetState ) {
152
+ // Compare the relevant cropper properties against reset state
153
+ const currentState = {
154
+ crop:
155
+ cropperState.croppedAreaPixels || cropperState.croppedArea,
156
+ zoom: cropperState.zoom,
157
+ rotation: normalizeRotation( cropperState.rotation ),
158
+ aspectRatio: cropperState.aspectRatio,
159
+ flip: cropperState.flip,
160
+ };
161
+ return false === dequal( currentState, resetState );
162
+ }
163
+
164
+ // Compare against default state using percentage values
165
+ const currentState = {
166
+ crop: cropperState.croppedArea,
167
+ zoom: cropperState.zoom,
168
+ rotation: normalizeRotation( cropperState.rotation ),
169
+ aspectRatio: cropperState.aspectRatio,
170
+ flip: cropperState.flip,
171
+ };
172
+ return false === dequal( currentState, DEFAULT_INITIAL_STATE );
173
+ }, [ cropperState, resetState ] );
174
+
175
+ /**
176
+ * Returns the cropped image.
177
+ *
178
+ * @param {string} src - The source of the image to crop.
179
+ * @return {Promise<string | null>} A promise that resolves to the cropped image.
180
+ */
181
+ const getCroppedImage = useCallback(
182
+ async ( src: string ) => {
183
+ if ( ! cropperState.croppedAreaPixels ) {
184
+ return null;
185
+ }
186
+ return getCroppedImageUtil(
187
+ src,
188
+ cropperState.croppedAreaPixels,
189
+ cropperState.rotation,
190
+ cropperState.flip
191
+ );
192
+ },
193
+ [
194
+ cropperState.croppedAreaPixels,
195
+ cropperState.rotation,
196
+ cropperState.flip,
197
+ ]
198
+ );
199
+
200
+ return useMemo(
201
+ () => ( {
202
+ cropperState,
203
+ setCropperState,
204
+ resetState,
205
+ setResetState,
206
+ isDirty,
207
+ reset,
208
+ getCroppedImage,
209
+ } ),
210
+ [
211
+ cropperState,
212
+ setCropperState,
213
+ resetState,
214
+ setResetState,
215
+ isDirty,
216
+ reset,
217
+ getCroppedImage,
218
+ ]
219
+ );
220
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useCallback, useRef, useState } from '@wordpress/element';
5
+ import { __, sprintf } from '@wordpress/i18n';
6
+ import {
7
+ __experimentalHStack as HStack,
8
+ __experimentalVStack as VStack,
9
+ __experimentalHeading as Heading,
10
+ SelectControl,
11
+ } from '@wordpress/components';
12
+
13
+ /**
14
+ * Internal dependencies
15
+ */
16
+ import ImageCropper from '../components/image-cropper';
17
+ import ImageCropperProvider, { useImageCropper } from '../provider';
18
+ import type { ImageCropperProps, MediaSize } from '../types';
19
+ import { MIN_ZOOM, MAX_ZOOM } from '../constants';
20
+ import './style.css';
21
+
22
+ export default {
23
+ title: 'ImageCropper/ImageCropper',
24
+ component: ImageCropper,
25
+ };
26
+
27
+ const DefaultComponent = ( args: ImageCropperProps ) => {
28
+ return (
29
+ <ImageCropperProvider>
30
+ <div className="image-cropper__container-wrapper-story">
31
+ <div className="image-cropper__container-story">
32
+ <ImageCropper { ...args } />
33
+ </div>
34
+ </div>
35
+ </ImageCropperProvider>
36
+ );
37
+ };
38
+
39
+ export const Default = {
40
+ render: DefaultComponent,
41
+ args: {
42
+ src: 'https://s.w.org/images/core/5.3/MtBlanc1.jpg',
43
+ minZoom: 1,
44
+ maxZoom: 5,
45
+ },
46
+ };
47
+
48
+ const WithControlsComponent = ( args: ImageCropperProps ) => {
49
+ return (
50
+ <ImageCropperProvider>
51
+ <WithControlsContent { ...args } />
52
+ </ImageCropperProvider>
53
+ );
54
+ };
55
+
56
+ const WithControlsContent = ( args: ImageCropperProps ) => {
57
+ const { cropperState, setCropperState } = useImageCropper();
58
+ const containerRef = useRef< HTMLDivElement | null >( null );
59
+ const { containerStyle, handleOnload } = useHandleOnload( containerRef );
60
+ const handleRotateLeft = useCallback( () => {
61
+ setCropperState( { rotation: cropperState.rotation - 90 } );
62
+ }, [ cropperState.rotation, setCropperState ] );
63
+
64
+ const handleRotateRight = useCallback( () => {
65
+ setCropperState( { rotation: cropperState.rotation + 90 } );
66
+ }, [ cropperState.rotation, setCropperState ] );
67
+
68
+ const handleFlipHorizontal = useCallback( () => {
69
+ setCropperState( {
70
+ flip: {
71
+ horizontal: ! cropperState.flip.horizontal,
72
+ vertical: cropperState.flip.vertical,
73
+ },
74
+ } );
75
+ }, [
76
+ cropperState.flip.vertical,
77
+ cropperState.flip.horizontal,
78
+ setCropperState,
79
+ ] );
80
+
81
+ const handleFlipVertical = useCallback( () => {
82
+ setCropperState( {
83
+ flip: {
84
+ horizontal: cropperState.flip.horizontal,
85
+ vertical: ! cropperState.flip.vertical,
86
+ },
87
+ } );
88
+ }, [
89
+ cropperState.flip.vertical,
90
+ cropperState.flip.horizontal,
91
+ setCropperState,
92
+ ] );
93
+
94
+ const handleZoomChange = useCallback(
95
+ ( event: React.ChangeEvent< HTMLInputElement > ) => {
96
+ setCropperState( { zoom: parseFloat( event.target.value ) } );
97
+ },
98
+ [ setCropperState ]
99
+ );
100
+
101
+ const handleAspectRatioChange = useCallback(
102
+ ( value: string ) => {
103
+ setCropperState( { aspectRatio: parseFloat( value ) } );
104
+ },
105
+ [ setCropperState ]
106
+ );
107
+
108
+ const reset = useCallback( () => {
109
+ setCropperState( {
110
+ rotation: 0,
111
+ zoom: MIN_ZOOM,
112
+ aspectRatio: 1,
113
+ flip: { horizontal: false, vertical: false },
114
+ crop: { x: 0, y: 0 },
115
+ } );
116
+ }, [ setCropperState ] );
117
+
118
+ const aspectRatioOptions = [
119
+ { label: __( '1:1 (Square)' ), value: '1' },
120
+ { label: __( '16:9 (Widescreen)' ), value: ( 16 / 9 ).toString() },
121
+ { label: __( '9:16 (Portrait)' ), value: ( 9 / 16 ).toString() },
122
+ { label: __( '4:3 (Standard)' ), value: ( 4 / 3 ).toString() },
123
+ { label: __( '3:4 (Portrait)' ), value: ( 3 / 4 ).toString() },
124
+ ];
125
+
126
+ return (
127
+ <>
128
+ <VStack spacing={ 4 }>
129
+ <VStack spacing={ 2 }>
130
+ <Heading level={ 5 }>
131
+ { ' ' }
132
+ { sprintf(
133
+ /* translators: %d: rotation anglein degrees */
134
+ __( 'Rotation: %d' ),
135
+ cropperState.rotation
136
+ ) }
137
+ </Heading>
138
+ <HStack justify="flex-start" spacing={ 4 }>
139
+ <button onClick={ handleRotateLeft }>
140
+ { __( 'Rotate left' ) }
141
+ </button>
142
+ <button onClick={ handleRotateRight }>
143
+ { __( 'Rotate right' ) }
144
+ </button>
145
+ </HStack>
146
+ </VStack>
147
+ <VStack spacing={ 2 }>
148
+ <Heading level={ 5 }>
149
+ { ' ' }
150
+ { sprintf(
151
+ /* translators: 1: horizontal flip, 2: vertical flip */
152
+ __( 'Flip: %1$s / %2$s' ),
153
+ cropperState.flip.horizontal
154
+ ? __( 'Horizontal' )
155
+ : __( 'None' ),
156
+ cropperState.flip.vertical
157
+ ? __( 'Vertical' )
158
+ : __( 'None' )
159
+ ) }
160
+ </Heading>
161
+ <HStack justify="flex-start" spacing={ 4 }>
162
+ <button onClick={ handleFlipHorizontal }>
163
+ { __( 'Flip horizontal' ) }
164
+ </button>
165
+ <button onClick={ handleFlipVertical }>
166
+ { __( 'Flip vertical' ) }
167
+ </button>
168
+ </HStack>
169
+ </VStack>
170
+ <VStack spacing={ 2 }>
171
+ <Heading level={ 5 }>
172
+ { sprintf(
173
+ /* translators: %s: zoom level */
174
+ __( 'Zoom: %s' ),
175
+ cropperState.zoom.toFixed( 2 )
176
+ ) }
177
+ </Heading>
178
+ <VStack spacing={ 2 }>
179
+ <input
180
+ type="range"
181
+ min={ MIN_ZOOM }
182
+ max={ MAX_ZOOM }
183
+ step="0.1"
184
+ value={ cropperState.zoom }
185
+ onChange={ handleZoomChange }
186
+ />
187
+ </VStack>
188
+ </VStack>
189
+ <VStack spacing={ 2 }>
190
+ <Heading level={ 5 }>
191
+ { sprintf(
192
+ /* translators: %s: aspect ratio */
193
+ __( 'Aspect Ratio: %s' ),
194
+ cropperState.aspectRatio.toFixed( 2 )
195
+ ) }
196
+ </Heading>
197
+ <SelectControl
198
+ value={ cropperState.aspectRatio.toString() }
199
+ options={ aspectRatioOptions }
200
+ onChange={ handleAspectRatioChange }
201
+ __next40pxDefaultSize
202
+ __nextHasNoMarginBottom
203
+ />
204
+ </VStack>
205
+ <HStack style={ { marginBottom: '20px' } } spacing={ 2 }>
206
+ <button onClick={ reset }>{ __( 'Reset' ) }</button>
207
+ </HStack>
208
+ </VStack>
209
+
210
+ <div className="image-cropper__container-wrapper-story">
211
+ <div
212
+ className="image-cropper__container-story"
213
+ ref={ containerRef }
214
+ style={ {
215
+ ...containerStyle,
216
+ } }
217
+ >
218
+ <ImageCropper { ...args } onLoad={ handleOnload } />
219
+ </div>
220
+ </div>
221
+ </>
222
+ );
223
+ };
224
+
225
+ export const WithControls = {
226
+ render: WithControlsComponent,
227
+ args: {
228
+ src: 'https://s.w.org/images/core/5.3/MtBlanc1.jpg',
229
+ minZoom: 1,
230
+ maxZoom: 5,
231
+ },
232
+ };
233
+
234
+ // Utils.
235
+
236
+ /**
237
+ * Handles the onload event of the image cropper, calculating the maximum scale that can be used to fit the image into the container.
238
+ *
239
+ * @param containerRef - The ref to the container element.
240
+ * @return The container style and the handleOnload function.
241
+ */
242
+ function useHandleOnload( containerRef: React.RefObject< HTMLDivElement > ) {
243
+ const [ containerStyle, setContainerStyle ] = useState< {
244
+ minHeight?: string;
245
+ minWidth?: string;
246
+ maxWidth?: string;
247
+ maxHeight?: string;
248
+ } | null >( null );
249
+ const handleOnload = useCallback(
250
+ ( mediaSize: MediaSize ) => {
251
+ /*
252
+ * Because rotation is performed using CSS transforms, the media is outside the flow
253
+ * of the container. The cropper lib does not handle dynamic resize by default,
254
+ * so the solution is just to adjust the container so that the image will fit
255
+ * inside the crop area regardless of the rotation.
256
+ */
257
+ if (
258
+ containerRef &&
259
+ containerRef.current?.offsetWidth &&
260
+ containerRef.current?.offsetHeight
261
+ ) {
262
+ const { scaledWidth, scaledHeight } =
263
+ getMaximumScaledDimensions(
264
+ mediaSize.width,
265
+ mediaSize.height,
266
+ containerRef.current.offsetWidth,
267
+ containerRef.current.offsetHeight
268
+ );
269
+ /*
270
+ * Depending on the image's aspect ration, allow scaling with window.
271
+ * Set minHeight/maxHeight to the container width to ensure that the image
272
+ * fits into the crop area, even when the image is rotated.
273
+ */
274
+ if ( mediaSize.width > containerRef.current.offsetHeight ) {
275
+ setContainerStyle( {
276
+ maxWidth: `${ scaledWidth }px`,
277
+ minHeight: `${ scaledWidth }px`,
278
+ } );
279
+ } else if (
280
+ mediaSize.height > containerRef.current.offsetWidth
281
+ ) {
282
+ setContainerStyle( {
283
+ maxHeight: `${ scaledHeight }px`,
284
+ minWidth: `${ scaledHeight }px`,
285
+ } );
286
+ }
287
+ }
288
+ },
289
+ [ containerRef ]
290
+ );
291
+ return { containerStyle, handleOnload };
292
+ }
293
+
294
+ /**
295
+ * Calculates the maximum scale that can be used to fit the image into the container.
296
+ * To maximize the bounding box, this function assumes 90° rotations (0°, 90°, 180°, 270°) only.
297
+ *
298
+ * Later, if 0-360° rotations are supported, this function will need to be updated
299
+ * to account for the maximum diagonal dimensions. E.g.,
300
+ *
301
+ * ```
302
+ * // The maximum bounding box dimensions occur at 45° for rectangles.
303
+ * // Max bounding width/height = (width + height) / √2 * √2 = width + height
304
+ * // But this is only true for squares. For rectangles, we need to be more precise.
305
+ * const diagonal = Math.sqrt(
306
+ * imageWidth * imageWidth + imageHeight * imageHeight
307
+ * );
308
+ *
309
+ * // Scale to fit this maximum bounding square in the container.
310
+ * const scale = Math.min(
311
+ * containerWidth / diagonal,
312
+ * containerHeight / diagonal
313
+ * );
314
+ * ```
315
+ *
316
+ * @param imageWidth - The width of the image.
317
+ * @param imageHeight - The height of the image.
318
+ * @param containerWidth - The width of the container.
319
+ * @param containerHeight - The height of the container.
320
+ */
321
+ function getMaximumScaledDimensions(
322
+ imageWidth: number,
323
+ imageHeight: number,
324
+ containerWidth: number,
325
+ containerHeight: number
326
+ ): {
327
+ scale: number;
328
+ scaledWidth: number;
329
+ scaledHeight: number;
330
+ } {
331
+ // Calculate scale for original orientation.
332
+ const scaleOriginal = Math.min(
333
+ containerWidth / imageWidth,
334
+ containerHeight / imageHeight
335
+ );
336
+
337
+ // Calculate scale for 90° rotated orientation.
338
+ const scaleRotated = Math.min(
339
+ containerWidth / imageHeight, // Rotated width is original height.
340
+ containerHeight / imageWidth // Rotated height is original width.
341
+ );
342
+
343
+ // Use the smaller scale to ensure it fits in both orientations
344
+ const scale = Math.min( scaleOriginal, scaleRotated );
345
+
346
+ return {
347
+ scale,
348
+ scaledWidth: imageWidth * scale,
349
+ scaledHeight: imageHeight * scale,
350
+ };
351
+ }
@@ -0,0 +1,17 @@
1
+ .image-cropper__container-wrapper-story {
2
+ background: rgba( 0, 0, 0, 0.1 ) !important;
3
+ height: 500px;
4
+ width: 100%;
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ box-sizing: border-box;
9
+ }
10
+ .image-cropper__container-story {
11
+ position: relative;
12
+ height: 100%;
13
+ width: 100%;
14
+ }
15
+ .image-cropper__crop-area {
16
+ box-shadow: unset !important;
17
+ }
package/src/types.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import type { Point, Area, MediaSize } from 'react-easy-crop';
5
+
6
+ // Re-export types for convenience.
7
+ export type { Point, Area, MediaSize };
8
+
9
+ export type Flip = {
10
+ horizontal: boolean;
11
+ vertical: boolean;
12
+ };
13
+
14
+ export interface ImageCropperState {
15
+ rotation?: number;
16
+ crop?: Area;
17
+ zoom?: number;
18
+ flip?: Flip;
19
+ aspectRatio?: number;
20
+ }
21
+
22
+ export interface CropperState {
23
+ crop: Point;
24
+ croppedArea?: Area;
25
+ croppedAreaPixels?: Area | null;
26
+ zoom: number;
27
+ rotation: number;
28
+ flip: Flip;
29
+ aspectRatio: number;
30
+ mediaSize: MediaSize | null;
31
+ }
32
+
33
+ export interface ImageCropperProps {
34
+ src: string;
35
+ onLoad?: ( mediaSize: MediaSize ) => void;
36
+ minZoom?: number;
37
+ maxZoom?: number;
38
+ }
39
+
40
+ export interface ImageCropperContextValue {
41
+ cropperState: CropperState;
42
+ setCropperState: (
43
+ newState:
44
+ | Partial< CropperState >
45
+ | ( ( prev: CropperState ) => Partial< CropperState > )
46
+ ) => void;
47
+ resetState: ImageCropperState | null;
48
+ setResetState: (
49
+ newResetState: Partial< ImageCropperState > | null
50
+ ) => void;
51
+ isDirty: boolean;
52
+ reset: () => void;
53
+ getCroppedImage: ( src: string ) => Promise< string | null >;
54
+ }