@moloodev/ui 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/package.json +139 -3
  2. package/registry/components.ts +135 -0
  3. package/registry/fonts.css +81 -0
  4. package/registry/suit-OFL.txt +94 -0
  5. package/registry/wanted-sans-OFL.txt +93 -0
  6. package/src/components/ui/accordion.tsx +74 -0
  7. package/src/components/ui/alert-dialog.tsx +187 -0
  8. package/src/components/ui/alert.tsx +83 -0
  9. package/src/components/ui/aspect-ratio.tsx +22 -0
  10. package/src/components/ui/attachment.tsx +207 -0
  11. package/src/components/ui/avatar.tsx +109 -0
  12. package/src/components/ui/badge.tsx +54 -0
  13. package/src/components/ui/breadcrumb.tsx +126 -0
  14. package/src/components/ui/bubble.tsx +128 -0
  15. package/src/components/ui/button-group.tsx +103 -0
  16. package/src/components/ui/button.tsx +68 -0
  17. package/src/components/ui/calendar.tsx +222 -0
  18. package/src/components/ui/card.tsx +103 -0
  19. package/src/components/ui/carousel.tsx +242 -0
  20. package/src/components/ui/chart.tsx +398 -0
  21. package/src/components/ui/checkbox.tsx +28 -0
  22. package/src/components/ui/collapsible.tsx +40 -0
  23. package/src/components/ui/combobox.tsx +299 -0
  24. package/src/components/ui/command.tsx +218 -0
  25. package/src/components/ui/context-menu.tsx +271 -0
  26. package/src/components/ui/data-table.tsx +919 -0
  27. package/src/components/ui/date-picker.tsx +65 -0
  28. package/src/components/ui/dialog.tsx +224 -0
  29. package/src/components/ui/drawer.tsx +231 -0
  30. package/src/components/ui/dropdown-menu.tsx +268 -0
  31. package/src/components/ui/dropzone.tsx +186 -0
  32. package/src/components/ui/empty.tsx +101 -0
  33. package/src/components/ui/field.tsx +238 -0
  34. package/src/components/ui/form.tsx +83 -0
  35. package/src/components/ui/highlighted-source.tsx +200 -0
  36. package/src/components/ui/hover-card.tsx +51 -0
  37. package/src/components/ui/image-field.tsx +796 -0
  38. package/src/components/ui/input-group.tsx +149 -0
  39. package/src/components/ui/input-otp.tsx +86 -0
  40. package/src/components/ui/input-step.ts +19 -0
  41. package/src/components/ui/input.tsx +100 -0
  42. package/src/components/ui/item.tsx +201 -0
  43. package/src/components/ui/kbd.tsx +91 -0
  44. package/src/components/ui/kind-icon-glob.ts +24 -0
  45. package/src/components/ui/kind-icon-load.ts +52 -0
  46. package/src/components/ui/kind-icon-static.ts +46 -0
  47. package/src/components/ui/kind-icon.tsx +84 -0
  48. package/src/components/ui/kind-mark.tsx +65 -0
  49. package/src/components/ui/label.tsx +29 -0
  50. package/src/components/ui/marker.tsx +71 -0
  51. package/src/components/ui/menubar.tsx +277 -0
  52. package/src/components/ui/message-scroller.tsx +137 -0
  53. package/src/components/ui/message.tsx +92 -0
  54. package/src/components/ui/moloo-logo.tsx +45 -0
  55. package/src/components/ui/native-select.tsx +61 -0
  56. package/src/components/ui/navigation-menu.tsx +168 -0
  57. package/src/components/ui/outline-list.tsx +73 -0
  58. package/src/components/ui/pagination.tsx +130 -0
  59. package/src/components/ui/panel.tsx +108 -0
  60. package/src/components/ui/popover.tsx +90 -0
  61. package/src/components/ui/progress.tsx +117 -0
  62. package/src/components/ui/radio-group.tsx +38 -0
  63. package/src/components/ui/resizable.tsx +52 -0
  64. package/src/components/ui/scroll-area.tsx +54 -0
  65. package/src/components/ui/select.tsx +205 -0
  66. package/src/components/ui/separator.tsx +25 -0
  67. package/src/components/ui/sheet.tsx +140 -0
  68. package/src/components/ui/sidebar.tsx +746 -0
  69. package/src/components/ui/skeleton.tsx +13 -0
  70. package/src/components/ui/slider.tsx +52 -0
  71. package/src/components/ui/spinner.tsx +18 -0
  72. package/src/components/ui/switch.tsx +32 -0
  73. package/src/components/ui/table.tsx +116 -0
  74. package/src/components/ui/tabs.tsx +83 -0
  75. package/src/components/ui/tag-list-input.tsx +194 -0
  76. package/src/components/ui/textarea.tsx +18 -0
  77. package/src/components/ui/timeline.tsx +265 -0
  78. package/src/components/ui/toast.tsx +237 -0
  79. package/src/components/ui/toggle-group.tsx +90 -0
  80. package/src/components/ui/toggle.tsx +45 -0
  81. package/src/components/ui/tooltip.tsx +66 -0
  82. package/src/css.d.ts +1 -0
  83. package/src/hooks/use-clipboard.ts +82 -0
  84. package/src/hooks/use-mobile.ts +21 -0
  85. package/src/index.ts +106 -0
  86. package/src/kind-icon.ts +4 -0
  87. package/src/lib/format-relative-time.ts +41 -0
  88. package/src/lib/list-selection.ts +61 -0
  89. package/src/lib/utils.ts +6 -0
  90. package/src/moloo.css +360 -0
  91. package/src/styles.css +36 -0
  92. package/src/typeset.css +518 -0
  93. 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 }