@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.
- 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-QIIV2QSV.js +108 -0
- package/dist/chunk-RUEQ3TGP.js +155 -0
- package/dist/clipboard-menu-BvXJCSFN.d.ts +93 -0
- 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.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 +6 -2
- package/dist/next/index.js +10 -6
- 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 +6 -2
- package/dist/react/index.js +10 -6
- package/dist/react/input.js +1 -1
- package/dist/react/video.d.ts +120 -0
- package/dist/react/video.js +3 -0
- package/dist/react-router/index.d.ts +6 -2
- package/dist/react-router/index.js +10 -6
- package/dist/viewer-73LSE6RR.js +3 -0
- package/package.json +15 -2
- package/recipes/image/styles.css +182 -0
- package/recipes/video/styles.css +154 -0
- package/registry.json +465 -0
- 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/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-3MGBZOAU.js → chunk-RMNXVWQ4.js} +0 -0
- /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
|
+
}
|