@voila.dev/ui 1.1.9
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/package.json +78 -0
- package/src/components/ui/accordion.tsx +79 -0
- package/src/components/ui/alert-dialog.tsx +198 -0
- package/src/components/ui/alert.tsx +105 -0
- package/src/components/ui/aspect-ratio.tsx +30 -0
- package/src/components/ui/avatar.tsx +125 -0
- package/src/components/ui/badge-variants.ts +166 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/banner.tsx +84 -0
- package/src/components/ui/breadcrumb.tsx +127 -0
- package/src/components/ui/button-group.tsx +99 -0
- package/src/components/ui/button-variants.ts +108 -0
- package/src/components/ui/button.tsx +54 -0
- package/src/components/ui/calendar.tsx +266 -0
- package/src/components/ui/card.tsx +155 -0
- package/src/components/ui/carousel.tsx +319 -0
- package/src/components/ui/chat.tsx +896 -0
- package/src/components/ui/checkbox-group.tsx +44 -0
- package/src/components/ui/checkbox.tsx +34 -0
- package/src/components/ui/chip.tsx +60 -0
- package/src/components/ui/collapsible.tsx +44 -0
- package/src/components/ui/color-picker.tsx +191 -0
- package/src/components/ui/combobox.tsx +317 -0
- package/src/components/ui/command.tsx +187 -0
- package/src/components/ui/confirm-dialog.tsx +125 -0
- package/src/components/ui/context-menu.tsx +254 -0
- package/src/components/ui/copyable-text.tsx +65 -0
- package/src/components/ui/date-picker.tsx +316 -0
- package/src/components/ui/date-time-picker.tsx +1071 -0
- package/src/components/ui/dialog.tsx +176 -0
- package/src/components/ui/direction.tsx +15 -0
- package/src/components/ui/drawer.tsx +178 -0
- package/src/components/ui/dropdown-menu.tsx +247 -0
- package/src/components/ui/empty.tsx +131 -0
- package/src/components/ui/field.tsx +257 -0
- package/src/components/ui/formatted-input.tsx +219 -0
- package/src/components/ui/gallery.tsx +161 -0
- package/src/components/ui/hover-card.tsx +77 -0
- package/src/components/ui/icon-picker.tsx +193 -0
- package/src/components/ui/image-cropper.tsx +787 -0
- package/src/components/ui/image-upload-field.tsx +344 -0
- package/src/components/ui/input-group.tsx +175 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/item.tsx +212 -0
- package/src/components/ui/kbd.tsx +62 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/list.tsx +46 -0
- package/src/components/ui/menu-variants.ts +71 -0
- package/src/components/ui/menubar.tsx +226 -0
- package/src/components/ui/money-input.tsx +96 -0
- package/src/components/ui/native-date-picker.tsx +120 -0
- package/src/components/ui/native-select.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +183 -0
- package/src/components/ui/pagination.tsx +150 -0
- package/src/components/ui/popover.tsx +104 -0
- package/src/components/ui/profile-header.tsx +196 -0
- package/src/components/ui/progress.tsx +71 -0
- package/src/components/ui/radio-group.tsx +115 -0
- package/src/components/ui/rating.tsx +200 -0
- package/src/components/ui/resizable.tsx +61 -0
- package/src/components/ui/responsive-dialog.tsx +278 -0
- package/src/components/ui/responsive-select.tsx +289 -0
- package/src/components/ui/responsive-sheet.tsx +225 -0
- package/src/components/ui/scroll-area.tsx +51 -0
- package/src/components/ui/section.tsx +106 -0
- package/src/components/ui/segmented-control.tsx +148 -0
- package/src/components/ui/select.tsx +216 -0
- package/src/components/ui/separator.tsx +69 -0
- package/src/components/ui/sheet.tsx +154 -0
- package/src/components/ui/shortcut.tsx +41 -0
- package/src/components/ui/sidebar.tsx +739 -0
- package/src/components/ui/skeleton.tsx +23 -0
- package/src/components/ui/slider.tsx +74 -0
- package/src/components/ui/sonner.tsx +64 -0
- package/src/components/ui/spinner.tsx +19 -0
- package/src/components/ui/stat-card.tsx +138 -0
- package/src/components/ui/stepper.tsx +181 -0
- package/src/components/ui/sticky-action-bar.tsx +42 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +132 -0
- package/src/components/ui/tabs.tsx +77 -0
- package/src/components/ui/textarea.tsx +24 -0
- package/src/components/ui/time-picker.tsx +221 -0
- package/src/components/ui/toggle-group.tsx +86 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +79 -0
- package/src/components/ui/translation-input.tsx +126 -0
- package/src/components/ui/user-avatar.tsx +74 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/use-command-palette.ts +28 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/hooks/use-pagination.ts +81 -0
- package/src/hooks/use-picker-state.ts +44 -0
- package/src/lib/cva.ts +18 -0
- package/src/lib/time-math.ts +62 -0
- package/src/lib/utils.ts +14 -0
- package/src/styles/globals.css +36 -0
- package/src/styles.css +10 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { UploadSimpleIcon } from "@phosphor-icons/react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "#/lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
// Composable image selection + cropping. <ImageCropper> fixes the crop format
|
|
6
|
+
// (aspectRatio prop) and owns the picked file plus the crop transform (pan
|
|
7
|
+
// offset + zoom relative to "cover" fit); <ImageCropperDropzone> handles
|
|
8
|
+
// click / drag-and-drop selection, <ImageCropperArea> renders the crop
|
|
9
|
+
// viewport where all zooming happens through gestures — pinch, wheel,
|
|
10
|
+
// double-tap and keyboard — and `useImageCropper` exposes actions
|
|
11
|
+
// (cropToBlob, removeImage, …) so consumers can compose their own controls.
|
|
12
|
+
|
|
13
|
+
const ORIGIN: ImageCropperPoint = { x: 0, y: 0 };
|
|
14
|
+
const KEYBOARD_PAN_STEP = 16;
|
|
15
|
+
const KEYBOARD_ZOOM_FACTOR = 1.2;
|
|
16
|
+
const WHEEL_ZOOM_INTENSITY = 0.002;
|
|
17
|
+
const DOUBLE_TAP_ZOOM_FACTOR = 2;
|
|
18
|
+
const DOUBLE_TAP_MAXIMUM_DELAY_MS = 300;
|
|
19
|
+
const DOUBLE_TAP_MAXIMUM_DISTANCE_PX = 24;
|
|
20
|
+
const TAP_MAXIMUM_TRAVEL_PX = 8;
|
|
21
|
+
|
|
22
|
+
const KEYBOARD_PAN_OFFSETS: Record<string, ImageCropperPoint> = {
|
|
23
|
+
ArrowLeft: { x: -KEYBOARD_PAN_STEP, y: 0 },
|
|
24
|
+
ArrowRight: { x: KEYBOARD_PAN_STEP, y: 0 },
|
|
25
|
+
ArrowUp: { x: 0, y: -KEYBOARD_PAN_STEP },
|
|
26
|
+
ArrowDown: { x: 0, y: KEYBOARD_PAN_STEP },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type ImageCropperPoint = { x: number; y: number };
|
|
30
|
+
type ImageCropperSize = { width: number; height: number };
|
|
31
|
+
|
|
32
|
+
/** Crop rectangle in natural (intrinsic) image pixels. */
|
|
33
|
+
type ImageCropperCropArea = {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type ImageCropperCropToBlobOptions = {
|
|
41
|
+
/** Output width in pixels; defaults to the natural crop width. */
|
|
42
|
+
width?: number;
|
|
43
|
+
/** Output height in pixels; defaults to `width / aspectRatio`. */
|
|
44
|
+
height?: number;
|
|
45
|
+
/** Canvas encoding MIME type, e.g. "image/jpeg" or "image/webp". */
|
|
46
|
+
type?: string;
|
|
47
|
+
/** Encoding quality between 0 and 1 for lossy types. */
|
|
48
|
+
quality?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function clampNumber(value: number, minimum: number, maximum: number): number {
|
|
52
|
+
return Math.min(Math.max(value, minimum), maximum);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Scale at which the image exactly covers the viewport (zoom = 1). */
|
|
56
|
+
function coverScaleFor(
|
|
57
|
+
image: ImageCropperSize,
|
|
58
|
+
viewport: ImageCropperSize,
|
|
59
|
+
): number {
|
|
60
|
+
return Math.max(viewport.width / image.width, viewport.height / image.height);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Keeps the image covering the viewport — no gaps on any edge. */
|
|
64
|
+
function clampOffset(
|
|
65
|
+
offset: ImageCropperPoint,
|
|
66
|
+
zoom: number,
|
|
67
|
+
image: ImageCropperSize,
|
|
68
|
+
viewport: ImageCropperSize,
|
|
69
|
+
): ImageCropperPoint {
|
|
70
|
+
const displayScale = coverScaleFor(image, viewport) * zoom;
|
|
71
|
+
const maximumX = Math.max(
|
|
72
|
+
0,
|
|
73
|
+
(image.width * displayScale - viewport.width) / 2,
|
|
74
|
+
);
|
|
75
|
+
const maximumY = Math.max(
|
|
76
|
+
0,
|
|
77
|
+
(image.height * displayScale - viewport.height) / 2,
|
|
78
|
+
);
|
|
79
|
+
return {
|
|
80
|
+
x: clampNumber(offset.x, -maximumX, maximumX),
|
|
81
|
+
y: clampNumber(offset.y, -maximumY, maximumY),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function distanceBetween(a: ImageCropperPoint, b: ImageCropperPoint): number {
|
|
86
|
+
return Math.hypot(b.x - a.x, b.y - a.y);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function midpointBetween(
|
|
90
|
+
a: ImageCropperPoint,
|
|
91
|
+
b: ImageCropperPoint,
|
|
92
|
+
): ImageCropperPoint {
|
|
93
|
+
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Client coordinates → point relative to the viewport center. */
|
|
97
|
+
function focalPointFor(
|
|
98
|
+
viewport: HTMLElement,
|
|
99
|
+
clientX: number,
|
|
100
|
+
clientY: number,
|
|
101
|
+
): ImageCropperPoint {
|
|
102
|
+
const rectangle = viewport.getBoundingClientRect();
|
|
103
|
+
return {
|
|
104
|
+
x: clientX - rectangle.left - rectangle.width / 2,
|
|
105
|
+
y: clientY - rectangle.top - rectangle.height / 2,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type ImageCropperContextValue = {
|
|
110
|
+
accept: string;
|
|
111
|
+
aspectRatio: number;
|
|
112
|
+
disabled: boolean;
|
|
113
|
+
minZoom: number;
|
|
114
|
+
maxZoom: number;
|
|
115
|
+
/** Source URL of the selected image, or null while nothing is selected. */
|
|
116
|
+
imageSource: string | null;
|
|
117
|
+
/** CORS mode applied to remote images so the export canvas stays untainted. */
|
|
118
|
+
imageCrossOrigin: React.ComponentProps<"img">["crossOrigin"];
|
|
119
|
+
/** Screen transform of the image; null until the image and viewport are measured. */
|
|
120
|
+
imageTransform: { x: number; y: number; scale: number } | null;
|
|
121
|
+
zoom: number;
|
|
122
|
+
/** Zoom to an absolute level, keeping the focal point (viewport-center relative) stationary. */
|
|
123
|
+
zoomTo: (zoom: number, focalPoint?: ImageCropperPoint) => void;
|
|
124
|
+
/** Multiply the current zoom, keeping the focal point stationary. */
|
|
125
|
+
zoomBy: (factor: number, focalPoint?: ImageCropperPoint) => void;
|
|
126
|
+
panBy: (deltaX: number, deltaY: number) => void;
|
|
127
|
+
resetCrop: () => void;
|
|
128
|
+
openFilePicker: () => void;
|
|
129
|
+
selectFile: (file: File) => void;
|
|
130
|
+
removeImage: () => void;
|
|
131
|
+
getCropArea: () => ImageCropperCropArea | null;
|
|
132
|
+
/** Renders the crop to an offscreen canvas and encodes it. */
|
|
133
|
+
cropToBlob: (options?: ImageCropperCropToBlobOptions) => Promise<Blob>;
|
|
134
|
+
imageElementRef: React.RefObject<HTMLImageElement | null>;
|
|
135
|
+
reportImageLoad: (element: HTMLImageElement) => void;
|
|
136
|
+
reportViewportSize: React.Dispatch<
|
|
137
|
+
React.SetStateAction<ImageCropperSize | null>
|
|
138
|
+
>;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const ImageCropperContext =
|
|
142
|
+
React.createContext<ImageCropperContextValue | null>(null);
|
|
143
|
+
|
|
144
|
+
function useImageCropperContext(part: string): ImageCropperContextValue {
|
|
145
|
+
const context = React.use(ImageCropperContext);
|
|
146
|
+
if (context === null) {
|
|
147
|
+
throw new Error(`${part} must be used within <ImageCropper>`);
|
|
148
|
+
}
|
|
149
|
+
return context;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Access cropper state and actions (cropToBlob, removeImage, …) below <ImageCropper>. */
|
|
153
|
+
function useImageCropper(): ImageCropperContextValue {
|
|
154
|
+
return useImageCropperContext("useImageCropper");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function ImageCropper({
|
|
158
|
+
aspectRatio = 1,
|
|
159
|
+
minZoom = 1,
|
|
160
|
+
maxZoom = 4,
|
|
161
|
+
accept = "image/*",
|
|
162
|
+
defaultImage,
|
|
163
|
+
crossOrigin = "anonymous",
|
|
164
|
+
disabled = false,
|
|
165
|
+
onImageChange,
|
|
166
|
+
onCropChange,
|
|
167
|
+
className,
|
|
168
|
+
children,
|
|
169
|
+
}: {
|
|
170
|
+
/** Width / height ratio of the crop viewport, e.g. 1 for avatars or 16 / 9 for covers. */
|
|
171
|
+
aspectRatio?: number;
|
|
172
|
+
/** Lowest zoom level; 1 means the image exactly covers the viewport. */
|
|
173
|
+
minZoom?: number;
|
|
174
|
+
maxZoom?: number;
|
|
175
|
+
/** File input accept filter. */
|
|
176
|
+
accept?: string;
|
|
177
|
+
/** Image URL to start cropping without going through file selection. */
|
|
178
|
+
defaultImage?: string;
|
|
179
|
+
/**
|
|
180
|
+
* CORS mode for remote image URLs — without it the export canvas is
|
|
181
|
+
* tainted and cropToBlob throws a SecurityError. Picked files load
|
|
182
|
+
* through blob: URLs and never need it.
|
|
183
|
+
*/
|
|
184
|
+
crossOrigin?: React.ComponentProps<"img">["crossOrigin"];
|
|
185
|
+
disabled?: boolean;
|
|
186
|
+
/** Reports the picked file, or null when the image is removed. */
|
|
187
|
+
onImageChange?: (file: File | null) => void;
|
|
188
|
+
/** Reports the crop rectangle in natural image pixels whenever it moves. */
|
|
189
|
+
onCropChange?: (cropArea: ImageCropperCropArea) => void;
|
|
190
|
+
className?: string;
|
|
191
|
+
children?: React.ReactNode;
|
|
192
|
+
}) {
|
|
193
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
194
|
+
const imageElementRef = React.useRef<HTMLImageElement | null>(null);
|
|
195
|
+
const ownedObjectUrlRef = React.useRef<string | null>(null);
|
|
196
|
+
const [imageSource, setImageSource] = React.useState<string | null>(
|
|
197
|
+
defaultImage ?? null,
|
|
198
|
+
);
|
|
199
|
+
const [naturalSize, setNaturalSize] = React.useState<ImageCropperSize | null>(
|
|
200
|
+
null,
|
|
201
|
+
);
|
|
202
|
+
const [viewportSize, setViewportSize] =
|
|
203
|
+
React.useState<ImageCropperSize | null>(null);
|
|
204
|
+
const [transform, setTransform] = React.useState<{
|
|
205
|
+
zoom: number;
|
|
206
|
+
offset: ImageCropperPoint;
|
|
207
|
+
}>({ zoom: minZoom, offset: ORIGIN });
|
|
208
|
+
|
|
209
|
+
const applyZoom = (
|
|
210
|
+
computeZoom: (previousZoom: number) => number,
|
|
211
|
+
focalPoint: ImageCropperPoint = ORIGIN,
|
|
212
|
+
) => {
|
|
213
|
+
setTransform((previous) => {
|
|
214
|
+
if (naturalSize === null || viewportSize === null) return previous;
|
|
215
|
+
const zoom = clampNumber(computeZoom(previous.zoom), minZoom, maxZoom);
|
|
216
|
+
// Keep the image point under the focal point stationary while scaling.
|
|
217
|
+
const factor = zoom / previous.zoom;
|
|
218
|
+
const offset = {
|
|
219
|
+
x: focalPoint.x + (previous.offset.x - focalPoint.x) * factor,
|
|
220
|
+
y: focalPoint.y + (previous.offset.y - focalPoint.y) * factor,
|
|
221
|
+
};
|
|
222
|
+
return {
|
|
223
|
+
zoom,
|
|
224
|
+
offset: clampOffset(offset, zoom, naturalSize, viewportSize),
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const zoomTo = (zoom: number, focalPoint?: ImageCropperPoint) =>
|
|
230
|
+
applyZoom(() => zoom, focalPoint);
|
|
231
|
+
|
|
232
|
+
const zoomBy = (factor: number, focalPoint?: ImageCropperPoint) =>
|
|
233
|
+
applyZoom((previousZoom) => previousZoom * factor, focalPoint);
|
|
234
|
+
|
|
235
|
+
const panBy = (deltaX: number, deltaY: number) => {
|
|
236
|
+
setTransform((previous) => {
|
|
237
|
+
if (naturalSize === null || viewportSize === null) return previous;
|
|
238
|
+
const offset = {
|
|
239
|
+
x: previous.offset.x + deltaX,
|
|
240
|
+
y: previous.offset.y + deltaY,
|
|
241
|
+
};
|
|
242
|
+
return {
|
|
243
|
+
zoom: previous.zoom,
|
|
244
|
+
offset: clampOffset(offset, previous.zoom, naturalSize, viewportSize),
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const resetCrop = () => {
|
|
250
|
+
setTransform({ zoom: minZoom, offset: ORIGIN });
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const openFilePicker = () => {
|
|
254
|
+
fileInputRef.current?.click();
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const selectFile = (file: File) => {
|
|
258
|
+
if (disabled || !file.type.startsWith("image/")) return;
|
|
259
|
+
const nextSource = URL.createObjectURL(file);
|
|
260
|
+
if (ownedObjectUrlRef.current !== null) {
|
|
261
|
+
URL.revokeObjectURL(ownedObjectUrlRef.current);
|
|
262
|
+
}
|
|
263
|
+
ownedObjectUrlRef.current = nextSource;
|
|
264
|
+
setNaturalSize(null);
|
|
265
|
+
setTransform({ zoom: minZoom, offset: ORIGIN });
|
|
266
|
+
setImageSource(nextSource);
|
|
267
|
+
onImageChange?.(file);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const removeImage = () => {
|
|
271
|
+
if (ownedObjectUrlRef.current !== null) {
|
|
272
|
+
URL.revokeObjectURL(ownedObjectUrlRef.current);
|
|
273
|
+
ownedObjectUrlRef.current = null;
|
|
274
|
+
}
|
|
275
|
+
setImageSource(null);
|
|
276
|
+
setNaturalSize(null);
|
|
277
|
+
setTransform({ zoom: minZoom, offset: ORIGIN });
|
|
278
|
+
onImageChange?.(null);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const reportImageLoad = (element: HTMLImageElement) => {
|
|
282
|
+
if (element.naturalWidth === 0 || element.naturalHeight === 0) return;
|
|
283
|
+
setNaturalSize({
|
|
284
|
+
width: element.naturalWidth,
|
|
285
|
+
height: element.naturalHeight,
|
|
286
|
+
});
|
|
287
|
+
setTransform({ zoom: minZoom, offset: ORIGIN });
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const cropArea = React.useMemo<ImageCropperCropArea | null>(() => {
|
|
291
|
+
if (naturalSize === null || viewportSize === null) return null;
|
|
292
|
+
const displayScale =
|
|
293
|
+
coverScaleFor(naturalSize, viewportSize) * transform.zoom;
|
|
294
|
+
const width = viewportSize.width / displayScale;
|
|
295
|
+
const height = viewportSize.height / displayScale;
|
|
296
|
+
return {
|
|
297
|
+
x: naturalSize.width / 2 - transform.offset.x / displayScale - width / 2,
|
|
298
|
+
y:
|
|
299
|
+
naturalSize.height / 2 - transform.offset.y / displayScale - height / 2,
|
|
300
|
+
width,
|
|
301
|
+
height,
|
|
302
|
+
};
|
|
303
|
+
}, [naturalSize, viewportSize, transform]);
|
|
304
|
+
|
|
305
|
+
const getCropArea = () => cropArea;
|
|
306
|
+
|
|
307
|
+
const cropToBlob = async (
|
|
308
|
+
options: ImageCropperCropToBlobOptions = {},
|
|
309
|
+
): Promise<Blob> => {
|
|
310
|
+
const imageElement = imageElementRef.current;
|
|
311
|
+
if (cropArea === null || imageElement === null) {
|
|
312
|
+
throw new Error("ImageCropper: no image to crop");
|
|
313
|
+
}
|
|
314
|
+
const outputWidth = Math.round(
|
|
315
|
+
options.width ??
|
|
316
|
+
(options.height !== undefined
|
|
317
|
+
? options.height * aspectRatio
|
|
318
|
+
: cropArea.width),
|
|
319
|
+
);
|
|
320
|
+
const outputHeight = Math.round(
|
|
321
|
+
options.height ?? outputWidth / aspectRatio,
|
|
322
|
+
);
|
|
323
|
+
const canvas = document.createElement("canvas");
|
|
324
|
+
canvas.width = outputWidth;
|
|
325
|
+
canvas.height = outputHeight;
|
|
326
|
+
const canvasContext = canvas.getContext("2d");
|
|
327
|
+
if (canvasContext === null) {
|
|
328
|
+
throw new Error("ImageCropper: canvas 2d context unavailable");
|
|
329
|
+
}
|
|
330
|
+
canvasContext.drawImage(
|
|
331
|
+
imageElement,
|
|
332
|
+
cropArea.x,
|
|
333
|
+
cropArea.y,
|
|
334
|
+
cropArea.width,
|
|
335
|
+
cropArea.height,
|
|
336
|
+
0,
|
|
337
|
+
0,
|
|
338
|
+
outputWidth,
|
|
339
|
+
outputHeight,
|
|
340
|
+
);
|
|
341
|
+
return await new Promise<Blob>((resolve, reject) => {
|
|
342
|
+
canvas.toBlob(
|
|
343
|
+
(blob) => {
|
|
344
|
+
if (blob === null) {
|
|
345
|
+
reject(new Error("ImageCropper: failed to encode the crop"));
|
|
346
|
+
} else {
|
|
347
|
+
resolve(blob);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
options.type ?? "image/png",
|
|
351
|
+
options.quality,
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// Re-clamp when the viewport is resized or a new image finishes loading.
|
|
357
|
+
React.useEffect(() => {
|
|
358
|
+
if (naturalSize === null || viewportSize === null) return;
|
|
359
|
+
setTransform((previous) => ({
|
|
360
|
+
zoom: previous.zoom,
|
|
361
|
+
offset: clampOffset(
|
|
362
|
+
previous.offset,
|
|
363
|
+
previous.zoom,
|
|
364
|
+
naturalSize,
|
|
365
|
+
viewportSize,
|
|
366
|
+
),
|
|
367
|
+
}));
|
|
368
|
+
}, [naturalSize, viewportSize]);
|
|
369
|
+
|
|
370
|
+
React.useEffect(() => {
|
|
371
|
+
if (cropArea !== null) onCropChange?.(cropArea);
|
|
372
|
+
}, [cropArea, onCropChange]);
|
|
373
|
+
|
|
374
|
+
React.useEffect(
|
|
375
|
+
() => () => {
|
|
376
|
+
if (ownedObjectUrlRef.current !== null) {
|
|
377
|
+
URL.revokeObjectURL(ownedObjectUrlRef.current);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
[],
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const imageTransform =
|
|
384
|
+
naturalSize !== null && viewportSize !== null
|
|
385
|
+
? {
|
|
386
|
+
x: transform.offset.x,
|
|
387
|
+
y: transform.offset.y,
|
|
388
|
+
scale: coverScaleFor(naturalSize, viewportSize) * transform.zoom,
|
|
389
|
+
}
|
|
390
|
+
: null;
|
|
391
|
+
|
|
392
|
+
const imageCrossOrigin =
|
|
393
|
+
imageSource !== null &&
|
|
394
|
+
!imageSource.startsWith("blob:") &&
|
|
395
|
+
!imageSource.startsWith("data:")
|
|
396
|
+
? crossOrigin
|
|
397
|
+
: undefined;
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<ImageCropperContext.Provider
|
|
401
|
+
value={{
|
|
402
|
+
accept,
|
|
403
|
+
aspectRatio,
|
|
404
|
+
disabled,
|
|
405
|
+
minZoom,
|
|
406
|
+
maxZoom,
|
|
407
|
+
imageSource,
|
|
408
|
+
imageCrossOrigin,
|
|
409
|
+
imageTransform,
|
|
410
|
+
zoom: transform.zoom,
|
|
411
|
+
zoomTo,
|
|
412
|
+
zoomBy,
|
|
413
|
+
panBy,
|
|
414
|
+
resetCrop,
|
|
415
|
+
openFilePicker,
|
|
416
|
+
selectFile,
|
|
417
|
+
removeImage,
|
|
418
|
+
getCropArea,
|
|
419
|
+
cropToBlob,
|
|
420
|
+
imageElementRef,
|
|
421
|
+
reportImageLoad,
|
|
422
|
+
reportViewportSize: setViewportSize,
|
|
423
|
+
}}
|
|
424
|
+
>
|
|
425
|
+
<div
|
|
426
|
+
data-slot="image-cropper"
|
|
427
|
+
className={cn("flex w-full flex-col gap-3", className)}
|
|
428
|
+
>
|
|
429
|
+
{children}
|
|
430
|
+
<input
|
|
431
|
+
ref={fileInputRef}
|
|
432
|
+
type="file"
|
|
433
|
+
accept={accept}
|
|
434
|
+
disabled={disabled}
|
|
435
|
+
data-slot="image-cropper-input"
|
|
436
|
+
className="hidden"
|
|
437
|
+
onChange={(event) => {
|
|
438
|
+
const file = event.target.files?.[0];
|
|
439
|
+
if (file !== undefined) selectFile(file);
|
|
440
|
+
// Allow re-selecting the same file after a removal.
|
|
441
|
+
event.target.value = "";
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
444
|
+
</div>
|
|
445
|
+
</ImageCropperContext.Provider>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function ImageCropperDropzone({
|
|
450
|
+
label = "Choose an image",
|
|
451
|
+
description = "Click to browse or drag and drop",
|
|
452
|
+
className,
|
|
453
|
+
children,
|
|
454
|
+
}: {
|
|
455
|
+
label?: string;
|
|
456
|
+
description?: string;
|
|
457
|
+
className?: string;
|
|
458
|
+
/** Replaces the default icon + label content entirely. */
|
|
459
|
+
children?: React.ReactNode;
|
|
460
|
+
}) {
|
|
461
|
+
const { aspectRatio, disabled, imageSource, openFilePicker, selectFile } =
|
|
462
|
+
useImageCropperContext("ImageCropperDropzone");
|
|
463
|
+
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
|
|
464
|
+
|
|
465
|
+
if (imageSource !== null) return null;
|
|
466
|
+
|
|
467
|
+
return (
|
|
468
|
+
<div
|
|
469
|
+
role="button"
|
|
470
|
+
tabIndex={disabled ? -1 : 0}
|
|
471
|
+
aria-disabled={disabled || undefined}
|
|
472
|
+
data-slot="image-cropper-dropzone"
|
|
473
|
+
data-dragging-over={isDraggingOver || undefined}
|
|
474
|
+
style={{ aspectRatio: String(aspectRatio) }}
|
|
475
|
+
className={cn(
|
|
476
|
+
"flex w-full cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-input border-dashed bg-muted/30 p-6 text-center outline-none transition-colors hover:border-ring/60 hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring/50 data-[dragging-over]:border-ring data-[dragging-over]:bg-muted/60 aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
|
|
477
|
+
className,
|
|
478
|
+
)}
|
|
479
|
+
onClick={() => {
|
|
480
|
+
if (!disabled) openFilePicker();
|
|
481
|
+
}}
|
|
482
|
+
onKeyDown={(event) => {
|
|
483
|
+
if (disabled) return;
|
|
484
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
openFilePicker();
|
|
487
|
+
}
|
|
488
|
+
}}
|
|
489
|
+
onDragOver={(event) => {
|
|
490
|
+
event.preventDefault();
|
|
491
|
+
if (!disabled) setIsDraggingOver(true);
|
|
492
|
+
}}
|
|
493
|
+
onDragLeave={() => setIsDraggingOver(false)}
|
|
494
|
+
onDrop={(event) => {
|
|
495
|
+
event.preventDefault();
|
|
496
|
+
setIsDraggingOver(false);
|
|
497
|
+
if (disabled) return;
|
|
498
|
+
const file = event.dataTransfer.files[0];
|
|
499
|
+
if (file !== undefined) selectFile(file);
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
{children ?? (
|
|
503
|
+
<>
|
|
504
|
+
<div className="flex size-10 items-center justify-center rounded-full bg-muted">
|
|
505
|
+
<UploadSimpleIcon
|
|
506
|
+
aria-hidden
|
|
507
|
+
className="size-5 text-muted-foreground"
|
|
508
|
+
/>
|
|
509
|
+
</div>
|
|
510
|
+
<div className="flex flex-col gap-1">
|
|
511
|
+
<p className="font-medium text-sm">{label}</p>
|
|
512
|
+
<p className="text-muted-foreground text-xs">{description}</p>
|
|
513
|
+
</div>
|
|
514
|
+
</>
|
|
515
|
+
)}
|
|
516
|
+
</div>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function ImageCropperArea({
|
|
521
|
+
shape = "rectangle",
|
|
522
|
+
"aria-label": ariaLabel = "Drag to move the image, pinch or scroll to zoom",
|
|
523
|
+
className,
|
|
524
|
+
}: {
|
|
525
|
+
/** Visual mask drawn over the viewport; "circle" pairs with aspectRatio 1. */
|
|
526
|
+
shape?: "rectangle" | "circle";
|
|
527
|
+
"aria-label"?: string;
|
|
528
|
+
className?: string;
|
|
529
|
+
}) {
|
|
530
|
+
const context = useImageCropperContext("ImageCropperArea");
|
|
531
|
+
const {
|
|
532
|
+
aspectRatio,
|
|
533
|
+
disabled,
|
|
534
|
+
imageSource,
|
|
535
|
+
imageCrossOrigin,
|
|
536
|
+
imageTransform,
|
|
537
|
+
minZoom,
|
|
538
|
+
zoom,
|
|
539
|
+
panBy,
|
|
540
|
+
zoomBy,
|
|
541
|
+
resetCrop,
|
|
542
|
+
imageElementRef,
|
|
543
|
+
reportImageLoad,
|
|
544
|
+
reportViewportSize,
|
|
545
|
+
} = context;
|
|
546
|
+
const contextRef = React.useRef(context);
|
|
547
|
+
const viewportRef = React.useRef<HTMLDivElement>(null);
|
|
548
|
+
const activePointersRef = React.useRef(new Map<number, ImageCropperPoint>());
|
|
549
|
+
const pointerOriginsRef = React.useRef(new Map<number, ImageCropperPoint>());
|
|
550
|
+
const lastTapRef = React.useRef<{
|
|
551
|
+
timeStamp: number;
|
|
552
|
+
point: ImageCropperPoint;
|
|
553
|
+
} | null>(null);
|
|
554
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
555
|
+
const hasImage = imageSource !== null;
|
|
556
|
+
|
|
557
|
+
React.useEffect(() => {
|
|
558
|
+
contextRef.current = context;
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
React.useEffect(() => {
|
|
562
|
+
if (!hasImage) return;
|
|
563
|
+
const viewport = viewportRef.current;
|
|
564
|
+
if (viewport === null) return;
|
|
565
|
+
const measure = () => {
|
|
566
|
+
reportViewportSize(
|
|
567
|
+
viewport.clientWidth > 0 && viewport.clientHeight > 0
|
|
568
|
+
? { width: viewport.clientWidth, height: viewport.clientHeight }
|
|
569
|
+
: null,
|
|
570
|
+
);
|
|
571
|
+
};
|
|
572
|
+
const observer = new ResizeObserver(measure);
|
|
573
|
+
observer.observe(viewport);
|
|
574
|
+
measure();
|
|
575
|
+
return () => {
|
|
576
|
+
observer.disconnect();
|
|
577
|
+
reportViewportSize(null);
|
|
578
|
+
};
|
|
579
|
+
}, [hasImage, reportViewportSize]);
|
|
580
|
+
|
|
581
|
+
// React registers wheel listeners as passive, so opting out of page scroll
|
|
582
|
+
// requires a native non-passive listener.
|
|
583
|
+
React.useEffect(() => {
|
|
584
|
+
if (!hasImage) return;
|
|
585
|
+
const viewport = viewportRef.current;
|
|
586
|
+
if (viewport === null) return;
|
|
587
|
+
const handleWheel = (event: WheelEvent) => {
|
|
588
|
+
if (contextRef.current.disabled) return;
|
|
589
|
+
event.preventDefault();
|
|
590
|
+
contextRef.current.zoomBy(
|
|
591
|
+
Math.exp(-event.deltaY * WHEEL_ZOOM_INTENSITY),
|
|
592
|
+
focalPointFor(viewport, event.clientX, event.clientY),
|
|
593
|
+
);
|
|
594
|
+
};
|
|
595
|
+
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
|
596
|
+
return () => viewport.removeEventListener("wheel", handleWheel);
|
|
597
|
+
}, [hasImage]);
|
|
598
|
+
|
|
599
|
+
const releasePointer = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
600
|
+
activePointersRef.current.delete(event.pointerId);
|
|
601
|
+
pointerOriginsRef.current.delete(event.pointerId);
|
|
602
|
+
if (activePointersRef.current.size === 0) setIsDragging(false);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
606
|
+
if (disabled) return;
|
|
607
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
608
|
+
const point = { x: event.clientX, y: event.clientY };
|
|
609
|
+
activePointersRef.current.set(event.pointerId, point);
|
|
610
|
+
pointerOriginsRef.current.set(event.pointerId, point);
|
|
611
|
+
setIsDragging(true);
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
615
|
+
const pointers = activePointersRef.current;
|
|
616
|
+
const previousPoint = pointers.get(event.pointerId);
|
|
617
|
+
if (previousPoint === undefined || disabled) return;
|
|
618
|
+
const currentPoint = { x: event.clientX, y: event.clientY };
|
|
619
|
+
if (pointers.size === 1) {
|
|
620
|
+
panBy(currentPoint.x - previousPoint.x, currentPoint.y - previousPoint.y);
|
|
621
|
+
} else {
|
|
622
|
+
// Pinch: pan with the midpoint, zoom with the distance ratio.
|
|
623
|
+
const otherEntry = [...pointers.entries()].find(
|
|
624
|
+
([pointerId]) => pointerId !== event.pointerId,
|
|
625
|
+
);
|
|
626
|
+
if (otherEntry !== undefined) {
|
|
627
|
+
const [, otherPoint] = otherEntry;
|
|
628
|
+
const previousMidpoint = midpointBetween(previousPoint, otherPoint);
|
|
629
|
+
const currentMidpoint = midpointBetween(currentPoint, otherPoint);
|
|
630
|
+
panBy(
|
|
631
|
+
currentMidpoint.x - previousMidpoint.x,
|
|
632
|
+
currentMidpoint.y - previousMidpoint.y,
|
|
633
|
+
);
|
|
634
|
+
const previousDistance = distanceBetween(previousPoint, otherPoint);
|
|
635
|
+
const currentDistance = distanceBetween(currentPoint, otherPoint);
|
|
636
|
+
if (previousDistance > 0 && viewportRef.current !== null) {
|
|
637
|
+
zoomBy(
|
|
638
|
+
currentDistance / previousDistance,
|
|
639
|
+
focalPointFor(
|
|
640
|
+
viewportRef.current,
|
|
641
|
+
currentMidpoint.x,
|
|
642
|
+
currentMidpoint.y,
|
|
643
|
+
),
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
pointers.set(event.pointerId, currentPoint);
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const applyDoubleTapZoom = (point: ImageCropperPoint) => {
|
|
652
|
+
const viewport = viewportRef.current;
|
|
653
|
+
if (viewport === null) return;
|
|
654
|
+
if (zoom > minZoom * 1.01) {
|
|
655
|
+
resetCrop();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
zoomBy(DOUBLE_TAP_ZOOM_FACTOR, focalPointFor(viewport, point.x, point.y));
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const registerTap = (
|
|
662
|
+
event: React.PointerEvent<HTMLDivElement>,
|
|
663
|
+
point: ImageCropperPoint,
|
|
664
|
+
) => {
|
|
665
|
+
const lastTap = lastTapRef.current;
|
|
666
|
+
const isDoubleTap =
|
|
667
|
+
lastTap !== null &&
|
|
668
|
+
event.timeStamp - lastTap.timeStamp <= DOUBLE_TAP_MAXIMUM_DELAY_MS &&
|
|
669
|
+
distanceBetween(lastTap.point, point) <= DOUBLE_TAP_MAXIMUM_DISTANCE_PX;
|
|
670
|
+
if (!isDoubleTap) {
|
|
671
|
+
lastTapRef.current = { timeStamp: event.timeStamp, point };
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
lastTapRef.current = null;
|
|
675
|
+
applyDoubleTapZoom(point);
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
679
|
+
const origin = pointerOriginsRef.current.get(event.pointerId);
|
|
680
|
+
const point = { x: event.clientX, y: event.clientY };
|
|
681
|
+
const isTap =
|
|
682
|
+
origin !== undefined &&
|
|
683
|
+
activePointersRef.current.size === 1 &&
|
|
684
|
+
distanceBetween(origin, point) <= TAP_MAXIMUM_TRAVEL_PX;
|
|
685
|
+
if (isTap && !disabled) {
|
|
686
|
+
registerTap(event, point);
|
|
687
|
+
}
|
|
688
|
+
releasePointer(event);
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
692
|
+
if (disabled) return;
|
|
693
|
+
const panOffset = KEYBOARD_PAN_OFFSETS[event.key];
|
|
694
|
+
if (panOffset !== undefined) {
|
|
695
|
+
event.preventDefault();
|
|
696
|
+
panBy(panOffset.x, panOffset.y);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (event.key === "+" || event.key === "=") {
|
|
700
|
+
event.preventDefault();
|
|
701
|
+
zoomBy(KEYBOARD_ZOOM_FACTOR);
|
|
702
|
+
} else if (event.key === "-") {
|
|
703
|
+
event.preventDefault();
|
|
704
|
+
zoomBy(1 / KEYBOARD_ZOOM_FACTOR);
|
|
705
|
+
} else if (event.key === "0") {
|
|
706
|
+
event.preventDefault();
|
|
707
|
+
resetCrop();
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
if (!hasImage) return null;
|
|
712
|
+
|
|
713
|
+
return (
|
|
714
|
+
<div
|
|
715
|
+
ref={viewportRef}
|
|
716
|
+
role="application"
|
|
717
|
+
aria-label={ariaLabel}
|
|
718
|
+
tabIndex={disabled ? -1 : 0}
|
|
719
|
+
data-slot="image-cropper-area"
|
|
720
|
+
data-shape={shape}
|
|
721
|
+
data-dragging={isDragging || undefined}
|
|
722
|
+
style={{ aspectRatio: String(aspectRatio) }}
|
|
723
|
+
className={cn(
|
|
724
|
+
"relative w-full touch-none select-none overflow-hidden rounded-lg bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
725
|
+
disabled
|
|
726
|
+
? "cursor-not-allowed opacity-50"
|
|
727
|
+
: "cursor-grab data-[dragging]:cursor-grabbing",
|
|
728
|
+
className,
|
|
729
|
+
)}
|
|
730
|
+
onPointerDown={handlePointerDown}
|
|
731
|
+
onPointerMove={handlePointerMove}
|
|
732
|
+
onPointerUp={handlePointerUp}
|
|
733
|
+
onPointerCancel={releasePointer}
|
|
734
|
+
onLostPointerCapture={releasePointer}
|
|
735
|
+
onKeyDown={handleKeyDown}
|
|
736
|
+
>
|
|
737
|
+
{/* translate(-50%, -50%) centers the image; the screen-space offset and
|
|
738
|
+
cover scale × zoom come from the crop transform. */}
|
|
739
|
+
<img
|
|
740
|
+
ref={imageElementRef}
|
|
741
|
+
src={imageSource}
|
|
742
|
+
alt=""
|
|
743
|
+
crossOrigin={imageCrossOrigin}
|
|
744
|
+
draggable={false}
|
|
745
|
+
data-slot="image-cropper-image"
|
|
746
|
+
className="absolute top-1/2 left-1/2 max-w-none"
|
|
747
|
+
style={
|
|
748
|
+
imageTransform === null
|
|
749
|
+
? { visibility: "hidden" }
|
|
750
|
+
: {
|
|
751
|
+
transform: `translate3d(${imageTransform.x}px, ${imageTransform.y}px, 0) translate(-50%, -50%) scale(${imageTransform.scale})`,
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
onLoad={(event) => reportImageLoad(event.currentTarget)}
|
|
755
|
+
/>
|
|
756
|
+
{shape === "circle" && (
|
|
757
|
+
<div
|
|
758
|
+
aria-hidden
|
|
759
|
+
data-slot="image-cropper-mask"
|
|
760
|
+
className="pointer-events-none absolute inset-0 rounded-full shadow-[0_0_0_9999px_rgba(0,0,0,0.45)]"
|
|
761
|
+
/>
|
|
762
|
+
)}
|
|
763
|
+
<div
|
|
764
|
+
aria-hidden
|
|
765
|
+
data-slot="image-cropper-grid"
|
|
766
|
+
className={cn(
|
|
767
|
+
"pointer-events-none absolute inset-0 transition-opacity duration-200",
|
|
768
|
+
isDragging ? "opacity-100" : "opacity-0",
|
|
769
|
+
)}
|
|
770
|
+
>
|
|
771
|
+
<div className="absolute inset-y-0 left-1/3 w-px bg-white/50" />
|
|
772
|
+
<div className="absolute inset-y-0 left-2/3 w-px bg-white/50" />
|
|
773
|
+
<div className="absolute inset-x-0 top-1/3 h-px bg-white/50" />
|
|
774
|
+
<div className="absolute inset-x-0 top-2/3 h-px bg-white/50" />
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export {
|
|
781
|
+
ImageCropper,
|
|
782
|
+
ImageCropperArea,
|
|
783
|
+
type ImageCropperCropArea,
|
|
784
|
+
type ImageCropperCropToBlobOptions,
|
|
785
|
+
ImageCropperDropzone,
|
|
786
|
+
useImageCropper,
|
|
787
|
+
};
|