@enigmax/primitives 0.24.0 → 0.26.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 (59) hide show
  1. package/dist/chunk-4SZ5N7BA.js +121 -0
  2. package/dist/chunk-EYZ366LP.js +189 -0
  3. package/dist/chunk-ISXB5RUH.js +380 -0
  4. package/dist/chunk-JJVZYW5C.js +946 -0
  5. package/dist/chunk-KVLSOQTI.js +66 -0
  6. package/dist/chunk-QIIV2QSV.js +108 -0
  7. package/dist/{chunk-QJJ34E4G.js → chunk-RMNXVWQ4.js} +32 -15
  8. package/dist/chunk-RUEQ3TGP.js +155 -0
  9. package/dist/clipboard-menu-BvXJCSFN.d.ts +93 -0
  10. package/dist/{color-OPV3BJV6.js → color-T63FLJNH.js} +183 -120
  11. package/dist/context-BfjYGPnH.d.ts +54 -0
  12. package/dist/{clipboard-menu-B_ouitfS.d.ts → context-menu-D3FtTn7v.d.ts} +1 -91
  13. package/dist/{index-ZPvlf9vs.d.ts → index-qXOkYQCU.d.ts} +4 -0
  14. package/dist/index.d.ts +266 -2
  15. package/dist/index.js +4 -2
  16. package/dist/menu-MSQSE6U5.js +54 -0
  17. package/dist/menu-PNQRTRNV.js +33 -0
  18. package/dist/next/index.d.ts +7 -3
  19. package/dist/next/index.js +11 -7
  20. package/dist/react/context-menu.d.ts +8 -54
  21. package/dist/react/image.d.ts +109 -0
  22. package/dist/react/image.js +3 -0
  23. package/dist/react/index.d.ts +8 -4
  24. package/dist/react/index.js +11 -7
  25. package/dist/react/input.d.ts +1 -1
  26. package/dist/react/input.js +2 -2
  27. package/dist/react/video.d.ts +120 -0
  28. package/dist/react/video.js +3 -0
  29. package/dist/react-router/index.d.ts +7 -3
  30. package/dist/react-router/index.js +11 -7
  31. package/dist/viewer-73LSE6RR.js +3 -0
  32. package/package.json +15 -2
  33. package/recipes/color/styles.css +20 -0
  34. package/recipes/image/styles.css +182 -0
  35. package/recipes/video/styles.css +154 -0
  36. package/registry.json +477 -3
  37. package/src/core/image-viewer.ts +222 -0
  38. package/src/core/player.ts +303 -0
  39. package/src/index.ts +52 -0
  40. package/src/react/image/icons.tsx +55 -0
  41. package/src/react/image/index.tsx +185 -0
  42. package/src/react/image/menu.tsx +76 -0
  43. package/src/react/image/styles.ts +213 -0
  44. package/src/react/image/types.ts +113 -0
  45. package/src/react/image/viewer.tsx +571 -0
  46. package/src/react/index.ts +3 -0
  47. package/src/react/input/color-styles.ts +20 -0
  48. package/src/react/input/color-swatch.tsx +96 -0
  49. package/src/react/input/color.tsx +84 -23
  50. package/src/react/input/index.tsx +27 -4
  51. package/src/react/input/types.ts +4 -0
  52. package/src/react/video/icons.tsx +135 -0
  53. package/src/react/video/index.tsx +724 -0
  54. package/src/react/video/menu.tsx +66 -0
  55. package/src/react/video/rail.tsx +99 -0
  56. package/src/react/video/styles.ts +161 -0
  57. package/src/react/video/types.ts +135 -0
  58. package/dist/chunk-S7EE57YB.js +0 -9
  59. /package/dist/{chunk-MSOCCQGH.js → chunk-T7I6WJSN.js} +0 -0
