@enigmax/primitives 0.25.0 → 0.27.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-2RIO4VDV.js +121 -0
- package/dist/{chunk-DZMHY3SU.js → chunk-CPJJ3FPW.js} +17 -7
- package/dist/chunk-EYZ366LP.js +189 -0
- package/dist/chunk-HUUINHPR.js +135 -0
- package/dist/chunk-JLZDS6CV.js +177 -0
- package/dist/chunk-O2UJ5ZIX.js +387 -0
- package/dist/chunk-PVDU6AZU.js +953 -0
- package/dist/{chunk-Z3VDE7OA.js → chunk-YQV5XZIA.js} +2 -2
- package/dist/clipboard-menu-eemzEBqB.d.ts +93 -0
- package/dist/context-DR1dpO8b.d.ts +54 -0
- package/dist/{clipboard-menu-B_ouitfS.d.ts → context-menu-qqpGBkny.d.ts} +7 -91
- package/dist/index.d.ts +292 -2
- package/dist/index.js +5 -3
- package/dist/menu-52PXC5PL.js +33 -0
- package/dist/menu-YIGMPFUP.js +58 -0
- package/dist/next/index.d.ts +6 -2
- package/dist/next/index.js +12 -8
- package/dist/react/context-menu.d.ts +15 -55
- package/dist/react/context-menu.js +2 -2
- package/dist/react/image.d.ts +112 -0
- package/dist/react/image.js +3 -0
- package/dist/react/index.d.ts +6 -2
- package/dist/react/index.js +12 -8
- package/dist/react/input.js +1 -1
- package/dist/react/video.d.ts +126 -0
- package/dist/react/video.js +3 -0
- package/dist/react-router/index.d.ts +6 -2
- package/dist/react-router/index.js +12 -8
- package/dist/viewer-N5WLFB24.js +3 -0
- package/package.json +15 -2
- package/recipes/context-menu/styles.css +8 -1
- package/recipes/image/styles.css +182 -0
- package/recipes/video/styles.css +154 -0
- package/registry.json +486 -5
- package/src/core/context-menu.ts +22 -3
- package/src/core/image-viewer.ts +269 -0
- package/src/core/player.ts +345 -0
- package/src/index.ts +55 -0
- package/src/react/context-menu/root.tsx +27 -5
- package/src/react/context-menu/styles.ts +8 -1
- package/src/react/image/icons.tsx +59 -0
- package/src/react/image/index.tsx +185 -0
- package/src/react/image/menu.tsx +80 -0
- package/src/react/image/styles.ts +213 -0
- package/src/react/image/types.ts +116 -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 +731 -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 +141 -0
- /package/dist/{chunk-3MGBZOAU.js → chunk-RMNXVWQ4.js} +0 -0
- /package/dist/{chunk-MSOCCQGH.js → chunk-T7I6WJSN.js} +0 -0
|
@@ -146,6 +146,12 @@ export interface ContextMenuRootProps {
|
|
|
146
146
|
* selection, Cut over a selection in something writable, Paste in anything writable.
|
|
147
147
|
*/
|
|
148
148
|
clipboard?: boolean | ClipboardMenuOptions;
|
|
149
|
+
/**
|
|
150
|
+
* A filter field over the rows. `"auto"` (the default) grows one only once there are
|
|
151
|
+
* enough rows to be worth filtering; `true` asks for one whatever the count - which is
|
|
152
|
+
* what a menu whose rows are fetched wants - and `false` never draws one.
|
|
153
|
+
*/
|
|
154
|
+
searchable?: boolean | "auto";
|
|
149
155
|
/** Fuse.js's constructor, for fuzzy filtering. Omit it for the built-in matcher. */
|
|
150
156
|
fuse?: ContextMenuOptions["fuse"];
|
|
151
157
|
fuseOptions?: Record<string, unknown>;
|
|
@@ -169,6 +175,7 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
|
|
|
169
175
|
const {
|
|
170
176
|
items,
|
|
171
177
|
title,
|
|
178
|
+
searchable,
|
|
172
179
|
disabled = false,
|
|
173
180
|
fuse,
|
|
174
181
|
fuseOptions,
|
|
@@ -208,6 +215,7 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
|
|
|
208
215
|
const instance = useMemo<ContextMenuInstance>(() => createContextMenu({
|
|
209
216
|
items: items as readonly ContextMenuEntry[],
|
|
210
217
|
title,
|
|
218
|
+
searchable,
|
|
211
219
|
fuse,
|
|
212
220
|
fuseOptions,
|
|
213
221
|
matcher,
|
|
@@ -307,8 +315,8 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
|
|
|
307
315
|
}, []);
|
|
308
316
|
|
|
309
317
|
useEffect(() => {
|
|
310
|
-
instance.update({ items: withClipboard(currentItems.current), title });
|
|
311
|
-
}, [instance, signature, title, withClipboard]);
|
|
318
|
+
instance.update({ items: withClipboard(currentItems.current), title, searchable });
|
|
319
|
+
}, [instance, signature, title, searchable, withClipboard]);
|
|
312
320
|
|
|
313
321
|
const cancelClose = useCallback(() => { window.clearTimeout(closing.current); }, []);
|
|
314
322
|
|
|
@@ -883,6 +891,18 @@ export function ContextMenuRow({ level, index, entry, children, ...props }: Cont
|
|
|
883
891
|
return <p {...props} data-enigma-menu-label="" aria-hidden="true">{entry.label}</p>;
|
|
884
892
|
}
|
|
885
893
|
|
|
894
|
+
/**
|
|
895
|
+
* Whether the level RESERVES the tick column, which is not the same as this row having a
|
|
896
|
+
* tick.
|
|
897
|
+
*
|
|
898
|
+
* A checkable row carries a check and, usually, an icon; its neighbours carry only the
|
|
899
|
+
* icon. Drawn per row, the ticked one's label therefore sat a column further right than
|
|
900
|
+
* everyone else's and the menu read as broken. Every desktop menu reserves the column for
|
|
901
|
+
* the whole level instead, so this asks the LEVEL. Read from `entries` rather than from
|
|
902
|
+
* what is visible: filtering a menu must not shift its rows sideways.
|
|
903
|
+
*/
|
|
904
|
+
const reserveCheck = state?.entries.some((entry) => isAction(entry) && (entry as ContextMenuItem).checked !== undefined) ?? false;
|
|
905
|
+
|
|
886
906
|
const checkable = item.checked !== undefined;
|
|
887
907
|
// A checkable row inside a group is a radio: choosing one is choosing INSTEAD of its
|
|
888
908
|
// siblings, and a screen reader announces the two differently.
|
|
@@ -942,16 +962,18 @@ export function ContextMenuRow({ level, index, entry, children, ...props }: Cont
|
|
|
942
962
|
menu.instance.select(level, index);
|
|
943
963
|
}}
|
|
944
964
|
>
|
|
945
|
-
{children ?? menu.renderItem?.(item, level, index) ?? <ContextMenuRowContent item={item} submenu={submenu} />}
|
|
965
|
+
{children ?? menu.renderItem?.(item, level, index) ?? <ContextMenuRowContent item={item} submenu={submenu} reserveCheck={reserveCheck} />}
|
|
946
966
|
</div>
|
|
947
967
|
);
|
|
948
968
|
}
|
|
949
969
|
|
|
950
970
|
/** The default row: what a desktop menu draws, in the order it draws it. */
|
|
951
|
-
function ContextMenuRowContent({ item, submenu }: { item: ContextMenuItem; submenu: boolean; }): ReactNode {
|
|
971
|
+
function ContextMenuRowContent({ item, submenu, reserveCheck }: { item: ContextMenuItem; submenu: boolean; reserveCheck: boolean; }): ReactNode {
|
|
952
972
|
return (
|
|
953
973
|
<>
|
|
954
|
-
{
|
|
974
|
+
{/* Empty when this row is not checkable and something else in the level is: the
|
|
975
|
+
column is the level's, not the row's. */}
|
|
976
|
+
{(item.checked !== undefined || reserveCheck) && <span data-enigma-menu-check="" aria-hidden="true" />}
|
|
955
977
|
{item.icon ? <span data-enigma-menu-icon="">{item.icon}</span> : null}
|
|
956
978
|
<span data-enigma-menu-item-text="">
|
|
957
979
|
<span data-enigma-menu-item-label="">{item.label}</span>
|
|
@@ -29,6 +29,13 @@ export const CONTEXT_MENU_STYLES = `
|
|
|
29
29
|
--enigma-menu-accent: #fbbf24;
|
|
30
30
|
--enigma-menu-danger: #f87171;
|
|
31
31
|
--enigma-menu-danger-bg: rgba(248, 113, 113, 0.12);
|
|
32
|
+
|
|
33
|
+
/* Above the lightbox, which sits at 9999, because a menu is opened FROM whatever is on
|
|
34
|
+
top: the image viewer's own three-dot menu was portalled to the same body and painted
|
|
35
|
+
underneath it, so its rows could be neither seen nor pressed. A menu is the topmost
|
|
36
|
+
transient surface on a page, and this is the number that says so. It belongs here and
|
|
37
|
+
not in the light-scheme block, or half the readers get an invalid z-index. */
|
|
38
|
+
--enigma-menu-z: 10000;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
@media (prefers-color-scheme: light) {
|
|
@@ -49,7 +56,7 @@ export const CONTEXT_MENU_STYLES = `
|
|
|
49
56
|
and what a menu opened at the pointer needs. Fixed rather than absolute so a scrolling
|
|
50
57
|
ancestor cannot drag it away from the place it was opened. */
|
|
51
58
|
[data-enigma-menu-panel] {
|
|
52
|
-
position: fixed; z-index:
|
|
59
|
+
position: fixed; z-index: var(--enigma-menu-z);
|
|
53
60
|
box-sizing: border-box;
|
|
54
61
|
min-width: var(--enigma-menu-min-width);
|
|
55
62
|
max-width: min(22rem, calc(100vw - 1rem));
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
}
|
|
56
|
+
|
|
57
|
+
export function NewTab(): ReactNode {
|
|
58
|
+
return <Glyph><path d="M14 4h6v6" /><path d="M20 4 11 13" /><path d="M18 14v5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 19V7.5A1.5 1.5 0 0 1 5.5 6H10" /></Glyph>;
|
|
59
|
+
}
|
|
@@ -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,80 @@
|
|
|
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, openInNewTab } 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.newTab !== false) {
|
|
35
|
+
rows.push({ id: "new-tab", label: labels.newTab ?? "Open in a new tab", icon: <icons.NewTab /> });
|
|
36
|
+
}
|
|
37
|
+
if (options.items?.length) {
|
|
38
|
+
if (rows.length > 0) rows.push({ type: "separator" });
|
|
39
|
+
rows.push(...options.items);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<ContextMenu.Root
|
|
44
|
+
items={rows}
|
|
45
|
+
// The rows act on the picture, not on a selection: Copy, Cut and Paste are built
|
|
46
|
+
// from what was right-clicked, and there is nothing writable in a lightbox.
|
|
47
|
+
clipboard={false}
|
|
48
|
+
onSelect={(row) => {
|
|
49
|
+
if (row.id === "download") void downloadFile(item.download ?? item.src, item.filename);
|
|
50
|
+
if (row.id === "new-tab") openInNewTab(item.src);
|
|
51
|
+
options.onSelect?.(row.id, item, index);
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<Trigger label={labels.menu ?? "More"} />
|
|
55
|
+
<ContextMenu.Content />
|
|
56
|
+
</ContextMenu.Root>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The button, wired to open the menu under itself the way a toolbar's overflow does. */
|
|
61
|
+
function Trigger({ label }: { label: string; }): ReactNode {
|
|
62
|
+
const menu = useContextMenuContext("Image.Menu");
|
|
63
|
+
const ref = useRef<HTMLSpanElement | null>(null);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<span ref={ref} style={{ display: "contents" }}>
|
|
67
|
+
<MenuButton
|
|
68
|
+
label={label}
|
|
69
|
+
expanded={menu.state.open}
|
|
70
|
+
onPress={() => {
|
|
71
|
+
const box = ref.current?.firstElementChild?.getBoundingClientRect();
|
|
72
|
+
if (!box) return;
|
|
73
|
+
// Under the button and aligned to its right edge, which is where a toolbar
|
|
74
|
+
// menu belongs - the panel flips itself if the window has no room.
|
|
75
|
+
menu.open({ x: box.right, y: box.bottom + 6 });
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
</span>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The image viewer's look.
|
|
3
|
+
*
|
|
4
|
+
* It ships one, for the reason the colour picker and the toast do: there is no useful
|
|
5
|
+
* "unstyled" lightbox. A dialog with no backdrop, no size and no stacking is not a plain
|
|
6
|
+
* viewer, it is the page with an image lying across it - so the panel arrives dressed and
|
|
7
|
+
* `styles={false}` takes it away for anyone drawing their own.
|
|
8
|
+
*
|
|
9
|
+
* A string because the component injects it. `scripts/sync-recipes.mjs` generates
|
|
10
|
+
* `recipes/image/styles.css` from here and CI fails when the two drift, so this module is the
|
|
11
|
+
* source and the stylesheet is the copy for anyone who prefers an import.
|
|
12
|
+
*
|
|
13
|
+
* Everything below is a custom property or an attribute selector: the sheet is PREPENDED to
|
|
14
|
+
* `<head>`, so anything the document already has wins at equal specificity.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const IMAGE_STYLES = `
|
|
18
|
+
:root {
|
|
19
|
+
--enigma-image-backdrop: rgba(0, 0, 0, 0.92);
|
|
20
|
+
--enigma-image-text: #f5f5f5;
|
|
21
|
+
--enigma-image-muted: #a3a3a3;
|
|
22
|
+
--enigma-image-control-bg: rgba(23, 23, 23, 0.72);
|
|
23
|
+
--enigma-image-control-hover: rgba(64, 64, 64, 0.82);
|
|
24
|
+
--enigma-image-control-size: 2.25rem;
|
|
25
|
+
--enigma-image-radius: 0.625rem;
|
|
26
|
+
--enigma-image-gap: 0.75rem;
|
|
27
|
+
--enigma-image-thumb-size: 3.5rem;
|
|
28
|
+
--enigma-image-focus: #f5f5f5;
|
|
29
|
+
/* Above the page's own chrome, not merely above its content: a header or a sticky sidebar
|
|
30
|
+
with a z-index of its own would otherwise paint over the dialog covering it. */
|
|
31
|
+
--enigma-image-z: 9999;
|
|
32
|
+
/* How long the picture takes to fly out of the page and back into it, and the curve it
|
|
33
|
+
flies on. They are the numbers core/image-viewer.ts waits for, so a project that
|
|
34
|
+
changes one here changes it there - see the note above the keyframes. */
|
|
35
|
+
--enigma-image-flight: 300ms;
|
|
36
|
+
--enigma-image-flight-ease: cubic-bezier(0.2, 0, 0.2, 1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* The thumbnail in the page. Only what the behaviour needs: the pointer says it opens, and
|
|
40
|
+
the box is the caller's to size. */
|
|
41
|
+
[data-enigma-image] { display: inline-flex; max-width: 100%; }
|
|
42
|
+
[data-enigma-image][data-clickable] { cursor: zoom-in; }
|
|
43
|
+
/* Stated on the picture as well as inherited: a project that restyles the trigger button is
|
|
44
|
+
one cursor: pointer away from a thumbnail that no longer says it enlarges. */
|
|
45
|
+
[data-enigma-image][data-clickable] img { cursor: zoom-in; }
|
|
46
|
+
[data-enigma-image] img { display: block; max-width: 100%; height: auto; }
|
|
47
|
+
/* A real box, never display:contents: an element without one cannot be focused in Chromium,
|
|
48
|
+
so a viewer closed with Escape would hand focus to the body instead of back to the picture
|
|
49
|
+
it was opened from, and the next Tab would start at the top of the page. */
|
|
50
|
+
[data-enigma-image-trigger] {
|
|
51
|
+
display: inline-flex; max-width: 100%; padding: 0; margin: 0;
|
|
52
|
+
background: none; border: 0; color: inherit; font: inherit; cursor: inherit;
|
|
53
|
+
}
|
|
54
|
+
[data-enigma-image-trigger]:focus-visible img { outline: 2px solid var(--enigma-image-focus); outline-offset: 3px; }
|
|
55
|
+
|
|
56
|
+
/* The viewer. Fixed and portalled: a lightbox inside the page's own stacking context is a
|
|
57
|
+
lightbox some ancestor's overflow or transform will clip. */
|
|
58
|
+
[data-enigma-image-viewer] {
|
|
59
|
+
position: fixed; inset: 0; z-index: var(--enigma-image-z);
|
|
60
|
+
display: grid; grid-template-rows: auto minmax(0, 1fr) auto;
|
|
61
|
+
background: var(--enigma-image-backdrop);
|
|
62
|
+
color: var(--enigma-image-text);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* The opening: the backdrop arrives while the picture flies out of the page, and the toolbar
|
|
66
|
+
and the strip arrive with it rather than being there first over a picture that is still on
|
|
67
|
+
its way. data-state is only ever "opening" or "closing" while an animation is meant to be
|
|
68
|
+
running, so a viewer that skipped the flight - reduced motion, animate={false} - matches
|
|
69
|
+
none of these rules and simply exists. */
|
|
70
|
+
@keyframes enigma-image-in { from { background-color: transparent; } to { background-color: var(--enigma-image-backdrop); } }
|
|
71
|
+
@keyframes enigma-image-chrome { from { opacity: 0; } to { opacity: 1; } }
|
|
72
|
+
[data-enigma-image-viewer][data-state="opening"] { animation: enigma-image-in var(--enigma-image-flight) ease-out; }
|
|
73
|
+
[data-enigma-image-viewer][data-state="opening"] [data-enigma-image-bar],
|
|
74
|
+
[data-enigma-image-viewer][data-state="opening"] [data-enigma-image-foot],
|
|
75
|
+
[data-enigma-image-viewer][data-state="opening"] [data-enigma-image-nav] {
|
|
76
|
+
animation: enigma-image-chrome var(--enigma-image-flight) ease-out;
|
|
77
|
+
}
|
|
78
|
+
/* On the way out the backdrop drains, and the dialog stops taking presses: it is over the
|
|
79
|
+
page again, so a click through it would land on whatever is now under the pointer. */
|
|
80
|
+
[data-enigma-image-viewer][data-state="closing"] {
|
|
81
|
+
background-color: transparent; pointer-events: none;
|
|
82
|
+
transition: background-color var(--enigma-image-flight) var(--enigma-image-flight-ease);
|
|
83
|
+
}
|
|
84
|
+
[data-enigma-image-viewer][data-state="closing"] [data-enigma-image-bar],
|
|
85
|
+
[data-enigma-image-viewer][data-state="closing"] [data-enigma-image-foot],
|
|
86
|
+
[data-enigma-image-viewer][data-state="closing"] [data-enigma-image-nav] {
|
|
87
|
+
opacity: 0; transition: opacity calc(var(--enigma-image-flight) / 2) ease-in;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
[data-enigma-image-bar] {
|
|
91
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
92
|
+
padding: var(--enigma-image-gap);
|
|
93
|
+
}
|
|
94
|
+
[data-enigma-image-counter] {
|
|
95
|
+
font-size: 0.8125rem; font-variant-numeric: tabular-nums; color: var(--enigma-image-muted);
|
|
96
|
+
margin-inline-end: auto;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
[data-enigma-image-button] {
|
|
100
|
+
display: grid; place-items: center; flex: none;
|
|
101
|
+
width: var(--enigma-image-control-size); height: var(--enigma-image-control-size);
|
|
102
|
+
padding: 0; color: var(--enigma-image-text);
|
|
103
|
+
background: var(--enigma-image-control-bg); border: 0; border-radius: 999px;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
}
|
|
106
|
+
[data-enigma-image-button]:hover { background: var(--enigma-image-control-hover); }
|
|
107
|
+
[data-enigma-image-button]:focus-visible { outline: 2px solid var(--enigma-image-focus); outline-offset: 2px; }
|
|
108
|
+
[data-enigma-image-button][disabled] { opacity: 0.35; cursor: not-allowed; }
|
|
109
|
+
|
|
110
|
+
/* The frame the picture is fitted into, and the surface every gesture is measured against. */
|
|
111
|
+
[data-enigma-image-frame] {
|
|
112
|
+
position: relative; overflow: hidden;
|
|
113
|
+
display: grid; place-items: center;
|
|
114
|
+
/* Room for the arrows down each side, so they sit beside the picture rather than on it. */
|
|
115
|
+
padding: 0 calc(var(--enigma-image-control-size) + var(--enigma-image-gap) * 1.5);
|
|
116
|
+
/* The drag IS the control once zoomed, so the page must not scroll under it. */
|
|
117
|
+
touch-action: none;
|
|
118
|
+
-webkit-user-select: none; user-select: none;
|
|
119
|
+
}
|
|
120
|
+
[data-enigma-image-frame] img {
|
|
121
|
+
max-width: 100%; max-height: 100%;
|
|
122
|
+
-webkit-user-drag: none;
|
|
123
|
+
will-change: transform;
|
|
124
|
+
}
|
|
125
|
+
/* Only while it is MOVING, which is not the same as "during the flight".
|
|
126
|
+
A transition left on permanently would put 300ms of lag between the wheel and the zoom, and
|
|
127
|
+
turn every pan into a drag the picture follows late. And one left on during "pending" is
|
|
128
|
+
worse than useless: putting the picture on the thumbnail is itself a transform change, so it
|
|
129
|
+
animated there over 300ms - behind a hidden element - and by the time anybody could see it
|
|
130
|
+
the flight was nearly over. */
|
|
131
|
+
[data-enigma-image-frame][data-flying="moving"] img { transition: transform var(--enigma-image-flight) var(--enigma-image-flight-ease); }
|
|
132
|
+
/* Not drawn until there is somewhere to fly from - see the note on the attribute. The spinner
|
|
133
|
+
is what stands in for it, the same one a slow picture gets. */
|
|
134
|
+
[data-enigma-image-frame][data-flying="pending"] img { visibility: hidden; }
|
|
135
|
+
@media (prefers-reduced-motion: reduce) {
|
|
136
|
+
[data-enigma-image-frame][data-flying="moving"] img { transition: none; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* The cursor says what a press does, which in a lightbox is three different things. The empty
|
|
140
|
+
part of the frame IS the backdrop and closes it; the picture at rest takes a double press to
|
|
141
|
+
enlarge; once it is enlarged the gesture is a drag. */
|
|
142
|
+
[data-enigma-image-frame] { cursor: zoom-out; }
|
|
143
|
+
[data-enigma-image-frame][data-zoom] img { cursor: zoom-in; }
|
|
144
|
+
[data-enigma-image-frame][data-zoomed],
|
|
145
|
+
[data-enigma-image-frame][data-zoomed] img { cursor: grab; }
|
|
146
|
+
[data-enigma-image-frame][data-panning],
|
|
147
|
+
[data-enigma-image-frame][data-panning] img { cursor: grabbing; }
|
|
148
|
+
[data-enigma-image-frame][data-loading] img { opacity: 0.35; }
|
|
149
|
+
|
|
150
|
+
/* Left and right sit over the picture rather than beside it: the frame is the biggest thing
|
|
151
|
+
on screen and the arrows have to be reachable without hunting for an edge. */
|
|
152
|
+
[data-enigma-image-nav] {
|
|
153
|
+
position: absolute; top: 50%; translate: 0 -50%;
|
|
154
|
+
width: var(--enigma-image-control-size); height: var(--enigma-image-control-size);
|
|
155
|
+
}
|
|
156
|
+
[data-enigma-image-nav="previous"] { left: var(--enigma-image-gap); }
|
|
157
|
+
[data-enigma-image-nav="next"] { right: var(--enigma-image-gap); }
|
|
158
|
+
|
|
159
|
+
[data-enigma-image-spinner] {
|
|
160
|
+
position: absolute; width: 1.75rem; height: 1.75rem;
|
|
161
|
+
border: 2px solid rgba(255, 255, 255, 0.25); border-top-color: var(--enigma-image-text);
|
|
162
|
+
border-radius: 999px; animation: enigma-image-spin 700ms linear infinite;
|
|
163
|
+
}
|
|
164
|
+
@keyframes enigma-image-spin { to { rotate: 360deg; } }
|
|
165
|
+
@media (prefers-reduced-motion: reduce) {
|
|
166
|
+
[data-enigma-image-spinner] { animation-duration: 2s; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
[data-enigma-image-foot] { display: grid; gap: 0.5rem; padding: var(--enigma-image-gap); }
|
|
170
|
+
[data-enigma-image-caption] {
|
|
171
|
+
margin: 0; text-align: center; font-size: 0.8125rem; line-height: 1.5;
|
|
172
|
+
color: var(--enigma-image-muted);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* The strip. Scrolls on its own rather than wrapping: a gallery of forty is one row you drag
|
|
176
|
+
through, not five rows that push the picture off the screen. */
|
|
177
|
+
[data-enigma-image-strip] {
|
|
178
|
+
display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: thin;
|
|
179
|
+
justify-content: safe center;
|
|
180
|
+
padding-bottom: 0.25rem;
|
|
181
|
+
}
|
|
182
|
+
[data-enigma-image-thumb] {
|
|
183
|
+
flex: none; width: var(--enigma-image-thumb-size); height: var(--enigma-image-thumb-size);
|
|
184
|
+
padding: 0; overflow: hidden;
|
|
185
|
+
background: none; border: 0; border-radius: 0.375rem;
|
|
186
|
+
opacity: 0.55; cursor: pointer;
|
|
187
|
+
}
|
|
188
|
+
[data-enigma-image-thumb] img { width: 100%; height: 100%; object-fit: cover; }
|
|
189
|
+
[data-enigma-image-thumb]:hover { opacity: 0.85; }
|
|
190
|
+
[data-enigma-image-thumb][aria-current="true"] { opacity: 1; box-shadow: 0 0 0 2px var(--enigma-image-focus); }
|
|
191
|
+
[data-enigma-image-thumb]:focus-visible { outline: 2px solid var(--enigma-image-focus); outline-offset: 2px; }
|
|
192
|
+
`;
|
|
193
|
+
|
|
194
|
+
let injected = false;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Put the sheet in the document, once.
|
|
198
|
+
*
|
|
199
|
+
* It lives here rather than in the component because BOTH halves need it and they are in
|
|
200
|
+
* different chunks: the thumbnail is in the page from the first paint, and the viewer arrives
|
|
201
|
+
* with its own module later. Injecting only from the viewer left the picture in the page
|
|
202
|
+
* unstyled until somebody opened one - so the cursor did not say it enlarges, and the focus
|
|
203
|
+
* ring around it did not exist, until after the first press.
|
|
204
|
+
*/
|
|
205
|
+
export function injectImageStyles(): void {
|
|
206
|
+
if (injected || typeof document === "undefined") return;
|
|
207
|
+
injected = true;
|
|
208
|
+
if (document.querySelector("[data-enigma-image-styles]")) return;
|
|
209
|
+
const element = document.createElement("style");
|
|
210
|
+
element.setAttribute("data-enigma-image-styles", "");
|
|
211
|
+
element.textContent = IMAGE_STYLES;
|
|
212
|
+
document.head.prepend(element);
|
|
213
|
+
}
|