@moloodev/ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +139 -3
- package/registry/components.ts +135 -0
- package/registry/fonts.css +81 -0
- package/registry/suit-OFL.txt +94 -0
- package/registry/wanted-sans-OFL.txt +93 -0
- package/src/components/ui/accordion.tsx +74 -0
- package/src/components/ui/alert-dialog.tsx +187 -0
- package/src/components/ui/alert.tsx +83 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +207 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +54 -0
- package/src/components/ui/breadcrumb.tsx +126 -0
- package/src/components/ui/bubble.tsx +128 -0
- package/src/components/ui/button-group.tsx +103 -0
- package/src/components/ui/button.tsx +68 -0
- package/src/components/ui/calendar.tsx +222 -0
- package/src/components/ui/card.tsx +103 -0
- package/src/components/ui/carousel.tsx +242 -0
- package/src/components/ui/chart.tsx +398 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/collapsible.tsx +40 -0
- package/src/components/ui/combobox.tsx +299 -0
- package/src/components/ui/command.tsx +218 -0
- package/src/components/ui/context-menu.tsx +271 -0
- package/src/components/ui/data-table.tsx +919 -0
- package/src/components/ui/date-picker.tsx +65 -0
- package/src/components/ui/dialog.tsx +224 -0
- package/src/components/ui/drawer.tsx +231 -0
- package/src/components/ui/dropdown-menu.tsx +268 -0
- package/src/components/ui/dropzone.tsx +186 -0
- package/src/components/ui/empty.tsx +101 -0
- package/src/components/ui/field.tsx +238 -0
- package/src/components/ui/form.tsx +83 -0
- package/src/components/ui/highlighted-source.tsx +200 -0
- package/src/components/ui/hover-card.tsx +51 -0
- package/src/components/ui/image-field.tsx +796 -0
- package/src/components/ui/input-group.tsx +149 -0
- package/src/components/ui/input-otp.tsx +86 -0
- package/src/components/ui/input-step.ts +19 -0
- package/src/components/ui/input.tsx +100 -0
- package/src/components/ui/item.tsx +201 -0
- package/src/components/ui/kbd.tsx +91 -0
- package/src/components/ui/kind-icon-glob.ts +24 -0
- package/src/components/ui/kind-icon-load.ts +52 -0
- package/src/components/ui/kind-icon-static.ts +46 -0
- package/src/components/ui/kind-icon.tsx +84 -0
- package/src/components/ui/kind-mark.tsx +65 -0
- package/src/components/ui/label.tsx +29 -0
- package/src/components/ui/marker.tsx +71 -0
- package/src/components/ui/menubar.tsx +277 -0
- package/src/components/ui/message-scroller.tsx +137 -0
- package/src/components/ui/message.tsx +92 -0
- package/src/components/ui/moloo-logo.tsx +45 -0
- package/src/components/ui/native-select.tsx +61 -0
- package/src/components/ui/navigation-menu.tsx +168 -0
- package/src/components/ui/outline-list.tsx +73 -0
- package/src/components/ui/pagination.tsx +130 -0
- package/src/components/ui/panel.tsx +108 -0
- package/src/components/ui/popover.tsx +90 -0
- package/src/components/ui/progress.tsx +117 -0
- package/src/components/ui/radio-group.tsx +38 -0
- package/src/components/ui/resizable.tsx +52 -0
- package/src/components/ui/scroll-area.tsx +54 -0
- package/src/components/ui/select.tsx +205 -0
- package/src/components/ui/separator.tsx +25 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/sidebar.tsx +746 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +52 -0
- package/src/components/ui/spinner.tsx +18 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/tabs.tsx +83 -0
- package/src/components/ui/tag-list-input.tsx +194 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/timeline.tsx +265 -0
- package/src/components/ui/toast.tsx +237 -0
- package/src/components/ui/toggle-group.tsx +90 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/tooltip.tsx +66 -0
- package/src/css.d.ts +1 -0
- package/src/hooks/use-clipboard.ts +82 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/index.ts +106 -0
- package/src/kind-icon.ts +4 -0
- package/src/lib/format-relative-time.ts +41 -0
- package/src/lib/list-selection.ts +61 -0
- package/src/lib/utils.ts +6 -0
- package/src/moloo.css +360 -0
- package/src/styles.css +36 -0
- package/src/typeset.css +518 -0
- package/README.md +0 -3
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { Button } from "./button"
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "./dialog"
|
|
14
|
+
import {
|
|
15
|
+
Dropzone,
|
|
16
|
+
partitionDropzoneFiles,
|
|
17
|
+
type DropzoneRejectReason,
|
|
18
|
+
} from "./dropzone"
|
|
19
|
+
import { Slider } from "./slider"
|
|
20
|
+
import { cn } from "../../lib/utils"
|
|
21
|
+
|
|
22
|
+
export const IMAGE_FIELD_ACCEPT = "image/jpeg,image/png,image/webp"
|
|
23
|
+
export const IMAGE_FIELD_MAX_BYTES = 5 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
const OUTPUT_EDGE = 512
|
|
26
|
+
const MAX_EDGE = 8192
|
|
27
|
+
|
|
28
|
+
export type CropArea = { x: number; y: number; width: number; height: number }
|
|
29
|
+
export type CropOffset = { x: number; y: number }
|
|
30
|
+
|
|
31
|
+
export type ImageFieldMessages = {
|
|
32
|
+
choose: string
|
|
33
|
+
change: string
|
|
34
|
+
remove: string
|
|
35
|
+
title: string
|
|
36
|
+
description: string
|
|
37
|
+
drop: string
|
|
38
|
+
formats: string
|
|
39
|
+
zoom: string
|
|
40
|
+
pan: string
|
|
41
|
+
reset: string
|
|
42
|
+
replace: string
|
|
43
|
+
cancel: string
|
|
44
|
+
apply: string
|
|
45
|
+
retry: string
|
|
46
|
+
uploading: string
|
|
47
|
+
preparing: string
|
|
48
|
+
uploadError: string
|
|
49
|
+
typeError: string
|
|
50
|
+
sizeError: string
|
|
51
|
+
readError: string
|
|
52
|
+
countError: string
|
|
53
|
+
preview: string
|
|
54
|
+
previewApply: string
|
|
55
|
+
ready: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const imageFieldMessages: ImageFieldMessages = {
|
|
59
|
+
choose: "Choose image",
|
|
60
|
+
change: "Change image",
|
|
61
|
+
remove: "Remove",
|
|
62
|
+
title: "Adjust image",
|
|
63
|
+
description: "Zoom the image, then drag it into place.",
|
|
64
|
+
drop: "Drop an image, or click to select",
|
|
65
|
+
formats: "JPG, PNG, or WebP. Up to 5 MB.",
|
|
66
|
+
zoom: "Zoom",
|
|
67
|
+
pan: "Image. Drag it or use the arrow keys to move it. Use + and − to zoom.",
|
|
68
|
+
reset: "Reset",
|
|
69
|
+
replace: "Choose another image",
|
|
70
|
+
cancel: "Cancel",
|
|
71
|
+
apply: "Apply image",
|
|
72
|
+
retry: "Upload again",
|
|
73
|
+
uploading: "Uploading",
|
|
74
|
+
preparing: "Preparing image",
|
|
75
|
+
uploadError: "Could not upload the image. Check your connection and try again.",
|
|
76
|
+
typeError: "Choose a JPG, PNG, or WebP image.",
|
|
77
|
+
sizeError: "Choose a non-empty file up to 5 MB.",
|
|
78
|
+
readError: "Could not open that image. Choose a different file.",
|
|
79
|
+
countError: "Choose one image at a time.",
|
|
80
|
+
preview: "Preview only. This image is not saved.",
|
|
81
|
+
previewApply: "Apply preview",
|
|
82
|
+
ready: "Image is ready. Save to keep this change.",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type ImageUpload = (
|
|
86
|
+
file: File,
|
|
87
|
+
options: { signal: AbortSignal; onProgress: (percentage: number) => void },
|
|
88
|
+
) => Promise<string>
|
|
89
|
+
|
|
90
|
+
export type ImageFieldProps = {
|
|
91
|
+
label: string
|
|
92
|
+
value: string | null
|
|
93
|
+
onChange: (value: string | null) => void
|
|
94
|
+
upload: ImageUpload
|
|
95
|
+
fallback?: React.ReactNode
|
|
96
|
+
shape?: "circle" | "square"
|
|
97
|
+
aspectRatio?: number
|
|
98
|
+
accept?: string
|
|
99
|
+
maxSize?: number
|
|
100
|
+
disabled?: boolean
|
|
101
|
+
previewOnly?: boolean
|
|
102
|
+
onBusyChange?: (busy: boolean) => void
|
|
103
|
+
messages?: Partial<ImageFieldMessages>
|
|
104
|
+
className?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const MAX_ZOOM = 4
|
|
108
|
+
const FRAME_SCALE = 0.8
|
|
109
|
+
|
|
110
|
+
/** Original-image pixels under the crop frame. `zoom` below 1 is 1. Positive offset pans the image right and down. */
|
|
111
|
+
export function getCropRect(
|
|
112
|
+
width: number,
|
|
113
|
+
height: number,
|
|
114
|
+
zoom: number,
|
|
115
|
+
offset: CropOffset,
|
|
116
|
+
aspectRatio = 1,
|
|
117
|
+
): CropArea {
|
|
118
|
+
if (!(width > 0) || !(height > 0)) return { x: 0, y: 0, width: 0, height: 0 }
|
|
119
|
+
const aspect = aspectRatio > 0 && Number.isFinite(aspectRatio) ? aspectRatio : 1
|
|
120
|
+
const scale = Math.max(1, Number.isFinite(zoom) ? zoom : 1)
|
|
121
|
+
const coverWidth = width / height > aspect ? height * aspect : width
|
|
122
|
+
const coverHeight = coverWidth / aspect
|
|
123
|
+
const cropWidth = coverWidth / scale
|
|
124
|
+
const cropHeight = coverHeight / scale
|
|
125
|
+
const panX = Number.isFinite(offset.x) ? offset.x : 0
|
|
126
|
+
const panY = Number.isFinite(offset.y) ? offset.y : 0
|
|
127
|
+
return {
|
|
128
|
+
x: Math.min(width - cropWidth, Math.max(0, (width - cropWidth) / 2 - panX)),
|
|
129
|
+
y: Math.min(height - cropHeight, Math.max(0, (height - cropHeight) / 2 - panY)),
|
|
130
|
+
width: cropWidth,
|
|
131
|
+
height: cropHeight,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function panFromRect(rect: CropArea, width: number, height: number): CropOffset {
|
|
136
|
+
return {
|
|
137
|
+
x: (width - rect.width) / 2 - rect.x,
|
|
138
|
+
y: (height - rect.height) / 2 - rect.y,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function rejectionText(reason: DropzoneRejectReason, messages: ImageFieldMessages) {
|
|
143
|
+
if (reason === "type") return messages.typeError
|
|
144
|
+
if (reason === "size") return messages.sizeError
|
|
145
|
+
return messages.countError
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function cropImage(source: string, crop: CropArea): Promise<File> {
|
|
149
|
+
const image = new Image()
|
|
150
|
+
image.src = source
|
|
151
|
+
await image.decode()
|
|
152
|
+
const width = Math.round(crop.width)
|
|
153
|
+
const height = Math.round(crop.height)
|
|
154
|
+
const fit = Math.min(1, OUTPUT_EDGE / Math.max(width, height))
|
|
155
|
+
const canvas = document.createElement("canvas")
|
|
156
|
+
canvas.width = Math.max(1, Math.round(width * fit))
|
|
157
|
+
canvas.height = Math.max(1, Math.round(height * fit))
|
|
158
|
+
const context = canvas.getContext("2d")
|
|
159
|
+
if (!context || width < 1 || height < 1) throw new Error("invalid_image")
|
|
160
|
+
context.drawImage(
|
|
161
|
+
image,
|
|
162
|
+
crop.x,
|
|
163
|
+
crop.y,
|
|
164
|
+
crop.width,
|
|
165
|
+
crop.height,
|
|
166
|
+
0,
|
|
167
|
+
0,
|
|
168
|
+
canvas.width,
|
|
169
|
+
canvas.height,
|
|
170
|
+
)
|
|
171
|
+
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
172
|
+
canvas.toBlob(
|
|
173
|
+
(value) => (value ? resolve(value) : reject(new Error("encode_failed"))),
|
|
174
|
+
"image/webp",
|
|
175
|
+
0.9,
|
|
176
|
+
)
|
|
177
|
+
})
|
|
178
|
+
if (blob.type !== "image/webp") throw new Error("unsupported_encoding")
|
|
179
|
+
return new File([blob], "image.webp", { type: blob.type })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function ImageField({
|
|
183
|
+
label,
|
|
184
|
+
value,
|
|
185
|
+
onChange,
|
|
186
|
+
upload,
|
|
187
|
+
fallback = null,
|
|
188
|
+
shape = "square",
|
|
189
|
+
aspectRatio = 1,
|
|
190
|
+
accept = IMAGE_FIELD_ACCEPT,
|
|
191
|
+
maxSize = IMAGE_FIELD_MAX_BYTES,
|
|
192
|
+
disabled,
|
|
193
|
+
previewOnly,
|
|
194
|
+
onBusyChange,
|
|
195
|
+
messages,
|
|
196
|
+
className,
|
|
197
|
+
}: ImageFieldProps) {
|
|
198
|
+
const copy = { ...imageFieldMessages, ...messages }
|
|
199
|
+
const [open, setOpen] = React.useState(false)
|
|
200
|
+
const [local, setLocal] = React.useState<string | null | undefined>(undefined)
|
|
201
|
+
const localUrl = React.useRef<string | null>(null)
|
|
202
|
+
const [ready, setReady] = React.useState(false)
|
|
203
|
+
const [failedSrc, setFailedSrc] = React.useState<string | null>(null)
|
|
204
|
+
const labelId = React.useId()
|
|
205
|
+
const shown = local === undefined ? value : local
|
|
206
|
+
const failed = Boolean(shown) && failedSrc === shown
|
|
207
|
+
|
|
208
|
+
function showLocal(url: string | null) {
|
|
209
|
+
if (localUrl.current) URL.revokeObjectURL(localUrl.current)
|
|
210
|
+
localUrl.current = url
|
|
211
|
+
setLocal(url)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
React.useEffect(() => {
|
|
215
|
+
return () => {
|
|
216
|
+
if (localUrl.current) URL.revokeObjectURL(localUrl.current)
|
|
217
|
+
}
|
|
218
|
+
}, [])
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div
|
|
222
|
+
data-slot="image-field"
|
|
223
|
+
role="group"
|
|
224
|
+
aria-labelledby={labelId}
|
|
225
|
+
className={cn("flex flex-col gap-3", className)}
|
|
226
|
+
>
|
|
227
|
+
<p id={labelId} className="text-sm font-semibold">
|
|
228
|
+
{label}
|
|
229
|
+
</p>
|
|
230
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
231
|
+
<div
|
|
232
|
+
data-slot="image-field-preview"
|
|
233
|
+
className={cn(
|
|
234
|
+
"flex size-16 shrink-0 items-center justify-center overflow-hidden bg-muted",
|
|
235
|
+
shape === "circle" ? "rounded-full" : "rounded-md",
|
|
236
|
+
)}
|
|
237
|
+
>
|
|
238
|
+
{shown && !failed ? (
|
|
239
|
+
<img
|
|
240
|
+
src={shown}
|
|
241
|
+
alt={label}
|
|
242
|
+
className="size-full object-cover"
|
|
243
|
+
onError={() => setFailedSrc(shown)}
|
|
244
|
+
/>
|
|
245
|
+
) : (
|
|
246
|
+
fallback
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
<div className="flex flex-col gap-2">
|
|
250
|
+
<div className="flex gap-2">
|
|
251
|
+
<Button
|
|
252
|
+
type="button"
|
|
253
|
+
variant="outline"
|
|
254
|
+
size="sm"
|
|
255
|
+
disabled={disabled}
|
|
256
|
+
onClick={() => setOpen(true)}
|
|
257
|
+
>
|
|
258
|
+
{shown ? copy.change : copy.choose}
|
|
259
|
+
</Button>
|
|
260
|
+
{shown ? (
|
|
261
|
+
<Button
|
|
262
|
+
type="button"
|
|
263
|
+
variant="ghost"
|
|
264
|
+
size="sm"
|
|
265
|
+
disabled={disabled}
|
|
266
|
+
onClick={() => {
|
|
267
|
+
if (previewOnly) showLocal(null)
|
|
268
|
+
else {
|
|
269
|
+
setLocal(undefined)
|
|
270
|
+
onChange(null)
|
|
271
|
+
}
|
|
272
|
+
setReady(true)
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
{copy.remove}
|
|
276
|
+
</Button>
|
|
277
|
+
) : null}
|
|
278
|
+
</div>
|
|
279
|
+
<p className="text-sm text-muted-foreground">{copy.formats}</p>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
{ready ? (
|
|
283
|
+
<p role="status" className="text-sm text-muted-foreground">
|
|
284
|
+
{previewOnly ? copy.preview : copy.ready}
|
|
285
|
+
</p>
|
|
286
|
+
) : null}
|
|
287
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
288
|
+
{open ? (
|
|
289
|
+
<ImageDialog
|
|
290
|
+
shape={shape}
|
|
291
|
+
aspectRatio={aspectRatio}
|
|
292
|
+
accept={accept}
|
|
293
|
+
maxSize={maxSize}
|
|
294
|
+
copy={copy}
|
|
295
|
+
upload={upload}
|
|
296
|
+
previewOnly={previewOnly}
|
|
297
|
+
onBusyChange={onBusyChange}
|
|
298
|
+
onOpenChange={setOpen}
|
|
299
|
+
onApply={(url, isLocal) => {
|
|
300
|
+
if (isLocal) showLocal(url)
|
|
301
|
+
else onChange(url)
|
|
302
|
+
setReady(true)
|
|
303
|
+
}}
|
|
304
|
+
/>
|
|
305
|
+
) : null}
|
|
306
|
+
</Dialog>
|
|
307
|
+
</div>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function ImageDialog({
|
|
312
|
+
onOpenChange,
|
|
313
|
+
shape,
|
|
314
|
+
aspectRatio,
|
|
315
|
+
accept,
|
|
316
|
+
maxSize,
|
|
317
|
+
copy,
|
|
318
|
+
upload,
|
|
319
|
+
previewOnly,
|
|
320
|
+
onApply,
|
|
321
|
+
onBusyChange,
|
|
322
|
+
}: {
|
|
323
|
+
onOpenChange: (open: boolean) => void
|
|
324
|
+
shape: "circle" | "square"
|
|
325
|
+
aspectRatio: number
|
|
326
|
+
accept: string
|
|
327
|
+
maxSize: number
|
|
328
|
+
copy: ImageFieldMessages
|
|
329
|
+
upload: ImageUpload
|
|
330
|
+
previewOnly?: boolean
|
|
331
|
+
onApply: (url: string, local: boolean) => void
|
|
332
|
+
onBusyChange?: (busy: boolean) => void
|
|
333
|
+
}) {
|
|
334
|
+
const [source, setSource] = React.useState<string | null>(null)
|
|
335
|
+
const sourceUrl = React.useRef<string | null>(null)
|
|
336
|
+
const [filename, setFilename] = React.useState("")
|
|
337
|
+
const [zoom, setZoom] = React.useState(1)
|
|
338
|
+
const [offset, setOffset] = React.useState<CropOffset>({ x: 0, y: 0 })
|
|
339
|
+
const [area, setArea] = React.useState<CropArea | null>(null)
|
|
340
|
+
const [state, setState] = React.useState<"idle" | "reading" | "preparing" | "uploading">("idle")
|
|
341
|
+
const [error, setError] = React.useState("")
|
|
342
|
+
const [retry, setRetry] = React.useState(false)
|
|
343
|
+
const [progress, setProgress] = React.useState(0)
|
|
344
|
+
const controller = React.useRef<AbortController | null>(null)
|
|
345
|
+
const generation = React.useRef(0)
|
|
346
|
+
const picker = React.useRef<HTMLInputElement>(null)
|
|
347
|
+
const zoomId = React.useId()
|
|
348
|
+
const busy = state !== "idle"
|
|
349
|
+
|
|
350
|
+
React.useEffect(() => {
|
|
351
|
+
onBusyChange?.(busy)
|
|
352
|
+
return () => onBusyChange?.(false)
|
|
353
|
+
}, [busy, onBusyChange])
|
|
354
|
+
React.useEffect(() => {
|
|
355
|
+
return () => {
|
|
356
|
+
generation.current += 1
|
|
357
|
+
controller.current?.abort()
|
|
358
|
+
if (sourceUrl.current) URL.revokeObjectURL(sourceUrl.current)
|
|
359
|
+
}
|
|
360
|
+
}, [])
|
|
361
|
+
|
|
362
|
+
function reject(reason: DropzoneRejectReason) {
|
|
363
|
+
setError(rejectionText(reason, copy))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function select(files: File[]) {
|
|
367
|
+
const file = files[0]
|
|
368
|
+
if (!file) return
|
|
369
|
+
const version = ++generation.current
|
|
370
|
+
const url = URL.createObjectURL(file)
|
|
371
|
+
setState("reading")
|
|
372
|
+
setError("")
|
|
373
|
+
setRetry(false)
|
|
374
|
+
try {
|
|
375
|
+
const image = new Image()
|
|
376
|
+
image.src = url
|
|
377
|
+
await image.decode()
|
|
378
|
+
if (image.naturalWidth > MAX_EDGE || image.naturalHeight > MAX_EDGE) {
|
|
379
|
+
throw new Error("image_too_large")
|
|
380
|
+
}
|
|
381
|
+
if (version !== generation.current) {
|
|
382
|
+
URL.revokeObjectURL(url)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
if (sourceUrl.current) URL.revokeObjectURL(sourceUrl.current)
|
|
386
|
+
sourceUrl.current = url
|
|
387
|
+
setSource(url)
|
|
388
|
+
setFilename(file.name)
|
|
389
|
+
setZoom(1)
|
|
390
|
+
setOffset({ x: 0, y: 0 })
|
|
391
|
+
setArea(null)
|
|
392
|
+
} catch {
|
|
393
|
+
URL.revokeObjectURL(url)
|
|
394
|
+
if (version === generation.current) setError(copy.readError)
|
|
395
|
+
} finally {
|
|
396
|
+
if (version === generation.current) setState("idle")
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function take(files: File[]) {
|
|
401
|
+
const result = partitionDropzoneFiles(files, { accept, multiple: false, maxSize })
|
|
402
|
+
const reason = result.rejected[0]?.reason
|
|
403
|
+
if (reason) reject(reason)
|
|
404
|
+
void select(result.accepted)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function apply() {
|
|
408
|
+
if (!source || !area || busy) return
|
|
409
|
+
const version = ++generation.current
|
|
410
|
+
const abort = new AbortController()
|
|
411
|
+
controller.current = abort
|
|
412
|
+
setState("preparing")
|
|
413
|
+
setError("")
|
|
414
|
+
setProgress(0)
|
|
415
|
+
try {
|
|
416
|
+
const file = await cropImage(source, area)
|
|
417
|
+
if (version !== generation.current) return
|
|
418
|
+
let url: string
|
|
419
|
+
if (previewOnly) url = URL.createObjectURL(file)
|
|
420
|
+
else {
|
|
421
|
+
setState("uploading")
|
|
422
|
+
url = await upload(file, {
|
|
423
|
+
signal: abort.signal,
|
|
424
|
+
onProgress: (value) => {
|
|
425
|
+
if (version === generation.current) setProgress(Math.min(100, Math.max(0, value)))
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
if (version !== generation.current) {
|
|
430
|
+
if (previewOnly) URL.revokeObjectURL(url)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
onApply(url, Boolean(previewOnly))
|
|
434
|
+
onOpenChange(false)
|
|
435
|
+
} catch {
|
|
436
|
+
if (version === generation.current) {
|
|
437
|
+
setError(copy.uploadError)
|
|
438
|
+
setRetry(true)
|
|
439
|
+
}
|
|
440
|
+
} finally {
|
|
441
|
+
if (version === generation.current) setState("idle")
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function close() {
|
|
446
|
+
generation.current += 1
|
|
447
|
+
controller.current?.abort()
|
|
448
|
+
onOpenChange(false)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<DialogContent
|
|
453
|
+
showCloseButton={false}
|
|
454
|
+
className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-md"
|
|
455
|
+
>
|
|
456
|
+
<DialogHeader>
|
|
457
|
+
<DialogTitle>{copy.title}</DialogTitle>
|
|
458
|
+
<DialogDescription>{copy.description}</DialogDescription>
|
|
459
|
+
</DialogHeader>
|
|
460
|
+
{source ? (
|
|
461
|
+
<>
|
|
462
|
+
<CropStage
|
|
463
|
+
key={source}
|
|
464
|
+
source={source}
|
|
465
|
+
zoom={zoom}
|
|
466
|
+
offset={offset}
|
|
467
|
+
aspectRatio={aspectRatio}
|
|
468
|
+
onZoomChange={setZoom}
|
|
469
|
+
onOffsetChange={setOffset}
|
|
470
|
+
onAreaChange={setArea}
|
|
471
|
+
shape={shape}
|
|
472
|
+
disabled={busy}
|
|
473
|
+
label={copy.pan}
|
|
474
|
+
/>
|
|
475
|
+
<div className="flex items-center justify-between gap-3">
|
|
476
|
+
<p className="min-w-0 truncate text-sm text-muted-foreground">{filename}</p>
|
|
477
|
+
<Button
|
|
478
|
+
type="button"
|
|
479
|
+
variant="ghost"
|
|
480
|
+
size="sm"
|
|
481
|
+
disabled={busy}
|
|
482
|
+
onClick={() => picker.current?.click()}
|
|
483
|
+
>
|
|
484
|
+
{copy.replace}
|
|
485
|
+
</Button>
|
|
486
|
+
</div>
|
|
487
|
+
<div className="flex items-center gap-3">
|
|
488
|
+
<span id={zoomId} className="text-sm font-semibold">
|
|
489
|
+
{copy.zoom}
|
|
490
|
+
</span>
|
|
491
|
+
<Slider
|
|
492
|
+
className="min-w-0 flex-1"
|
|
493
|
+
aria-labelledby={zoomId}
|
|
494
|
+
min={100}
|
|
495
|
+
max={MAX_ZOOM * 100}
|
|
496
|
+
step={1}
|
|
497
|
+
value={[Math.round(zoom * 100)]}
|
|
498
|
+
disabled={busy}
|
|
499
|
+
format={{ style: "unit", unit: "percent", maximumFractionDigits: 0 }}
|
|
500
|
+
onValueChange={(next) => {
|
|
501
|
+
const value = typeof next === "number" ? next : next[0]
|
|
502
|
+
if (value !== undefined) setZoom(value / 100)
|
|
503
|
+
}}
|
|
504
|
+
/>
|
|
505
|
+
<Button
|
|
506
|
+
type="button"
|
|
507
|
+
variant="ghost"
|
|
508
|
+
size="sm"
|
|
509
|
+
disabled={busy}
|
|
510
|
+
onClick={() => {
|
|
511
|
+
setZoom(1)
|
|
512
|
+
setOffset({ x: 0, y: 0 })
|
|
513
|
+
}}
|
|
514
|
+
>
|
|
515
|
+
{copy.reset}
|
|
516
|
+
</Button>
|
|
517
|
+
</div>
|
|
518
|
+
</>
|
|
519
|
+
) : (
|
|
520
|
+
<Dropzone
|
|
521
|
+
accept={accept}
|
|
522
|
+
maxSize={maxSize}
|
|
523
|
+
disabled={busy}
|
|
524
|
+
label={copy.drop}
|
|
525
|
+
description={copy.formats}
|
|
526
|
+
aria-invalid={error ? true : undefined}
|
|
527
|
+
onReject={(rejections) => {
|
|
528
|
+
const reason = rejections[0]?.reason
|
|
529
|
+
if (reason) reject(reason)
|
|
530
|
+
}}
|
|
531
|
+
onFiles={(files) => void select(files)}
|
|
532
|
+
/>
|
|
533
|
+
)}
|
|
534
|
+
<input
|
|
535
|
+
ref={picker}
|
|
536
|
+
type="file"
|
|
537
|
+
accept={accept}
|
|
538
|
+
className="hidden"
|
|
539
|
+
tabIndex={-1}
|
|
540
|
+
onChange={(event) => {
|
|
541
|
+
take(Array.from(event.target.files ?? []))
|
|
542
|
+
event.target.value = ""
|
|
543
|
+
}}
|
|
544
|
+
/>
|
|
545
|
+
{previewOnly ? <p className="text-sm text-muted-foreground">{copy.preview}</p> : null}
|
|
546
|
+
{busy ? (
|
|
547
|
+
<div className="flex flex-col gap-2" role="status" aria-live="polite">
|
|
548
|
+
<div className="flex justify-between text-sm">
|
|
549
|
+
<span>{state === "uploading" ? copy.uploading : copy.preparing}</span>
|
|
550
|
+
{state === "uploading" ? (
|
|
551
|
+
<span className="tabular-nums">{Math.round(progress)}%</span>
|
|
552
|
+
) : null}
|
|
553
|
+
</div>
|
|
554
|
+
{state === "uploading" ? (
|
|
555
|
+
<progress
|
|
556
|
+
className="h-1.5 w-full accent-primary"
|
|
557
|
+
aria-label={copy.uploading}
|
|
558
|
+
value={progress}
|
|
559
|
+
max={100}
|
|
560
|
+
/>
|
|
561
|
+
) : null}
|
|
562
|
+
</div>
|
|
563
|
+
) : null}
|
|
564
|
+
{error ? (
|
|
565
|
+
<p role="alert" className="text-sm text-destructive">
|
|
566
|
+
{error}
|
|
567
|
+
</p>
|
|
568
|
+
) : null}
|
|
569
|
+
<DialogFooter>
|
|
570
|
+
<Button type="button" variant="outline" onClick={close}>
|
|
571
|
+
{copy.cancel}
|
|
572
|
+
</Button>
|
|
573
|
+
<Button type="button" disabled={!area || busy} onClick={() => void apply()}>
|
|
574
|
+
{previewOnly ? copy.previewApply : retry ? copy.retry : copy.apply}
|
|
575
|
+
</Button>
|
|
576
|
+
</DialogFooter>
|
|
577
|
+
</DialogContent>
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function CropStage({
|
|
582
|
+
source,
|
|
583
|
+
zoom,
|
|
584
|
+
offset,
|
|
585
|
+
aspectRatio,
|
|
586
|
+
onZoomChange,
|
|
587
|
+
onOffsetChange,
|
|
588
|
+
onAreaChange,
|
|
589
|
+
shape,
|
|
590
|
+
disabled,
|
|
591
|
+
label,
|
|
592
|
+
}: {
|
|
593
|
+
source: string
|
|
594
|
+
zoom: number
|
|
595
|
+
offset: CropOffset
|
|
596
|
+
aspectRatio: number
|
|
597
|
+
onZoomChange: (zoom: number) => void
|
|
598
|
+
onOffsetChange: (offset: CropOffset) => void
|
|
599
|
+
onAreaChange: (area: CropArea) => void
|
|
600
|
+
shape: "circle" | "square"
|
|
601
|
+
disabled: boolean
|
|
602
|
+
label: string
|
|
603
|
+
}) {
|
|
604
|
+
const stage = React.useRef<HTMLDivElement>(null)
|
|
605
|
+
const drag = React.useRef<{ id: number; x: number; y: number; offset: CropOffset } | null>(null)
|
|
606
|
+
const view = React.useRef({ zoom, offset })
|
|
607
|
+
const metrics = React.useRef({ width: 0, height: 0, aspect: 1 })
|
|
608
|
+
const [box, setBox] = React.useState({ width: 0, height: 0 })
|
|
609
|
+
const [natural, setNatural] = React.useState({ width: 0, height: 0 })
|
|
610
|
+
const aspect = aspectRatio > 0 && Number.isFinite(aspectRatio) ? aspectRatio : 1
|
|
611
|
+
const zoomUsed = Math.min(MAX_ZOOM, Math.max(1, Number.isFinite(zoom) ? zoom : 1))
|
|
612
|
+
const rect = getCropRect(natural.width, natural.height, zoomUsed, offset, aspect)
|
|
613
|
+
const side = Math.min(box.width, box.height) * FRAME_SCALE
|
|
614
|
+
const frame =
|
|
615
|
+
side > 0
|
|
616
|
+
? aspect >= 1
|
|
617
|
+
? { width: side, height: side / aspect }
|
|
618
|
+
: { width: side * aspect, height: side }
|
|
619
|
+
: { width: 0, height: 0 }
|
|
620
|
+
const scale = rect.width > 0 && frame.width > 0 ? frame.width / rect.width : 0
|
|
621
|
+
const offsetX = offset.x
|
|
622
|
+
const offsetY = offset.y
|
|
623
|
+
const cropX = rect.x
|
|
624
|
+
const cropY = rect.y
|
|
625
|
+
const cropWidth = rect.width
|
|
626
|
+
const cropHeight = rect.height
|
|
627
|
+
|
|
628
|
+
React.useEffect(() => {
|
|
629
|
+
view.current = { zoom: zoomUsed, offset: { x: offsetX, y: offsetY } }
|
|
630
|
+
metrics.current = { width: natural.width, height: natural.height, aspect }
|
|
631
|
+
}, [zoomUsed, offsetX, offsetY, natural.width, natural.height, aspect])
|
|
632
|
+
|
|
633
|
+
React.useEffect(() => {
|
|
634
|
+
const element = stage.current
|
|
635
|
+
if (!element) return
|
|
636
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
637
|
+
if (entry) setBox({ width: entry.contentRect.width, height: entry.contentRect.height })
|
|
638
|
+
})
|
|
639
|
+
observer.observe(element)
|
|
640
|
+
return () => observer.disconnect()
|
|
641
|
+
}, [])
|
|
642
|
+
|
|
643
|
+
React.useEffect(() => {
|
|
644
|
+
if (cropWidth <= 0) return
|
|
645
|
+
const nextRect = { x: cropX, y: cropY, width: cropWidth, height: cropHeight }
|
|
646
|
+
onAreaChange(nextRect)
|
|
647
|
+
const next = panFromRect(nextRect, natural.width, natural.height)
|
|
648
|
+
if (Math.abs(next.x - offsetX) > 1e-4 || Math.abs(next.y - offsetY) > 1e-4) onOffsetChange(next)
|
|
649
|
+
}, [cropX, cropY, cropWidth, cropHeight, natural.width, natural.height, offsetX, offsetY, onAreaChange, onOffsetChange])
|
|
650
|
+
|
|
651
|
+
React.useEffect(() => {
|
|
652
|
+
const element = stage.current
|
|
653
|
+
if (!element || disabled) return
|
|
654
|
+
const page = element.clientHeight
|
|
655
|
+
function onWheel(event: WheelEvent) {
|
|
656
|
+
const size = metrics.current
|
|
657
|
+
if (size.width <= 0 || size.height <= 0) return
|
|
658
|
+
event.preventDefault()
|
|
659
|
+
const line = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? page : 1
|
|
660
|
+
const current = view.current
|
|
661
|
+
const nextZoom = Math.min(MAX_ZOOM, Math.max(1, current.zoom * Math.exp(-event.deltaY * line * 0.002)))
|
|
662
|
+
const next = getCropRect(size.width, size.height, nextZoom, current.offset, size.aspect)
|
|
663
|
+
const nextOffset = panFromRect(next, size.width, size.height)
|
|
664
|
+
view.current = { zoom: nextZoom, offset: nextOffset }
|
|
665
|
+
if (nextZoom !== current.zoom) onZoomChange(nextZoom)
|
|
666
|
+
if (nextOffset.x !== current.offset.x || nextOffset.y !== current.offset.y) onOffsetChange(nextOffset)
|
|
667
|
+
}
|
|
668
|
+
element.addEventListener("wheel", onWheel, { passive: false })
|
|
669
|
+
return () => element.removeEventListener("wheel", onWheel)
|
|
670
|
+
}, [disabled, onZoomChange, onOffsetChange])
|
|
671
|
+
|
|
672
|
+
function publish(nextZoom: number, nextOffset: CropOffset) {
|
|
673
|
+
const size = metrics.current
|
|
674
|
+
if (size.width <= 0 || size.height <= 0) return
|
|
675
|
+
const zoomNext = Math.min(MAX_ZOOM, Math.max(1, nextZoom))
|
|
676
|
+
const next = getCropRect(size.width, size.height, zoomNext, nextOffset, size.aspect)
|
|
677
|
+
const clamped = panFromRect(next, size.width, size.height)
|
|
678
|
+
const current = view.current
|
|
679
|
+
view.current = { zoom: zoomNext, offset: clamped }
|
|
680
|
+
if (zoomNext !== current.zoom) onZoomChange(zoomNext)
|
|
681
|
+
if (clamped.x !== current.offset.x || clamped.y !== current.offset.y) onOffsetChange(clamped)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return (
|
|
685
|
+
<div
|
|
686
|
+
ref={stage}
|
|
687
|
+
role="group"
|
|
688
|
+
tabIndex={disabled ? -1 : 0}
|
|
689
|
+
aria-label={label}
|
|
690
|
+
aria-disabled={disabled || undefined}
|
|
691
|
+
aria-busy={disabled}
|
|
692
|
+
data-slot="image-field-crop"
|
|
693
|
+
className={cn(
|
|
694
|
+
"relative aspect-square max-h-[45dvh] touch-none overflow-hidden rounded-md bg-muted outline-none select-none moloo-focus",
|
|
695
|
+
disabled ? "pointer-events-none" : "cursor-grab active:cursor-grabbing",
|
|
696
|
+
)}
|
|
697
|
+
onPointerDown={(event) => {
|
|
698
|
+
if (disabled || event.button !== 0 || scale <= 0) return
|
|
699
|
+
event.preventDefault()
|
|
700
|
+
event.currentTarget.focus()
|
|
701
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
702
|
+
drag.current = { id: event.pointerId, x: event.clientX, y: event.clientY, offset: view.current.offset }
|
|
703
|
+
}}
|
|
704
|
+
onPointerMove={(event) => {
|
|
705
|
+
const start = drag.current
|
|
706
|
+
if (!start || start.id !== event.pointerId || disabled || scale <= 0) return
|
|
707
|
+
publish(view.current.zoom, {
|
|
708
|
+
x: start.offset.x + (event.clientX - start.x) / scale,
|
|
709
|
+
y: start.offset.y + (event.clientY - start.y) / scale,
|
|
710
|
+
})
|
|
711
|
+
}}
|
|
712
|
+
onPointerUp={() => {
|
|
713
|
+
drag.current = null
|
|
714
|
+
}}
|
|
715
|
+
onPointerCancel={() => {
|
|
716
|
+
drag.current = null
|
|
717
|
+
}}
|
|
718
|
+
onLostPointerCapture={() => {
|
|
719
|
+
drag.current = null
|
|
720
|
+
}}
|
|
721
|
+
onKeyDown={(event) => {
|
|
722
|
+
if (disabled || rect.width <= 0) return
|
|
723
|
+
const current = view.current
|
|
724
|
+
if (event.key === "+" || event.key === "=" || event.key === "-" || event.key === "_") {
|
|
725
|
+
event.preventDefault()
|
|
726
|
+
const zoomNext = event.key === "-" || event.key === "_" ? current.zoom / 1.1 : current.zoom * 1.1
|
|
727
|
+
publish(zoomNext, current.offset)
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]
|
|
731
|
+
if (!delta) return
|
|
732
|
+
event.preventDefault()
|
|
733
|
+
const step = (event.shiftKey ? 0.1 : 0.02) * Math.min(rect.width, rect.height)
|
|
734
|
+
publish(current.zoom, {
|
|
735
|
+
x: current.offset.x + delta[0] * step,
|
|
736
|
+
y: current.offset.y + delta[1] * step,
|
|
737
|
+
})
|
|
738
|
+
}}
|
|
739
|
+
>
|
|
740
|
+
<img
|
|
741
|
+
src={source}
|
|
742
|
+
alt=""
|
|
743
|
+
draggable={false}
|
|
744
|
+
className={cn("pointer-events-none absolute max-w-none select-none", scale === 0 && "invisible")}
|
|
745
|
+
style={
|
|
746
|
+
scale > 0
|
|
747
|
+
? {
|
|
748
|
+
left: (box.width - frame.width) / 2 - rect.x * scale,
|
|
749
|
+
top: (box.height - frame.height) / 2 - rect.y * scale,
|
|
750
|
+
width: natural.width * scale,
|
|
751
|
+
height: natural.height * scale,
|
|
752
|
+
}
|
|
753
|
+
: undefined
|
|
754
|
+
}
|
|
755
|
+
onLoad={(event) => {
|
|
756
|
+
const width = event.currentTarget.naturalWidth
|
|
757
|
+
const height = event.currentTarget.naturalHeight
|
|
758
|
+
metrics.current = { ...metrics.current, width, height }
|
|
759
|
+
setNatural({ width, height })
|
|
760
|
+
}}
|
|
761
|
+
/>
|
|
762
|
+
{scale > 0 ? (
|
|
763
|
+
<div
|
|
764
|
+
aria-hidden
|
|
765
|
+
data-slot="image-field-frame"
|
|
766
|
+
className={cn(
|
|
767
|
+
"pointer-events-none absolute border-2 border-white shadow-[0_0_0_100vmax_rgb(from_var(--foreground)_r_g_b_/_28%)] dark:shadow-[0_0_0_100vmax_rgb(from_var(--background)_r_g_b_/_40%)]",
|
|
768
|
+
shape === "circle" && "rounded-full",
|
|
769
|
+
)}
|
|
770
|
+
style={{
|
|
771
|
+
left: (box.width - frame.width) / 2,
|
|
772
|
+
top: (box.height - frame.height) / 2,
|
|
773
|
+
width: frame.width,
|
|
774
|
+
height: frame.height,
|
|
775
|
+
}}
|
|
776
|
+
>
|
|
777
|
+
<div
|
|
778
|
+
className={cn(
|
|
779
|
+
"absolute inset-0 grid grid-cols-3 grid-rows-3",
|
|
780
|
+
shape === "circle" && "overflow-hidden rounded-full",
|
|
781
|
+
)}
|
|
782
|
+
>
|
|
783
|
+
{Array.from({ length: 9 }, (_, index) => (
|
|
784
|
+
<span
|
|
785
|
+
key={index}
|
|
786
|
+
className={cn(index % 3 !== 2 && "border-r border-white/70", index < 6 && "border-b border-white/70")}
|
|
787
|
+
/>
|
|
788
|
+
))}
|
|
789
|
+
</div>
|
|
790
|
+
</div>
|
|
791
|
+
) : null}
|
|
792
|
+
</div>
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export { ImageField }
|