@@ -0,0 +1,571 @@
1
+ "use client";
2
+
3
+ import { createPortal } from "react-dom";
4
+ import * as icons from "@/react/image/icons";
5
+ import * as viewer from "@/core/image-viewer";
6
+ import { injectImageStyles } from "@/react/image/styles";
7
+ import type { ImageItem, ImageLabels, ImageMenuOptions, ZoomOptions } from "@/react/image/types";
8
+ import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
9
+
10
+ /**
11
+ * The lightbox: the picture, what moves it, and what the toolbar offers.
12
+ *
13
+ * Its own chunk, fetched when a viewer is first opened, so a page of images that nobody
14
+ * enlarges downloads none of it. What the page renders - the thumbnail and the press that
15
+ * opens this - stays in the base module, because a control that arrives with its chunk is a
16
+ * control that is missing for as long as the network takes.
17
+ *
18
+ * The panel is PORTALLED to the body, unlike the colour picker's, which hangs off its field.
19
+ * A lightbox covers the window: left in place it would be clipped by the first ancestor with
20
+ * `overflow: hidden`, and stacked under anything the page gave a `z-index` to.
21
+ */
22
+
23
+ const ImageMenu = lazy(() => import("@/react/image/menu").then((module) => ({ default: module.ImageMenu })));
24
+
25
+ export interface ImageViewerProps {
26
+ items: readonly ImageItem[];
27
+ index: number;
28
+ onIndex: (index: number) => void;
29
+ onClose: () => void;
30
+ zoom: Required<Pick<ZoomOptions, "min" | "max" | "wheel" | "doubleClick">> | null;
31
+ navigation: boolean;
32
+ thumbnails: boolean;
33
+ menu: ImageMenuOptions | null;
34
+ discardable: boolean;
35
+ onDiscard?: (item: ImageItem, index: number) => void;
36
+ loop: boolean;
37
+ caption?: ReactNode;
38
+ /**
39
+ * Where the picture is in the PAGE, read at the moment it is needed.
40
+ *
41
+ * A getter rather than a rectangle: the flight back happens whenever the viewer is closed,
42
+ * and by then the page may have scrolled, reflowed, or be showing a different image of the
43
+ * set. A rectangle captured on open would fly the picture at where the thumbnail used to be.
44
+ */
45
+ origin?: () => DOMRect | null;
46
+ /** Fly in and out of the thumbnail. Off leaves the dialog appearing outright. */
47
+ animate: boolean;
48
+ styles: boolean;
49
+ labels: ImageLabels;
50
+ }
51
+
52
+ export function ImageViewer({
53
+ items,
54
+ index,
55
+ onIndex,
56
+ onClose,
57
+ zoom,
58
+ navigation,
59
+ thumbnails,
60
+ menu,
61
+ discardable,
62
+ onDiscard,
63
+ loop,
64
+ caption,
65
+ origin,
66
+ animate,
67
+ styles,
68
+ labels
69
+ }: ImageViewerProps): ReactNode {
70
+ // Before paint: a sheet applied after the first frame shows the dialog undressed first.
71
+ useLayoutEffect(() => { if (styles) injectImageStyles(); }, [styles]);
72
+
73
+ const frameRef = useRef<HTMLDivElement | null>(null);
74
+ const stripRef = useRef<HTMLDivElement | null>(null);
75
+ const imageRef = useRef<HTMLImageElement | null>(null);
76
+
77
+ const [transform, setTransform] = useState<viewer.Transform>(viewer.IDENTITY);
78
+ const [loading, setLoading] = useState(true);
79
+ const [panning, setPanning] = useState(false);
80
+ /**
81
+ * Where the dialog is in its own life.
82
+ *
83
+ * "measuring" is the beat before the picture can be drawn at all: the dialog is up, and
84
+ * where the picture is going to land is not known until it has been laid out. Then it
85
+ * flies, then it sits there, then it flies home.
86
+ *
87
+ * It starts at "open" whenever there is nothing to fly from - no origin, no animation
88
+ * asked for, or a reader who has asked for less movement - so every path below has a
89
+ * viewer that simply exists, and the flight is the decoration on top of it.
90
+ *
91
+ * The phases are four rather than three because "no flight yet" and "the flight has been
92
+ * released" are not the same state and cannot share one: written as `flight === null` they
93
+ * did, and the picture was hidden for the whole animation.
94
+ */
95
+ const [phase, setPhase] = useState<"measuring" | "flying" | "open" | "closing">(() => (
96
+ animate && origin && !viewer.prefersReducedMotion() ? "measuring" : "open"
97
+ ));
98
+ /** The transform that holds the picture ON the thumbnail, for the frame before it is let go. */
99
+ const [flight, setFlight] = useState<viewer.Transform | null>(null);
100
+ /**
101
+ * What has been discarded, for as long as this viewer is open.
102
+ *
103
+ * The list belongs to the CALLER: `onDiscard` is how they drop it from their own state,
104
+ * and a set that lived past the dialog would fight whatever they did with it. So the
105
+ * skipping is a session, and reopening starts from whatever they now pass in.
106
+ */
107
+ const [discarded, setDiscarded] = useState<ReadonlySet<number>>(() => new Set());
108
+
109
+ const limits = useMemo(() => ({ min: zoom?.min ?? viewer.ZOOM_LIMITS.min, max: zoom?.max ?? viewer.ZOOM_LIMITS.max }), [zoom]);
110
+ const item = items[index];
111
+ const zoomed = transform.scale > limits.min;
112
+
113
+ const remaining = items.length - discarded.size;
114
+ const position = useMemo(() => {
115
+ let seen = 0;
116
+ for (let at = 0; at <= index && at < items.length; at += 1) if (!discarded.has(at)) seen += 1;
117
+ return seen;
118
+ }, [index, items.length, discarded]);
119
+
120
+ /* -------- the flight between the thumbnail and the frame -------- */
121
+
122
+ /**
123
+ * Where the picture SITS, ignoring whatever transform it is carrying.
124
+ *
125
+ * `getBoundingClientRect` would report the zoomed, panned box, which is useless for
126
+ * computing the transform that lands it somewhere. `offsetLeft`/`offsetWidth` are layout,
127
+ * so they are the same number mid-zoom as they are at rest, and the frame - which carries
128
+ * no transform of its own - anchors them to the viewport.
129
+ */
130
+ const layoutBox = useCallback((): viewer.Box | null => {
131
+ const frame = frameRef.current;
132
+ const element = imageRef.current;
133
+ if (!frame || !element?.offsetWidth) return null;
134
+ const box = frame.getBoundingClientRect();
135
+ return { left: box.left + element.offsetLeft, top: box.top + element.offsetTop, width: element.offsetWidth, height: element.offsetHeight };
136
+ }, []);
137
+
138
+ /**
139
+ * Close, flying the picture back onto the thumbnail it came from.
140
+ *
141
+ * Every dismissal goes through here - Escape, the button, a press on the backdrop, the
142
+ * last image being discarded - so there is one place where the dialog can be leaving, and
143
+ * `onClose` is called once, at the end of it.
144
+ */
145
+ const requestClose = useCallback(() => {
146
+ if (phase === "closing" || phase === "measuring") return onClose();
147
+ const from = origin?.();
148
+ const to = layoutBox();
149
+ const back = animate && !viewer.prefersReducedMotion() && from && to ? viewer.flightFrom(from, to) : null;
150
+ if (!back) return onClose();
151
+ setPhase("closing");
152
+ setFlight(back);
153
+ }, [phase, origin, layoutBox, animate, onClose]);
154
+
155
+ /* -------- moving through the set -------- */
156
+
157
+ const go = useCallback((step: number) => {
158
+ const next = viewer.nextIndex(index, items.length, step, { loop, skip: discarded });
159
+ if (next === -1 || next === index) return;
160
+ onIndex(next);
161
+ }, [index, items.length, loop, discarded, onIndex]);
162
+
163
+ const showAt = useCallback((next: number) => {
164
+ if (next === index || discarded.has(next)) return;
165
+ onIndex(next);
166
+ }, [index, discarded, onIndex]);
167
+
168
+ const discard = useCallback(() => {
169
+ if (!discardable || !item) return;
170
+ const next = viewer.nextIndex(index, items.length, 1, { loop: true, skip: new Set([...discarded, index]) });
171
+ setDiscarded((current) => new Set(current).add(index));
172
+ onDiscard?.(item, index);
173
+ // Nothing left to show: an empty frame is not a viewer, so it closes rather than
174
+ // sitting there with the toolbar over a black rectangle.
175
+ if (next === -1) requestClose();
176
+ else onIndex(next);
177
+ }, [discardable, item, index, items.length, discarded, onDiscard, onIndex, requestClose]);
178
+
179
+ /* -------- zoom and pan -------- */
180
+
181
+ const reset = useCallback(() => setTransform(viewer.IDENTITY), []);
182
+
183
+ // A different picture is a different frame: keeping the last one's zoom would open the
184
+ // next image already halfway into a corner of it.
185
+ useEffect(() => {
186
+ setTransform(viewer.IDENTITY);
187
+ setLoading(true);
188
+ }, [item?.src]);
189
+
190
+ const frameBox = useCallback((): viewer.Box | null => {
191
+ const box = frameRef.current?.getBoundingClientRect();
192
+ return box ? { left: box.left, top: box.top, width: box.width, height: box.height } : null;
193
+ }, []);
194
+
195
+ const bounded = useCallback((next: viewer.Transform): viewer.Transform => {
196
+ const box = frameBox();
197
+ const element = imageRef.current;
198
+ if (!box || !element) return next;
199
+ const fitted = viewer.fittedSize({ width: element.naturalWidth, height: element.naturalHeight }, box);
200
+ return viewer.clampPan(next, box, fitted);
201
+ }, [frameBox]);
202
+
203
+ const zoomBy = useCallback((factor: number, point?: { x: number; y: number; }) => {
204
+ const box = frameBox();
205
+ if (!box) return;
206
+ const at = point ?? { x: box.left + box.width / 2, y: box.top + box.height / 2 };
207
+ setTransform((current) => bounded(viewer.zoomAt(current, factor, at, box, limits)));
208
+ }, [frameBox, bounded, limits]);
209
+
210
+ /**
211
+ * The wheel, bound by hand because React's `onWheel` is passive.
212
+ *
213
+ * A passive listener cannot call `preventDefault`, so every notch would zoom the image AND
214
+ * scroll the page behind the dialog.
215
+ */
216
+ useEffect(() => {
217
+ const element = frameRef.current;
218
+ if (!element || !zoom?.wheel) return;
219
+ const onWheel = (event: WheelEvent): void => {
220
+ event.preventDefault();
221
+ zoomBy(viewer.wheelFactor(event.deltaY, event.deltaMode), { x: event.clientX, y: event.clientY });
222
+ };
223
+ element.addEventListener("wheel", onWheel, { passive: false });
224
+ return () => element.removeEventListener("wheel", onWheel);
225
+ }, [zoom?.wheel, zoomBy]);
226
+
227
+ /**
228
+ * One press, and everything until the last finger comes up.
229
+ *
230
+ * Two pointers are a pinch and one is a drag, tracked in the same place because the
231
+ * gestures interrupt each other: a second finger has to stop the pan rather than fight it.
232
+ */
233
+ const pointers = useRef(new Map<number, { x: number; y: number; }>());
234
+ const pinch = useRef<{ distance: number; scale: number; } | null>(null);
235
+
236
+ /** Where a press on the empty part of the frame started, so a drag is not read as a click. */
237
+ const backdropPress = useRef<{ x: number; y: number; } | null>(null);
238
+
239
+ const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
240
+ if (event.button !== 0 && event.pointerType === "mouse") return;
241
+ const element = event.currentTarget;
242
+ // The dark area beside the picture IS the backdrop, whatever element it belongs to:
243
+ // pressing it is how every lightbox is closed, and a viewer that only answers the few
244
+ // pixels outside the frame is one people press twice and then hunt for the X.
245
+ backdropPress.current = event.target === element && !zoomed ? { x: event.clientX, y: event.clientY } : null;
246
+ pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
247
+ if (pointers.current.size === 2) {
248
+ const [a, b] = [...pointers.current.values()];
249
+ pinch.current = { distance: viewer.pinchDistance(a, b), scale: transform.scale };
250
+ setPanning(false);
251
+ return;
252
+ }
253
+ if (!zoomed) return;
254
+ event.preventDefault();
255
+ setPanning(true);
256
+ try { element.setPointerCapture(event.pointerId); } catch { /* a pointer already gone */ }
257
+ }, [zoomed, transform.scale]);
258
+
259
+ const onPointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
260
+ const previous = pointers.current.get(event.pointerId);
261
+ if (!previous) return;
262
+ pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
263
+
264
+ if (pointers.current.size === 2 && pinch.current) {
265
+ const [a, b] = [...pointers.current.values()];
266
+ const distance = viewer.pinchDistance(a, b);
267
+ if (!pinch.current.distance) return;
268
+ const box = frameBox();
269
+ if (!box) return;
270
+ const factor = (distance / pinch.current.distance) * (pinch.current.scale / transform.scale);
271
+ setTransform((current) => bounded(viewer.zoomAt(current, factor, { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, box, limits)));
272
+ return;
273
+ }
274
+
275
+ if (!panning) return;
276
+ setTransform((current) => bounded({ ...current, x: current.x + (event.clientX - previous.x), y: current.y + (event.clientY - previous.y) }));
277
+ }, [panning, bounded, frameBox, limits, transform.scale]);
278
+
279
+ const endPointer = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
280
+ pointers.current.delete(event.pointerId);
281
+ if (pointers.current.size < 2) pinch.current = null;
282
+ if (pointers.current.size === 0) setPanning(false);
283
+
284
+ const started = backdropPress.current;
285
+ backdropPress.current = null;
286
+ if (!started || event.target !== event.currentTarget) return;
287
+ // A drag that happens to end on the backdrop is not a press on it: 4px of slop, which
288
+ // is what a hand resting on a trackpad moves.
289
+ if (Math.abs(event.clientX - started.x) < 4 && Math.abs(event.clientY - started.y) < 4) requestClose();
290
+ }, [requestClose]);
291
+
292
+ /**
293
+ * The flight in.
294
+ *
295
+ * FLIP, and in that order: the full-size picture is laid out where it belongs, measured,
296
+ * then drawn back ON the thumbnail and released. Reversing it - animating a small image up
297
+ * - would need the destination size before layout has produced it.
298
+ *
299
+ * It waits for the picture to have a size. A flight measured before the image decodes flies
300
+ * from the thumbnail into a box of nothing, and the usual case is not slow: the viewer
301
+ * opens on an image the page has already loaded, so `complete` is true on the first pass.
302
+ */
303
+ useLayoutEffect(() => {
304
+ if (phase !== "measuring") return;
305
+ const element = imageRef.current;
306
+ const from = origin?.();
307
+ if (!element || !from) {
308
+ setPhase("open");
309
+ return;
310
+ }
311
+ // Not ready: this effect runs again when `loading` turns over, which is the load event
312
+ // the picture is waiting on.
313
+ if (!element.complete) return;
314
+
315
+ const to = layoutBox();
316
+ const start = to ? viewer.flightFrom(from, to) : null;
317
+ if (!start) {
318
+ setPhase("open");
319
+ return;
320
+ }
321
+
322
+ setFlight(start);
323
+ /**
324
+ * Released two frames later, and it has to be two.
325
+ *
326
+ * A layout effect commits its own state change before the browser paints, and a
327
+ * callback booked from there runs at the START of the next frame - still before that
328
+ * paint. Releasing there means the picture is never once drawn on the thumbnail, the
329
+ * browser sees identity to identity, and there is no transition at all: the viewer
330
+ * simply appears. The second frame is what guarantees the first position was painted.
331
+ *
332
+ * Both go in one update: the frame that lets the transform go is the frame the picture
333
+ * becomes visible in, or the flight happens behind a hidden element.
334
+ */
335
+ let second = 0;
336
+ const first = requestAnimationFrame(() => {
337
+ second = requestAnimationFrame(() => {
338
+ setFlight(null);
339
+ setPhase("flying");
340
+ });
341
+ });
342
+ return () => {
343
+ cancelAnimationFrame(first);
344
+ cancelAnimationFrame(second);
345
+ };
346
+ }, [phase, origin, layoutBox, loading]);
347
+
348
+ /**
349
+ * The end of either flight.
350
+ *
351
+ * The timer is not belt and braces: a transform that happens to equal the one already on
352
+ * the element fires no `transitionend` at all, and a dialog left in "closing" would be a
353
+ * lightbox that never goes away.
354
+ */
355
+ useEffect(() => {
356
+ if (phase !== "flying" && phase !== "closing") return;
357
+ const element = imageRef.current;
358
+ const done = (event?: TransitionEvent): void => {
359
+ if (event && event.propertyName !== "transform") return;
360
+ if (phase === "closing") onClose();
361
+ else setPhase("open");
362
+ };
363
+ element?.addEventListener("transitionend", done);
364
+ const timer = window.setTimeout(() => done(), viewer.flightMs(element) + 80);
365
+ return () => {
366
+ element?.removeEventListener("transitionend", done);
367
+ window.clearTimeout(timer);
368
+ };
369
+ }, [phase, onClose]);
370
+
371
+ /* -------- the keyboard, which is the same viewer by other means -------- */
372
+
373
+ useEffect(() => {
374
+ const onKeyDown = (event: KeyboardEvent): void => {
375
+ if (event.defaultPrevented) return;
376
+ const key = event.key;
377
+
378
+ if (key === "Escape") return void (event.preventDefault(), requestClose());
379
+ if (navigation && (key === "ArrowRight" || key === "ArrowLeft") && !zoomed) {
380
+ event.preventDefault();
381
+ return go(key === "ArrowRight" ? 1 : -1);
382
+ }
383
+ if (zoom && (key === "+" || key === "=")) return void (event.preventDefault(), zoomBy(viewer.ZOOM_STEP));
384
+ if (zoom && key === "-") return void (event.preventDefault(), zoomBy(1 / viewer.ZOOM_STEP));
385
+ if (zoom && key === "0") return void (event.preventDefault(), reset());
386
+ // Panning with the arrows, once there is somewhere to pan to.
387
+ if (zoomed && key.startsWith("Arrow")) {
388
+ event.preventDefault();
389
+ const step = event.shiftKey ? 120 : 40;
390
+ const by = key === "ArrowLeft" ? [step, 0] : key === "ArrowRight" ? [-step, 0] : key === "ArrowUp" ? [0, step] : [0, -step];
391
+ return setTransform((current) => bounded({ ...current, x: current.x + by[0], y: current.y + by[1] }));
392
+ }
393
+ if (discardable && (key === "Delete" || key === "Backspace")) return void (event.preventDefault(), discard());
394
+ };
395
+
396
+ document.addEventListener("keydown", onKeyDown);
397
+ return () => document.removeEventListener("keydown", onKeyDown);
398
+ }, [requestClose, navigation, zoom, zoomed, go, zoomBy, reset, bounded, discardable, discard]);
399
+
400
+ /* -------- what a dialog owes the page it covers -------- */
401
+
402
+ useEffect(() => {
403
+ frameRef.current?.focus();
404
+ const { body } = document;
405
+ const overflow = body.style.overflow;
406
+ // The page behind must not scroll under the backdrop: a wheel over the dialog's own
407
+ // margins would otherwise move the article nobody can see.
408
+ body.style.overflow = "hidden";
409
+ return () => { body.style.overflow = overflow; };
410
+ }, []);
411
+
412
+ // The strip follows the picture, or the current thumbnail walks off the end of a long
413
+ // gallery and the only way back is to drag the row.
414
+ useEffect(() => {
415
+ if (!thumbnails) return;
416
+ stripRef.current?.querySelector<HTMLElement>("[aria-current=true]")?.scrollIntoView({ block: "nearest", inline: "center" });
417
+ }, [thumbnails, index]);
418
+
419
+ if (!item) return null;
420
+
421
+ // The flight wins while there is one: it is the same transform channel, and the pan the
422
+ // reader left behind is where the picture flies back FROM.
423
+ const drawn = flight ?? transform;
424
+ const style = { transform: `translate(${drawn.x}px, ${drawn.y}px) scale(${drawn.scale})` } satisfies CSSProperties;
425
+ const counter = (labels.counter ?? "{index} of {total}").replace("{index}", String(position)).replace("{total}", String(remaining));
426
+
427
+ return createPortal(
428
+ <div
429
+ data-enigma-image-viewer=""
430
+ // Three states outside, four inside: "measuring" and "flying" are one thing to a
431
+ // stylesheet - the dialog is arriving - and splitting them in the theme would make
432
+ // an internal beat part of the API.
433
+ data-state={phase === "measuring" || phase === "flying" ? "opening" : phase}
434
+ role="dialog"
435
+ aria-modal="true"
436
+ aria-label={labels.viewer ?? "Image viewer"}
437
+ // A press on the backdrop closes; one that started on the picture does not, or a
438
+ // drag that ends outside the image would shut the viewer mid-pan.
439
+ onPointerDown={(event) => { if (event.target === event.currentTarget) requestClose(); }}
440
+ >
441
+ <div data-enigma-image-bar="">
442
+ {navigation && <span data-enigma-image-counter="">{counter}</span>}
443
+ {!navigation && <span data-enigma-image-counter="" />}
444
+
445
+ {zoom && (
446
+ <>
447
+ <button type="button" data-enigma-image-button="" aria-label={labels.zoomOut ?? "Zoom out"} title={labels.zoomOut ?? "Zoom out"} disabled={transform.scale <= limits.min} onClick={() => zoomBy(1 / viewer.ZOOM_STEP)}>
448
+ <icons.Minus />
449
+ </button>
450
+ <button type="button" data-enigma-image-button="" aria-label={labels.zoomIn ?? "Zoom in"} title={labels.zoomIn ?? "Zoom in"} disabled={transform.scale >= limits.max} onClick={() => zoomBy(viewer.ZOOM_STEP)}>
451
+ <icons.Plus />
452
+ </button>
453
+ </>
454
+ )}
455
+
456
+ {discardable && (
457
+ <button type="button" data-enigma-image-button="" aria-label={labels.discard ?? "Discard this image"} title={labels.discard ?? "Discard this image"} onClick={discard}>
458
+ <icons.Trash />
459
+ </button>
460
+ )}
461
+
462
+ {menu && (
463
+ <Suspense fallback={<MenuButton label={labels.menu ?? "More"} onPress={undefined} />}>
464
+ <ImageMenu item={item} index={index} options={menu} labels={labels} />
465
+ </Suspense>
466
+ )}
467
+
468
+ <button type="button" data-enigma-image-button="" aria-label={labels.close ?? "Close"} title={labels.close ?? "Close"} onClick={requestClose}>
469
+ <icons.Close />
470
+ </button>
471
+ </div>
472
+
473
+ <div
474
+ ref={frameRef}
475
+ data-enigma-image-frame=""
476
+ data-zoom={zoom ? "" : undefined}
477
+ // "pending" is the beat before the picture can be drawn anywhere sensible.
478
+ // Drawing during it would put one frame of the full-size image where it is
479
+ // going to LAND, and the flight would then start by jumping back to the
480
+ // thumbnail.
481
+ data-flying={phase === "open" ? undefined : phase === "measuring" ? "pending" : "moving"}
482
+ data-zoomed={zoomed ? "" : undefined}
483
+ data-panning={panning ? "" : undefined}
484
+ data-loading={loading ? "" : undefined}
485
+ tabIndex={-1}
486
+ onPointerDown={onPointerDown}
487
+ onPointerMove={onPointerMove}
488
+ onPointerUp={endPointer}
489
+ onPointerCancel={endPointer}
490
+ onDoubleClick={(event) => {
491
+ if (!zoom?.doubleClick || phase !== "open") return;
492
+ if (zoomed) return reset();
493
+ zoomBy(viewer.ZOOM_DOUBLE / transform.scale, { x: event.clientX, y: event.clientY });
494
+ }}
495
+ >
496
+ <img
497
+ ref={imageRef}
498
+ // Keyed on the source: without it React keeps the previous <img> element,
499
+ // which means the next picture arrives with the last one's `complete` and
500
+ // the spinner never shows.
501
+ key={item.src}
502
+ src={item.src}
503
+ alt={item.alt ?? ""}
504
+ style={style}
505
+ draggable={false}
506
+ onLoad={() => setLoading(false)}
507
+ onError={() => setLoading(false)}
508
+ />
509
+ {loading && <span data-enigma-image-spinner="" role="progressbar" aria-label={labels.viewer ?? "Image viewer"} />}
510
+
511
+ {navigation && remaining > 1 && (
512
+ <>
513
+ <button type="button" data-enigma-image-button="" data-enigma-image-nav="previous" aria-label={labels.previous ?? "Previous image"} title={labels.previous ?? "Previous image"} onClick={() => go(-1)}>
514
+ <icons.ChevronLeft />
515
+ </button>
516
+ <button type="button" data-enigma-image-button="" data-enigma-image-nav="next" aria-label={labels.next ?? "Next image"} title={labels.next ?? "Next image"} onClick={() => go(1)}>
517
+ <icons.ChevronRight />
518
+ </button>
519
+ </>
520
+ )}
521
+ </div>
522
+
523
+ <div data-enigma-image-foot="">
524
+ {(item.caption ?? caption) && <p data-enigma-image-caption="">{item.caption ?? caption}</p>}
525
+
526
+ {thumbnails && remaining > 1 && (
527
+ <div ref={stripRef} data-enigma-image-strip="" role="tablist" aria-label={labels.thumbnails ?? "Images"}>
528
+ {items.map((entry, at) => discarded.has(at) ? null : (
529
+ <button
530
+ key={entry.src}
531
+ type="button"
532
+ role="tab"
533
+ data-enigma-image-thumb=""
534
+ aria-current={at === index}
535
+ aria-selected={at === index}
536
+ aria-label={entry.alt ?? `${at + 1}`}
537
+ onClick={() => showAt(at)}
538
+ >
539
+ <img src={entry.thumbnail ?? entry.src} alt="" loading="lazy" draggable={false} />
540
+ </button>
541
+ ))}
542
+ </div>
543
+ )}
544
+ </div>
545
+ </div>,
546
+ document.body
547
+ );
548
+ }
549
+
550
+ /**
551
+ * The three dots, on its own so the fallback and the loaded menu draw the same button.
552
+ *
553
+ * `onPress` is absent while the menu's code is in flight: the button is there, and pressing
554
+ * it does nothing rather than moving under the pointer once the chunk lands.
555
+ */
556
+ export function MenuButton({ label, onPress, expanded }: { label: string; onPress?: () => void; expanded?: boolean; }): ReactNode {
557
+ return (
558
+ <button
559
+ type="button"
560
+ data-enigma-image-button=""
561
+ data-enigma-image-menu=""
562
+ aria-haspopup="menu"
563
+ aria-expanded={expanded ?? false}
564
+ aria-label={label}
565
+ title={label}
566
+ onClick={onPress}
567
+ >
568
+ <icons.Dots />
569
+ </button>
570
+ );
571
+ }
@@ -190,3 +190,6 @@ export {
190
190
  type SelectionInstance
191
191
  } from "@/core/selection";
