@enigmax/primitives 0.25.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 (48) 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-QIIV2QSV.js +108 -0
  6. package/dist/chunk-RUEQ3TGP.js +155 -0
  7. package/dist/clipboard-menu-BvXJCSFN.d.ts +93 -0
  8. package/dist/context-BfjYGPnH.d.ts +54 -0
  9. package/dist/{clipboard-menu-B_ouitfS.d.ts → context-menu-D3FtTn7v.d.ts} +1 -91
  10. package/dist/index.d.ts +266 -2
  11. package/dist/index.js +4 -2
  12. package/dist/menu-MSQSE6U5.js +54 -0
  13. package/dist/menu-PNQRTRNV.js +33 -0
  14. package/dist/next/index.d.ts +6 -2
  15. package/dist/next/index.js +10 -6
  16. package/dist/react/context-menu.d.ts +8 -54
  17. package/dist/react/image.d.ts +109 -0
  18. package/dist/react/image.js +3 -0
  19. package/dist/react/index.d.ts +6 -2
  20. package/dist/react/index.js +10 -6
  21. package/dist/react/input.js +1 -1
  22. package/dist/react/video.d.ts +120 -0
  23. package/dist/react/video.js +3 -0
  24. package/dist/react-router/index.d.ts +6 -2
  25. package/dist/react-router/index.js +10 -6
  26. package/dist/viewer-73LSE6RR.js +3 -0
  27. package/package.json +15 -2
  28. package/recipes/image/styles.css +182 -0
  29. package/recipes/video/styles.css +154 -0
  30. package/registry.json +465 -0
  31. package/src/core/image-viewer.ts +222 -0
  32. package/src/core/player.ts +303 -0
  33. package/src/index.ts +52 -0
  34. package/src/react/image/icons.tsx +55 -0
  35. package/src/react/image/index.tsx +185 -0
  36. package/src/react/image/menu.tsx +76 -0
  37. package/src/react/image/styles.ts +213 -0
  38. package/src/react/image/types.ts +113 -0
  39. package/src/react/image/viewer.tsx +571 -0
  40. package/src/react/index.ts +3 -0
  41. package/src/react/video/icons.tsx +135 -0
  42. package/src/react/video/index.tsx +724 -0
  43. package/src/react/video/menu.tsx +66 -0
  44. package/src/react/video/rail.tsx +99 -0
  45. package/src/react/video/styles.ts +161 -0
  46. package/src/react/video/types.ts +135 -0
  47. /package/dist/{chunk-3MGBZOAU.js → chunk-RMNXVWQ4.js} +0 -0
  48. /package/dist/{chunk-MSOCCQGH.js → chunk-T7I6WJSN.js} +0 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * The arithmetic behind the video player: times, seeking, buffering, volume and the keys.
