@justin_evo/evo-ui 1.1.0 → 1.2.1

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 (80) hide show
  1. package/README.md +3 -3
  2. package/dist/TopNav/TopNav.d.ts +19 -0
  3. package/dist/declarations.d.ts +6 -6
  4. package/dist/evo-ui.css +1 -1
  5. package/dist/index.cjs.js +1 -1
  6. package/dist/index.es.js +3301 -3197
  7. package/package.json +52 -52
  8. package/src/Alert/Alert.tsx +49 -49
  9. package/src/AutoComplete/AutoComplete.tsx +810 -810
  10. package/src/Badge/Badge.tsx +53 -53
  11. package/src/Breadcrumb/Breadcrumb.tsx +53 -53
  12. package/src/Button/Button.tsx +125 -125
  13. package/src/Card/Card.tsx +257 -257
  14. package/src/Checkbox/Checkbox.tsx +59 -59
  15. package/src/CommandPalette/CommandPalette.tsx +185 -185
  16. package/src/Container/Container.tsx +31 -31
  17. package/src/Divider/Divider.tsx +31 -31
  18. package/src/Form/Form.tsx +185 -185
  19. package/src/Grid/Grid.tsx +66 -66
  20. package/src/ImageCropper/ImageCropper.tsx +911 -911
  21. package/src/Input/Input.tsx +74 -74
  22. package/src/Modal/Modal.tsx +77 -77
  23. package/src/Nav/Nav.tsx +708 -708
  24. package/src/Notification/Notification.tsx +1503 -1503
  25. package/src/Pagination/Pagination.tsx +76 -76
  26. package/src/Radio/Radio.tsx +69 -69
  27. package/src/RichTextArea/RichTextArea.tsx +886 -869
  28. package/src/Select/Select.tsx +515 -515
  29. package/src/Skeleton/Skeleton.tsx +70 -70
  30. package/src/Stack/Stack.tsx +52 -52
  31. package/src/Table/Table.tsx +335 -335
  32. package/src/Tabs/Tabs.tsx +90 -90
  33. package/src/Theme/ThemeProvider.tsx +253 -253
  34. package/src/Theme/ThemeToggle.tsx +79 -79
  35. package/src/Toggle/Toggle.tsx +48 -48
  36. package/src/Tooltip/Tooltip.tsx +38 -38
  37. package/src/TopNav/TopNav.tsx +1163 -994
  38. package/src/TreeSelect/TreeSelect.tsx +825 -825
  39. package/src/css/alert.module.scss +93 -93
  40. package/src/css/autocomplete.module.scss +416 -416
  41. package/src/css/badge.module.scss +82 -82
  42. package/src/css/base/_color.scss +159 -159
  43. package/src/css/base/_theme.scss +237 -237
  44. package/src/css/base/_variables.scss +161 -161
  45. package/src/css/breadcrumb.module.scss +50 -50
  46. package/src/css/button.module.scss +385 -385
  47. package/src/css/card.module.scss +217 -217
  48. package/src/css/checkbox.module.scss +123 -120
  49. package/src/css/commandpalette.module.scss +211 -211
  50. package/src/css/container.module.scss +18 -18
  51. package/src/css/divider.module.scss +41 -41
  52. package/src/css/form.module.scss +245 -245
  53. package/src/css/imagecropper.module.scss +397 -397
  54. package/src/css/input.module.scss +89 -89
  55. package/src/css/modal.module.scss +105 -105
  56. package/src/css/nav.module.scss +494 -494
  57. package/src/css/notification.module.scss +691 -691
  58. package/src/css/pagination.module.scss +63 -63
  59. package/src/css/radio.module.scss +89 -89
  60. package/src/css/richtextarea.module.scss +307 -307
  61. package/src/css/select.module.scss +525 -525
  62. package/src/css/skeleton.module.scss +30 -30
  63. package/src/css/table.module.scss +386 -386
  64. package/src/css/tabs.module.scss +63 -63
  65. package/src/css/theme-toggle.module.scss +83 -83
  66. package/src/css/toggle.module.scss +54 -54
  67. package/src/css/tooltip.module.scss +97 -97
  68. package/src/css/topnav.module.scss +568 -396
  69. package/src/css/treeselect.module.scss +558 -558
  70. package/src/css/utilities/_borders.scss +111 -111
  71. package/src/css/utilities/_colors.scss +66 -66
  72. package/src/css/utilities/_effects.scss +216 -216
  73. package/src/css/utilities/_layout.scss +181 -181
  74. package/src/css/utilities/_position.scss +75 -75
  75. package/src/css/utilities/_sizing.scss +138 -138
  76. package/src/css/utilities/_spacing.scss +99 -99
  77. package/src/css/utilities/_typography.scss +121 -121
  78. package/src/css/utilities/index.scss +24 -24
  79. package/src/declarations.d.ts +6 -6
  80. package/src/index.ts +60 -60