192
192
  export { parseShortcut, matchesShortcut, shortcutTokens, shortcutText, isApplePlatform, type Shortcut, type ShortcutSpec } from "@/core/keys";
193
+
194
+ export { Image, type ImageProps, type ImageItem, type ImageSource, type ImageLabels, type ImageMenuOptions, type ZoomOptions } from "@/react/image";
195
+ export { Video, type VideoProps, type VideoSource, type VideoTrack, type VideoControls, type VideoLabels } from "@/react/video";
@@ -146,6 +146,26 @@ export const COLOR_STYLES = `
146
146
  [data-enigma-color-eyedropper]:hover { color: var(--enigma-color-text); }
147
147
  [data-enigma-color-eyedropper]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 2px; }
148
148
 
149
+ /* The value as text, with the notation beside it. Monospace on purpose: a hex whose digits
150
+ shift width as you drag is a value nobody can read at a glance. */
151
+ [data-enigma-color-value] { display: flex; align-items: center; gap: 0.375rem; }
152
+ [data-enigma-color-format] {
153
+ flex: none; padding: 0.3125rem 0.375rem;
154
+ font: inherit; font-size: 0.625rem; line-height: 1; letter-spacing: 0.06em;
155
+ color: var(--enigma-color-muted); background: none;
156
+ border: 1px solid var(--enigma-color-border); border-radius: 0.25rem; cursor: pointer;
157
+ }
158
+ [data-enigma-color-format]:hover { color: var(--enigma-color-text); }
159
+ [data-enigma-color-input] {
160
+ flex: 1; min-width: 0; padding: 0.25rem 0.375rem;
161
+ font: inherit; font-family: var(--enigma-color-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
162
+ font-size: 0.75rem; line-height: 1.4;
163
+ color: var(--enigma-color-text); background: none;
164
+ border: 1px solid var(--enigma-color-border); border-radius: 0.25rem;
165
+ }
166
+ [data-enigma-color-format]:focus-visible,
167
+ [data-enigma-color-input]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 1px; }
168
+
149
169
  [data-enigma-color-swatches] { display: flex; flex-wrap: wrap; gap: 0.25rem; }
150
170
  [data-enigma-color-preset] {
151
171
  width: 1.125rem; height: 1.125rem; padding: 0;