@justin_evo/evo-ui 1.0.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.
- package/dist/Alert/Alert.d.ts +11 -0
- package/dist/AutoComplete/AutoComplete.d.ts +95 -0
- package/dist/Badge/Badge.d.ts +23 -0
- package/dist/Breadcrumb/Breadcrumb.d.ts +16 -0
- package/dist/Button/Button.d.ts +54 -0
- package/dist/Card/Card.d.ts +60 -0
- package/dist/Checkbox/Checkbox.d.ts +16 -0
- package/dist/CommandPalette/CommandPalette.d.ts +17 -0
- package/dist/Container/Container.d.ts +10 -0
- package/dist/Divider/Divider.d.ts +7 -0
- package/dist/Form/Form.d.ts +61 -0
- package/dist/Grid/Grid.d.ts +23 -0
- package/dist/ImageCropper/ImageCropper.d.ts +111 -0
- package/dist/Input/Input.d.ts +12 -0
- package/dist/Modal/Modal.d.ts +26 -0
- package/dist/Nav/Nav.d.ts +63 -0
- package/dist/Notification/Notification.d.ts +186 -0
- package/dist/Pagination/Pagination.d.ts +10 -0
- package/dist/Radio/Radio.d.ts +20 -0
- package/dist/RichTextArea/RichTextArea.d.ts +70 -0
- package/dist/Select/Select.d.ts +44 -0
- package/dist/Skeleton/Skeleton.d.ts +23 -0
- package/dist/Stack/Stack.d.ts +16 -0
- package/dist/Table/Table.d.ts +77 -0
- package/dist/Tabs/Tabs.d.ts +28 -0
- package/dist/Theme/ThemeProvider.d.ts +96 -0
- package/dist/Theme/ThemeToggle.d.ts +22 -0
- package/dist/Toggle/Toggle.d.ts +11 -0
- package/dist/Tooltip/Tooltip.d.ts +10 -0
- package/dist/TopNav/TopNav.d.ts +76 -0
- package/dist/TreeSelect/TreeSelect.d.ts +50 -0
- package/dist/declarations.d.ts +6 -0
- package/dist/evo-ui.css +1 -0
- package/dist/index.cjs.js +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.es.js +5688 -0
- package/package.json +52 -0
- package/src/Alert/Alert.tsx +49 -0
- package/src/AutoComplete/AutoComplete.tsx +810 -0
- package/src/Badge/Badge.tsx +53 -0
- package/src/Breadcrumb/Breadcrumb.tsx +53 -0
- package/src/Button/Button.tsx +125 -0
- package/src/Card/Card.tsx +257 -0
- package/src/Checkbox/Checkbox.tsx +59 -0
- package/src/CommandPalette/CommandPalette.tsx +185 -0
- package/src/Container/Container.tsx +31 -0
- package/src/Divider/Divider.tsx +31 -0
- package/src/Form/Form.tsx +185 -0
- package/src/Grid/Grid.tsx +66 -0
- package/src/ImageCropper/ImageCropper.tsx +911 -0
- package/src/Input/Input.tsx +74 -0
- package/src/Modal/Modal.tsx +77 -0
- package/src/Nav/Nav.tsx +626 -0
- package/src/Notification/Notification.tsx +1503 -0
- package/src/Pagination/Pagination.tsx +76 -0
- package/src/Radio/Radio.tsx +69 -0
- package/src/RichTextArea/RichTextArea.tsx +869 -0
- package/src/Select/Select.tsx +515 -0
- package/src/Skeleton/Skeleton.tsx +70 -0
- package/src/Stack/Stack.tsx +52 -0
- package/src/Table/Table.tsx +335 -0
- package/src/Tabs/Tabs.tsx +90 -0
- package/src/Theme/ThemeProvider.tsx +253 -0
- package/src/Theme/ThemeToggle.tsx +79 -0
- package/src/Toggle/Toggle.tsx +48 -0
- package/src/Tooltip/Tooltip.tsx +38 -0
- package/src/TopNav/TopNav.tsx +994 -0
- package/src/TreeSelect/TreeSelect.tsx +825 -0
- package/src/css/alert.module.scss +93 -0
- package/src/css/autocomplete.module.scss +416 -0
- package/src/css/badge.module.scss +82 -0
- package/src/css/base/_color.scss +159 -0
- package/src/css/base/_theme.scss +237 -0
- package/src/css/base/_variables.scss +161 -0
- package/src/css/breadcrumb.module.scss +50 -0
- package/src/css/button.module.scss +385 -0
- package/src/css/card.module.scss +217 -0
- package/src/css/checkbox.module.scss +120 -0
- package/src/css/commandpalette.module.scss +211 -0
- package/src/css/container.module.scss +18 -0
- package/src/css/divider.module.scss +41 -0
- package/src/css/form.module.scss +245 -0
- package/src/css/imagecropper.module.scss +397 -0
- package/src/css/input.module.scss +89 -0
- package/src/css/modal.module.scss +105 -0
- package/src/css/nav.module.scss +339 -0
- package/src/css/notification.module.scss +691 -0
- package/src/css/pagination.module.scss +63 -0
- package/src/css/radio.module.scss +89 -0
- package/src/css/richtextarea.module.scss +307 -0
- package/src/css/select.module.scss +525 -0
- package/src/css/skeleton.module.scss +30 -0
- package/src/css/table.module.scss +386 -0
- package/src/css/tabs.module.scss +63 -0
- package/src/css/theme-toggle.module.scss +83 -0
- package/src/css/toggle.module.scss +54 -0
- package/src/css/tooltip.module.scss +97 -0
- package/src/css/topnav.module.scss +396 -0
- package/src/css/treeselect.module.scss +558 -0
- package/src/css/utilities/_borders.scss +111 -0
- package/src/css/utilities/_colors.scss +66 -0
- package/src/css/utilities/_effects.scss +216 -0
- package/src/css/utilities/_layout.scss +181 -0
- package/src/css/utilities/_position.scss +75 -0
- package/src/css/utilities/_sizing.scss +138 -0
- package/src/css/utilities/_spacing.scss +99 -0
- package/src/css/utilities/_typography.scss +121 -0
- package/src/css/utilities/index.scss +24 -0
- package/src/declarations.d.ts +6 -0
- package/src/index.ts +60 -0
|
@@ -0,0 +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
|
+
);
|