sanity-plugin-image-field 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1513 @@
1
+ // src/ImageFieldInput.tsx
2
+ import { useEffect, useRef as useRef2, useState as useState3 } from "react";
3
+ import {
4
+ Box as Box2,
5
+ Button as Button2,
6
+ Card as Card2,
7
+ Flex as Flex2,
8
+ Inline,
9
+ Menu,
10
+ MenuButton,
11
+ MenuItem,
12
+ Spinner,
13
+ Stack as Stack2,
14
+ Text as Text2
15
+ } from "@sanity/ui";
16
+ import {
17
+ CropIcon,
18
+ ImageIcon,
19
+ ImagesIcon,
20
+ TrashIcon,
21
+ UploadIcon
22
+ } from "@sanity/icons";
23
+ import {
24
+ ObjectInputMembers,
25
+ set,
26
+ setIfMissing,
27
+ unset,
28
+ useClient,
29
+ useWorkspace
30
+ } from "sanity";
31
+
32
+ // src/cropMath.ts
33
+ import "sanity";
34
+
35
+ // src/utils.ts
36
+ var validateAspectRatio = (input) => {
37
+ if (typeof input === "number") {
38
+ if (!Number.isFinite(input) || input <= 0) {
39
+ throw new Error("aspectRatio must be a finite number greater than 0.");
40
+ }
41
+ return;
42
+ }
43
+ if (Array.isArray(input)) {
44
+ if (input.length === 0) {
45
+ throw new Error("aspectRatio must not be an empty array.");
46
+ }
47
+ if (input.some((ratio) => !Number.isFinite(ratio) || ratio <= 0)) {
48
+ throw new Error(
49
+ "aspectRatio array values must be finite numbers greater than 0."
50
+ );
51
+ }
52
+ return;
53
+ }
54
+ if (!Number.isFinite(input.min) || !Number.isFinite(input.max)) {
55
+ throw new Error("aspectRatio min/max must be finite numbers.");
56
+ }
57
+ if (input.min <= 0 || input.max <= 0) {
58
+ throw new Error("aspectRatio min and max must be greater than 0.");
59
+ }
60
+ if (input.max < input.min) {
61
+ throw new Error("aspectRatio max must be greater than or equal to min.");
62
+ }
63
+ };
64
+ var clamp = (value, min, max) => Math.min(Math.max(value, min), max);
65
+ var clampRatio = (ratio, range) => clamp(ratio, range.min, range.max);
66
+ var nearestRatio = (ratio, ratios) => ratios.reduce(
67
+ (best, candidate) => Math.abs(candidate - ratio) < Math.abs(best - ratio) ? candidate : best
68
+ );
69
+
70
+ // src/cropMath.ts
71
+ var MIN_CROP_SIZE = 24;
72
+ var FREE_ASPECT_RATIO_RANGE = {
73
+ min: 0.01,
74
+ max: 100
75
+ };
76
+ var resolveAspectRatio = (input) => {
77
+ if (input === void 0) {
78
+ return { aspectRatioRange: FREE_ASPECT_RATIO_RANGE };
79
+ }
80
+ if (typeof input === "number") {
81
+ return { aspectRatioRange: { min: input, max: input } };
82
+ }
83
+ if (Array.isArray(input)) {
84
+ const snapRatios = Array.from(new Set(input)).toSorted((a, b) => a - b);
85
+ return {
86
+ aspectRatioRange: {
87
+ min: Math.min(...snapRatios),
88
+ max: Math.max(...snapRatios)
89
+ },
90
+ snapRatios
91
+ };
92
+ }
93
+ return { aspectRatioRange: { min: input.min, max: input.max } };
94
+ };
95
+ var largestRectForRatio = (source, ratio) => {
96
+ let width = source.width;
97
+ let height = width / ratio;
98
+ if (height > source.height) {
99
+ height = source.height;
100
+ width = height * ratio;
101
+ }
102
+ return {
103
+ x: (source.width - width) / 2,
104
+ y: (source.height - height) / 2,
105
+ width,
106
+ height
107
+ };
108
+ };
109
+ var defaultCrop = (source, range, snapRatios) => {
110
+ const sourceRatio = source.width / source.height;
111
+ const ratio = snapRatios ? nearestRatio(sourceRatio, snapRatios) : clampRatio(sourceRatio, range);
112
+ return largestRectForRatio(source, ratio);
113
+ };
114
+ var moveRect = (rect, deltaX, deltaY, source) => ({
115
+ ...rect,
116
+ x: clamp(rect.x + deltaX, 0, source.width - rect.width),
117
+ y: clamp(rect.y + deltaY, 0, source.height - rect.height)
118
+ });
119
+ var resize = (rect, dir, pointer, source, range, opts = {}) => {
120
+ const { lockRatio = false, mirror = false, snapRatios } = opts;
121
+ const centeredX = mirror || dir.dx === 0;
122
+ const centeredY = mirror || dir.dy === 0;
123
+ const anchorX = centeredX ? rect.x + rect.width / 2 : dir.dx > 0 ? rect.x : rect.x + rect.width;
124
+ const anchorY = centeredY ? rect.y + rect.height / 2 : dir.dy > 0 ? rect.y : rect.y + rect.height;
125
+ const pointerX = clamp(pointer.x, 0, source.width);
126
+ const pointerY = clamp(pointer.y, 0, source.height);
127
+ const availWidth = centeredX ? 2 * Math.min(anchorX, source.width - anchorX) : dir.dx > 0 ? source.width - anchorX : anchorX;
128
+ const availHeight = centeredY ? 2 * Math.min(anchorY, source.height - anchorY) : dir.dy > 0 ? source.height - anchorY : anchorY;
129
+ const desiredWidth = (centeredX ? 2 : 1) * Math.abs(pointerX - anchorX);
130
+ const desiredHeight = (centeredY ? 2 : 1) * Math.abs(pointerY - anchorY);
131
+ const askedRatio = lockRatio ? rect.width / rect.height : dir.dx === 0 ? desiredHeight === 0 ? range.max : rect.width / desiredHeight : dir.dy === 0 ? desiredWidth / rect.height : desiredHeight === 0 ? range.max : desiredWidth / desiredHeight;
132
+ const ratio = snapRatios ? nearestRatio(askedRatio, snapRatios) : clampRatio(askedRatio, range);
133
+ const targetWidth = dir.dx !== 0 ? desiredWidth : desiredHeight * ratio;
134
+ let width = Math.min(targetWidth, availWidth);
135
+ let height = width / ratio;
136
+ if (height > availHeight) {
137
+ height = availHeight;
138
+ width = height * ratio;
139
+ }
140
+ const minWidth = Math.min(MIN_CROP_SIZE, availWidth, availHeight * ratio);
141
+ if (width < minWidth) {
142
+ width = minWidth;
143
+ height = width / ratio;
144
+ }
145
+ return {
146
+ x: centeredX ? anchorX - width / 2 : dir.dx > 0 ? anchorX : anchorX - width,
147
+ y: centeredY ? anchorY - height / 2 : dir.dy > 0 ? anchorY : anchorY - height,
148
+ width,
149
+ height
150
+ };
151
+ };
152
+ var applyRatio = (rect, ratio, source) => {
153
+ const centerX = rect.x + rect.width / 2;
154
+ const centerY = rect.y + rect.height / 2;
155
+ const width = Math.min(rect.width, rect.height * ratio);
156
+ const height = width / ratio;
157
+ return {
158
+ x: clamp(centerX - width / 2, 0, source.width - width),
159
+ y: clamp(centerY - height / 2, 0, source.height - height),
160
+ width,
161
+ height
162
+ };
163
+ };
164
+ var toNormalizedPoint = (pointer, box) => ({
165
+ x: clamp((pointer.x - box.left) / box.width, 0, 1),
166
+ y: clamp((pointer.y - box.top) / box.height, 0, 1)
167
+ });
168
+ var hotspotToFocalPoint = (hotspot, rect, source) => ({
169
+ x: clamp((hotspot.x * source.width - rect.x) / rect.width, 0, 1),
170
+ y: clamp((hotspot.y * source.height - rect.y) / rect.height, 0, 1)
171
+ });
172
+ var focalPointToHotspot = (point, rect, source, base) => ({
173
+ ...base,
174
+ x: (rect.x + point.x * rect.width) / source.width,
175
+ y: (rect.y + point.y * rect.height) / source.height,
176
+ width: Math.min(base.width, rect.width / source.width),
177
+ height: Math.min(base.height, rect.height / source.height)
178
+ });
179
+ var pixelRectToCrop = (rect, source) => ({
180
+ _type: "sanity.imageCrop",
181
+ top: Math.max(0, rect.y / source.height),
182
+ left: Math.max(0, rect.x / source.width),
183
+ right: Math.max(0, (source.width - rect.x - rect.width) / source.width),
184
+ bottom: Math.max(0, (source.height - rect.y - rect.height) / source.height)
185
+ });
186
+ var cropToPixelRect = (crop, source) => ({
187
+ x: crop.left * source.width,
188
+ y: crop.top * source.height,
189
+ width: (1 - crop.left - crop.right) * source.width,
190
+ height: (1 - crop.top - crop.bottom) * source.height
191
+ });
192
+ var croppedDimensions = (rect) => ({
193
+ width: Math.round(rect.width),
194
+ height: Math.round(rect.height)
195
+ });
196
+ var dimensionsAtLeast = (dimensions, threshold) => dimensions.width >= threshold.width && dimensions.height >= threshold.height;
197
+ var formatAspectRatio = (ratio) => {
198
+ for (let height = 1; height <= 16; height++) {
199
+ const width = ratio * height;
200
+ const rounded = Math.round(width);
201
+ if (rounded >= 1 && Math.abs(width - rounded) < 0.02) {
202
+ return `${rounded}:${height}`;
203
+ }
204
+ }
205
+ return `${ratio.toFixed(2)}:1`;
206
+ };
207
+
208
+ // src/CropDialog.tsx
209
+ import { useState as useState2 } from "react";
210
+ import { Box, Button, Card, Dialog, Flex, Stack, Text } from "@sanity/ui";
211
+ import {
212
+ CheckmarkCircleIcon,
213
+ ErrorOutlineIcon,
214
+ ResetIcon,
215
+ WarningOutlineIcon
216
+ } from "@sanity/icons";
217
+ import "sanity";
218
+
219
+ // src/CropArea.tsx
220
+ import { useCallback, useRef } from "react";
221
+
222
+ // src/useContentWidth.ts
223
+ import { useLayoutEffect, useState } from "react";
224
+ function useContentWidth(ref) {
225
+ const [width, setWidth] = useState(0);
226
+ useLayoutEffect(() => {
227
+ const element = ref.current;
228
+ if (!element) {
229
+ return void 0;
230
+ }
231
+ const observer = new ResizeObserver(([entry]) => {
232
+ if (!entry) {
233
+ return;
234
+ }
235
+ setWidth(entry.contentRect.width);
236
+ });
237
+ observer.observe(element);
238
+ return () => {
239
+ observer.disconnect();
240
+ };
241
+ }, [ref]);
242
+ return width;
243
+ }
244
+
245
+ // src/CropArea.tsx
246
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
247
+ var HANDLES = [
248
+ { dx: -1, dy: -1 },
249
+ { dx: 0, dy: -1 },
250
+ { dx: 1, dy: -1 },
251
+ { dx: 1, dy: 0 },
252
+ { dx: 1, dy: 1 },
253
+ { dx: 0, dy: 1 },
254
+ { dx: -1, dy: 1 },
255
+ { dx: -1, dy: 0 }
256
+ ];
257
+ var HANDLE_SIZE = 14;
258
+ var BAR_LENGTH = 22;
259
+ var BAR_THICKNESS = 8;
260
+ var HOTSPOT_SIZE = 22;
261
+ var KEYBOARD_STEP = 0.02;
262
+ var CROP_ARIA_LABEL = "Crop selection. Use arrow keys to move, hold Shift with arrow keys to resize.";
263
+ function usePointerDrag(onMove) {
264
+ const dragRef = useRef(null);
265
+ const start = useCallback(
266
+ (event, state, { stopPropagation = false } = {}) => {
267
+ event.preventDefault();
268
+ if (stopPropagation) {
269
+ event.stopPropagation();
270
+ }
271
+ event.currentTarget.setPointerCapture(event.pointerId);
272
+ dragRef.current = state;
273
+ },
274
+ []
275
+ );
276
+ const handlePointerMove = useCallback(
277
+ (event) => {
278
+ const state = dragRef.current;
279
+ if (state === null) {
280
+ return;
281
+ }
282
+ onMove(state, event);
283
+ },
284
+ [onMove]
285
+ );
286
+ const handleLostPointerCapture = useCallback(() => {
287
+ dragRef.current = null;
288
+ }, []);
289
+ return { start, handlePointerMove, handleLostPointerCapture };
290
+ }
291
+ var CropArea = ({
292
+ imageUrl,
293
+ sourceDimensions: sourceDimensions2,
294
+ range,
295
+ snapRatios,
296
+ rect,
297
+ onChange,
298
+ focalPoint,
299
+ onFocalPointChange,
300
+ maxHeight = 460
301
+ }) => {
302
+ const frameRef = useRef(null);
303
+ const imageBoxRef = useRef(null);
304
+ const frameWidth = useContentWidth(frameRef);
305
+ const scale = displayScale(sourceDimensions2, frameWidth, maxHeight);
306
+ const displayWidth = sourceDimensions2.width * scale;
307
+ const displayHeight = sourceDimensions2.height * scale;
308
+ const cropBox = {
309
+ left: rect.x * scale,
310
+ top: rect.y * scale,
311
+ width: rect.width * scale,
312
+ height: rect.height * scale
313
+ };
314
+ const pointerToSource = useCallback(
315
+ (event) => {
316
+ const box = imageBoxRef.current?.getBoundingClientRect();
317
+ if (!box || scale === 0) {
318
+ return { x: 0, y: 0 };
319
+ }
320
+ return {
321
+ x: (event.clientX - box.left) / scale,
322
+ y: (event.clientY - box.top) / scale
323
+ };
324
+ },
325
+ [scale]
326
+ );
327
+ const pointerToFocal = useCallback(
328
+ (event) => {
329
+ const box = imageBoxRef.current?.getBoundingClientRect();
330
+ if (!box || cropBox.width === 0 || cropBox.height === 0) {
331
+ return { x: 0, y: 0 };
332
+ }
333
+ return toNormalizedPoint(
334
+ { x: event.clientX, y: event.clientY },
335
+ {
336
+ left: box.left + cropBox.left,
337
+ top: box.top + cropBox.top,
338
+ width: cropBox.width,
339
+ height: cropBox.height
340
+ }
341
+ );
342
+ },
343
+ [cropBox.left, cropBox.top, cropBox.width, cropBox.height]
344
+ );
345
+ const onCropMove = useCallback(
346
+ (drag, event) => {
347
+ const pointer = pointerToSource(event);
348
+ const next = drag.mode === "move" ? moveRect(
349
+ drag.startRect,
350
+ pointer.x - drag.startPointer.x,
351
+ pointer.y - drag.startPointer.y,
352
+ sourceDimensions2
353
+ ) : (
354
+ // Modifier keys are read at move time so they can change mid-drag.
355
+ // Shift locks the ratio; Option/Alt resizes from center. With
356
+ // snapRatios, the crop snaps to the nearest allowed ratio.
357
+ resize(drag.startRect, drag.dir, pointer, sourceDimensions2, range, {
358
+ lockRatio: event.shiftKey,
359
+ mirror: event.altKey,
360
+ snapRatios
361
+ })
362
+ );
363
+ onChange(next);
364
+ },
365
+ [onChange, pointerToSource, range, snapRatios, sourceDimensions2]
366
+ );
367
+ const cropDrag = usePointerDrag(onCropMove);
368
+ const onFocalMove = useCallback(
369
+ (_active, event) => {
370
+ if (!onFocalPointChange) {
371
+ return;
372
+ }
373
+ onFocalPointChange(pointerToFocal(event));
374
+ },
375
+ [onFocalPointChange, pointerToFocal]
376
+ );
377
+ const focalDrag = usePointerDrag(onFocalMove);
378
+ const handleKeyDown = (event) => {
379
+ const stepX = sourceDimensions2.width * KEYBOARD_STEP;
380
+ const stepY = sourceDimensions2.height * KEYBOARD_STEP;
381
+ if (event.shiftKey) {
382
+ const ratio = rect.width / rect.height;
383
+ const widthSteps = /* @__PURE__ */ new Map([
384
+ ["ArrowRight", stepX],
385
+ ["ArrowLeft", -stepX],
386
+ ["ArrowDown", stepY * ratio],
387
+ ["ArrowUp", -stepY * ratio]
388
+ ]);
389
+ const widthStep = widthSteps.get(event.key);
390
+ if (widthStep === void 0) {
391
+ return;
392
+ }
393
+ event.preventDefault();
394
+ const east = rect.x + rect.width;
395
+ const south = rect.y + rect.height;
396
+ onChange(
397
+ resize(
398
+ rect,
399
+ { dx: 1, dy: 1 },
400
+ { x: east + widthStep, y: south },
401
+ sourceDimensions2,
402
+ range,
403
+ { lockRatio: true, snapRatios }
404
+ )
405
+ );
406
+ return;
407
+ }
408
+ const moves = /* @__PURE__ */ new Map([
409
+ ["ArrowLeft", [-stepX, 0]],
410
+ ["ArrowRight", [stepX, 0]],
411
+ ["ArrowUp", [0, -stepY]],
412
+ ["ArrowDown", [0, stepY]]
413
+ ]);
414
+ const delta = moves.get(event.key);
415
+ if (!delta) {
416
+ return;
417
+ }
418
+ event.preventDefault();
419
+ onChange(moveRect(rect, delta[0], delta[1], sourceDimensions2));
420
+ };
421
+ return /* @__PURE__ */ jsx(
422
+ "div",
423
+ {
424
+ ref: frameRef,
425
+ style: {
426
+ width: "100%",
427
+ display: "flex",
428
+ justifyContent: "center",
429
+ userSelect: "none",
430
+ touchAction: "none"
431
+ },
432
+ children: /* @__PURE__ */ jsxs(
433
+ "div",
434
+ {
435
+ ref: imageBoxRef,
436
+ style: {
437
+ position: "relative",
438
+ width: displayWidth,
439
+ height: displayHeight,
440
+ maxWidth: "100%"
441
+ },
442
+ children: [
443
+ /* @__PURE__ */ jsx(
444
+ "img",
445
+ {
446
+ src: imageUrl,
447
+ alt: "",
448
+ draggable: false,
449
+ style: {
450
+ display: "block",
451
+ width: "100%",
452
+ height: "100%",
453
+ objectFit: "fill"
454
+ }
455
+ }
456
+ ),
457
+ scale > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
458
+ /* @__PURE__ */ jsx(
459
+ "div",
460
+ {
461
+ "aria-hidden": true,
462
+ style: {
463
+ position: "absolute",
464
+ inset: 0,
465
+ overflow: "hidden",
466
+ pointerEvents: "none"
467
+ },
468
+ children: /* @__PURE__ */ jsx(
469
+ "div",
470
+ {
471
+ style: {
472
+ position: "absolute",
473
+ ...cropBox,
474
+ boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.55)"
475
+ }
476
+ }
477
+ )
478
+ }
479
+ ),
480
+ /* @__PURE__ */ jsxs(
481
+ "div",
482
+ {
483
+ role: "group",
484
+ "aria-label": CROP_ARIA_LABEL,
485
+ tabIndex: 0,
486
+ onPointerDown: (event) => {
487
+ cropDrag.start(event, {
488
+ mode: "move",
489
+ startRect: rect,
490
+ startPointer: pointerToSource(event)
491
+ });
492
+ },
493
+ onPointerMove: cropDrag.handlePointerMove,
494
+ onLostPointerCapture: cropDrag.handleLostPointerCapture,
495
+ onKeyDown: handleKeyDown,
496
+ style: {
497
+ position: "absolute",
498
+ ...cropBox,
499
+ cursor: "move",
500
+ outline: "1px solid rgba(255, 255, 255, 0.9)"
501
+ },
502
+ children: [
503
+ /* @__PURE__ */ jsx(ThirdsGuides, {}),
504
+ HANDLES.map((dir) => /* @__PURE__ */ jsx(
505
+ Handle,
506
+ {
507
+ dir,
508
+ onPointerDown: (event) => {
509
+ cropDrag.start(
510
+ event,
511
+ { mode: "resize", dir, startRect: rect },
512
+ { stopPropagation: true }
513
+ );
514
+ },
515
+ onPointerMove: cropDrag.handlePointerMove,
516
+ onLostPointerCapture: cropDrag.handleLostPointerCapture
517
+ },
518
+ `${dir.dx},${dir.dy}`
519
+ ))
520
+ ]
521
+ }
522
+ ),
523
+ focalPoint && onFocalPointChange && /* @__PURE__ */ jsx(
524
+ HotspotMarker,
525
+ {
526
+ left: cropBox.left + focalPoint.x * cropBox.width,
527
+ top: cropBox.top + focalPoint.y * cropBox.height,
528
+ onPointerDown: (event) => {
529
+ focalDrag.start(event, true, { stopPropagation: true });
530
+ },
531
+ onPointerMove: focalDrag.handlePointerMove,
532
+ onLostPointerCapture: focalDrag.handleLostPointerCapture
533
+ }
534
+ )
535
+ ] })
536
+ ]
537
+ }
538
+ )
539
+ }
540
+ );
541
+ };
542
+ var displayScale = (sourceDimensions2, frameWidth, maxHeight) => {
543
+ if (frameWidth === 0) {
544
+ return 0;
545
+ }
546
+ return Math.min(
547
+ frameWidth / sourceDimensions2.width,
548
+ maxHeight / sourceDimensions2.height
549
+ );
550
+ };
551
+ var ThirdsGuides = () => /* @__PURE__ */ jsx(
552
+ "div",
553
+ {
554
+ style: {
555
+ position: "absolute",
556
+ inset: 0,
557
+ pointerEvents: "none",
558
+ backgroundImage: `
559
+ linear-gradient(to right, transparent 33.33%, rgba(255, 255, 255, 0.35) 33.33%, rgba(255, 255, 255, 0.35) calc(33.33% + 1px), transparent calc(33.33% + 1px), transparent 66.66%, rgba(255, 255, 255, 0.35) 66.66%, rgba(255, 255, 255, 0.35) calc(66.66% + 1px), transparent calc(66.66% + 1px)),
560
+ linear-gradient(to bottom, transparent 33.33%, rgba(255, 255, 255, 0.35) 33.33%, rgba(255, 255, 255, 0.35) calc(33.33% + 1px), transparent calc(33.33% + 1px), transparent 66.66%, rgba(255, 255, 255, 0.35) 66.66%, rgba(255, 255, 255, 0.35) calc(66.66% + 1px), transparent calc(66.66% + 1px))
561
+ `
562
+ }
563
+ }
564
+ );
565
+ var cursorForDirection = (dir) => {
566
+ if (dir.dx === 0) {
567
+ return "ns-resize";
568
+ }
569
+ if (dir.dy === 0) {
570
+ return "ew-resize";
571
+ }
572
+ return dir.dx === dir.dy ? "nwse-resize" : "nesw-resize";
573
+ };
574
+ var Handle = ({
575
+ dir,
576
+ onPointerDown,
577
+ onPointerMove,
578
+ onLostPointerCapture
579
+ }) => {
580
+ const isCorner = dir.dx !== 0 && dir.dy !== 0;
581
+ const horizontalBar = dir.dx === 0;
582
+ const width = isCorner ? HANDLE_SIZE : horizontalBar ? BAR_LENGTH : BAR_THICKNESS;
583
+ const height = isCorner ? HANDLE_SIZE : horizontalBar ? BAR_THICKNESS : BAR_LENGTH;
584
+ return /* @__PURE__ */ jsx(
585
+ "div",
586
+ {
587
+ onPointerDown,
588
+ onPointerMove,
589
+ onLostPointerCapture,
590
+ style: {
591
+ position: "absolute",
592
+ left: `${(dir.dx + 1) * 50}%`,
593
+ top: `${(dir.dy + 1) * 50}%`,
594
+ transform: "translate(-50%, -50%)",
595
+ width,
596
+ height,
597
+ borderRadius: isCorner ? "50%" : BAR_THICKNESS / 2,
598
+ background: "#fff",
599
+ boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.4)",
600
+ cursor: cursorForDirection(dir),
601
+ touchAction: "none"
602
+ }
603
+ }
604
+ );
605
+ };
606
+ var HotspotMarker = ({
607
+ left,
608
+ top,
609
+ onPointerDown,
610
+ onPointerMove,
611
+ onLostPointerCapture
612
+ }) => /* @__PURE__ */ jsx(
613
+ "div",
614
+ {
615
+ title: "Focal point",
616
+ onPointerDown,
617
+ onPointerMove,
618
+ onLostPointerCapture,
619
+ style: {
620
+ position: "absolute",
621
+ left,
622
+ top,
623
+ transform: "translate(-50%, -50%)",
624
+ width: HOTSPOT_SIZE,
625
+ height: HOTSPOT_SIZE,
626
+ borderRadius: "50%",
627
+ border: "2px solid #fff",
628
+ background: "rgba(255, 255, 255, 0.2)",
629
+ boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.5), 0 0 6px rgba(0, 0, 0, 0.5)",
630
+ cursor: "grab",
631
+ touchAction: "none"
632
+ },
633
+ children: /* @__PURE__ */ jsx(
634
+ "div",
635
+ {
636
+ style: {
637
+ position: "absolute",
638
+ left: "50%",
639
+ top: "50%",
640
+ transform: "translate(-50%, -50%)",
641
+ width: 4,
642
+ height: 4,
643
+ borderRadius: "50%",
644
+ background: "#fff",
645
+ boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.5)"
646
+ }
647
+ }
648
+ )
649
+ }
650
+ );
651
+
652
+ // src/imageUrl.ts
653
+ import { buildSrc, parseImageId } from "@sanity-image/url-builder";
654
+ import "sanity";
655
+ var sourceDimensions = (assetRef) => parseImageId(assetRef).dimensions;
656
+ var isSvg = (ref) => /^image-[a-f0-9]+(?:-\d+x\d+)?-svg$/.test(ref);
657
+ var isCroppable = (assetRef) => Boolean(assetRef && /-\d+x\d+-\w+$/.test(assetRef) && !isSvg(assetRef));
658
+ var isImageAssetRef = (ref) => {
659
+ if (isSvg(ref)) {
660
+ return true;
661
+ }
662
+ try {
663
+ const { assetId, format } = parseImageId(ref);
664
+ return Boolean(assetId && format);
665
+ } catch {
666
+ return false;
667
+ }
668
+ };
669
+ var croppedDimensionsFromValue = (value) => {
670
+ if (!isCroppable(value?.asset?._ref)) {
671
+ return null;
672
+ }
673
+ const source = sourceDimensions(value.asset._ref);
674
+ if (!value.crop) {
675
+ return source;
676
+ }
677
+ return croppedDimensions(cropToPixelRect(value.crop, source));
678
+ };
679
+ var cdnBaseUrl = (source) => `https://cdn.sanity.io/images/${source.projectId}/${source.dataset}/`;
680
+ var sourceImageUrl = (assetRef, source) => {
681
+ const { assetId, dimensions, format } = parseImageId(assetRef);
682
+ const filename = `${assetId}-${dimensions.width}x${dimensions.height}.${format}`;
683
+ return `${cdnBaseUrl(source)}${filename}`;
684
+ };
685
+ var croppedImageUrl = (value, source, displayWidth) => {
686
+ const assetRef = value.asset?._ref;
687
+ if (!assetRef) {
688
+ return null;
689
+ }
690
+ if (isSvg(assetRef)) {
691
+ const assetId = assetRef.slice("image-".length, -"-svg".length);
692
+ return `${cdnBaseUrl(source)}${assetId}.svg`;
693
+ }
694
+ const { src } = buildSrc({
695
+ baseUrl: cdnBaseUrl(source),
696
+ id: assetRef,
697
+ width: Math.round(displayWidth),
698
+ crop: value.crop
699
+ });
700
+ return src;
701
+ };
702
+
703
+ // src/CropDialog.tsx
704
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
705
+ var CENTERED_HOTSPOT = {
706
+ _type: "sanity.imageHotspot",
707
+ x: 0.5,
708
+ y: 0.5,
709
+ width: 0.01,
710
+ height: 0.01
711
+ };
712
+ var CropDialog = ({
713
+ assetRef,
714
+ imageSource,
715
+ sourceDimensions: sourceDimensions2,
716
+ config,
717
+ initialCrop,
718
+ hotspotEnabled,
719
+ initialHotspot,
720
+ onConfirm,
721
+ onClose
722
+ }) => {
723
+ const {
724
+ aspectRatioRange,
725
+ snapRatios,
726
+ recommendedDimensions,
727
+ requiredDimensions
728
+ } = config;
729
+ const [rect, setRect] = useState2(() => {
730
+ if (!initialCrop) {
731
+ return defaultCrop(sourceDimensions2, aspectRatioRange, snapRatios);
732
+ }
733
+ const loaded = cropToPixelRect(initialCrop, sourceDimensions2);
734
+ return snapRatios ? applyRatio(
735
+ loaded,
736
+ nearestRatio(loaded.width / loaded.height, snapRatios),
737
+ sourceDimensions2
738
+ ) : loaded;
739
+ });
740
+ const [focalPoint, setFocalPoint] = useState2(
741
+ () => initialHotspot ? hotspotToFocalPoint(initialHotspot, rect, sourceDimensions2) : { x: 0.5, y: 0.5 }
742
+ );
743
+ const dimensions = croppedDimensions(rect);
744
+ const belowRequired = requiredDimensions && !dimensionsAtLeast(dimensions, requiredDimensions);
745
+ const belowRecommended = recommendedDimensions && !dimensionsAtLeast(dimensions, recommendedDimensions);
746
+ return /* @__PURE__ */ jsx2(
747
+ Dialog,
748
+ {
749
+ id: "aspect-ratio-crop-dialog",
750
+ header: "Crop image",
751
+ width: 2,
752
+ onClose,
753
+ footer: /* @__PURE__ */ jsxs2(Flex, { padding: 3, justify: "space-between", align: "center", gap: 3, children: [
754
+ /* @__PURE__ */ jsx2(
755
+ Button,
756
+ {
757
+ mode: "bleed",
758
+ icon: ResetIcon,
759
+ text: "Reset crop",
760
+ fontSize: 1,
761
+ onClick: () => {
762
+ setRect(
763
+ defaultCrop(sourceDimensions2, aspectRatioRange, snapRatios)
764
+ );
765
+ }
766
+ }
767
+ ),
768
+ /* @__PURE__ */ jsxs2(Flex, { gap: 2, children: [
769
+ /* @__PURE__ */ jsx2(Button, { mode: "ghost", text: "Cancel", onClick: onClose }),
770
+ /* @__PURE__ */ jsx2(
771
+ Button,
772
+ {
773
+ tone: "primary",
774
+ text: "Confirm crop",
775
+ disabled: belowRequired,
776
+ onClick: () => {
777
+ onConfirm({
778
+ crop: pixelRectToCrop(rect, sourceDimensions2),
779
+ hotspot: hotspotEnabled ? focalPointToHotspot(
780
+ focalPoint,
781
+ rect,
782
+ sourceDimensions2,
783
+ initialHotspot ?? CENTERED_HOTSPOT
784
+ ) : void 0
785
+ });
786
+ }
787
+ }
788
+ )
789
+ ] })
790
+ ] }),
791
+ children: /* @__PURE__ */ jsxs2(Stack, { gap: 3, padding: 3, children: [
792
+ /* @__PURE__ */ jsx2(
793
+ CropArea,
794
+ {
795
+ imageUrl: sourceImageUrl(assetRef, imageSource),
796
+ sourceDimensions: sourceDimensions2,
797
+ range: aspectRatioRange,
798
+ snapRatios,
799
+ rect,
800
+ onChange: setRect,
801
+ focalPoint: hotspotEnabled ? focalPoint : void 0,
802
+ onFocalPointChange: hotspotEnabled ? setFocalPoint : void 0
803
+ }
804
+ ),
805
+ /* @__PURE__ */ jsxs2(Flex, { justify: "space-between", align: "center", gap: 3, wrap: "wrap", children: [
806
+ /* @__PURE__ */ jsxs2(Text, { size: 1, muted: true, children: [
807
+ "Allowed aspect ratio: ",
808
+ describeAspectRatio(config)
809
+ ] }),
810
+ /* @__PURE__ */ jsxs2(Text, { size: 1, weight: "medium", children: [
811
+ dimensions.width,
812
+ " \xD7 ",
813
+ dimensions.height,
814
+ " px"
815
+ ] })
816
+ ] }),
817
+ hotspotEnabled && /* @__PURE__ */ jsx2(Text, { size: 1, muted: true, children: "Drag the circle to set the focal point. It governs how cover-mode crops frame the image downstream, not this preview." }),
818
+ /* @__PURE__ */ jsx2(
819
+ DimensionStatusMessage,
820
+ {
821
+ requiredDimensions,
822
+ recommendedDimensions,
823
+ belowRequired,
824
+ belowRecommended
825
+ }
826
+ )
827
+ ] })
828
+ }
829
+ );
830
+ };
831
+ var StatusMessage = ({
832
+ tone,
833
+ icon,
834
+ message
835
+ }) => /* @__PURE__ */ jsx2(Card, { tone, padding: 3, radius: 2, border: true, children: /* @__PURE__ */ jsxs2(Flex, { gap: 3, align: "center", children: [
836
+ /* @__PURE__ */ jsx2(Text, { size: 1, children: icon }),
837
+ /* @__PURE__ */ jsx2(Box, { flex: 1, children: /* @__PURE__ */ jsx2(Text, { size: 1, textOverflow: "ellipsis", children: message }) })
838
+ ] }) });
839
+ var DimensionStatusMessage = ({
840
+ requiredDimensions,
841
+ recommendedDimensions,
842
+ belowRequired,
843
+ belowRecommended
844
+ }) => {
845
+ if (requiredDimensions && belowRequired) {
846
+ return /* @__PURE__ */ jsx2(
847
+ StatusMessage,
848
+ {
849
+ tone: "critical",
850
+ icon: /* @__PURE__ */ jsx2(ErrorOutlineIcon, {}),
851
+ message: `Crop must be at least ${describeDimensions(requiredDimensions)}.`
852
+ }
853
+ );
854
+ }
855
+ if (recommendedDimensions && belowRecommended) {
856
+ return /* @__PURE__ */ jsx2(
857
+ StatusMessage,
858
+ {
859
+ tone: "caution",
860
+ icon: /* @__PURE__ */ jsx2(WarningOutlineIcon, {}),
861
+ message: `For best results, keep the crop at least ${describeDimensions(
862
+ recommendedDimensions
863
+ )}.`
864
+ }
865
+ );
866
+ }
867
+ if (requiredDimensions || recommendedDimensions) {
868
+ return /* @__PURE__ */ jsx2(
869
+ StatusMessage,
870
+ {
871
+ tone: "positive",
872
+ icon: /* @__PURE__ */ jsx2(CheckmarkCircleIcon, {}),
873
+ message: "Crop size looks good."
874
+ }
875
+ );
876
+ }
877
+ return null;
878
+ };
879
+ var describeDimensions = (dimensions) => `${dimensions.width} \xD7 ${dimensions.height} px`;
880
+ var describeAspectRatio = ({
881
+ aspectRatioRange,
882
+ snapRatios
883
+ }) => {
884
+ if (snapRatios) {
885
+ if (snapRatios.length <= 3) {
886
+ return snapRatios.map(formatAspectRatio).join(", ");
887
+ }
888
+ const lo = formatAspectRatio(Math.min(...snapRatios));
889
+ const hi = formatAspectRatio(Math.max(...snapRatios));
890
+ return `${snapRatios.length} ratios (${lo} \u2013 ${hi})`;
891
+ }
892
+ return aspectRatioRange.min === aspectRatioRange.max ? formatAspectRatio(aspectRatioRange.min) : `${formatAspectRatio(aspectRatioRange.min)} \u2013 ${formatAspectRatio(
893
+ aspectRatioRange.max
894
+ )}`;
895
+ };
896
+
897
+ // src/upload.ts
898
+ var acceptsType = (mimeType, accept) => {
899
+ const type = mimeType.trim().toLowerCase();
900
+ const patterns = accept.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
901
+ if (patterns.length === 0) {
902
+ return true;
903
+ }
904
+ return patterns.some((pattern) => {
905
+ if (pattern === "*" || pattern === "*/*") {
906
+ return true;
907
+ }
908
+ if (pattern.endsWith("/*")) {
909
+ return type.startsWith(pattern.slice(0, -1));
910
+ }
911
+ return type === pattern;
912
+ });
913
+ };
914
+ var isTextEntryTarget = (target) => {
915
+ if (!(target instanceof HTMLElement)) {
916
+ return false;
917
+ }
918
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
919
+ return true;
920
+ }
921
+ const attribute = target.getAttribute("contenteditable");
922
+ return target.isContentEditable || attribute === "" || attribute === "true";
923
+ };
924
+
925
+ // src/ImageFieldInput.tsx
926
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
927
+ var API_VERSION = "2025-02-19";
928
+ var ImageFieldInput = ({
929
+ value,
930
+ onChange,
931
+ path,
932
+ members,
933
+ readOnly,
934
+ schemaType,
935
+ ...renderObjectProps
936
+ }) => {
937
+ const client = useClient({ apiVersion: API_VERSION });
938
+ const { projectId, dataset } = client.config();
939
+ const assetSources = useWorkspace().form.image.assetSources;
940
+ const imageFieldOptions = schemaType.options?.imageField;
941
+ const aspectRatio = imageFieldOptions?.aspectRatio;
942
+ const hasAspectConstraint = aspectRatio !== void 0;
943
+ const hotspotEnabled = Boolean(schemaType.options?.hotspot);
944
+ const resolved = resolveAspectRatio(aspectRatio);
945
+ const config = {
946
+ aspectRatioRange: resolved.aspectRatioRange,
947
+ snapRatios: resolved.snapRatios,
948
+ recommendedDimensions: imageFieldOptions?.recommendedDimensions,
949
+ requiredDimensions: imageFieldOptions?.requiredDimensions
950
+ };
951
+ const accept = schemaType.options?.accept ?? "image/*";
952
+ const [upload, setUpload] = useState3({ status: "idle" });
953
+ const [dialogOpen, setDialogOpen] = useState3(false);
954
+ const [pendingAsset, setPendingAsset] = useState3(null);
955
+ const [draggingOver, setDraggingOver] = useState3(false);
956
+ const [browsingSource, setBrowsingSource] = useState3(null);
957
+ const fileInputRef = useRef2(null);
958
+ const dragDepth = useRef2(0);
959
+ const subscription = useRef2(null);
960
+ useEffect(() => () => subscription.current?.unsubscribe(), []);
961
+ const assetRef = value?.asset?._ref;
962
+ const assetCrop = value?.crop;
963
+ const assetHotspot = value?.hotspot;
964
+ const activeAssetRef = pendingAsset ?? assetRef;
965
+ const dialogSource = activeAssetRef && isCroppable(activeAssetRef) ? sourceDimensions(activeAssetRef) : null;
966
+ const imageSource = projectId && dataset ? { projectId, dataset } : null;
967
+ const customFieldMembers = members.filter(isCustomFieldMember);
968
+ const commitAsset = (ref, extraPatches = []) => {
969
+ onChange([
970
+ setIfMissing({ _type: "image" }),
971
+ set({ _type: "reference", _ref: ref }, ["asset"]),
972
+ ...extraPatches
973
+ ]);
974
+ setUpload({ status: "idle" });
975
+ };
976
+ const applyAsset = (selectedAssetRef) => {
977
+ if (isSvg(selectedAssetRef)) {
978
+ commitAsset(selectedAssetRef, [unset(["crop"])]);
979
+ return;
980
+ }
981
+ const dimensions = isCroppable(selectedAssetRef) ? sourceDimensions(selectedAssetRef) : null;
982
+ const openEditor = Boolean(dimensions) && (hasAspectConstraint || hotspotEnabled);
983
+ if (openEditor) {
984
+ setPendingAsset(selectedAssetRef);
985
+ setUpload({ status: "idle" });
986
+ setDialogOpen(true);
987
+ return;
988
+ }
989
+ if (dimensions && config.requiredDimensions && !dimensionsAtLeast(dimensions, config.requiredDimensions)) {
990
+ setUpload({
991
+ status: "error",
992
+ message: `That image is too small. It must be at least ${config.requiredDimensions.width} \xD7 ${config.requiredDimensions.height} px.`
993
+ });
994
+ return;
995
+ }
996
+ commitAsset(selectedAssetRef, [unset(["crop"])]);
997
+ };
998
+ const uploadFile = (file) => {
999
+ if (!acceptsType(file.type, accept)) {
1000
+ setUpload({
1001
+ status: "error",
1002
+ message: "That file type isn't accepted here. Choose a supported image."
1003
+ });
1004
+ return;
1005
+ }
1006
+ setUpload({ status: "uploading", progress: 0 });
1007
+ subscription.current?.unsubscribe();
1008
+ subscription.current = client.observable.assets.upload("image", file, { filename: file.name }).subscribe({
1009
+ next: (event) => {
1010
+ if (event.type === "progress") {
1011
+ setUpload({ status: "uploading", progress: event.percent });
1012
+ } else {
1013
+ applyAsset(event.body.document._id);
1014
+ }
1015
+ },
1016
+ error: () => {
1017
+ setUpload({
1018
+ status: "error",
1019
+ message: "Upload failed. Please try again."
1020
+ });
1021
+ }
1022
+ });
1023
+ };
1024
+ const selectAsset = (assets) => {
1025
+ setBrowsingSource(null);
1026
+ const chosen = assets.at(0);
1027
+ if (chosen?.kind === "assetDocumentId" && typeof chosen.value === "string" && isImageAssetRef(chosen.value)) {
1028
+ applyAsset(chosen.value);
1029
+ return;
1030
+ }
1031
+ setUpload({
1032
+ status: "error",
1033
+ message: "That selection can't be used here. Choose an uploaded image."
1034
+ });
1035
+ };
1036
+ const confirmCropAndHotspot = ({
1037
+ crop,
1038
+ hotspot
1039
+ }) => {
1040
+ const assetPatches = pendingAsset ? [
1041
+ setIfMissing({ _type: "image" }),
1042
+ set({ _type: "reference", _ref: pendingAsset }, ["asset"])
1043
+ ] : [];
1044
+ onChange([
1045
+ ...assetPatches,
1046
+ set(crop, ["crop"]),
1047
+ ...hotspot ? [set(hotspot, ["hotspot"])] : []
1048
+ ]);
1049
+ setPendingAsset(null);
1050
+ setDialogOpen(false);
1051
+ };
1052
+ const removeImage = () => {
1053
+ const keys = Object.keys(value ?? {});
1054
+ const hasMemberFields = keys.some((key) => !RESERVED_KEYS.has(key));
1055
+ const isArrayElement = typeof path.at(-1) !== "string";
1056
+ const removeKeys = ["asset", "media"].concat(keys.filter((key) => ASSET_BOUND_KEYS.has(key))).map((key) => unset([key]));
1057
+ onChange(hasMemberFields || isArrayElement ? removeKeys : unset());
1058
+ setUpload({ status: "idle" });
1059
+ };
1060
+ const onFilePicked = (event) => {
1061
+ const file = event.target.files?.[0];
1062
+ if (file) {
1063
+ uploadFile(file);
1064
+ }
1065
+ event.target.value = "";
1066
+ };
1067
+ const handlePaste = (event) => {
1068
+ if (readOnly || upload.status === "uploading") {
1069
+ return;
1070
+ }
1071
+ if (isTextEntryTarget(event.target)) {
1072
+ return;
1073
+ }
1074
+ const file = Array.from(event.clipboardData.files).find(
1075
+ (item) => acceptsType(item.type, accept)
1076
+ );
1077
+ if (!file) {
1078
+ return;
1079
+ }
1080
+ event.preventDefault();
1081
+ uploadFile(file);
1082
+ };
1083
+ const dropHandlers = useDropHandlers({
1084
+ disabled: readOnly || upload.status === "uploading",
1085
+ dragDepth,
1086
+ setDraggingOver,
1087
+ onFile: uploadFile
1088
+ });
1089
+ if (!imageSource) {
1090
+ return /* @__PURE__ */ jsx3(Card2, { padding: 4, radius: 2, tone: "critical", border: true, children: /* @__PURE__ */ jsx3(Text2, { size: 1, children: "Image input unavailable: missing project configuration." }) });
1091
+ }
1092
+ return /* @__PURE__ */ jsxs3(Stack2, { gap: 3, ...dropHandlers, onPaste: handlePaste, children: [
1093
+ /* @__PURE__ */ jsx3(
1094
+ "input",
1095
+ {
1096
+ ref: fileInputRef,
1097
+ type: "file",
1098
+ accept,
1099
+ hidden: true,
1100
+ "aria-label": "Upload image",
1101
+ onChange: onFilePicked
1102
+ }
1103
+ ),
1104
+ upload.status === "uploading" ? /* @__PURE__ */ jsx3(UploadingCard, { progress: upload.progress }) : value?.asset ? /* @__PURE__ */ jsx3(
1105
+ ConfirmedPreview,
1106
+ {
1107
+ value,
1108
+ imageSource,
1109
+ config,
1110
+ croppable: Boolean(assetRef && isCroppable(assetRef)),
1111
+ readOnly,
1112
+ draggingOver,
1113
+ onEditCrop: () => {
1114
+ setDialogOpen(true);
1115
+ },
1116
+ onReplace: () => fileInputRef.current?.click(),
1117
+ onRemove: removeImage,
1118
+ selectAffordance: /* @__PURE__ */ jsx3(
1119
+ SelectExistingButton,
1120
+ {
1121
+ sources: assetSources,
1122
+ disabled: readOnly,
1123
+ fontSize: 1,
1124
+ onBrowse: setBrowsingSource
1125
+ }
1126
+ )
1127
+ }
1128
+ ) : /* @__PURE__ */ jsx3(
1129
+ EmptyDropzone,
1130
+ {
1131
+ readOnly,
1132
+ draggingOver,
1133
+ onUploadClick: () => fileInputRef.current?.click(),
1134
+ selectAffordance: /* @__PURE__ */ jsx3(
1135
+ SelectExistingButton,
1136
+ {
1137
+ sources: assetSources,
1138
+ disabled: readOnly,
1139
+ onBrowse: setBrowsingSource
1140
+ }
1141
+ )
1142
+ }
1143
+ ),
1144
+ upload.status === "error" && /* @__PURE__ */ jsx3(Card2, { padding: 3, radius: 2, tone: "critical", border: true, children: /* @__PURE__ */ jsx3(Text2, { size: 1, children: upload.message }) }),
1145
+ customFieldMembers.length > 0 && /* @__PURE__ */ jsx3(
1146
+ ObjectInputMembers,
1147
+ {
1148
+ members: customFieldMembers,
1149
+ ...renderObjectProps
1150
+ }
1151
+ ),
1152
+ browsingSource && /* @__PURE__ */ jsx3(
1153
+ browsingSource.component,
1154
+ {
1155
+ assetSource: browsingSource,
1156
+ action: "select",
1157
+ assetType: "image",
1158
+ accept,
1159
+ selectionType: "single",
1160
+ selectedAssets: [],
1161
+ dialogHeaderTitle: "Select image",
1162
+ onClose: () => {
1163
+ setBrowsingSource(null);
1164
+ },
1165
+ onSelect: selectAsset
1166
+ }
1167
+ ),
1168
+ dialogOpen && activeAssetRef && dialogSource && /* @__PURE__ */ jsx3(
1169
+ CropDialog,
1170
+ {
1171
+ assetRef: activeAssetRef,
1172
+ imageSource,
1173
+ sourceDimensions: dialogSource,
1174
+ config,
1175
+ initialCrop: pendingAsset ? void 0 : assetCrop,
1176
+ hotspotEnabled,
1177
+ initialHotspot: pendingAsset ? void 0 : assetHotspot,
1178
+ onConfirm: confirmCropAndHotspot,
1179
+ onClose: () => {
1180
+ setPendingAsset(null);
1181
+ setDialogOpen(false);
1182
+ }
1183
+ },
1184
+ activeAssetRef
1185
+ )
1186
+ ] });
1187
+ };
1188
+ var UploadingCard = ({ progress }) => /* @__PURE__ */ jsx3(Card2, { padding: 5, radius: 2, border: true, children: /* @__PURE__ */ jsxs3(Flex2, { direction: "column", align: "center", gap: 3, children: [
1189
+ /* @__PURE__ */ jsx3(Spinner, { muted: true }),
1190
+ /* @__PURE__ */ jsxs3(Text2, { size: 1, muted: true, children: [
1191
+ "Uploading\u2026 ",
1192
+ Math.round(progress),
1193
+ "%"
1194
+ ] })
1195
+ ] }) });
1196
+ var EmptyDropzone = ({
1197
+ readOnly,
1198
+ draggingOver,
1199
+ onUploadClick,
1200
+ selectAffordance
1201
+ }) => /* @__PURE__ */ jsx3(
1202
+ Card2,
1203
+ {
1204
+ padding: 5,
1205
+ radius: 2,
1206
+ border: true,
1207
+ tone: draggingOver ? "primary" : "default",
1208
+ style: { borderStyle: "dashed" },
1209
+ children: /* @__PURE__ */ jsxs3(Flex2, { direction: "column", align: "center", gap: 4, children: [
1210
+ /* @__PURE__ */ jsx3(Text2, { size: 4, muted: true, children: /* @__PURE__ */ jsx3(ImageIcon, {}) }),
1211
+ /* @__PURE__ */ jsx3(Stack2, { gap: 2, children: /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, align: "center", children: draggingOver ? "Drop the image to upload" : "Drag and drop an image here, or" }) }),
1212
+ /* @__PURE__ */ jsxs3(Inline, { gap: 2, children: [
1213
+ /* @__PURE__ */ jsx3(
1214
+ Button2,
1215
+ {
1216
+ icon: UploadIcon,
1217
+ text: "Upload",
1218
+ mode: "ghost",
1219
+ disabled: readOnly,
1220
+ onClick: onUploadClick
1221
+ }
1222
+ ),
1223
+ selectAffordance
1224
+ ] })
1225
+ ] })
1226
+ }
1227
+ );
1228
+ var ConfirmedPreview = ({
1229
+ value,
1230
+ imageSource,
1231
+ config,
1232
+ croppable,
1233
+ readOnly,
1234
+ draggingOver,
1235
+ onEditCrop,
1236
+ onReplace,
1237
+ onRemove,
1238
+ selectAffordance
1239
+ }) => {
1240
+ const previewUrl = croppedImageUrl(value, imageSource, 640);
1241
+ const dimensions = croppedDimensionsFromValue(value);
1242
+ const dimensionIssue = dimensions ? getDimensionIssue(dimensions, config) : void 0;
1243
+ return /* @__PURE__ */ jsxs3(Stack2, { gap: 3, children: [
1244
+ /* @__PURE__ */ jsx3(
1245
+ Card2,
1246
+ {
1247
+ radius: 2,
1248
+ border: true,
1249
+ tone: draggingOver ? "primary" : "default",
1250
+ style: { overflow: "hidden" },
1251
+ children: previewUrl ? /* @__PURE__ */ jsx3(Flex2, { justify: "center", children: /* @__PURE__ */ jsx3(
1252
+ "img",
1253
+ {
1254
+ src: previewUrl,
1255
+ alt: "",
1256
+ style: { display: "block", maxWidth: "100%", maxHeight: 300 }
1257
+ }
1258
+ ) }) : /* @__PURE__ */ jsx3(Box2, { padding: 4, children: /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, children: "Preview unavailable." }) })
1259
+ }
1260
+ ),
1261
+ /* @__PURE__ */ jsxs3(Flex2, { justify: "space-between", align: "center", gap: 3, wrap: "wrap", children: [
1262
+ dimensions && /* @__PURE__ */ jsxs3(Text2, { size: 1, muted: true, children: [
1263
+ dimensions.width,
1264
+ " \xD7 ",
1265
+ dimensions.height,
1266
+ " px"
1267
+ ] }),
1268
+ /* @__PURE__ */ jsxs3(Inline, { gap: 2, children: [
1269
+ croppable && /* @__PURE__ */ jsx3(
1270
+ Button2,
1271
+ {
1272
+ icon: CropIcon,
1273
+ text: "Edit crop",
1274
+ mode: "ghost",
1275
+ fontSize: 1,
1276
+ disabled: readOnly,
1277
+ onClick: onEditCrop
1278
+ }
1279
+ ),
1280
+ /* @__PURE__ */ jsx3(
1281
+ Button2,
1282
+ {
1283
+ icon: ImageIcon,
1284
+ text: "Replace",
1285
+ mode: "ghost",
1286
+ fontSize: 1,
1287
+ disabled: readOnly,
1288
+ onClick: onReplace
1289
+ }
1290
+ ),
1291
+ selectAffordance,
1292
+ /* @__PURE__ */ jsx3(
1293
+ Button2,
1294
+ {
1295
+ icon: TrashIcon,
1296
+ text: "Remove",
1297
+ mode: "bleed",
1298
+ tone: "critical",
1299
+ fontSize: 1,
1300
+ disabled: readOnly,
1301
+ onClick: onRemove
1302
+ }
1303
+ )
1304
+ ] })
1305
+ ] }),
1306
+ dimensionIssue && /* @__PURE__ */ jsx3(Card2, { padding: 3, radius: 2, tone: dimensionIssue.tone, border: true, children: /* @__PURE__ */ jsx3(Text2, { size: 1, children: dimensionIssue.message }) })
1307
+ ] });
1308
+ };
1309
+ var SelectExistingButton = ({
1310
+ sources,
1311
+ disabled,
1312
+ fontSize,
1313
+ onBrowse
1314
+ }) => {
1315
+ const [firstSource] = sources;
1316
+ if (!firstSource) {
1317
+ return null;
1318
+ }
1319
+ if (sources.length === 1) {
1320
+ return /* @__PURE__ */ jsx3(
1321
+ Button2,
1322
+ {
1323
+ icon: ImagesIcon,
1324
+ text: "Select existing",
1325
+ mode: "ghost",
1326
+ fontSize,
1327
+ disabled,
1328
+ onClick: () => {
1329
+ onBrowse(firstSource);
1330
+ }
1331
+ }
1332
+ );
1333
+ }
1334
+ return /* @__PURE__ */ jsx3(
1335
+ MenuButton,
1336
+ {
1337
+ id: "image-field-select-existing",
1338
+ button: /* @__PURE__ */ jsx3(
1339
+ Button2,
1340
+ {
1341
+ icon: ImagesIcon,
1342
+ text: "Select existing",
1343
+ mode: "ghost",
1344
+ fontSize,
1345
+ disabled
1346
+ }
1347
+ ),
1348
+ menu: /* @__PURE__ */ jsx3(Menu, { children: sources.map((source) => /* @__PURE__ */ jsx3(
1349
+ MenuItem,
1350
+ {
1351
+ text: source.title ?? source.name,
1352
+ icon: source.icon,
1353
+ fontSize,
1354
+ onClick: () => {
1355
+ onBrowse(source);
1356
+ }
1357
+ },
1358
+ source.name
1359
+ )) })
1360
+ }
1361
+ );
1362
+ };
1363
+ var ASSET_MANAGED_FIELDS = /* @__PURE__ */ new Set(["asset", "media", "crop", "hotspot"]);
1364
+ var isCustomFieldMember = (member) => member.kind !== "field" || !ASSET_MANAGED_FIELDS.has(member.name);
1365
+ var RESERVED_KEYS = /* @__PURE__ */ new Set([
1366
+ "_type",
1367
+ "_key",
1368
+ "_upload",
1369
+ "asset",
1370
+ "crop",
1371
+ "hotspot",
1372
+ "media"
1373
+ ]);
1374
+ var ASSET_BOUND_KEYS = /* @__PURE__ */ new Set(["crop", "hotspot", "_upload"]);
1375
+ var getDimensionIssue = (dimensions, config) => {
1376
+ const { requiredDimensions, recommendedDimensions } = config;
1377
+ if (requiredDimensions && !dimensionsAtLeast(dimensions, requiredDimensions)) {
1378
+ return {
1379
+ tone: "critical",
1380
+ message: `Cropped image is below the required ${requiredDimensions.width} \xD7 ${requiredDimensions.height} px. Edit the crop to enlarge it.`
1381
+ };
1382
+ }
1383
+ if (recommendedDimensions && !dimensionsAtLeast(dimensions, recommendedDimensions)) {
1384
+ return {
1385
+ tone: "caution",
1386
+ message: `Cropped image is below the recommended ${recommendedDimensions.width} \xD7 ${recommendedDimensions.height} px.`
1387
+ };
1388
+ }
1389
+ return void 0;
1390
+ };
1391
+ var hasFiles = (event) => Array.from(event.dataTransfer.types).includes("Files");
1392
+ var useDropHandlers = ({
1393
+ disabled,
1394
+ dragDepth,
1395
+ setDraggingOver,
1396
+ onFile
1397
+ }) => {
1398
+ return {
1399
+ onDragEnter: (event) => {
1400
+ if (disabled || !hasFiles(event)) {
1401
+ return;
1402
+ }
1403
+ event.preventDefault();
1404
+ dragDepth.current += 1;
1405
+ setDraggingOver(true);
1406
+ },
1407
+ onDragOver: (event) => {
1408
+ if (disabled || !hasFiles(event)) {
1409
+ return;
1410
+ }
1411
+ event.preventDefault();
1412
+ event.dataTransfer.dropEffect = "copy";
1413
+ },
1414
+ onDragLeave: (event) => {
1415
+ if (disabled) {
1416
+ return;
1417
+ }
1418
+ event.preventDefault();
1419
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
1420
+ if (dragDepth.current === 0) {
1421
+ setDraggingOver(false);
1422
+ }
1423
+ },
1424
+ onDrop: (event) => {
1425
+ if (disabled) {
1426
+ return;
1427
+ }
1428
+ event.preventDefault();
1429
+ dragDepth.current = 0;
1430
+ setDraggingOver(false);
1431
+ const file = event.dataTransfer.files.item(0);
1432
+ if (file) {
1433
+ onFile(file);
1434
+ }
1435
+ }
1436
+ };
1437
+ };
1438
+
1439
+ // src/defineImageField.ts
1440
+ import { defineField } from "sanity";
1441
+ var defineImageField = ({
1442
+ aspectRatio,
1443
+ recommendedDimensions,
1444
+ requiredDimensions,
1445
+ options,
1446
+ validation,
1447
+ ...overrides
1448
+ }) => {
1449
+ if (aspectRatio !== void 0) {
1450
+ validateAspectRatio(aspectRatio);
1451
+ }
1452
+ const imageField = {
1453
+ aspectRatio,
1454
+ recommendedDimensions,
1455
+ requiredDimensions
1456
+ };
1457
+ return defineField({
1458
+ ...overrides,
1459
+ type: "image",
1460
+ options: { ...options, imageField },
1461
+ components: { input: ImageFieldInput },
1462
+ validation: (rule, context) => {
1463
+ const rules = [];
1464
+ if (requiredDimensions) {
1465
+ rules.push(
1466
+ rule.custom(
1467
+ (value) => validateCroppedSize(value, requiredDimensions, "error")
1468
+ )
1469
+ );
1470
+ }
1471
+ if (recommendedDimensions) {
1472
+ rules.push(
1473
+ rule.custom(
1474
+ (value) => validateCroppedSize(value, recommendedDimensions, "warning")
1475
+ ).warning()
1476
+ );
1477
+ }
1478
+ if (validation) {
1479
+ const userRules = validation(rule, context);
1480
+ rules.push(...Array.isArray(userRules) ? userRules : [userRules]);
1481
+ }
1482
+ return rules;
1483
+ }
1484
+ });
1485
+ };
1486
+ var validateCroppedSize = (value, threshold, level) => {
1487
+ const dimensions = croppedDimensionsFromValue(value);
1488
+ if (!dimensions || dimensionsAtLeast(dimensions, threshold)) {
1489
+ return true;
1490
+ }
1491
+ const verb = level === "warning" ? "should" : "must";
1492
+ const suffix = level === "warning" ? " for best results" : "";
1493
+ return `Cropped image ${verb} be at least ${threshold.width} \xD7 ${threshold.height} px${suffix}`;
1494
+ };
1495
+
1496
+ // src/formComponents.tsx
1497
+ import "sanity";
1498
+ import { jsx as jsx4 } from "react/jsx-runtime";
1499
+ var isImageInput = (props) => {
1500
+ for (let current = props.schemaType; current; current = current.type ?? void 0) {
1501
+ if (current.name === "image") {
1502
+ return true;
1503
+ }
1504
+ }
1505
+ return false;
1506
+ };
1507
+ var imageFieldFormInput = (props) => isImageInput(props) ? /* @__PURE__ */ jsx4(ImageFieldInput, { ...props }) : props.renderDefault(props);
1508
+ export {
1509
+ ImageFieldInput,
1510
+ defineImageField,
1511
+ imageFieldFormInput
1512
+ };
1513
+ //# sourceMappingURL=index.js.map