@@ -1,911 +1,911 @@
1
- import {
2
- forwardRef,
3
- useCallback,
4
- useEffect,
5
- useImperativeHandle,
6
- useLayoutEffect,
7
- useMemo,
8
- useRef,
9
- useState,
10
- } from 'react';
11
- import type { CSSProperties, PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent } from 'react';
12
- import styles from '../css/imagecropper.module.scss';
13
-
14
- // ----------------------------------------------------------------------------
15
- // Types
16
- // ----------------------------------------------------------------------------
17
-
18
- /** Output of a crop operation, in the source image's natural pixel space. */
19
- export interface EvoCropArea {
20
- /** Left offset in source image pixels. */
21
- x: number;
22
- /** Top offset in source image pixels. */
23
- y: number;
24
- /** Width in source image pixels. */
25
- width: number;
26
- /** Height in source image pixels. */
27
- height: number;
28
- /** Rotation applied (0, 90, 180, 270). */
29
- rotation: number;
30
- /** Natural dimensions of the source image — handy for callers. */
31
- sourceWidth: number;
32
- sourceHeight: number;
33
- }
34
-
35
- /** Options for producing a cropped image from the current state. */
36
- export interface EvoCropOutputOptions {
37
- /** Output MIME type. @default 'image/png' */
38
- type?: 'image/png' | 'image/jpeg' | 'image/webp';
39
- /** Quality, 0–1, for lossy formats. @default 0.92 */
40
- quality?: number;
41
- /** Cap the output width. Height scales to keep the crop's aspect. */
42
- maxWidth?: number;
43
- /** Cap the output height. Width scales to keep the crop's aspect. */
44
- maxHeight?: number;
45
- }
46
-
47
- /** Imperative handle returned via `ref`. */
48
- export interface EvoImageCropperHandle {
49
- /** Current crop in source image pixels. */
50
- getCrop: () => EvoCropArea | null;
51
- /** Cropped image as a Blob. */
52
- getCroppedBlob: (opts?: EvoCropOutputOptions) => Promise<Blob>;
53
- /** Cropped image as a data URL. */
54
- getCroppedDataURL: (opts?: EvoCropOutputOptions) => Promise<string>;
55
- /** Cropped image as a fresh, detached canvas (caller owns it). */
56
- getCroppedCanvas: (opts?: Pick<EvoCropOutputOptions, 'maxWidth' | 'maxHeight'>) => HTMLCanvasElement | null;
57
- /** Reset crop, zoom, pan, and rotation back to their defaults. */
58
- reset: () => void;
59
- /** Rotate by ±90° (or set to a specific 90° multiple). */
60
- rotate: (deg?: 90 | -90 | 0 | 180 | 270) => void;
61
- /** Set zoom directly. Clamped to [minZoom, maxZoom]. */
62
- setZoom: (zoom: number) => void;
63
- }
64
-
65
- export interface EvoImageCropperProps {
66
- /** Source image URL or data URL. Required. */
67
- src: string;
68
- /**
69
- * Locks the crop rectangle to this width/height ratio.
70
- * Omit for a free-form crop. Use `1` for a square, `16/9` for widescreen, etc.
71
- */
72
- aspectRatio?: number;
73
- /** Render the crop rect as a circle (avatars). Implies `aspectRatio: 1`. */
74
- circular?: boolean;
75
- /** Show a rule-of-thirds grid inside the crop rectangle. @default true */
76
- showGrid?: boolean;
77
- /** Show the bottom controls toolbar (zoom, rotate, presets). @default true */
78
- showControls?: boolean;
79
- /** Show aspect ratio preset chips above the canvas. Ignored if `circular`. */
80
- ratioPresets?: Array<{ label: string; value: number | null }>;
81
- /**
82
- * Minimum zoom level (relative to fit-to-stage = 1). @default 1
83
- * @example minZoom={0.5} allows zooming out below fit.
84
- */
85
- minZoom?: number;
86
- /** Maximum zoom level. @default 4 */
87
- maxZoom?: number;
88
- /** Initial zoom. @default 1 */
89
- defaultZoom?: number;
90
- /** Initial rotation in degrees. Snapped to multiples of 90. @default 0 */
91
- defaultRotation?: number;
92
- /**
93
- * Initial crop rectangle as percentages of the stage (0–100).
94
- * Omit to start with the crop covering 80% of the visible image.
95
- */
96
- defaultCrop?: { x: number; y: number; width: number; height: number };
97
- /** Stage (canvas area) height. @default 360 */
98
- height?: number | string;
99
- /** Background under the image. @default 'checker' */
100
- background?: 'checker' | 'surface' | 'transparent';
101
- /** Optional label rendered above the cropper. */
102
- label?: string;
103
- /** Optional helper text rendered below the cropper. */
104
- helperText?: string;
105
- /** Fires whenever the resulting crop changes. */
106
- onChange?: (crop: EvoCropArea) => void;
107
- /** Fires once the image has loaded and is ready to crop. */
108
- onReady?: (info: { naturalWidth: number; naturalHeight: number }) => void;
109
- /** Fires if the image fails to load. */
110
- onError?: (error: Error) => void;
111
- /** Disable all interaction. */
112
- disabled?: boolean;
113
- /** Stretch to the parent's width. @default true */
114
- fullWidth?: boolean;
115
- /** Extra class on the root element. */
116
- className?: string;
117
- /** Accessible label for the cropper region. */
118
- ariaLabel?: string;
119
- }
120
-
121
- // ----------------------------------------------------------------------------
122
- // Constants & helpers
123
- // ----------------------------------------------------------------------------
124
-
125
- const DEFAULT_RATIO_PRESETS: NonNullable<EvoImageCropperProps['ratioPresets']> = [
126
- { label: 'Free', value: null },
127
- { label: '1:1', value: 1 },
128
- { label: '4:3', value: 4 / 3 },
129
- { label: '3:4', value: 3 / 4 },
130
- { label: '16:9', value: 16 / 9 },
131
- { label: '9:16', value: 9 / 16 },
132
- ];
133
-
134
- const MIN_CROP_PX = 24;
135
-
136
- const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
137
-
138
- const normalizeRotation = (deg: number): number => {
139
- const r = ((Math.round(deg / 90) * 90) % 360 + 360) % 360;
140
- return r;
141
- };
142
-
143
- interface Rect { left: number; top: number; width: number; height: number; }
144
-
145
- // ----------------------------------------------------------------------------
146
- // Component
147
- // ----------------------------------------------------------------------------
148
-
149
- export const EvoImageCropper = forwardRef<EvoImageCropperHandle, EvoImageCropperProps>(
150
- function EvoImageCropper(
151
- {
152
- src,
153
- aspectRatio,
154
- circular = false,
155
- showGrid = true,
156
- showControls = true,
157
- ratioPresets,
158
- minZoom = 1,
159
- maxZoom = 4,
160
- defaultZoom = 1,
161
- defaultRotation = 0,
162
- defaultCrop,
163
- height = 360,
164
- background = 'checker',
165
- label,
166
- helperText,
167
- onChange,
168
- onReady,
169
- onError,
170
- disabled = false,
171
- fullWidth = true,
172
- className,
173
- ariaLabel,
174
- },
175
- ref,
176
- ) {
177
- // Circular mode forces a 1:1 aspect.
178
- const effectiveAspect = circular ? 1 : aspectRatio;
179
-
180
- const stageRef = useRef<HTMLDivElement>(null);
181
- const imgRef = useRef<HTMLImageElement>(null);
182
-
183
- const [stageSize, setStageSize] = useState({ w: 0, h: 0 });
184
- const [imgNatural, setImgNatural] = useState<{ w: number; h: number } | null>(null);
185
- const [imgLoaded, setImgLoaded] = useState(false);
186
- const [imgError, setImgError] = useState(false);
187
-
188
- // Image transform state
189
- const [zoom, setZoomState] = useState(defaultZoom);
190
- const [rotation, setRotation] = useState(normalizeRotation(defaultRotation));
191
- const [pan, setPan] = useState({ x: 0, y: 0 });
192
-
193
- // Crop rect — stored as stage-relative percentages so it survives resizes.
194
- const [cropPct, setCropPct] = useState<Rect>({
195
- left: defaultCrop?.x ?? 10,
196
- top: defaultCrop?.y ?? 10,
197
- width: defaultCrop?.width ?? 80,
198
- height: defaultCrop?.height ?? 80,
199
- });
200
-
201
- // Aspect ratio chip selection — independently tracked from `aspectRatio`
202
- // so that uncontrolled callers can let users switch presets at runtime.
203
- const [selectedRatio, setSelectedRatio] = useState<number | null>(
204
- effectiveAspect ?? null,
205
- );
206
- useEffect(() => {
207
- setSelectedRatio(effectiveAspect ?? null);
208
- }, [effectiveAspect]);
209
-
210
- // ---------- Stage size tracking ----------
211
- useLayoutEffect(() => {
212
- const el = stageRef.current;
213
- if (!el) return;
214
- const update = () => {
215
- const rect = el.getBoundingClientRect();
216
- setStageSize({ w: rect.width, h: rect.height });
217
- };
218
- update();
219
- const ro = new ResizeObserver(update);
220
- ro.observe(el);
221
- return () => ro.disconnect();
222
- }, []);
223
-
224
- // ---------- Image load ----------
225
- useEffect(() => {
226
- setImgLoaded(false);
227
- setImgError(false);
228
- setImgNatural(null);
229
- if (!src) return;
230
- const img = new Image();
231
- img.crossOrigin = 'anonymous';
232
- img.onload = () => {
233
- setImgNatural({ w: img.naturalWidth, h: img.naturalHeight });
234
- setImgLoaded(true);
235
- onReady?.({ naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight });
236
- };
237
- img.onerror = () => {
238
- setImgError(true);
239
- onError?.(new Error(`Failed to load image: ${src}`));
240
- };
241
- img.src = src;
242
- return () => {
243
- img.onload = null;
244
- img.onerror = null;
245
- };
246
- // onReady/onError intentionally omitted — they're handlers, not deps
247
- // eslint-disable-next-line react-hooks/exhaustive-deps
248
- }, [src]);
249
-
250
- // ---------- Derived: image base size & display rect ----------
251
- const baseScale = useMemo(() => {
252
- if (!imgNatural || !stageSize.w || !stageSize.h) return 1;
253
- // Account for rotation: 90°/270° swap dimensions
254
- const isQuarter = rotation === 90 || rotation === 270;
255
- const iw = isQuarter ? imgNatural.h : imgNatural.w;
256
- const ih = isQuarter ? imgNatural.w : imgNatural.h;
257
- return Math.min(stageSize.w / iw, stageSize.h / ih);
258
- }, [imgNatural, stageSize.w, stageSize.h, rotation]);
259
-
260
- // Image displayed size before rotation (the actual rendered <img> size).
261
- const imgDisplay = useMemo(() => {
262
- if (!imgNatural) return { w: 0, h: 0 };
263
- const s = baseScale * zoom;
264
- return { w: imgNatural.w * s, h: imgNatural.h * s };
265
- }, [imgNatural, baseScale, zoom]);
266
-
267
- // Crop rect in stage pixels
268
- const cropPx: Rect = useMemo(() => ({
269
- left: (cropPct.left / 100) * stageSize.w,
270
- top: (cropPct.top / 100) * stageSize.h,
271
- width: (cropPct.width / 100) * stageSize.w,
272
- height: (cropPct.height / 100) * stageSize.h,
273
- }), [cropPct, stageSize]);
274
-
275
- // ---------- Crop area in source image coordinates ----------
276
- const computeCropArea = useCallback((): EvoCropArea | null => {
277
- if (!imgNatural || !stageSize.w || !stageSize.h) return null;
278
- const s = baseScale * zoom;
279
- // Top-left of the *un-rotated* image rect in stage coords:
280
- // Image is centered at stage center + pan; rotation happens around image center.
281
- // For axis-aligned (0°/180°) we can compute directly; for 90°/270° dimensions swap.
282
- const cx = stageSize.w / 2 + pan.x;
283
- const cy = stageSize.h / 2 + pan.y;
284
-
285
- // Crop rect's center & size in stage coords:
286
- const rcx = cropPx.left + cropPx.width / 2;
287
- const rcy = cropPx.top + cropPx.height / 2;
288
-
289
- // Translate crop center into image-local (un-rotated) coords:
290
- const dx = rcx - cx;
291
- const dy = rcy - cy;
292
- // Inverse rotation
293
- const rad = (-rotation * Math.PI) / 180;
294
- const ix = dx * Math.cos(rad) - dy * Math.sin(rad);
295
- const iy = dx * Math.sin(rad) + dy * Math.cos(rad);
296
-
297
- // For 90/270 the crop rect's width/height effectively swap in image space.
298
- const isQuarter = rotation === 90 || rotation === 270;
299
- const cw = isQuarter ? cropPx.height : cropPx.width;
300
- const ch = isQuarter ? cropPx.width : cropPx.height;
301
-
302
- // Source pixel coords (top-left)
303
- const sx = (ix - cw / 2) / s + imgNatural.w / 2;
304
- const sy = (iy - ch / 2) / s + imgNatural.h / 2;
305
- const sw = cw / s;
306
- const sh = ch / s;
307
-
308
- return {
309
- x: sx,
310
- y: sy,
311
- width: sw,
312
- height: sh,
313
- rotation,
314
- sourceWidth: imgNatural.w,
315
- sourceHeight: imgNatural.h,
316
- };
317
- }, [imgNatural, stageSize, baseScale, zoom, pan, cropPx, rotation]);
318
-
319
- // Fire onChange whenever the derived crop area changes.
320
- const lastEmittedRef = useRef<string>('');
321
- useEffect(() => {
322
- if (!imgLoaded) return;
323
- const area = computeCropArea();
324
- if (!area) return;
325
- const key = `${area.x.toFixed(2)},${area.y.toFixed(2)},${area.width.toFixed(2)},${area.height.toFixed(2)},${area.rotation}`;
326
- if (key === lastEmittedRef.current) return;
327
- lastEmittedRef.current = key;
328
- onChange?.(area);
329
- }, [imgLoaded, computeCropArea, onChange]);
330
-
331
- // ---------- Aspect ratio enforcement ----------
332
- // When the selected aspect ratio changes, snap the crop rect to it.
333
- useEffect(() => {
334
- if (selectedRatio == null || !stageSize.w || !stageSize.h) return;
335
- setCropPct((prev) => {
336
- const w = (prev.width / 100) * stageSize.w;
337
- const h = (prev.height / 100) * stageSize.h;
338
- // Keep the larger dimension, derive the other from the ratio.
339
- const targetH = w / selectedRatio;
340
- let newW = w;
341
- let newH = targetH;
342
- if (targetH > h) {
343
- newH = h;
344
- newW = h * selectedRatio;
345
- }
346
- // Re-center around the previous center
347
- const cx = (prev.left + prev.width / 2) / 100 * stageSize.w;
348
- const cy = (prev.top + prev.height / 2) / 100 * stageSize.h;
349
- let left = cx - newW / 2;
350
- let top = cy - newH / 2;
351
- left = clamp(left, 0, stageSize.w - newW);
352
- top = clamp(top, 0, stageSize.h - newH);
353
- return {
354
- left: (left / stageSize.w) * 100,
355
- top: (top / stageSize.h) * 100,
356
- width: (newW / stageSize.w) * 100,
357
- height: (newH / stageSize.h) * 100,
358
- };
359
- });
360
- }, [selectedRatio, stageSize.w, stageSize.h]);
361
-
362
- // ---------- Pointer interactions ----------
363
- type DragKind =
364
- | { type: 'pan'; startX: number; startY: number; startPan: { x: number; y: number } }
365
- | { type: 'move'; startX: number; startY: number; startCrop: Rect }
366
- | { type: 'resize'; corner: ResizeCorner; startX: number; startY: number; startCrop: Rect };
367
-
368
- const dragRef = useRef<DragKind | null>(null);
369
- const pinchRef = useRef<{ startDist: number; startZoom: number } | null>(null);
370
- const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
371
-
372
- const onStagePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
373
- if (disabled || !imgLoaded) return;
374
- (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
375
- pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
376
- // Pinch zoom kicks in once a second pointer arrives
377
- if (pointersRef.current.size === 2) {
378
- const pts = Array.from(pointersRef.current.values());
379
- const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
380
- pinchRef.current = { startDist: dist, startZoom: zoom };
381
- dragRef.current = null;
382
- return;
383
- }
384
- // Single-pointer: drag the *image* (pan) when stage background was hit.
385
- dragRef.current = {
386
- type: 'pan',
387
- startX: e.clientX,
388
- startY: e.clientY,
389
- startPan: pan,
390
- };
391
- };
392
-
393
- const onStagePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
394
- if (!pointersRef.current.has(e.pointerId)) return;
395
- pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
396
-
397
- // Pinch zoom takes priority
398
- if (pinchRef.current && pointersRef.current.size >= 2) {
399
- const pts = Array.from(pointersRef.current.values());
400
- const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
401
- const next = clamp(
402
- pinchRef.current.startZoom * (dist / pinchRef.current.startDist),
403
- minZoom,
404
- maxZoom,
405
- );
406
- setZoomState(next);
407
- return;
408
- }
409
-
410
- const d = dragRef.current;
411
- if (!d) return;
412
- const dx = e.clientX - d.startX;
413
- const dy = e.clientY - d.startY;
414
-
415
- if (d.type === 'pan') {
416
- setPan({ x: d.startPan.x + dx, y: d.startPan.y + dy });
417
- } else if (d.type === 'move') {
418
- moveCropBy(dx, dy, d.startCrop);
419
- } else if (d.type === 'resize') {
420
- resizeCrop(d.corner, dx, dy, d.startCrop);
421
- }
422
- };
423
-
424
- const endPointer = (e: ReactPointerEvent<HTMLDivElement>) => {
425
- pointersRef.current.delete(e.pointerId);
426
- if (pointersRef.current.size < 2) pinchRef.current = null;
427
- if (pointersRef.current.size === 0) dragRef.current = null;
428
- };
429
-
430
- // ---------- Wheel zoom ----------
431
- const onWheel = (e: ReactWheelEvent<HTMLDivElement>) => {
432
- if (disabled || !imgLoaded) return;
433
- e.preventDefault();
434
- const delta = -e.deltaY * 0.0015;
435
- setZoomState((z) => clamp(z * (1 + delta), minZoom, maxZoom));
436
- };
437
-
438
- // ---------- Crop rect manipulation ----------
439
- const moveCropBy = (dx: number, dy: number, startCrop: Rect) => {
440
- const stageW = stageSize.w, stageH = stageSize.h;
441
- const startLeft = (startCrop.left / 100) * stageW;
442
- const startTop = (startCrop.top / 100) * stageH;
443
- const w = (startCrop.width / 100) * stageW;
444
- const h = (startCrop.height / 100) * stageH;
445
- const left = clamp(startLeft + dx, 0, stageW - w);
446
- const top = clamp(startTop + dy, 0, stageH - h);
447
- setCropPct({
448
- left: (left / stageW) * 100,
449
- top: (top / stageH) * 100,
450
- width: startCrop.width,
451
- height: startCrop.height,
452
- });
453
- };
454
-
455
- type ResizeCorner = 'tl' | 'tr' | 'bl' | 'br' | 't' | 'b' | 'l' | 'r';
456
-
457
- const resizeCrop = (corner: ResizeCorner, dx: number, dy: number, startCrop: Rect) => {
458
- const stageW = stageSize.w, stageH = stageSize.h;
459
- let left = (startCrop.left / 100) * stageW;
460
- let top = (startCrop.top / 100) * stageH;
461
- let width = (startCrop.width / 100) * stageW;
462
- let height = (startCrop.height / 100) * stageH;
463
- const right = left + width;
464
- const bottom = top + height;
465
-
466
- let newLeft = left, newTop = top, newRight = right, newBottom = bottom;
467
- if (corner.includes('l')) newLeft = clamp(left + dx, 0, right - MIN_CROP_PX);
468
- if (corner.includes('r')) newRight = clamp(right + dx, left + MIN_CROP_PX, stageW);
469
- if (corner.includes('t')) newTop = clamp(top + dy, 0, bottom - MIN_CROP_PX);
470
- if (corner.includes('b')) newBottom = clamp(bottom + dy, top + MIN_CROP_PX, stageH);
471
-
472
- let w = newRight - newLeft;
473
- let h = newBottom - newTop;
474
-
475
- // Enforce aspect ratio if locked
476
- if (selectedRatio != null) {
477
- const isCorner = corner.length === 2;
478
- if (isCorner) {
479
- // Use the larger axis change to drive the smaller one
480
- const aw = w;
481
- const ah = w / selectedRatio;
482
- if (ah <= h || corner === 'tl' || corner === 'tr' || corner === 'bl' || corner === 'br') {
483
- h = aw / selectedRatio;
484
- } else {
485
- w = ah * selectedRatio;
486
- }
487
- if (corner.includes('t')) newTop = newBottom - h;
488
- if (corner.includes('b')) newBottom = newTop + h;
489
- if (corner.includes('l')) newLeft = newRight - w;
490
- if (corner.includes('r')) newRight = newLeft + w;
491
- } else if (corner === 'l' || corner === 'r') {
492
- h = w / selectedRatio;
493
- const cy = (top + bottom) / 2;
494
- newTop = cy - h / 2;
495
- newBottom = cy + h / 2;
496
- } else if (corner === 't' || corner === 'b') {
497
- w = h * selectedRatio;
498
- const cx = (left + right) / 2;
499
- newLeft = cx - w / 2;
500
- newRight = cx + w / 2;
501
- }
502
- // Re-clamp inside the stage; if we overflow, shrink to fit
503
- if (newLeft < 0) { newRight += -newLeft; newLeft = 0; }
504
- if (newTop < 0) { newBottom += -newTop; newTop = 0; }
505
- if (newRight > stageW) { const o = newRight - stageW; newRight = stageW; newLeft -= o; }
506
- if (newBottom > stageH) { const o = newBottom - stageH; newBottom = stageH; newTop -= o; }
507
- w = newRight - newLeft;
508
- h = newBottom - newTop;
509
- // If clamping broke the aspect, shrink to satisfy it
510
- if (Math.abs(w / h - selectedRatio) > 0.001) {
511
- if (w / h > selectedRatio) {
512
- const targetW = h * selectedRatio;
513
- const cx = (newLeft + newRight) / 2;
514
- newLeft = cx - targetW / 2;
515
- newRight = cx + targetW / 2;
516
- } else {
517
- const targetH = w / selectedRatio;
518
- const cy = (newTop + newBottom) / 2;
519
- newTop = cy - targetH / 2;
520
- newBottom = cy + targetH / 2;
521
- }
522
- }
523
- }
524
-
525
- setCropPct({
526
- left: (newLeft / stageW) * 100,
527
- top: (newTop / stageH) * 100,
528
- width: ((newRight - newLeft) / stageW) * 100,
529
- height: ((newBottom - newTop) / stageH) * 100,
530
- });
531
- };
532
-
533
- const onOverlayPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
534
- if (disabled || !imgLoaded) return;
535
- e.stopPropagation();
536
- (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
537
- pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
538
- dragRef.current = {
539
- type: 'move',
540
- startX: e.clientX,
541
- startY: e.clientY,
542
- startCrop: { ...cropPct },
543
- };
544
- };
545
-
546
- const onHandlePointerDown = (corner: ResizeCorner) => (e: ReactPointerEvent<HTMLDivElement>) => {
547
- if (disabled || !imgLoaded) return;
548
- e.stopPropagation();
549
- (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
550
- pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
551
- dragRef.current = {
552
- type: 'resize',
553
- corner,
554
- startX: e.clientX,
555
- startY: e.clientY,
556
- startCrop: { ...cropPct },
557
- };
558
- };
559
-
560
- // ---------- Output (canvas drawing) ----------
561
- const drawToCanvas = useCallback((opts?: Pick<EvoCropOutputOptions, 'maxWidth' | 'maxHeight'>): HTMLCanvasElement | null => {
562
- const area = computeCropArea();
563
- if (!area || !imgRef.current) return null;
564
- let outW = Math.max(1, Math.round(area.width));
565
- let outH = Math.max(1, Math.round(area.height));
566
- const { maxWidth, maxHeight } = opts ?? {};
567
- if (maxWidth && outW > maxWidth) {
568
- const r = maxWidth / outW;
569
- outW = maxWidth;
570
- outH = Math.round(outH * r);
571
- }
572
- if (maxHeight && outH > maxHeight) {
573
- const r = maxHeight / outH;
574
- outH = maxHeight;
575
- outW = Math.round(outW * r);
576
- }
577
- const canvas = document.createElement('canvas');
578
- canvas.width = outW;
579
- canvas.height = outH;
580
- const ctx = canvas.getContext('2d');
581
- if (!ctx) return null;
582
- ctx.imageSmoothingQuality = 'high';
583
-
584
- const img = imgRef.current;
585
- // Apply rotation around the canvas center so the *source rect* aligns.
586
- ctx.save();
587
- if (area.rotation === 0) {
588
- ctx.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, outW, outH);
589
- } else {
590
- // Rotate the canvas so that the source coordinate system matches.
591
- ctx.translate(outW / 2, outH / 2);
592
- ctx.rotate((area.rotation * Math.PI) / 180);
593
- const swapped = area.rotation === 90 || area.rotation === 270;
594
- const drawW = swapped ? outH : outW;
595
- const drawH = swapped ? outW : outH;
596
- // Map the rotated crop back to source-image coords. For 90/270 the
597
- // crop's width/height correspond to height/width of the source rect.
598
- const srcW = swapped ? area.height : area.width;
599
- const srcH = swapped ? area.width : area.height;
600
- // The crop area's (x,y) we computed is already in the un-rotated
601
- // source's coordinate space for the rotated rect — but we computed
602
- // the un-rotated bounding rect, so use it directly.
603
- ctx.drawImage(
604
- img,
605
- area.x, area.y, srcW, srcH,
606
- -drawW / 2, -drawH / 2, drawW, drawH,
607
- );
608
- }
609
- ctx.restore();
610
-
611
- // Circular mask
612
- if (circular) {
613
- ctx.globalCompositeOperation = 'destination-in';
614
- ctx.beginPath();
615
- ctx.arc(outW / 2, outH / 2, Math.min(outW, outH) / 2, 0, Math.PI * 2);
616
- ctx.closePath();
617
- ctx.fill();
618
- }
619
- return canvas;
620
- }, [computeCropArea, circular]);
621
-
622
- // ---------- Imperative handle ----------
623
- useImperativeHandle(ref, () => ({
624
- getCrop: () => computeCropArea(),
625
- getCroppedCanvas: (opts) => drawToCanvas(opts),
626
- getCroppedBlob: (opts) =>
627
- new Promise<Blob>((resolve, reject) => {
628
- const canvas = drawToCanvas(opts);
629
- if (!canvas) return reject(new Error('Cropper not ready'));
630
- canvas.toBlob(
631
- (b) => (b ? resolve(b) : reject(new Error('Canvas export failed'))),
632
- opts?.type ?? 'image/png',
633
- opts?.quality ?? 0.92,
634
- );
635
- }),
636
- getCroppedDataURL: async (opts) => {
637
- const canvas = drawToCanvas(opts);
638
- if (!canvas) throw new Error('Cropper not ready');
639
- return canvas.toDataURL(opts?.type ?? 'image/png', opts?.quality ?? 0.92);
640
- },
641
- reset: () => {
642
- setZoomState(defaultZoom);
643
- setRotation(normalizeRotation(defaultRotation));
644
- setPan({ x: 0, y: 0 });
645
- setCropPct({
646
- left: defaultCrop?.x ?? 10,
647
- top: defaultCrop?.y ?? 10,
648
- width: defaultCrop?.width ?? 80,
649
- height: defaultCrop?.height ?? 80,
650
- });
651
- },
652
- rotate: (deg) => {
653
- if (deg === undefined) {
654
- setRotation((r) => normalizeRotation(r + 90));
655
- } else {
656
- setRotation(normalizeRotation(deg));
657
- }
658
- },
659
- setZoom: (z) => setZoomState(clamp(z, minZoom, maxZoom)),
660
- }), [computeCropArea, drawToCanvas, defaultZoom, defaultRotation, defaultCrop, minZoom, maxZoom]);
661
-
662
- // ---------- Render ----------
663
- const stageStyle: CSSProperties = {
664
- height: typeof height === 'number' ? `${height}px` : height,
665
- };
666
-
667
- const imageStyle: CSSProperties = imgNatural
668
- ? {
669
- width: imgDisplay.w,
670
- height: imgDisplay.h,
671
- transform: `translate(${pan.x}px, ${pan.y}px) rotate(${rotation}deg)`,
672
- }
673
- : { display: 'none' };
674
-
675
- const overlayStyle: CSSProperties = {
676
- left: cropPx.left,
677
- top: cropPx.top,
678
- width: cropPx.width,
679
- height: cropPx.height,
680
- };
681
-
682
- const circleMaskStyle: CSSProperties = circular
683
- ? ({
684
- ['--mask-cx' as string]: `${cropPx.left + cropPx.width / 2}px`,
685
- ['--mask-cy' as string]: `${cropPx.top + cropPx.height / 2}px`,
686
- ['--mask-r' as string]: `${Math.min(cropPx.width, cropPx.height) / 2}px`,
687
- } as CSSProperties)
688
- : {};
689
-
690
- const stageClasses = [
691
- styles.stage,
692
- background === 'checker' ? styles.bgChecker : '',
693
- disabled ? styles.disabled : '',
694
- ].filter(Boolean).join(' ');
695
-
696
- const rootClasses = [
697
- styles.root,
698
- fullWidth ? styles.fullWidth : '',
699
- className ?? '',
700
- ].filter(Boolean).join(' ');
701
-
702
- const presets = ratioPresets ?? DEFAULT_RATIO_PRESETS;
703
- const showPresets = showControls && !circular && presets.length > 0;
704
-
705
- return (
706
- <div className={rootClasses}>
707
- {label && <div className={styles.label}>{label}</div>}
708
-
709
- {showPresets && (
710
- <div className={styles.ratioRow} role="radiogroup" aria-label="Aspect ratio">
711
- {presets.map((p) => (
712
- <button
713
- key={p.label}
714
- type="button"
715
- role="radio"
716
- aria-checked={selectedRatio === p.value}
717
- className={`${styles.ratioChip} ${selectedRatio === p.value ? styles.active : ''}`}
718
- disabled={disabled}
719
- onClick={() => setSelectedRatio(p.value)}
720
- >
721
- {p.label}
722
- </button>
723
- ))}
724
- </div>
725
- )}
726
-
727
- <div
728
- ref={stageRef}
729
- className={stageClasses}
730
- style={stageStyle}
731
- role="application"
732
- aria-label={ariaLabel ?? 'Image cropper'}
733
- aria-disabled={disabled}
734
- onPointerDown={onStagePointerDown}
735
- onPointerMove={onStagePointerMove}
736
- onPointerUp={endPointer}
737
- onPointerCancel={endPointer}
738
- onWheel={onWheel}
739
- >
740
- {!imgLoaded && !imgError && (
741
- <div className={styles.placeholder}>
742
- <div className={styles.spinner} aria-hidden />
743
- <div>Loading image…</div>
744
- </div>
745
- )}
746
- {imgError && (
747
- <div className={styles.placeholder}>
748
- <div>Couldn't load image.</div>
749
- </div>
750
- )}
751
-
752
- <div className={styles.imageWrap}>
753
- <img
754
- ref={imgRef}
755
- src={src}
756
- alt=""
757
- draggable={false}
758
- className={styles.image}
759
- style={imageStyle}
760
- />
761
- </div>
762
-
763
- {imgLoaded && (
764
- <>
765
- {circular && <div className={styles.circleMask} style={circleMaskStyle} />}
766
- <div
767
- className={`${styles.overlay} ${circular ? styles.overlayCircle : ''}`}
768
- style={overlayStyle}
769
- onPointerDown={onOverlayPointerDown}
770
- onPointerMove={onStagePointerMove}
771
- onPointerUp={endPointer}
772
- onPointerCancel={endPointer}
773
- >
774
- {showGrid && !circular && (
775
- <div className={styles.grid} aria-hidden>
776
- <div className={`${styles.gridLine} ${styles.h}`} style={{ top: '33.33%' }} />
777
- <div className={`${styles.gridLine} ${styles.h}`} style={{ top: '66.66%' }} />
778
- <div className={`${styles.gridLine} ${styles.v}`} style={{ left: '33.33%' }} />
779
- <div className={`${styles.gridLine} ${styles.v}`} style={{ left: '66.66%' }} />
780
- </div>
781
- )}
782
- <div className={`${styles.handle} ${styles.handleTL}`} onPointerDown={onHandlePointerDown('tl')} />
783
- <div className={`${styles.handle} ${styles.handleTR}`} onPointerDown={onHandlePointerDown('tr')} />
784
- <div className={`${styles.handle} ${styles.handleBL}`} onPointerDown={onHandlePointerDown('bl')} />
785
- <div className={`${styles.handle} ${styles.handleBR}`} onPointerDown={onHandlePointerDown('br')} />
786
- <div className={`${styles.handle} ${styles.handleT}`} onPointerDown={onHandlePointerDown('t')} />
787
- <div className={`${styles.handle} ${styles.handleB}`} onPointerDown={onHandlePointerDown('b')} />
788
- <div className={`${styles.handle} ${styles.handleL}`} onPointerDown={onHandlePointerDown('l')} />
789
- <div className={`${styles.handle} ${styles.handleR}`} onPointerDown={onHandlePointerDown('r')} />
790
- </div>
791
- </>
792
- )}
793
- </div>
794
-
795
- {showControls && (
796
- <div className={styles.controls} aria-label="Cropper controls">
797
- <div className={styles.controlGroup}>
798
- <span className={styles.controlLabel}>Zoom</span>
799
- <button
800
- type="button"
801
- className={styles.iconBtn}
802
- aria-label="Zoom out"
803
- disabled={disabled || zoom <= minZoom}
804
- onClick={() => setZoomState((z) => clamp(z - 0.1, minZoom, maxZoom))}
805
- >
806
- <MinusIcon />
807
- </button>
808
- <input
809
- type="range"
810
- className={styles.zoomSlider}
811
- min={minZoom}
812
- max={maxZoom}
813
- step={0.01}
814
- value={zoom}
815
- disabled={disabled}
816
- onChange={(e) => setZoomState(parseFloat(e.target.value))}
817
- aria-label="Zoom level"
818
- />
819
- <button
820
- type="button"
821
- className={styles.iconBtn}
822
- aria-label="Zoom in"
823
- disabled={disabled || zoom >= maxZoom}
824
- onClick={() => setZoomState((z) => clamp(z + 0.1, minZoom, maxZoom))}
825
- >
826
- <PlusIcon />
827
- </button>
828
- </div>
829
- <div className={styles.divider} aria-hidden />
830
- <div className={styles.controlGroup}>
831
- <span className={styles.controlLabel}>Rotate</span>
832
- <button
833
- type="button"
834
- className={styles.iconBtn}
835
- aria-label="Rotate left"
836
- disabled={disabled}
837
- onClick={() => setRotation((r) => normalizeRotation(r - 90))}
838
- >
839
- <RotateLeftIcon />
840
- </button>
841
- <button
842
- type="button"
843
- className={styles.iconBtn}
844
- aria-label="Rotate right"
845
- disabled={disabled}
846
- onClick={() => setRotation((r) => normalizeRotation(r + 90))}
847
- >
848
- <RotateRightIcon />
849
- </button>
850
- </div>
851
- <div className={styles.divider} aria-hidden />
852
- <div className={styles.controlGroup}>
853
- <button
854
- type="button"
855
- className={styles.iconBtn}
856
- aria-label="Reset"
857
- disabled={disabled}
858
- onClick={() => {
859
- setZoomState(defaultZoom);
860
- setRotation(normalizeRotation(defaultRotation));
861
- setPan({ x: 0, y: 0 });
862
- setCropPct({
863
- left: defaultCrop?.x ?? 10,
864
- top: defaultCrop?.y ?? 10,
865
- width: defaultCrop?.width ?? 80,
866
- height: defaultCrop?.height ?? 80,
867
- });
868
- }}
869
- >
870
- <ResetIcon />
871
- </button>
872
- </div>
873
- </div>
874
- )}
875
-
876
- {helperText && <div className={styles.helper}>{helperText}</div>}
877
- </div>
878
- );
879
- },
880
- );
881
-
882
- // ----------------------------------------------------------------------------
883
- // Inline icons — kept inline so the component has zero external deps
884
- // ----------------------------------------------------------------------------
885
-
886
- const iconProps = {
887
- width: 16,
888
- height: 16,
889
- viewBox: '0 0 24 24',
890
- fill: 'none',
891
- stroke: 'currentColor',
892
- strokeWidth: 2,
893
- strokeLinecap: 'round' as const,
894
- strokeLinejoin: 'round' as const,
895
- };
896
-
897
- const PlusIcon = () => (
898
- <svg {...iconProps}><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
899
- );
900
- const MinusIcon = () => (
901
- <svg {...iconProps}><line x1="5" y1="12" x2="19" y2="12" /></svg>
902
- );
903
- const RotateLeftIcon = () => (
904
- <svg {...iconProps}><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" /></svg>
905
- );
906
- const RotateRightIcon = () => (
907
- <svg {...iconProps}><polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10" /></svg>
908
- );
909
- const ResetIcon = () => (
910
- <svg {...iconProps}><path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><polyline points="3 3 3 8 8 8" /></svg>
911
- );
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import type { CSSProperties, PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent } from 'react';
12
+ import styles from '../css/imagecropper.module.scss';
13
+
14
+ // ----------------------------------------------------------------------------
15
+ // Types
16
+ // ----------------------------------------------------------------------------
17
+
18
+ /** Output of a crop operation, in the source image's natural pixel space. */
19
+ export interface EvoCropArea {
20
+ /** Left offset in source image pixels. */
21
+ x: number;
22
+ /** Top offset in source image pixels. */
23
+ y: number;
24
+ /** Width in source image pixels. */
25
+ width: number;
26
+ /** Height in source image pixels. */
27
+ height: number;
28
+ /** Rotation applied (0, 90, 180, 270). */
29
+ rotation: number;
30
+ /** Natural dimensions of the source image — handy for callers. */
31
+ sourceWidth: number;
32
+ sourceHeight: number;
33
+ }
34
+
35
+ /** Options for producing a cropped image from the current state. */
36
+ export interface EvoCropOutputOptions {
37
+ /** Output MIME type. @default 'image/png' */
38
+ type?: 'image/png' | 'image/jpeg' | 'image/webp';
39
+ /** Quality, 0–1, for lossy formats. @default 0.92 */
40
+ quality?: number;
41
+ /** Cap the output width. Height scales to keep the crop's aspect. */
42
+ maxWidth?: number;
43
+ /** Cap the output height. Width scales to keep the crop's aspect. */
44
+ maxHeight?: number;
45
+ }
46
+
47
+ /** Imperative handle returned via `ref`. */
48
+ export interface EvoImageCropperHandle {
49
+ /** Current crop in source image pixels. */
50
+ getCrop: () => EvoCropArea | null;
51
+ /** Cropped image as a Blob. */
52
+ getCroppedBlob: (opts?: EvoCropOutputOptions) => Promise<Blob>;
53
+ /** Cropped image as a data URL. */
54
+ getCroppedDataURL: (opts?: EvoCropOutputOptions) => Promise<string>;
55
+ /** Cropped image as a fresh, detached canvas (caller owns it). */
56
+ getCroppedCanvas: (opts?: Pick<EvoCropOutputOptions, 'maxWidth' | 'maxHeight'>) => HTMLCanvasElement | null;
57
+ /** Reset crop, zoom, pan, and rotation back to their defaults. */
58
+ reset: () => void;
59
+ /** Rotate by ±90° (or set to a specific 90° multiple). */
60
+ rotate: (deg?: 90 | -90 | 0 | 180 | 270) => void;
61
+ /** Set zoom directly. Clamped to [minZoom, maxZoom]. */
62
+ setZoom: (zoom: number) => void;
63
+ }
64
+
65
+ export interface EvoImageCropperProps {
66
+ /** Source image URL or data URL. Required. */
67
+ src: string;
68
+ /**
69
+ * Locks the crop rectangle to this width/height ratio.
70
+ * Omit for a free-form crop. Use `1` for a square, `16/9` for widescreen, etc.
71
+ */
72
+ aspectRatio?: number;
73
+ /** Render the crop rect as a circle (avatars). Implies `aspectRatio: 1`. */
74
+ circular?: boolean;
75
+ /** Show a rule-of-thirds grid inside the crop rectangle. @default true */
76
+ showGrid?: boolean;
77
+ /** Show the bottom controls toolbar (zoom, rotate, presets). @default true */
78
+ showControls?: boolean;
79
+ /** Show aspect ratio preset chips above the canvas. Ignored if `circular`. */
80
+ ratioPresets?: Array<{ label: string; value: number | null }>;
81
+ /**
82
+ * Minimum zoom level (relative to fit-to-stage = 1). @default 1
83
+ * @example minZoom={0.5} allows zooming out below fit.
84
+ */
85
+ minZoom?: number;
86
+ /** Maximum zoom level. @default 4 */
87
+ maxZoom?: number;
88
+ /** Initial zoom. @default 1 */
89
+ defaultZoom?: number;
90
+ /** Initial rotation in degrees. Snapped to multiples of 90. @default 0 */
91
+ defaultRotation?: number;
92
+ /**
93
+ * Initial crop rectangle as percentages of the stage (0–100).
94
+ * Omit to start with the crop covering 80% of the visible image.
95
+ */
96
+ defaultCrop?: { x: number; y: number; width: number; height: number };
97
+ /** Stage (canvas area) height. @default 360 */
98
+ height?: number | string;
99
+ /** Background under the image. @default 'checker' */
100
+ background?: 'checker' | 'surface' | 'transparent';
101
+ /** Optional label rendered above the cropper. */
102
+ label?: string;
103
+ /** Optional helper text rendered below the cropper. */
104
+ helperText?: string;
105
+ /** Fires whenever the resulting crop changes. */
106
+ onChange?: (crop: EvoCropArea) => void;
107
+ /** Fires once the image has loaded and is ready to crop. */
108
+ onReady?: (info: { naturalWidth: number; naturalHeight: number }) => void;
109
+ /** Fires if the image fails to load. */
110
+ onError?: (error: Error) => void;
111
+ /** Disable all interaction. */
112
+ disabled?: boolean;
113
+ /** Stretch to the parent's width. @default true */
114
+ fullWidth?: boolean;
115
+ /** Extra class on the root element. */
116
+ className?: string;
117
+ /** Accessible label for the cropper region. */
118
+ ariaLabel?: string;
119
+ }
120
+
121
+ // ----------------------------------------------------------------------------
122
+ // Constants & helpers
123
+ // ----------------------------------------------------------------------------
124
+
125
+ const DEFAULT_RATIO_PRESETS: NonNullable<EvoImageCropperProps['ratioPresets']> = [
126
+ { label: 'Free', value: null },
127
+ { label: '1:1', value: 1 },
128
+ { label: '4:3', value: 4 / 3 },
129
+ { label: '3:4', value: 3 / 4 },
130
+ { label: '16:9', value: 16 / 9 },
131
+ { label: '9:16', value: 9 / 16 },
132
+ ];
133
+
134
+ const MIN_CROP_PX = 24;
135
+
136
+ const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
137
+
138
+ const normalizeRotation = (deg: number): number => {
139
+ const r = ((Math.round(deg / 90) * 90) % 360 + 360) % 360;
140
+ return r;
141
+ };
142
+
143
+ interface Rect { left: number; top: number; width: number; height: number; }
144
+
145
+ // ----------------------------------------------------------------------------
146
+ // Component
147
+ // ----------------------------------------------------------------------------
148
+
149
+ export const EvoImageCropper = forwardRef<EvoImageCropperHandle, EvoImageCropperProps>(
150
+ function EvoImageCropper(
151
+ {
152
+ src,
153
+ aspectRatio,
154
+ circular = false,
155
+ showGrid = true,
156
+ showControls = true,
157
+ ratioPresets,
158
+ minZoom = 1,
159
+ maxZoom = 4,
160
+ defaultZoom = 1,
161
+ defaultRotation = 0,
162
+ defaultCrop,
163
+ height = 360,
164
+ background = 'checker',
165
+ label,
166
+ helperText,
167
+ onChange,
168
+ onReady,
169
+ onError,
170
+ disabled = false,
171
+ fullWidth = true,
172
+ className,
173
+ ariaLabel,
174
+ },
175
+ ref,
176
+ ) {
177
+ // Circular mode forces a 1:1 aspect.
178
+ const effectiveAspect = circular ? 1 : aspectRatio;
179
+
180
+ const stageRef = useRef<HTMLDivElement>(null);
181
+ const imgRef = useRef<HTMLImageElement>(null);
182
+
183
+ const [stageSize, setStageSize] = useState({ w: 0, h: 0 });
184
+ const [imgNatural, setImgNatural] = useState<{ w: number; h: number } | null>(null);
185
+ const [imgLoaded, setImgLoaded] = useState(false);
186
+ const [imgError, setImgError] = useState(false);
187
+
188
+ // Image transform state
189
+ const [zoom, setZoomState] = useState(defaultZoom);
190
+ const [rotation, setRotation] = useState(normalizeRotation(defaultRotation));
191
+ const [pan, setPan] = useState({ x: 0, y: 0 });
192
+
193
+ // Crop rect — stored as stage-relative percentages so it survives resizes.
194
+ const [cropPct, setCropPct] = useState<Rect>({
195
+ left: defaultCrop?.x ?? 10,
196
+ top: defaultCrop?.y ?? 10,
197
+ width: defaultCrop?.width ?? 80,
198
+ height: defaultCrop?.height ?? 80,
199
+ });
200
+
201
+ // Aspect ratio chip selection — independently tracked from `aspectRatio`
202
+ // so that uncontrolled callers can let users switch presets at runtime.
203
+ const [selectedRatio, setSelectedRatio] = useState<number | null>(
204
+ effectiveAspect ?? null,
205
+ );
206
+ useEffect(() => {
207
+ setSelectedRatio(effectiveAspect ?? null);
208
+ }, [effectiveAspect]);
209
+
210
+ // ---------- Stage size tracking ----------
211
+ useLayoutEffect(() => {
212
+ const el = stageRef.current;
213
+ if (!el) return;
214
+ const update = () => {
215
+ const rect = el.getBoundingClientRect();
216
+ setStageSize({ w: rect.width, h: rect.height });
217
+ };
218
+ update();
219
+ const ro = new ResizeObserver(update);
220
+ ro.observe(el);
221
+ return () => ro.disconnect();
222
+ }, []);
223
+
224
+ // ---------- Image load ----------
225
+ useEffect(() => {
226
+ setImgLoaded(false);
227
+ setImgError(false);
228
+ setImgNatural(null);
229
+ if (!src) return;
230
+ const img = new Image();
231
+ img.crossOrigin = 'anonymous';
232
+ img.onload = () => {
233
+ setImgNatural({ w: img.naturalWidth, h: img.naturalHeight });
234
+ setImgLoaded(true);
235
+ onReady?.({ naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight });
236
+ };
237
+ img.onerror = () => {
238
+ setImgError(true);
239
+ onError?.(new Error(`Failed to load image: ${src}`));
240
+ };
241
+ img.src = src;
242
+ return () => {
243
+ img.onload = null;
244
+ img.onerror = null;
245
+ };
246
+ // onReady/onError intentionally omitted — they're handlers, not deps
247
+ // eslint-disable-next-line react-hooks/exhaustive-deps
248
+ }, [src]);
249
+
250
+ // ---------- Derived: image base size & display rect ----------
251
+ const baseScale = useMemo(() => {
252
+ if (!imgNatural || !stageSize.w || !stageSize.h) return 1;
253
+ // Account for rotation: 90°/270° swap dimensions
254
+ const isQuarter = rotation === 90 || rotation === 270;
255
+ const iw = isQuarter ? imgNatural.h : imgNatural.w;
256
+ const ih = isQuarter ? imgNatural.w : imgNatural.h;
257
+ return Math.min(stageSize.w / iw, stageSize.h / ih);
258
+ }, [imgNatural, stageSize.w, stageSize.h, rotation]);
259
+
260
+ // Image displayed size before rotation (the actual rendered <img> size).
261
+ const imgDisplay = useMemo(() => {
262
+ if (!imgNatural) return { w: 0, h: 0 };
263
+ const s = baseScale * zoom;
264
+ return { w: imgNatural.w * s, h: imgNatural.h * s };
265
+ }, [imgNatural, baseScale, zoom]);
266
+
267
+ // Crop rect in stage pixels
268
+ const cropPx: Rect = useMemo(() => ({
269
+ left: (cropPct.left / 100) * stageSize.w,
270
+ top: (cropPct.top / 100) * stageSize.h,
271
+ width: (cropPct.width / 100) * stageSize.w,
272
+ height: (cropPct.height / 100) * stageSize.h,
273
+ }), [cropPct, stageSize]);
274
+
275
+ // ---------- Crop area in source image coordinates ----------
276
+ const computeCropArea = useCallback((): EvoCropArea | null => {
277
+ if (!imgNatural || !stageSize.w || !stageSize.h) return null;
278
+ const s = baseScale * zoom;
279
+ // Top-left of the *un-rotated* image rect in stage coords:
280
+ // Image is centered at stage center + pan; rotation happens around image center.
281
+ // For axis-aligned (0°/180°) we can compute directly; for 90°/270° dimensions swap.
282
+ const cx = stageSize.w / 2 + pan.x;
283
+ const cy = stageSize.h / 2 + pan.y;
284
+
285
+ // Crop rect's center & size in stage coords:
286
+ const rcx = cropPx.left + cropPx.width / 2;
287
+ const rcy = cropPx.top + cropPx.height / 2;
288
+
289
+ // Translate crop center into image-local (un-rotated) coords:
290
+ const dx = rcx - cx;
291
+ const dy = rcy - cy;
292
+ // Inverse rotation
293
+ const rad = (-rotation * Math.PI) / 180;
294
+ const ix = dx * Math.cos(rad) - dy * Math.sin(rad);
295
+ const iy = dx * Math.sin(rad) + dy * Math.cos(rad);
296
+
297
+ // For 90/270 the crop rect's width/height effectively swap in image space.
298
+ const isQuarter = rotation === 90 || rotation === 270;
299
+ const cw = isQuarter ? cropPx.height : cropPx.width;
300
+ const ch = isQuarter ? cropPx.width : cropPx.height;
301
+
302
+ // Source pixel coords (top-left)
303
+ const sx = (ix - cw / 2) / s + imgNatural.w / 2;
304
+ const sy = (iy - ch / 2) / s + imgNatural.h / 2;
305
+ const sw = cw / s;
306
+ const sh = ch / s;
307
+
308
+ return {
309
+ x: sx,
310
+ y: sy,
311
+ width: sw,
312
+ height: sh,
313
+ rotation,
314
+ sourceWidth: imgNatural.w,
315
+ sourceHeight: imgNatural.h,
316
+ };
317
+ }, [imgNatural, stageSize, baseScale, zoom, pan, cropPx, rotation]);
318
+
319
+ // Fire onChange whenever the derived crop area changes.
320
+ const lastEmittedRef = useRef<string>('');
321
+ useEffect(() => {
322
+ if (!imgLoaded) return;
323
+ const area = computeCropArea();
324
+ if (!area) return;
325
+ const key = `${area.x.toFixed(2)},${area.y.toFixed(2)},${area.width.toFixed(2)},${area.height.toFixed(2)},${area.rotation}`;
326
+ if (key === lastEmittedRef.current) return;
327
+ lastEmittedRef.current = key;
328
+ onChange?.(area);
329
+ }, [imgLoaded, computeCropArea, onChange]);
330
+
331
+ // ---------- Aspect ratio enforcement ----------
332
+ // When the selected aspect ratio changes, snap the crop rect to it.
333
+ useEffect(() => {
334
+ if (selectedRatio == null || !stageSize.w || !stageSize.h) return;
335
+ setCropPct((prev) => {
336
+ const w = (prev.width / 100) * stageSize.w;
337
+ const h = (prev.height / 100) * stageSize.h;
338
+ // Keep the larger dimension, derive the other from the ratio.
339
+ const targetH = w / selectedRatio;
340
+ let newW = w;
341
+ let newH = targetH;
342
+ if (targetH > h) {
343
+ newH = h;
344
+ newW = h * selectedRatio;
345
+ }
346
+ // Re-center around the previous center
347
+ const cx = (prev.left + prev.width / 2) / 100 * stageSize.w;
348
+ const cy = (prev.top + prev.height / 2) / 100 * stageSize.h;
349
+ let left = cx - newW / 2;
350
+ let top = cy - newH / 2;
351
+ left = clamp(left, 0, stageSize.w - newW);
352
+ top = clamp(top, 0, stageSize.h - newH);
353
+ return {
354
+ left: (left / stageSize.w) * 100,
355
+ top: (top / stageSize.h) * 100,
356
+ width: (newW / stageSize.w) * 100,
357
+ height: (newH / stageSize.h) * 100,
358
+ };
359
+ });
360
+ }, [selectedRatio, stageSize.w, stageSize.h]);
361
+
362
+ // ---------- Pointer interactions ----------
363
+ type DragKind =
364
+ | { type: 'pan'; startX: number; startY: number; startPan: { x: number; y: number } }
365
+ | { type: 'move'; startX: number; startY: number; startCrop: Rect }
366
+ | { type: 'resize'; corner: ResizeCorner; startX: number; startY: number; startCrop: Rect };
367
+
368
+ const dragRef = useRef<DragKind | null>(null);
369
+ const pinchRef = useRef<{ startDist: number; startZoom: number } | null>(null);
370
+ const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
371
+
372
+ const onStagePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
373
+ if (disabled || !imgLoaded) return;
374
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
375
+ pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
376
+ // Pinch zoom kicks in once a second pointer arrives
377
+ if (pointersRef.current.size === 2) {
378
+ const pts = Array.from(pointersRef.current.values());
379
+ const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
380
+ pinchRef.current = { startDist: dist, startZoom: zoom };
381
+ dragRef.current = null;
382
+ return;
383
+ }
384
+ // Single-pointer: drag the *image* (pan) when stage background was hit.
385
+ dragRef.current = {
386
+ type: 'pan',
387
+ startX: e.clientX,
388
+ startY: e.clientY,
389
+ startPan: pan,
390
+ };
391
+ };
392
+
393
+ const onStagePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
394
+ if (!pointersRef.current.has(e.pointerId)) return;
395
+ pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
396
+
397
+ // Pinch zoom takes priority
398
+ if (pinchRef.current && pointersRef.current.size >= 2) {
399
+ const pts = Array.from(pointersRef.current.values());
400
+ const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
401
+ const next = clamp(
402
+ pinchRef.current.startZoom * (dist / pinchRef.current.startDist),
403
+ minZoom,
404
+ maxZoom,
405
+ );
406
+ setZoomState(next);
407
+ return;
408
+ }
409
+
410
+ const d = dragRef.current;
411
+ if (!d) return;
412
+ const dx = e.clientX - d.startX;
413
+ const dy = e.clientY - d.startY;
414
+
415
+ if (d.type === 'pan') {
416
+ setPan({ x: d.startPan.x + dx, y: d.startPan.y + dy });
417
+ } else if (d.type === 'move') {
418
+ moveCropBy(dx, dy, d.startCrop);
419
+ } else if (d.type === 'resize') {
420
+ resizeCrop(d.corner, dx, dy, d.startCrop);
421
+ }
422
+ };
423
+
424
+ const endPointer = (e: ReactPointerEvent<HTMLDivElement>) => {
425
+ pointersRef.current.delete(e.pointerId);
426
+ if (pointersRef.current.size < 2) pinchRef.current = null;
427
+ if (pointersRef.current.size === 0) dragRef.current = null;
428
+ };
429
+
430
+ // ---------- Wheel zoom ----------
431
+ const onWheel = (e: ReactWheelEvent<HTMLDivElement>) => {
432
+ if (disabled || !imgLoaded) return;
433
+ e.preventDefault();
434
+ const delta = -e.deltaY * 0.0015;
435
+ setZoomState((z) => clamp(z * (1 + delta), minZoom, maxZoom));
436
+ };
437
+
438
+ // ---------- Crop rect manipulation ----------
439
+ const moveCropBy = (dx: number, dy: number, startCrop: Rect) => {
440
+ const stageW = stageSize.w, stageH = stageSize.h;
441
+ const startLeft = (startCrop.left / 100) * stageW;
442
+ const startTop = (startCrop.top / 100) * stageH;
443
+ const w = (startCrop.width / 100) * stageW;
444
+ const h = (startCrop.height / 100) * stageH;
445
+ const left = clamp(startLeft + dx, 0, stageW - w);
446
+ const top = clamp(startTop + dy, 0, stageH - h);
447
+ setCropPct({
448
+ left: (left / stageW) * 100,
449
+ top: (top / stageH) * 100,
450
+ width: startCrop.width,
451
+ height: startCrop.height,
452
+ });
453
+ };
454
+
455
+ type ResizeCorner = 'tl' | 'tr' | 'bl' | 'br' | 't' | 'b' | 'l' | 'r';
456
+
457
+ const resizeCrop = (corner: ResizeCorner, dx: number, dy: number, startCrop: Rect) => {
458
+ const stageW = stageSize.w, stageH = stageSize.h;
459
+ let left = (startCrop.left / 100) * stageW;
460
+ let top = (startCrop.top / 100) * stageH;
461
+ let width = (startCrop.width / 100) * stageW;
462
+ let height = (startCrop.height / 100) * stageH;
463
+ const right = left + width;
464
+ const bottom = top + height;
465
+
466
+ let newLeft = left, newTop = top, newRight = right, newBottom = bottom;
467
+ if (corner.includes('l')) newLeft = clamp(left + dx, 0, right - MIN_CROP_PX);
468
+ if (corner.includes('r')) newRight = clamp(right + dx, left + MIN_CROP_PX, stageW);
469
+ if (corner.includes('t')) newTop = clamp(top + dy, 0, bottom - MIN_CROP_PX);
470
+ if (corner.includes('b')) newBottom = clamp(bottom + dy, top + MIN_CROP_PX, stageH);
471
+
472
+ let w = newRight - newLeft;
473
+ let h = newBottom - newTop;
474
+
475
+ // Enforce aspect ratio if locked
476
+ if (selectedRatio != null) {
477
+ const isCorner = corner.length === 2;
478
+ if (isCorner) {
479
+ // Use the larger axis change to drive the smaller one
480
+ const aw = w;
481
+ const ah = w / selectedRatio;
482
+ if (ah <= h || corner === 'tl' || corner === 'tr' || corner === 'bl' || corner === 'br') {
483
+ h = aw / selectedRatio;
484
+ } else {
485
+ w = ah * selectedRatio;
486
+ }
487
+ if (corner.includes('t')) newTop = newBottom - h;
488
+ if (corner.includes('b')) newBottom = newTop + h;
489
+ if (corner.includes('l')) newLeft = newRight - w;
490
+ if (corner.includes('r')) newRight = newLeft + w;
491
+ } else if (corner === 'l' || corner === 'r') {
492
+ h = w / selectedRatio;
493
+ const cy = (top + bottom) / 2;
494
+ newTop = cy - h / 2;
495
+ newBottom = cy + h / 2;
496
+ } else if (corner === 't' || corner === 'b') {
497
+ w = h * selectedRatio;
498
+ const cx = (left + right) / 2;
499
+ newLeft = cx - w / 2;
500
+ newRight = cx + w / 2;
501
+ }
502
+ // Re-clamp inside the stage; if we overflow, shrink to fit
503
+ if (newLeft < 0) { newRight += -newLeft; newLeft = 0; }
504
+ if (newTop < 0) { newBottom += -newTop; newTop = 0; }
505
+ if (newRight > stageW) { const o = newRight - stageW; newRight = stageW; newLeft -= o; }
506
+ if (newBottom > stageH) { const o = newBottom - stageH; newBottom = stageH; newTop -= o; }
507
+ w = newRight - newLeft;
508
+ h = newBottom - newTop;
509
+ // If clamping broke the aspect, shrink to satisfy it
510
+ if (Math.abs(w / h - selectedRatio) > 0.001) {
511
+ if (w / h > selectedRatio) {
512
+ const targetW = h * selectedRatio;
513
+ const cx = (newLeft + newRight) / 2;
514
+ newLeft = cx - targetW / 2;
515
+ newRight = cx + targetW / 2;
516
+ } else {
517
+ const targetH = w / selectedRatio;
518
+ const cy = (newTop + newBottom) / 2;
519
+ newTop = cy - targetH / 2;
520
+ newBottom = cy + targetH / 2;
521
+ }
522
+ }
523
+ }
524
+
525
+ setCropPct({
526
+ left: (newLeft / stageW) * 100,
527
+ top: (newTop / stageH) * 100,
528
+ width: ((newRight - newLeft) / stageW) * 100,
529
+ height: ((newBottom - newTop) / stageH) * 100,
530
+ });
531
+ };
532
+
533
+ const onOverlayPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
534
+ if (disabled || !imgLoaded) return;
535
+ e.stopPropagation();
536
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
537
+ pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
538
+ dragRef.current = {
539
+ type: 'move',
540
+ startX: e.clientX,
541
+ startY: e.clientY,
542
+ startCrop: { ...cropPct },
543
+ };
544
+ };
545
+
546
+ const onHandlePointerDown = (corner: ResizeCorner) => (e: ReactPointerEvent<HTMLDivElement>) => {
547
+ if (disabled || !imgLoaded) return;
548
+ e.stopPropagation();
549
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
550
+ pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
551
+ dragRef.current = {
552
+ type: 'resize',
553
+ corner,
554
+ startX: e.clientX,
555
+ startY: e.clientY,
556
+ startCrop: { ...cropPct },
557
+ };
558
+ };
559
+
560
+ // ---------- Output (canvas drawing) ----------
561
+ const drawToCanvas = useCallback((opts?: Pick<EvoCropOutputOptions, 'maxWidth' | 'maxHeight'>): HTMLCanvasElement | null => {
562
+ const area = computeCropArea();
563
+ if (!area || !imgRef.current) return null;
564
+ let outW = Math.max(1, Math.round(area.width));
565
+ let outH = Math.max(1, Math.round(area.height));
566
+ const { maxWidth, maxHeight } = opts ?? {};
567
+ if (maxWidth && outW > maxWidth) {
568
+ const r = maxWidth / outW;
569
+ outW = maxWidth;
570
+ outH = Math.round(outH * r);
571
+ }
572
+ if (maxHeight && outH > maxHeight) {
573
+ const r = maxHeight / outH;
574
+ outH = maxHeight;
575
+ outW = Math.round(outW * r);
576
+ }
577
+ const canvas = document.createElement('canvas');
578
+ canvas.width = outW;
579
+ canvas.height = outH;
580
+ const ctx = canvas.getContext('2d');
581
+ if (!ctx) return null;
582
+ ctx.imageSmoothingQuality = 'high';
583
+
584
+ const img = imgRef.current;
585
+ // Apply rotation around the canvas center so the *source rect* aligns.
586
+ ctx.save();
587
+ if (area.rotation === 0) {
588
+ ctx.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, outW, outH);
589
+ } else {
590
+ // Rotate the canvas so that the source coordinate system matches.
591
+ ctx.translate(outW / 2, outH / 2);
592
+ ctx.rotate((area.rotation * Math.PI) / 180);
593
+ const swapped = area.rotation === 90 || area.rotation === 270;
594
+ const drawW = swapped ? outH : outW;
595
+ const drawH = swapped ? outW : outH;
596
+ // Map the rotated crop back to source-image coords. For 90/270 the
597
+ // crop's width/height correspond to height/width of the source rect.
598
+ const srcW = swapped ? area.height : area.width;
599
+ const srcH = swapped ? area.width : area.height;
600
+ // The crop area's (x,y) we computed is already in the un-rotated
601
+ // source's coordinate space for the rotated rect — but we computed
602
+ // the un-rotated bounding rect, so use it directly.
603
+ ctx.drawImage(
604
+ img,
605
+ area.x, area.y, srcW, srcH,
606
+ -drawW / 2, -drawH / 2, drawW, drawH,
607
+ );
608
+ }
609
+ ctx.restore();
610
+
611
+ // Circular mask
612
+ if (circular) {
613
+ ctx.globalCompositeOperation = 'destination-in';
614
+ ctx.beginPath();
615
+ ctx.arc(outW / 2, outH / 2, Math.min(outW, outH) / 2, 0, Math.PI * 2);
616
+ ctx.closePath();
617
+ ctx.fill();
618
+ }
619
+ return canvas;
620
+ }, [computeCropArea, circular]);
621
+
622
+ // ---------- Imperative handle ----------
623
+ useImperativeHandle(ref, () => ({
624
+ getCrop: () => computeCropArea(),
625
+ getCroppedCanvas: (opts) => drawToCanvas(opts),
626
+ getCroppedBlob: (opts) =>
627
+ new Promise<Blob>((resolve, reject) => {
628
+ const canvas = drawToCanvas(opts);
629
+ if (!canvas) return reject(new Error('Cropper not ready'));
630
+ canvas.toBlob(
631
+ (b) => (b ? resolve(b) : reject(new Error('Canvas export failed'))),
632
+ opts?.type ?? 'image/png',
633
+ opts?.quality ?? 0.92,
634
+ );
635
+ }),
636
+ getCroppedDataURL: async (opts) => {
637
+ const canvas = drawToCanvas(opts);
638
+ if (!canvas) throw new Error('Cropper not ready');
639
+ return canvas.toDataURL(opts?.type ?? 'image/png', opts?.quality ?? 0.92);
640
+ },
641
+ reset: () => {
642
+ setZoomState(defaultZoom);
643
+ setRotation(normalizeRotation(defaultRotation));
644
+ setPan({ x: 0, y: 0 });
645
+ setCropPct({
646
+ left: defaultCrop?.x ?? 10,
647
+ top: defaultCrop?.y ?? 10,
648
+ width: defaultCrop?.width ?? 80,
649
+ height: defaultCrop?.height ?? 80,
650
+ });
651
+ },
652
+ rotate: (deg) => {
653
+ if (deg === undefined) {
654
+ setRotation((r) => normalizeRotation(r + 90));
655
+ } else {
656
+ setRotation(normalizeRotation(deg));
657
+ }
658
+ },
659
+ setZoom: (z) => setZoomState(clamp(z, minZoom, maxZoom)),
660
+ }), [computeCropArea, drawToCanvas, defaultZoom, defaultRotation, defaultCrop, minZoom, maxZoom]);
661
+
662
+ // ---------- Render ----------
663
+ const stageStyle: CSSProperties = {
664
+ height: typeof height === 'number' ? `${height}px` : height,
665
+ };
666
+
667
+ const imageStyle: CSSProperties = imgNatural
668
+ ? {
669
+ width: imgDisplay.w,
670
+ height: imgDisplay.h,
671
+ transform: `translate(${pan.x}px, ${pan.y}px) rotate(${rotation}deg)`,
672
+ }
673
+ : { display: 'none' };
674
+
675
+ const overlayStyle: CSSProperties = {
676
+ left: cropPx.left,
677
+ top: cropPx.top,
678
+ width: cropPx.width,
679
+ height: cropPx.height,
680
+ };
681
+
682
+ const circleMaskStyle: CSSProperties = circular
683
+ ? ({
684
+ ['--mask-cx' as string]: `${cropPx.left + cropPx.width / 2}px`,
685
+ ['--mask-cy' as string]: `${cropPx.top + cropPx.height / 2}px`,
686
+ ['--mask-r' as string]: `${Math.min(cropPx.width, cropPx.height) / 2}px`,
687
+ } as CSSProperties)
688
+ : {};
689
+
690
+ const stageClasses = [
691
+ styles.stage,
692
+ background === 'checker' ? styles.bgChecker : '',
693
+ disabled ? styles.disabled : '',
694
+ ].filter(Boolean).join(' ');
695
+
696
+ const rootClasses = [
697
+ styles.root,
698
+ fullWidth ? styles.fullWidth : '',
699
+ className ?? '',
700
+ ].filter(Boolean).join(' ');
701
+
702
+ const presets = ratioPresets ?? DEFAULT_RATIO_PRESETS;
703
+ const showPresets = showControls && !circular && presets.length > 0;
704
+
705
+ return (
706
+ <div className={rootClasses}>
707
+ {label && <div className={styles.label}>{label}</div>}
708
+
709
+ {showPresets && (
710
+ <div className={styles.ratioRow} role="radiogroup" aria-label="Aspect ratio">
711
+ {presets.map((p) => (
712
+ <button
713
+ key={p.label}
714
+ type="button"
715
+ role="radio"
716
+ aria-checked={selectedRatio === p.value}
717
+ className={`${styles.ratioChip} ${selectedRatio === p.value ? styles.active : ''}`}
718
+ disabled={disabled}
719
+ onClick={() => setSelectedRatio(p.value)}
720
+ >
721
+ {p.label}
722
+ </button>
723
+ ))}
724
+ </div>
725
+ )}
726
+
727
+ <div
728
+ ref={stageRef}
729
+ className={stageClasses}
730
+ style={stageStyle}
731
+ role="application"
732
+ aria-label={ariaLabel ?? 'Image cropper'}
733
+ aria-disabled={disabled}
734
+ onPointerDown={onStagePointerDown}
735
+ onPointerMove={onStagePointerMove}
736
+ onPointerUp={endPointer}
737
+ onPointerCancel={endPointer}
738
+ onWheel={onWheel}
739
+ >
740
+ {!imgLoaded && !imgError && (
741
+ <div className={styles.placeholder}>
742
+ <div className={styles.spinner} aria-hidden />
743
+ <div>Loading image…</div>
744
+ </div>
745
+ )}
746
+ {imgError && (
747
+ <div className={styles.placeholder}>
748
+ <div>Couldn't load image.</div>
749
+ </div>
750
+ )}
751
+
752
+ <div className={styles.imageWrap}>
753
+ <img
754
+ ref={imgRef}
755
+ src={src}
756
+ alt=""
757
+ draggable={false}
758
+ className={styles.image}
759
+ style={imageStyle}
760
+ />
761
+ </div>
762
+
763
+ {imgLoaded && (
764
+ <>
765
+ {circular && <div className={styles.circleMask} style={circleMaskStyle} />}
766
+ <div
767
+ className={`${styles.overlay} ${circular ? styles.overlayCircle : ''}`}
768
+ style={overlayStyle}
769
+ onPointerDown={onOverlayPointerDown}
770
+ onPointerMove={onStagePointerMove}
771
+ onPointerUp={endPointer}
772
+ onPointerCancel={endPointer}
773
+ >
774
+ {showGrid && !circular && (
775
+ <div className={styles.grid} aria-hidden>
776
+ <div className={`${styles.gridLine} ${styles.h}`} style={{ top: '33.33%' }} />
777
+ <div className={`${styles.gridLine} ${styles.h}`} style={{ top: '66.66%' }} />
778
+ <div className={`${styles.gridLine} ${styles.v}`} style={{ left: '33.33%' }} />
779
+ <div className={`${styles.gridLine} ${styles.v}`} style={{ left: '66.66%' }} />
780
+ </div>
781
+ )}
782
+ <div className={`${styles.handle} ${styles.handleTL}`} onPointerDown={onHandlePointerDown('tl')} />
783
+ <div className={`${styles.handle} ${styles.handleTR}`} onPointerDown={onHandlePointerDown('tr')} />
784
+ <div className={`${styles.handle} ${styles.handleBL}`} onPointerDown={onHandlePointerDown('bl')} />
785
+ <div className={`${styles.handle} ${styles.handleBR}`} onPointerDown={onHandlePointerDown('br')} />
786
+ <div className={`${styles.handle} ${styles.handleT}`} onPointerDown={onHandlePointerDown('t')} />
787
+ <div className={`${styles.handle} ${styles.handleB}`} onPointerDown={onHandlePointerDown('b')} />
788
+ <div className={`${styles.handle} ${styles.handleL}`} onPointerDown={onHandlePointerDown('l')} />
789
+ <div className={`${styles.handle} ${styles.handleR}`} onPointerDown={onHandlePointerDown('r')} />
790
+ </div>
791
+ </>
792
+ )}
793
+ </div>
794
+
795
+ {showControls && (
796
+ <div className={styles.controls} aria-label="Cropper controls">
797
+ <div className={styles.controlGroup}>
798
+ <span className={styles.controlLabel}>Zoom</span>
799
+ <button
800
+ type="button"
801
+ className={styles.iconBtn}
802
+ aria-label="Zoom out"
803
+ disabled={disabled || zoom <= minZoom}
804
+ onClick={() => setZoomState((z) => clamp(z - 0.1, minZoom, maxZoom))}
805
+ >
806
+ <MinusIcon />
807
+ </button>
808
+ <input
809
+ type="range"
810
+ className={styles.zoomSlider}
811
+ min={minZoom}
812
+ max={maxZoom}
813
+ step={0.01}
814
+ value={zoom}
815
+ disabled={disabled}
816
+ onChange={(e) => setZoomState(parseFloat(e.target.value))}
817
+ aria-label="Zoom level"
818
+ />
819
+ <button
820
+ type="button"
821
+ className={styles.iconBtn}
822
+ aria-label="Zoom in"
823
+ disabled={disabled || zoom >= maxZoom}
824
+ onClick={() => setZoomState((z) => clamp(z + 0.1, minZoom, maxZoom))}
825
+ >
826
+ <PlusIcon />
827
+ </button>
828
+ </div>
829
+ <div className={styles.divider} aria-hidden />
830
+ <div className={styles.controlGroup}>
831
+ <span className={styles.controlLabel}>Rotate</span>
832
+ <button
833
+ type="button"
834
+ className={styles.iconBtn}
835
+ aria-label="Rotate left"
836
+ disabled={disabled}
837
+ onClick={() => setRotation((r) => normalizeRotation(r - 90))}
838
+ >
839
+ <RotateLeftIcon />
840
+ </button>
841
+ <button
842
+ type="button"
843
+ className={styles.iconBtn}
844
+ aria-label="Rotate right"
845
+ disabled={disabled}
846
+ onClick={() => setRotation((r) => normalizeRotation(r + 90))}
847
+ >
848
+ <RotateRightIcon />
849
+ </button>
850
+ </div>
851
+ <div className={styles.divider} aria-hidden />
852
+ <div className={styles.controlGroup}>
853
+ <button
854
+ type="button"
855
+ className={styles.iconBtn}
856
+ aria-label="Reset"
857
+ disabled={disabled}
858
+ onClick={() => {
859
+ setZoomState(defaultZoom);
860
+ setRotation(normalizeRotation(defaultRotation));
861
+ setPan({ x: 0, y: 0 });
862
+ setCropPct({
863
+ left: defaultCrop?.x ?? 10,
864
+ top: defaultCrop?.y ?? 10,
865
+ width: defaultCrop?.width ?? 80,
866
+ height: defaultCrop?.height ?? 80,
867
+ });
868
+ }}
869
+ >
870
+ <ResetIcon />
871
+ </button>
872
+ </div>
873
+ </div>
874
+ )}
875
+
876
+ {helperText && <div className={styles.helper}>{helperText}</div>}
877
+ </div>
878
+ );
879
+ },
880
+ );
881
+
882
+ // ----------------------------------------------------------------------------
883
+ // Inline icons — kept inline so the component has zero external deps
884
+ // ----------------------------------------------------------------------------
885
+
886
+ const iconProps = {
887
+ width: 16,
888
+ height: 16,
889
+ viewBox: '0 0 24 24',
890
+ fill: 'none',
891
+ stroke: 'currentColor',
892
+ strokeWidth: 2,
893
+ strokeLinecap: 'round' as const,
894
+ strokeLinejoin: 'round' as const,
895
+ };
896
+
897
+ const PlusIcon = () => (
898
+ <svg {...iconProps}><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
899
+ );
900
+ const MinusIcon = () => (
901
+ <svg {...iconProps}><line x1="5" y1="12" x2="19" y2="12" /></svg>
902
+ );
903
+ const RotateLeftIcon = () => (
904
+ <svg {...iconProps}><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" /></svg>
905
+ );
906
+ const RotateRightIcon = () => (
907
+ <svg {...iconProps}><polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10" /></svg>
908
+ );
909
+ const ResetIcon = () => (
910
+ <svg {...iconProps}><path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><polyline points="3 3 3 8 8 8" /></svg>
911
+ );