@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.
- package/dist/chunk-4SZ5N7BA.js +121 -0
- package/dist/chunk-EYZ366LP.js +189 -0
- package/dist/chunk-ISXB5RUH.js +380 -0
- package/dist/chunk-JJVZYW5C.js +946 -0
- package/dist/chunk-KVLSOQTI.js +66 -0
- package/dist/chunk-QIIV2QSV.js +108 -0
- package/dist/{chunk-QJJ34E4G.js → chunk-RMNXVWQ4.js} +32 -15
- package/dist/chunk-RUEQ3TGP.js +155 -0
- package/dist/clipboard-menu-BvXJCSFN.d.ts +93 -0
- package/dist/{color-OPV3BJV6.js → color-T63FLJNH.js} +183 -120
- package/dist/context-BfjYGPnH.d.ts +54 -0
- package/dist/{clipboard-menu-B_ouitfS.d.ts → context-menu-D3FtTn7v.d.ts} +1 -91
- package/dist/{index-ZPvlf9vs.d.ts → index-qXOkYQCU.d.ts} +4 -0
- package/dist/index.d.ts +266 -2
- package/dist/index.js +4 -2
- package/dist/menu-MSQSE6U5.js +54 -0
- package/dist/menu-PNQRTRNV.js +33 -0
- package/dist/next/index.d.ts +7 -3
- package/dist/next/index.js +11 -7
- package/dist/react/context-menu.d.ts +8 -54
- package/dist/react/image.d.ts +109 -0
- package/dist/react/image.js +3 -0
- package/dist/react/index.d.ts +8 -4
- package/dist/react/index.js +11 -7
- package/dist/react/input.d.ts +1 -1
- package/dist/react/input.js +2 -2
- package/dist/react/video.d.ts +120 -0
- package/dist/react/video.js +3 -0
- package/dist/react-router/index.d.ts +7 -3
- package/dist/react-router/index.js +11 -7
- package/dist/viewer-73LSE6RR.js +3 -0
- package/package.json +15 -2
- package/recipes/color/styles.css +20 -0
- package/recipes/image/styles.css +182 -0
- package/recipes/video/styles.css +154 -0
- package/registry.json +477 -3
- package/src/core/image-viewer.ts +222 -0
- package/src/core/player.ts +303 -0
- package/src/index.ts +52 -0
- package/src/react/image/icons.tsx +55 -0
- package/src/react/image/index.tsx +185 -0
- package/src/react/image/menu.tsx +76 -0
- package/src/react/image/styles.ts +213 -0
- package/src/react/image/types.ts +113 -0
- package/src/react/image/viewer.tsx +571 -0
- package/src/react/index.ts +3 -0
- package/src/react/input/color-styles.ts +20 -0
- package/src/react/input/color-swatch.tsx +96 -0
- package/src/react/input/color.tsx +84 -23
- package/src/react/input/index.tsx +27 -4
- package/src/react/input/types.ts +4 -0
- package/src/react/video/icons.tsx +135 -0
- package/src/react/video/index.tsx +724 -0
- package/src/react/video/menu.tsx +66 -0
- package/src/react/video/rail.tsx +99 -0
- package/src/react/video/styles.ts +161 -0
- package/src/react/video/types.ts +135 -0
- package/dist/chunk-S7EE57YB.js +0 -9
- /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
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -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;
|