3
+ *
4
+ * None of it is rendering, so it is testable without a browser and reusable by a page that
5
+ * draws its own controls - the same split the colour maths and the image viewer's are in.
6
+ */
7
+
8
+ /** The playback rates the settings menu offers, in the order it lists them. */
9
+ export const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] as const;
10
+
11
+ /** How far the arrows seek, in seconds, and what a bigger jump moves by. */
12
+ export const SEEK_STEP = 5;
13
+ export const SEEK_JUMP = 10;
14
+ export const VOLUME_STEP = 0.05;
15
+
16
+ /**
17
+ * Seconds as a clock, sized by the LONGEST time it will have to show.
18
+ *
19
+ * `duration` is what stops the label changing width mid-playback: a 1h04m video passing 10:00
20
+ * would otherwise go from `9:59` to `10:00` to `1:00:00`, and every control after it moves.
21
+ */
22
+ export function formatTime(seconds: number, duration = seconds): string {
23
+ if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
24
+ const total = Math.floor(seconds);
25
+ const hours = Math.floor(total / 3600);
26
+ const minutes = Math.floor((total % 3600) / 60);
27
+ const rest = total % 60;
28
+ const withHours = Number.isFinite(duration) && duration >= 3600;
29
+
30
+ const pad = (value: number): string => String(value).padStart(2, "0");
31
+ if (withHours || hours > 0) return `${hours}:${pad(minutes)}:${pad(rest)}`;
32
+ return `${minutes}:${pad(rest)}`;
33
+ }
34
+
35
+ /** A time as a fraction of the duration, safe before the metadata says what that is. */
36
+ export function progress(time: number, duration: number): number {
37
+ if (!Number.isFinite(duration) || duration <= 0) return 0;
38
+ return Math.min(Math.max(time / duration, 0), 1);
39
+ }
40
+
41
+ /** Where a press on a horizontal rail lands, as a fraction of it. */
42
+ export function fractionAt(box: { left: number; width: number; }, clientX: number): number {
43
+ if (!box.width) return 0;
44
+ return Math.min(Math.max((clientX - box.left) / box.width, 0), 1);
45
+ }
46
+
47
+ /**
48
+ * How much is buffered AHEAD of where playback is.
49
+ *
50
+ * The range under the playhead, not the largest one: a viewer who seeks past the buffer is
51
+ * looking at a bar that says nothing is loaded, which is true of the part they are watching.
52
+ */
53
+ export function bufferedAhead(ranges: TimeRanges | null, time: number, duration: number): number {
54
+ if (!ranges || !Number.isFinite(duration) || duration <= 0) return 0;
55
+ for (let at = 0; at < ranges.length; at += 1) {
56
+ if (ranges.start(at) <= time && ranges.end(at) >= time) return Math.min(ranges.end(at) / duration, 1);
57
+ }
58
+ return 0;
59
+ }
60
+
61
+ /** The next rate in the list, wrapping - what a repeated press on the speed row does. */
62
+ export function nextSpeed(current: number, step = 1): number {
63
+ const at = SPEEDS.indexOf(current as typeof SPEEDS[number]);
64
+ const from = at === -1 ? SPEEDS.indexOf(1) : at;
65
+ return SPEEDS[(from + step + SPEEDS.length) % SPEEDS.length] as number;
66
+ }
67
+
68
+ /** What a key press means, or null when the player should leave it to the page. */
69
+ export type PlayerCommand =
70
+ | { type: "toggle"; }
71
+ | { type: "seek"; by: number; }
72
+ | { type: "seekTo"; fraction: number; }
73
+ | { type: "volume"; by: number; }
74
+ | { type: "mute"; }
75
+ | { type: "fullscreen"; }
76
+ | { type: "captions"; }
77
+ | { type: "pip"; };
78
+
79
+ /**
80
+ * The shortcuts, as the platform's video players define them.
81
+ *
82
+ * Space and K play, J and L jump ten, the arrows nudge five and the volume, M mutes, F is
83
+ * fullscreen, C captions, and a digit seeks to that tenth of the video.
84
+ */
85
+ export function commandFor(key: string): PlayerCommand | null {
86
+ if (key === " " || key === "k" || key === "K") return { type: "toggle" };
87
+ if (key === "ArrowRight") return { type: "seek", by: SEEK_STEP };
88
+ if (key === "ArrowLeft") return { type: "seek", by: -SEEK_STEP };
89
+ if (key === "l" || key === "L") return { type: "seek", by: SEEK_JUMP };
90
+ if (key === "j" || key === "J") return { type: "seek", by: -SEEK_JUMP };
91
+ if (key === "ArrowUp") return { type: "volume", by: VOLUME_STEP };
92
+ if (key === "ArrowDown") return { type: "volume", by: -VOLUME_STEP };
93
+ if (key === "m" || key === "M") return { type: "mute" };
94
+ if (key === "f" || key === "F") return { type: "fullscreen" };
95
+ if (key === "c" || key === "C") return { type: "captions" };
96
+ if (key === "p" || key === "P") return { type: "pip" };
97
+ if (key >= "0" && key <= "9") return { type: "seekTo", fraction: Number(key) / 10 };
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Whether a key press belongs to the page rather than to the player.
103
+ *
104
+ * A shortcut that fires while somebody is typing turns their space bar into a pause, so the
105
+ * player stands down inside anything that takes text.
106
+ */
107
+ export function isTypingTarget(target: EventTarget | null): boolean {
108
+ const element = target as HTMLElement | null;
109
+ if (!element || typeof element.tagName !== "string") return false;
110
+ if (element.isContentEditable) return true;
111
+ return ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName);
112
+ }
113
+
114
+ /** Whether the document is showing something fullscreen, across the two spellings of it. */
115
+ export function fullscreenElement(): Element | null {
116
+ if (typeof document === "undefined") return null;
117
+ const legacy = document as Document & { webkitFullscreenElement?: Element | null; };
118
+ return document.fullscreenElement ?? legacy.webkitFullscreenElement ?? null;
119
+ }
120
+
121
+ /**
122
+ * Fullscreen, including the one platform that will not give it to a container.
123
+ *
124
+ * iOS Safari has no Fullscreen API on an arbitrary element: the only thing that can fill the
125
+ * screen is the video itself, through `webkitEnterFullscreen`. Without that branch the button
126
+ * does nothing at all on an iPhone, which is where most video is watched.
127
+ */
128
+ export async function toggleFullscreen(container: HTMLElement, video: HTMLVideoElement | null): Promise<void> {
129
+ const legacyDocument = document as Document & { webkitExitFullscreen?: () => Promise<void>; };
130
+ if (fullscreenElement()) {
131
+ await (document.exitFullscreen?.() ?? legacyDocument.webkitExitFullscreen?.());
132
+ return;
133
+ }
134
+
135
+ const legacyElement = container as HTMLElement & { webkitRequestFullscreen?: () => Promise<void>; };
136
+ if (container.requestFullscreen) return void await container.requestFullscreen();
137
+ if (legacyElement.webkitRequestFullscreen) return void await legacyElement.webkitRequestFullscreen();
138
+
139
+ const iosVideo = video as (HTMLVideoElement & { webkitEnterFullscreen?: () => void; }) | null;
140
+ iosVideo?.webkitEnterFullscreen?.();
141
+ }
142
+
143
+ /** Picture in picture, where the browser has it. Firefox has its own button and no API. */
144
+ export function supportsPip(): boolean {
145
+ return typeof document !== "undefined" && Boolean(document.pictureInPictureEnabled);
146
+ }
147
+
148
+ export async function togglePip(video: HTMLVideoElement): Promise<void> {
149
+ if (document.pictureInPictureElement) return void await document.exitPictureInPicture();
150
+ await video.requestPictureInPicture();
151
+ }
152
+
153
+ /* -------- captions -------- */
154
+
155
+ /** One subtitle track, as the menu lists it. */
156
+ export interface CaptionTrack {
157
+ /** Where it sits in the element's own `textTracks`, which is what turns it on. */
158
+ index: number;
159
+ label: string;
160
+ language: string;
161
+ }
162
+
163
+ /** Which of the element's text tracks are subtitles a viewer would choose between. */
164
+ export function captionTracks(list: TextTrackList | null | undefined): CaptionTrack[] {
165
+ if (!list) return [];
166
+ const found: CaptionTrack[] = [];
167
+ for (let at = 0; at < list.length; at += 1) {
168
+ const track = list[at];
169
+ // Chapters and metadata are for the page, not for the viewer: listing them offers a
170
+ // language picker entry that draws nothing over the picture.
171
+ if (!track || (track.kind !== "subtitles" && track.kind !== "captions")) continue;
172
+ found.push({ index: at, label: track.label || track.language || `Track ${found.length + 1}`, language: track.language });
173
+ }
174
+ return found;
175
+ }
176
+
177
+ /**
178
+ * The track the element is SHOWING, or -1.
179
+ *
180
+ * Read rather than remembered: a `default` track is showing before any button was pressed, and
181
+ * a player that assumed "off" would need two presses to turn something off.
182
+ */
183
+ export function activeCaption(list: TextTrackList | null | undefined): number {
184
+ if (!list) return -1;
185
+ for (let at = 0; at < list.length; at += 1) if (list[at]?.mode === "showing") return at;
186
+ return -1;
187
+ }
188
+
189
+ /**
190
+ * Show one track and disable the rest. -1 turns them all off.
191
+ *
192
+ * "disabled" rather than "hidden": a hidden track still fires its cues, so a page listening to
193
+ * `cuechange` for its own transcript would keep receiving a language nobody asked for.
194
+ */
195
+ export function showCaption(list: TextTrackList | null | undefined, index: number): void {
196
+ if (!list) return;
197
+ for (let at = 0; at < list.length; at += 1) {
198
+ const track = list[at];
199
+ if (track) track.mode = at === index ? "showing" : "disabled";
200
+ }
201
+ }
202
+
203
+ /* -------- casting: the remote screen this is played on -------- */
204
+
205
+ /** What the cast control knows about the world. */
206
+ export interface RemoteState {
207
+ /** There is somewhere to cast TO. Before the browser has looked, this is optimistic. */
208
+ available: boolean;
209
+ /** Playing on that screen right now. */
210
+ connected: boolean;
211
+ }
212
+
213
+ interface RemotePlaybackLike {
214
+ state: "connected" | "connecting" | "disconnected";
215
+ watchAvailability: (callback: (available: boolean) => void) => Promise<number>;
216
+ cancelWatchAvailability: (id: number) => Promise<void>;
217
+ prompt: () => Promise<void>;
218
+ addEventListener: (type: string, listener: () => void) => void;
219
+ removeEventListener: (type: string, listener: () => void) => void;
220
+ }
221
+
222
+ /** The element, with the two spellings of "play this somewhere else" on it. */
223
+ type CastableVideo = HTMLVideoElement & {
224
+ remote?: RemotePlaybackLike;
225
+ /** Safari's AirPlay picker, which predates the standard and is still the only one it has on iOS. */
226
+ webkitShowPlaybackTargetPicker?: () => void;
227
+ webkitCurrentPlaybackTargetIsWireless?: boolean;
228
+ };
229
+
230
+ /**
231
+ * Whether this element can be cast at all.
232
+ *
233
+ * `disableRemotePlayback` is honoured: a page that asked for the button to be gone does not
234
+ * get one drawn over its video by a component.
235
+ */
236
+ export function supportsRemote(video: HTMLVideoElement | null): boolean {
237
+ const element = video as CastableVideo | null;
238
+ if (!element || element.disableRemotePlayback) return false;
239
+ return Boolean(element.remote?.prompt ?? element.webkitShowPlaybackTargetPicker);
240
+ }
241
+
242
+ /**
243
+ * Watch for a screen to cast to, and for the connection to it.
244
+ *
245
+ * WHY AVAILABILITY IS OPTIMISTIC WHEN THE WATCH FAILS. `watchAvailability` rejects with
246
+ * NotSupportedError on the platforms that can still `prompt()` - Safari, and Chromium for some
247
+ * sources - so treating a rejection as "no devices" hides a control that works. A button that
248
+ * opens an empty picker is a smaller loss than a missing one.
249
+ */
250
+ export function watchRemote(video: HTMLVideoElement, onChange: (state: RemoteState) => void): () => void {
251
+ const element = video as CastableVideo;
252
+ const state: RemoteState = { available: false, connected: false };
253
+ const publish = (next: Partial<RemoteState>): void => {
254
+ Object.assign(state, next);
255
+ onChange({ ...state });
256
+ };
257
+
258
+ if (element.remote) {
259
+ const remote = element.remote;
260
+ const onState = (): void => publish({ connected: remote.state === "connected" });
261
+ let watch: number | null = null;
262
+ let cancelled = false;
263
+
264
+ remote.watchAvailability((available) => { if (!cancelled) publish({ available }); })
265
+ .then((id) => {
266
+ if (cancelled) void remote.cancelWatchAvailability(id).catch(() => { /* already gone */ });
267
+ else watch = id;
268
+ })
269
+ .catch(() => { if (!cancelled) publish({ available: true }); });
270
+
271
+ for (const name of ["connect", "connecting", "disconnect"]) remote.addEventListener(name, onState);
272
+ onState();
273
+
274
+ return () => {
275
+ cancelled = true;
276
+ for (const name of ["connect", "connecting", "disconnect"]) remote.removeEventListener(name, onState);
277
+ if (watch !== null) void remote.cancelWatchAvailability(watch).catch(() => { /* already gone */ });
278
+ };
279
+ }
280
+
281
+ const onAvailability = (event: Event): void => publish({ available: (event as Event & { availability?: string; }).availability === "available" });
282
+ const onWireless = (): void => publish({ connected: Boolean(element.webkitCurrentPlaybackTargetIsWireless) });
283
+ element.addEventListener("webkitplaybacktargetavailabilitychanged", onAvailability);
284
+ element.addEventListener("webkitcurrentplaybacktargetiswirelesschanged", onWireless);
285
+ onWireless();
286
+
287
+ return () => {
288
+ element.removeEventListener("webkitplaybacktargetavailabilitychanged", onAvailability);
289
+ element.removeEventListener("webkitcurrentplaybacktargetiswirelesschanged", onWireless);
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Open the browser's own device picker.
295
+ *
296
+ * There is no list to draw ourselves: both APIs hand the choice to the platform, which is the
297
+ * only thing that can enumerate the screens on the network.
298
+ */
299
+ export async function promptRemote(video: HTMLVideoElement): Promise<void> {
300
+ const element = video as CastableVideo;
301
+ if (element.remote?.prompt) return void await element.remote.prompt();
302
+ element.webkitShowPlaybackTargetPicker?.();
303
+ }
package/src/index.ts CHANGED
@@ -152,3 +152,55 @@ export {
152
152
  type TypeaheadState,
153
153
  type TypeaheadStep
154
154
  } from "@/core/keys";
155
+
156
+ // The image viewer's arithmetic: zoom that stays under the cursor, a pan that cannot lose the
157
+ // picture, and moving through a set. The lightbox is React; none of this is.
158
+ export {
159
+ zoomAt,
160
+ clampPan,
161
+ clampScale,
162
+ wheelFactor,
163
+ fittedSize,
164
+ pinchDistance,
165
+ nextIndex,
166
+ filenameFrom,
167
+ downloadFile,
168
+ flightFrom,
169
+ flightMs,
170
+ prefersReducedMotion,
171
+ FLIGHT_MS,
172
+ IDENTITY,
173
+ ZOOM_LIMITS,
174
+ ZOOM_STEP,
175
+ ZOOM_DOUBLE,
176
+ type Transform,
177
+ type ZoomLimits,
178
+ type Box
179
+ } from "@/core/image-viewer";
180
+ // The video player's arithmetic: times, seeking, buffering and the shortcut map.
181
+ export {
182
+ formatTime,
183
+ progress,
184
+ fractionAt,
185
+ bufferedAhead,
186
+ nextSpeed,
187
+ commandFor,
188
+ isTypingTarget,
189
+ fullscreenElement,
190
+ toggleFullscreen,
191
+ supportsPip,
192
+ togglePip,
193
+ captionTracks,
194
+ activeCaption,
195
+ showCaption,
196
+ supportsRemote,
197
+ watchRemote,
198
+ promptRemote,
199
+ SPEEDS,
200
+ SEEK_STEP,
201
+ SEEK_JUMP,
202
+ VOLUME_STEP,
203
+ type PlayerCommand,
204
+ type CaptionTrack,
205
+ type RemoteState
206
+ } from "@/core/player";
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * The viewer's icons, drawn rather than loaded.
5
+ *
6
+ * A sprite or an SVG file would be a request, and an icon package would be a dependency for
7
+ * eight paths. They sit in one module so the viewer and its menu share them without either
8
+ * pulling the other's chunk.
9
+ */
10
+
11
+ function Glyph({ children }: { children: ReactNode; }): ReactNode {
12
+ return (
13
+ <svg viewBox="0 0 24 24" width="1.125em" height="1.125em" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
14
+ {children}
15
+ </svg>
16
+ );
17
+ }
18
+
19
+ export function Close(): ReactNode {
20
+ return <Glyph><path d="M6 6 18 18M18 6 6 18" /></Glyph>;
21
+ }
22
+
23
+ export function Plus(): ReactNode {
24
+ return <Glyph><path d="M12 5v14M5 12h14" /></Glyph>;
25
+ }
26
+
27
+ export function Minus(): ReactNode {
28
+ return <Glyph><path d="M5 12h14" /></Glyph>;
29
+ }
30
+
31
+ export function ChevronLeft(): ReactNode {
32
+ return <Glyph><path d="m15 5-7 7 7 7" /></Glyph>;
33
+ }
34
+
35
+ export function ChevronRight(): ReactNode {
36
+ return <Glyph><path d="m9 5 7 7-7 7" /></Glyph>;
37
+ }
38
+
39
+ export function Trash(): ReactNode {
40
+ return <Glyph><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" /></Glyph>;
41
+ }
42
+
43
+ export function Dots(): ReactNode {
44
+ return (
45
+ <svg viewBox="0 0 24 24" width="1.125em" height="1.125em" fill="currentColor" aria-hidden="true">
46
+ <circle cx="12" cy="5" r="1.75" />
47
+ <circle cx="12" cy="12" r="1.75" />
48
+ <circle cx="12" cy="19" r="1.75" />
49
+ </svg>
50
+ );
51
+ }
52
+
53
+ export function Download(): ReactNode {
54
+ return <Glyph><path d="M12 3v12M7 11l5 5 5-5M4 20h16" /></Glyph>;
55
+ }
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import { toItem } from "@/react/image/types";
4
+ import { injectImageStyles } from "@/react/image/styles";
5
+ import type { ImageItem, ImageProps, ZoomOptions } from "@/react/image/types";
6
+ import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
+
8
+ /**
9
+ * `<Image>` - the picture in the page, and the viewer a press on it opens.
10
+ *
11
+ * ```tsx
12
+ * <Image src="/shot.png" alt="The dashboard" />
13
+ * <Image src={shot} alt="..." images={gallery} navigation thumbnails menu discardable />
14
+ * ```
15
+ *
16
+ * WHAT IS ON BY DEFAULT is the API: click to enlarge, and zoom with the wheel. The arrows,
17
+ * the strip of previews, the menu and the discard action are each a decision about the page
18
+ * this sits in - a product gallery wants all four, an avatar wants none - so each is a prop
19
+ * you turn on rather than one you have to remember to turn off.
20
+ *
21
+ * WHAT LOADS. This module is the `<img>` and the press. The lightbox is its own chunk, and
22
+ * its menu another under that, so a page of images nobody enlarges downloads neither. The
23
+ * chunk is fetched on INTENT - a pointer over the image, or focus reaching it - so by the
24
+ * time the press lands it is usually already here; a press that beats it opens the viewer as
25
+ * soon as it arrives, rather than being dropped.
26
+ */
27
+
28
+ const ImageViewer = lazy(() => import("@/react/image/viewer").then((module) => ({ default: module.ImageViewer })));
29
+
30
+ /** Kept out of the render so the hover prefetch fires once per session rather than per event. */
31
+ let prefetched = false;
32
+
33
+ function prefetchViewer(): void {
34
+ if (prefetched) return;
35
+ prefetched = true;
36
+ void import("@/react/image/viewer");
37
+ }
38
+
39
+ const ZOOM_DEFAULTS = { min: 1, max: 8, wheel: true, doubleClick: true } as const;
40
+
41
+ export function Image(props: ImageProps): ReactNode {
42
+ const {
43
+ src,
44
+ alt,
45
+ images,
46
+ index,
47
+ lightbox = true,
48
+ zoom = true,
49
+ animate = true,
50
+ navigation = false,
51
+ thumbnails = false,
52
+ menu = false,
53
+ discardable = false,
54
+ onDiscard,
55
+ loop = true,
56
+ caption,
57
+ onOpenChange,
58
+ onIndexChange,
59
+ styles = true,
60
+ labels = {},
61
+ wrapperProps,
62
+ ...rest
63
+ } = props;
64
+
65
+ // Before paint, and from HERE rather than only from the viewer: the picture in the page is
66
+ // styled from the first frame, so it says it enlarges before anybody has opened one.
67
+ useLayoutEffect(() => { if (styles) injectImageStyles(); }, [styles]);
68
+
69
+ const [open, setOpen] = useState(false);
70
+
71
+ const items = useMemo<ImageItem[]>(() => {
72
+ const set = (images ?? [src]).map(toItem);
73
+ // A set that does not contain this image would open the viewer on somebody else's
74
+ // picture, so the one that was pressed is always in it.
75
+ return set.some((entry) => entry.src === src) ? set : [{ src, alt }, ...set];
76
+ }, [images, src, alt]);
77
+
78
+ const initial = useMemo(() => {
79
+ if (typeof index === "number" && index >= 0 && index < items.length) return index;
80
+ const found = items.findIndex((entry) => entry.src === src);
81
+ return found === -1 ? 0 : found;
82
+ }, [index, items, src]);
83
+
84
+ const [current, setCurrent] = useState(initial);
85
+ useEffect(() => setCurrent(initial), [initial]);
86
+
87
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
88
+ const thumbnailRef = useRef<HTMLImageElement | null>(null);
89
+
90
+ const shown = useRef(current);
91
+ shown.current = current;
92
+
93
+ /**
94
+ * Where this picture is in the page, read at the moment the viewer needs it.
95
+ *
96
+ * Read live rather than captured on open: the flight back happens whenever the viewer is
97
+ * closed, and by then the page may have scrolled. A rectangle taken at open would land the
98
+ * picture where the thumbnail used to be.
99
+ *
100
+ * Null once the reader has moved through a set to a different image. This component knows
101
+ * where ITS picture is and nothing about anyone else's, so flying the fourth image back
102
+ * onto the first one's thumbnail would be a lie; the viewer then closes without one.
103
+ */
104
+ const origin = useCallback((): DOMRect | null => (
105
+ shown.current === initial ? thumbnailRef.current?.getBoundingClientRect() ?? null : null
106
+ ), [initial]);
107
+
108
+ const zoomOptions = useMemo(() => {
109
+ if (zoom === false) return null;
110
+ const given: ZoomOptions = zoom === true ? {} : zoom;
111
+ return { ...ZOOM_DEFAULTS, ...given, doubleClick: given.doubleClick !== false, wheel: given.wheel !== false };
112
+ }, [zoom]);
113
+
114
+ const menuOptions = useMemo(() => (menu === false ? null : menu === true ? {} : menu), [menu]);
115
+
116
+ const show = useCallback(() => {
117
+ if (!lightbox) return;
118
+ // A press before the chunk lands is not lost: this is state, and the viewer mounts
119
+ // with it the moment its code is here.
120
+ setOpen(true);
121
+ onOpenChange?.(true);
122
+ }, [lightbox, onOpenChange]);
123
+
124
+ const close = useCallback(() => {
125
+ setOpen(false);
126
+ onOpenChange?.(false);
127
+ // Back where the press came from: closing a dialog that leaves focus on the body
128
+ // sends the next Tab to the top of the page.
129
+ triggerRef.current?.focus();
130
+ }, [onOpenChange]);
131
+
132
+ const goTo = useCallback((next: number) => {
133
+ setCurrent(next);
134
+ const item = items[next];
135
+ if (item) onIndexChange?.(next, item);
136
+ }, [items, onIndexChange]);
137
+
138
+ const image = <img ref={thumbnailRef} src={src} alt={alt} {...rest} />;
139
+
140
+ return (
141
+ <span {...wrapperProps} data-enigma-image="" data-clickable={lightbox ? "" : undefined}>
142
+ {lightbox ? (
143
+ <button
144
+ ref={triggerRef}
145
+ type="button"
146
+ data-enigma-image-trigger=""
147
+ // The image IS the label: a second name here would have a screen reader
148
+ // read the picture twice, once for the button and once for the alt.
149
+ aria-label={labels.open ? `${labels.open}: ${alt}` : alt}
150
+ aria-haspopup="dialog"
151
+ onClick={show}
152
+ onPointerEnter={prefetchViewer}
153
+ onFocus={prefetchViewer}
154
+ >
155
+ {image}
156
+ </button>
157
+ ) : image}
158
+
159
+ {open && (
160
+ <Suspense fallback={null}>
161
+ <ImageViewer
162
+ items={items}
163
+ index={current}
164
+ onIndex={goTo}
165
+ onClose={close}
166
+ zoom={zoomOptions}
167
+ navigation={navigation}
168
+ thumbnails={thumbnails}
169
+ menu={menuOptions}
170
+ discardable={discardable}
171
+ onDiscard={onDiscard}
172
+ loop={loop}
173
+ caption={caption}
174
+ origin={origin}
175
+ animate={animate}
176
+ styles={styles}
177
+ labels={labels}
178
+ />
179
+ </Suspense>
180
+ )}
181
+ </span>
182
+ );
183
+ }
184
+
185
+ export type { ImageProps, ImageItem, ImageSource, ImageLabels, ImageMenuOptions, ZoomOptions } from "@/react/image/types";
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import * as icons from "@/react/image/icons";
4
+ import { useRef, type ReactNode } from "react";
5
+ import { MenuButton } from "@/react/image/viewer";
6
+ import { downloadFile } from "@/core/image-viewer";
7
+ import type { ImageItem, ImageLabels, ImageMenuOptions } from "@/react/image/types";
8
+ import { ContextMenu, useContextMenuContext, type ContextMenuNode } from "@/react/context-menu";
9
+
10
+ /**
11
+ * The three dots, and the rows under them.
12
+ *
13
+ * The MENU is the context menu component, opened from a left press at the button's corner
14
+ * rather than from a right-click - which is the composition its own docs describe. Writing a
15
+ * second popup here would mean a second keyboard model, a second set of roles and a second
16
+ * thing to fix, and the `fe-context-menu-hand-rolled` guardrail exists to say so.
17
+ *
18
+ * Its own chunk under the viewer's: the menu is off by default, so a viewer that was never
19
+ * given one downloads neither this module nor the component it composes.
20
+ */
21
+
22
+ export interface ImageMenuProps {
23
+ item: ImageItem;
24
+ index: number;
25
+ options: ImageMenuOptions;
26
+ labels: ImageLabels;
27
+ }
28
+
29
+ export function ImageMenu({ item, index, options, labels }: ImageMenuProps): ReactNode {
30
+ const rows: ContextMenuNode[] = [];
31
+ if (options.download !== false) {
32
+ rows.push({ id: "download", label: labels.download ?? "Download", icon: <icons.Download /> });
33
+ }
34
+ if (options.items?.length) {
35
+ if (rows.length > 0) rows.push({ type: "separator" });
36
+ rows.push(...options.items);
37
+ }
38
+
39
+ return (
40
+ <ContextMenu.Root
41
+ items={rows}
42
+ // The rows act on the picture, not on a selection: Copy, Cut and Paste are built
43
+ // from what was right-clicked, and there is nothing writable in a lightbox.
44
+ clipboard={false}
45
+ onSelect={(row) => {
46
+ if (row.id === "download") void downloadFile(item.download ?? item.src, item.filename);
47
+ options.onSelect?.(row.id, item, index);
48
+ }}
49
+ >
50
+ <Trigger label={labels.menu ?? "More"} />
51
+ <ContextMenu.Content />
52
+ </ContextMenu.Root>
53
+ );
54
+ }
55
+
56
+ /** The button, wired to open the menu under itself the way a toolbar's overflow does. */
57
+ function Trigger({ label }: { label: string; }): ReactNode {
58
+ const menu = useContextMenuContext("Image.Menu");
59
+ const ref = useRef<HTMLSpanElement | null>(null);
60
+
61
+ return (
62
+ <span ref={ref} style={{ display: "contents" }}>
63
+ <MenuButton
64
+ label={label}
65
+ expanded={menu.state.open}
66
+ onPress={() => {
67
+ const box = ref.current?.firstElementChild?.getBoundingClientRect();
68
+ if (!box) return;
69
+ // Under the button and aligned to its right edge, which is where a toolbar
70
+ // menu belongs - the panel flips itself if the window has no room.
71
+ menu.open({ x: box.right, y: box.bottom + 6 });
72
+ }}
73
+ />
74
+ </span>
75
+ );
76
+ }