@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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { injectImageStyles } from './chunk-EYZ366LP.js';
|
|
2
|
+
import { lazy, useLayoutEffect, useState, useMemo, useEffect, useRef, useCallback, Suspense } from 'react';
|
|
3
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
// src/react/image/types.ts
|
|
6
|
+
function toItem(source) {
|
|
7
|
+
return typeof source === "string" ? { src: source } : source;
|
|
8
|
+
}
|
|
9
|
+
var ImageViewer = lazy(() => import('./viewer-N5WLFB24.js').then((module) => ({ default: module.ImageViewer })));
|
|
10
|
+
var prefetched = false;
|
|
11
|
+
function prefetchViewer() {
|
|
12
|
+
if (prefetched) return;
|
|
13
|
+
prefetched = true;
|
|
14
|
+
void import('./viewer-N5WLFB24.js');
|
|
15
|
+
}
|
|
16
|
+
var ZOOM_DEFAULTS = { min: 1, max: 8, wheel: true, doubleClick: true };
|
|
17
|
+
function Image(props) {
|
|
18
|
+
const {
|
|
19
|
+
src,
|
|
20
|
+
alt,
|
|
21
|
+
images,
|
|
22
|
+
index,
|
|
23
|
+
lightbox = true,
|
|
24
|
+
zoom = true,
|
|
25
|
+
animate = true,
|
|
26
|
+
navigation = false,
|
|
27
|
+
thumbnails = false,
|
|
28
|
+
menu = false,
|
|
29
|
+
discardable = false,
|
|
30
|
+
onDiscard,
|
|
31
|
+
loop = true,
|
|
32
|
+
caption,
|
|
33
|
+
onOpenChange,
|
|
34
|
+
onIndexChange,
|
|
35
|
+
styles = true,
|
|
36
|
+
labels = {},
|
|
37
|
+
wrapperProps,
|
|
38
|
+
...rest
|
|
39
|
+
} = props;
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
if (styles) injectImageStyles();
|
|
42
|
+
}, [styles]);
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
const items = useMemo(() => {
|
|
45
|
+
const set = (images ?? [src]).map(toItem);
|
|
46
|
+
return set.some((entry) => entry.src === src) ? set : [{ src, alt }, ...set];
|
|
47
|
+
}, [images, src, alt]);
|
|
48
|
+
const initial = useMemo(() => {
|
|
49
|
+
if (typeof index === "number" && index >= 0 && index < items.length) return index;
|
|
50
|
+
const found = items.findIndex((entry) => entry.src === src);
|
|
51
|
+
return found === -1 ? 0 : found;
|
|
52
|
+
}, [index, items, src]);
|
|
53
|
+
const [current, setCurrent] = useState(initial);
|
|
54
|
+
useEffect(() => setCurrent(initial), [initial]);
|
|
55
|
+
const triggerRef = useRef(null);
|
|
56
|
+
const thumbnailRef = useRef(null);
|
|
57
|
+
const shown = useRef(current);
|
|
58
|
+
shown.current = current;
|
|
59
|
+
const origin = useCallback(() => shown.current === initial ? thumbnailRef.current?.getBoundingClientRect() ?? null : null, [initial]);
|
|
60
|
+
const zoomOptions = useMemo(() => {
|
|
61
|
+
if (zoom === false) return null;
|
|
62
|
+
const given = zoom === true ? {} : zoom;
|
|
63
|
+
return { ...ZOOM_DEFAULTS, ...given, doubleClick: given.doubleClick !== false, wheel: given.wheel !== false };
|
|
64
|
+
}, [zoom]);
|
|
65
|
+
const menuOptions = useMemo(() => menu === false ? null : menu === true ? {} : menu, [menu]);
|
|
66
|
+
const show = useCallback(() => {
|
|
67
|
+
if (!lightbox) return;
|
|
68
|
+
setOpen(true);
|
|
69
|
+
onOpenChange?.(true);
|
|
70
|
+
}, [lightbox, onOpenChange]);
|
|
71
|
+
const close = useCallback(() => {
|
|
72
|
+
setOpen(false);
|
|
73
|
+
onOpenChange?.(false);
|
|
74
|
+
triggerRef.current?.focus();
|
|
75
|
+
}, [onOpenChange]);
|
|
76
|
+
const goTo = useCallback((next) => {
|
|
77
|
+
setCurrent(next);
|
|
78
|
+
const item = items[next];
|
|
79
|
+
if (item) onIndexChange?.(next, item);
|
|
80
|
+
}, [items, onIndexChange]);
|
|
81
|
+
const image = /* @__PURE__ */ jsx("img", { ref: thumbnailRef, src, alt, ...rest });
|
|
82
|
+
return /* @__PURE__ */ jsxs("span", { ...wrapperProps, "data-enigma-image": "", "data-clickable": lightbox ? "" : void 0, children: [
|
|
83
|
+
lightbox ? /* @__PURE__ */ jsx(
|
|
84
|
+
"button",
|
|
85
|
+
{
|
|
86
|
+
ref: triggerRef,
|
|
87
|
+
type: "button",
|
|
88
|
+
"data-enigma-image-trigger": "",
|
|
89
|
+
"aria-label": labels.open ? `${labels.open}: ${alt}` : alt,
|
|
90
|
+
"aria-haspopup": "dialog",
|
|
91
|
+
onClick: show,
|
|
92
|
+
onPointerEnter: prefetchViewer,
|
|
93
|
+
onFocus: prefetchViewer,
|
|
94
|
+
children: image
|
|
95
|
+
}
|
|
96
|
+
) : image,
|
|
97
|
+
open && /* @__PURE__ */ jsx(Suspense, { fallback: null, children: /* @__PURE__ */ jsx(
|
|
98
|
+
ImageViewer,
|
|
99
|
+
{
|
|
100
|
+
items,
|
|
101
|
+
index: current,
|
|
102
|
+
onIndex: goTo,
|
|
103
|
+
onClose: close,
|
|
104
|
+
zoom: zoomOptions,
|
|
105
|
+
navigation,
|
|
106
|
+
thumbnails,
|
|
107
|
+
menu: menuOptions,
|
|
108
|
+
discardable,
|
|
109
|
+
onDiscard,
|
|
110
|
+
loop,
|
|
111
|
+
caption,
|
|
112
|
+
origin,
|
|
113
|
+
animate,
|
|
114
|
+
styles,
|
|
115
|
+
labels
|
|
116
|
+
}
|
|
117
|
+
) })
|
|
118
|
+
] });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export { Image };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContextMenu, clipboardAction, performClipboardAction, inspectClipboardTarget, CLIPBOARD_PREFIX, clipboardEntries, isAction, clipboardHasText } from './chunk-
|
|
1
|
+
import { createContextMenu, clipboardAction, performClipboardAction, inspectClipboardTarget, CLIPBOARD_PREFIX, clipboardEntries, isAction, clipboardHasText } from './chunk-YQV5XZIA.js';
|
|
2
2
|
import { shortcutTokens } from './chunk-4VUHQFAT.js';
|
|
3
3
|
import { Slot } from './chunk-HS3X3XCW.js';
|
|
4
4
|
import { shortenQuery } from './chunk-4ZPMP47J.js';
|
|
@@ -23,6 +23,13 @@ var CONTEXT_MENU_STYLES = `
|
|
|
23
23
|
--enigma-menu-accent: #fbbf24;
|
|
24
24
|
--enigma-menu-danger: #f87171;
|
|
25
25
|
--enigma-menu-danger-bg: rgba(248, 113, 113, 0.12);
|
|
26
|
+
|
|
27
|
+
/* Above the lightbox, which sits at 9999, because a menu is opened FROM whatever is on
|
|
28
|
+
top: the image viewer's own three-dot menu was portalled to the same body and painted
|
|
29
|
+
underneath it, so its rows could be neither seen nor pressed. A menu is the topmost
|
|
30
|
+
transient surface on a page, and this is the number that says so. It belongs here and
|
|
31
|
+
not in the light-scheme block, or half the readers get an invalid z-index. */
|
|
32
|
+
--enigma-menu-z: 10000;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
@media (prefers-color-scheme: light) {
|
|
@@ -43,7 +50,7 @@ var CONTEXT_MENU_STYLES = `
|
|
|
43
50
|
and what a menu opened at the pointer needs. Fixed rather than absolute so a scrolling
|
|
44
51
|
ancestor cannot drag it away from the place it was opened. */
|
|
45
52
|
[data-enigma-menu-panel] {
|
|
46
|
-
position: fixed; z-index:
|
|
53
|
+
position: fixed; z-index: var(--enigma-menu-z);
|
|
47
54
|
box-sizing: border-box;
|
|
48
55
|
min-width: var(--enigma-menu-min-width);
|
|
49
56
|
max-width: min(22rem, calc(100vw - 1rem));
|
|
@@ -245,6 +252,7 @@ function ContextMenuRoot(props) {
|
|
|
245
252
|
const {
|
|
246
253
|
items,
|
|
247
254
|
title,
|
|
255
|
+
searchable,
|
|
248
256
|
disabled = false,
|
|
249
257
|
fuse,
|
|
250
258
|
fuseOptions,
|
|
@@ -272,6 +280,7 @@ function ContextMenuRoot(props) {
|
|
|
272
280
|
const instance = useMemo(() => createContextMenu({
|
|
273
281
|
items,
|
|
274
282
|
title,
|
|
283
|
+
searchable,
|
|
275
284
|
fuse,
|
|
276
285
|
fuseOptions,
|
|
277
286
|
matcher,
|
|
@@ -328,8 +337,8 @@ function ContextMenuRoot(props) {
|
|
|
328
337
|
return [...clip, { type: "separator", id: `${CLIPBOARD_PREFIX}rule` }, ...rows];
|
|
329
338
|
}, []);
|
|
330
339
|
useEffect(() => {
|
|
331
|
-
instance.update({ items: withClipboard(currentItems.current), title });
|
|
332
|
-
}, [instance, signature, title, withClipboard]);
|
|
340
|
+
instance.update({ items: withClipboard(currentItems.current), title, searchable });
|
|
341
|
+
}, [instance, signature, title, searchable, withClipboard]);
|
|
333
342
|
const cancelClose = useCallback(() => {
|
|
334
343
|
window.clearTimeout(closing.current);
|
|
335
344
|
}, []);
|
|
@@ -699,6 +708,7 @@ function ContextMenuRow({ level, index, entry, children, ...props }) {
|
|
|
699
708
|
if (entry.type === "separator") return /* @__PURE__ */ jsx("div", { ...props, role: "separator", "data-enigma-menu-separator": "" });
|
|
700
709
|
return /* @__PURE__ */ jsx("p", { ...props, "data-enigma-menu-label": "", "aria-hidden": "true", children: entry.label });
|
|
701
710
|
}
|
|
711
|
+
const reserveCheck = state?.entries.some((entry2) => isAction(entry2) && entry2.checked !== void 0) ?? false;
|
|
702
712
|
const checkable = item.checked !== void 0;
|
|
703
713
|
const role = checkable ? item.group ? "menuitemradio" : "menuitemcheckbox" : "menuitem";
|
|
704
714
|
return /* @__PURE__ */ jsx(
|
|
@@ -741,13 +751,13 @@ function ContextMenuRow({ level, index, entry, children, ...props }) {
|
|
|
741
751
|
if (event.defaultPrevented || item.disabled || event.detail !== 0) return;
|
|
742
752
|
menu.instance.select(level, index);
|
|
743
753
|
},
|
|
744
|
-
children: children ?? menu.renderItem?.(item, level, index) ?? /* @__PURE__ */ jsx(ContextMenuRowContent, { item, submenu })
|
|
754
|
+
children: children ?? menu.renderItem?.(item, level, index) ?? /* @__PURE__ */ jsx(ContextMenuRowContent, { item, submenu, reserveCheck })
|
|
745
755
|
}
|
|
746
756
|
);
|
|
747
757
|
}
|
|
748
|
-
function ContextMenuRowContent({ item, submenu }) {
|
|
758
|
+
function ContextMenuRowContent({ item, submenu, reserveCheck }) {
|
|
749
759
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
750
|
-
item.checked !== void 0 && /* @__PURE__ */ jsx("span", { "data-enigma-menu-check": "", "aria-hidden": "true" }),
|
|
760
|
+
(item.checked !== void 0 || reserveCheck) && /* @__PURE__ */ jsx("span", { "data-enigma-menu-check": "", "aria-hidden": "true" }),
|
|
751
761
|
item.icon ? /* @__PURE__ */ jsx("span", { "data-enigma-menu-icon": "", children: item.icon }) : null,
|
|
752
762
|
/* @__PURE__ */ jsxs("span", { "data-enigma-menu-item-text": "", children: [
|
|
753
763
|
/* @__PURE__ */ jsx("span", { "data-enigma-menu-item-label": "", children: item.label }),
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// src/react/image/styles.ts
|
|
2
|
+
var IMAGE_STYLES = `
|
|
3
|
+
:root {
|
|
4
|
+
--enigma-image-backdrop: rgba(0, 0, 0, 0.92);
|
|
5
|
+
--enigma-image-text: #f5f5f5;
|
|
6
|
+
--enigma-image-muted: #a3a3a3;
|
|
7
|
+
--enigma-image-control-bg: rgba(23, 23, 23, 0.72);
|
|
8
|
+
--enigma-image-control-hover: rgba(64, 64, 64, 0.82);
|
|
9
|
+
--enigma-image-control-size: 2.25rem;
|
|
10
|
+
--enigma-image-radius: 0.625rem;
|
|
11
|
+
--enigma-image-gap: 0.75rem;
|
|
12
|
+
--enigma-image-thumb-size: 3.5rem;
|
|
13
|
+
--enigma-image-focus: #f5f5f5;
|
|
14
|
+
/* Above the page's own chrome, not merely above its content: a header or a sticky sidebar
|
|
15
|
+
with a z-index of its own would otherwise paint over the dialog covering it. */
|
|
16
|
+
--enigma-image-z: 9999;
|
|
17
|
+
/* How long the picture takes to fly out of the page and back into it, and the curve it
|
|
18
|
+
flies on. They are the numbers core/image-viewer.ts waits for, so a project that
|
|
19
|
+
changes one here changes it there - see the note above the keyframes. */
|
|
20
|
+
--enigma-image-flight: 300ms;
|
|
21
|
+
--enigma-image-flight-ease: cubic-bezier(0.2, 0, 0.2, 1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* The thumbnail in the page. Only what the behaviour needs: the pointer says it opens, and
|
|
25
|
+
the box is the caller's to size. */
|
|
26
|
+
[data-enigma-image] { display: inline-flex; max-width: 100%; }
|
|
27
|
+
[data-enigma-image][data-clickable] { cursor: zoom-in; }
|
|
28
|
+
/* Stated on the picture as well as inherited: a project that restyles the trigger button is
|
|
29
|
+
one cursor: pointer away from a thumbnail that no longer says it enlarges. */
|
|
30
|
+
[data-enigma-image][data-clickable] img { cursor: zoom-in; }
|
|
31
|
+
[data-enigma-image] img { display: block; max-width: 100%; height: auto; }
|
|
32
|
+
/* A real box, never display:contents: an element without one cannot be focused in Chromium,
|
|
33
|
+
so a viewer closed with Escape would hand focus to the body instead of back to the picture
|
|
34
|
+
it was opened from, and the next Tab would start at the top of the page. */
|
|
35
|
+
[data-enigma-image-trigger] {
|
|
36
|
+
display: inline-flex; max-width: 100%; padding: 0; margin: 0;
|
|
37
|
+
background: none; border: 0; color: inherit; font: inherit; cursor: inherit;
|
|
38
|
+
}
|
|
39
|
+
[data-enigma-image-trigger]:focus-visible img { outline: 2px solid var(--enigma-image-focus); outline-offset: 3px; }
|
|
40
|
+
|
|
41
|
+
/* The viewer. Fixed and portalled: a lightbox inside the page's own stacking context is a
|
|
42
|
+
lightbox some ancestor's overflow or transform will clip. */
|
|
43
|
+
[data-enigma-image-viewer] {
|
|
44
|
+
position: fixed; inset: 0; z-index: var(--enigma-image-z);
|
|
45
|
+
display: grid; grid-template-rows: auto minmax(0, 1fr) auto;
|
|
46
|
+
background: var(--enigma-image-backdrop);
|
|
47
|
+
color: var(--enigma-image-text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* The opening: the backdrop arrives while the picture flies out of the page, and the toolbar
|
|
51
|
+
and the strip arrive with it rather than being there first over a picture that is still on
|
|
52
|
+
its way. data-state is only ever "opening" or "closing" while an animation is meant to be
|
|
53
|
+
running, so a viewer that skipped the flight - reduced motion, animate={false} - matches
|
|
54
|
+
none of these rules and simply exists. */
|
|
55
|
+
@keyframes enigma-image-in { from { background-color: transparent; } to { background-color: var(--enigma-image-backdrop); } }
|
|
56
|
+
@keyframes enigma-image-chrome { from { opacity: 0; } to { opacity: 1; } }
|
|
57
|
+
[data-enigma-image-viewer][data-state="opening"] { animation: enigma-image-in var(--enigma-image-flight) ease-out; }
|
|
58
|
+
[data-enigma-image-viewer][data-state="opening"] [data-enigma-image-bar],
|
|
59
|
+
[data-enigma-image-viewer][data-state="opening"] [data-enigma-image-foot],
|
|
60
|
+
[data-enigma-image-viewer][data-state="opening"] [data-enigma-image-nav] {
|
|
61
|
+
animation: enigma-image-chrome var(--enigma-image-flight) ease-out;
|
|
62
|
+
}
|
|
63
|
+
/* On the way out the backdrop drains, and the dialog stops taking presses: it is over the
|
|
64
|
+
page again, so a click through it would land on whatever is now under the pointer. */
|
|
65
|
+
[data-enigma-image-viewer][data-state="closing"] {
|
|
66
|
+
background-color: transparent; pointer-events: none;
|
|
67
|
+
transition: background-color var(--enigma-image-flight) var(--enigma-image-flight-ease);
|
|
68
|
+
}
|
|
69
|
+
[data-enigma-image-viewer][data-state="closing"] [data-enigma-image-bar],
|
|
70
|
+
[data-enigma-image-viewer][data-state="closing"] [data-enigma-image-foot],
|
|
71
|
+
[data-enigma-image-viewer][data-state="closing"] [data-enigma-image-nav] {
|
|
72
|
+
opacity: 0; transition: opacity calc(var(--enigma-image-flight) / 2) ease-in;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
[data-enigma-image-bar] {
|
|
76
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
77
|
+
padding: var(--enigma-image-gap);
|
|
78
|
+
}
|
|
79
|
+
[data-enigma-image-counter] {
|
|
80
|
+
font-size: 0.8125rem; font-variant-numeric: tabular-nums; color: var(--enigma-image-muted);
|
|
81
|
+
margin-inline-end: auto;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
[data-enigma-image-button] {
|
|
85
|
+
display: grid; place-items: center; flex: none;
|
|
86
|
+
width: var(--enigma-image-control-size); height: var(--enigma-image-control-size);
|
|
87
|
+
padding: 0; color: var(--enigma-image-text);
|
|
88
|
+
background: var(--enigma-image-control-bg); border: 0; border-radius: 999px;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
}
|
|
91
|
+
[data-enigma-image-button]:hover { background: var(--enigma-image-control-hover); }
|
|
92
|
+
[data-enigma-image-button]:focus-visible { outline: 2px solid var(--enigma-image-focus); outline-offset: 2px; }
|
|
93
|
+
[data-enigma-image-button][disabled] { opacity: 0.35; cursor: not-allowed; }
|
|
94
|
+
|
|
95
|
+
/* The frame the picture is fitted into, and the surface every gesture is measured against. */
|
|
96
|
+
[data-enigma-image-frame] {
|
|
97
|
+
position: relative; overflow: hidden;
|
|
98
|
+
display: grid; place-items: center;
|
|
99
|
+
/* Room for the arrows down each side, so they sit beside the picture rather than on it. */
|
|
100
|
+
padding: 0 calc(var(--enigma-image-control-size) + var(--enigma-image-gap) * 1.5);
|
|
101
|
+
/* The drag IS the control once zoomed, so the page must not scroll under it. */
|
|
102
|
+
touch-action: none;
|
|
103
|
+
-webkit-user-select: none; user-select: none;
|
|
104
|
+
}
|
|
105
|
+
[data-enigma-image-frame] img {
|
|
106
|
+
max-width: 100%; max-height: 100%;
|
|
107
|
+
-webkit-user-drag: none;
|
|
108
|
+
will-change: transform;
|
|
109
|
+
}
|
|
110
|
+
/* Only while it is MOVING, which is not the same as "during the flight".
|
|
111
|
+
A transition left on permanently would put 300ms of lag between the wheel and the zoom, and
|
|
112
|
+
turn every pan into a drag the picture follows late. And one left on during "pending" is
|
|
113
|
+
worse than useless: putting the picture on the thumbnail is itself a transform change, so it
|
|
114
|
+
animated there over 300ms - behind a hidden element - and by the time anybody could see it
|
|
115
|
+
the flight was nearly over. */
|
|
116
|
+
[data-enigma-image-frame][data-flying="moving"] img { transition: transform var(--enigma-image-flight) var(--enigma-image-flight-ease); }
|
|
117
|
+
/* Not drawn until there is somewhere to fly from - see the note on the attribute. The spinner
|
|
118
|
+
is what stands in for it, the same one a slow picture gets. */
|
|
119
|
+
[data-enigma-image-frame][data-flying="pending"] img { visibility: hidden; }
|
|
120
|
+
@media (prefers-reduced-motion: reduce) {
|
|
121
|
+
[data-enigma-image-frame][data-flying="moving"] img { transition: none; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* The cursor says what a press does, which in a lightbox is three different things. The empty
|
|
125
|
+
part of the frame IS the backdrop and closes it; the picture at rest takes a double press to
|
|
126
|
+
enlarge; once it is enlarged the gesture is a drag. */
|
|
127
|
+
[data-enigma-image-frame] { cursor: zoom-out; }
|
|
128
|
+
[data-enigma-image-frame][data-zoom] img { cursor: zoom-in; }
|
|
129
|
+
[data-enigma-image-frame][data-zoomed],
|
|
130
|
+
[data-enigma-image-frame][data-zoomed] img { cursor: grab; }
|
|
131
|
+
[data-enigma-image-frame][data-panning],
|
|
132
|
+
[data-enigma-image-frame][data-panning] img { cursor: grabbing; }
|
|
133
|
+
[data-enigma-image-frame][data-loading] img { opacity: 0.35; }
|
|
134
|
+
|
|
135
|
+
/* Left and right sit over the picture rather than beside it: the frame is the biggest thing
|
|
136
|
+
on screen and the arrows have to be reachable without hunting for an edge. */
|
|
137
|
+
[data-enigma-image-nav] {
|
|
138
|
+
position: absolute; top: 50%; translate: 0 -50%;
|
|
139
|
+
width: var(--enigma-image-control-size); height: var(--enigma-image-control-size);
|
|
140
|
+
}
|
|
141
|
+
[data-enigma-image-nav="previous"] { left: var(--enigma-image-gap); }
|
|
142
|
+
[data-enigma-image-nav="next"] { right: var(--enigma-image-gap); }
|
|
143
|
+
|
|
144
|
+
[data-enigma-image-spinner] {
|
|
145
|
+
position: absolute; width: 1.75rem; height: 1.75rem;
|
|
146
|
+
border: 2px solid rgba(255, 255, 255, 0.25); border-top-color: var(--enigma-image-text);
|
|
147
|
+
border-radius: 999px; animation: enigma-image-spin 700ms linear infinite;
|
|
148
|
+
}
|
|
149
|
+
@keyframes enigma-image-spin { to { rotate: 360deg; } }
|
|
150
|
+
@media (prefers-reduced-motion: reduce) {
|
|
151
|
+
[data-enigma-image-spinner] { animation-duration: 2s; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
[data-enigma-image-foot] { display: grid; gap: 0.5rem; padding: var(--enigma-image-gap); }
|
|
155
|
+
[data-enigma-image-caption] {
|
|
156
|
+
margin: 0; text-align: center; font-size: 0.8125rem; line-height: 1.5;
|
|
157
|
+
color: var(--enigma-image-muted);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* The strip. Scrolls on its own rather than wrapping: a gallery of forty is one row you drag
|
|
161
|
+
through, not five rows that push the picture off the screen. */
|
|
162
|
+
[data-enigma-image-strip] {
|
|
163
|
+
display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: thin;
|
|
164
|
+
justify-content: safe center;
|
|
165
|
+
padding-bottom: 0.25rem;
|
|
166
|
+
}
|
|
167
|
+
[data-enigma-image-thumb] {
|
|
168
|
+
flex: none; width: var(--enigma-image-thumb-size); height: var(--enigma-image-thumb-size);
|
|
169
|
+
padding: 0; overflow: hidden;
|
|
170
|
+
background: none; border: 0; border-radius: 0.375rem;
|
|
171
|
+
opacity: 0.55; cursor: pointer;
|
|
172
|
+
}
|
|
173
|
+
[data-enigma-image-thumb] img { width: 100%; height: 100%; object-fit: cover; }
|
|
174
|
+
[data-enigma-image-thumb]:hover { opacity: 0.85; }
|
|
175
|
+
[data-enigma-image-thumb][aria-current="true"] { opacity: 1; box-shadow: 0 0 0 2px var(--enigma-image-focus); }
|
|
176
|
+
[data-enigma-image-thumb]:focus-visible { outline: 2px solid var(--enigma-image-focus); outline-offset: 2px; }
|
|
177
|
+
`;
|
|
178
|
+
var injected = false;
|
|
179
|
+
function injectImageStyles() {
|
|
180
|
+
if (injected || typeof document === "undefined") return;
|
|
181
|
+
injected = true;
|
|
182
|
+
if (document.querySelector("[data-enigma-image-styles]")) return;
|
|
183
|
+
const element = document.createElement("style");
|
|
184
|
+
element.setAttribute("data-enigma-image-styles", "");
|
|
185
|
+
element.textContent = IMAGE_STYLES;
|
|
186
|
+
document.head.prepend(element);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export { injectImageStyles };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/core/image-viewer.ts
|
|
2
|
+
var IDENTITY = { scale: 1, x: 0, y: 0 };
|
|
3
|
+
var ZOOM_LIMITS = { min: 1, max: 8 };
|
|
4
|
+
var ZOOM_STEP = 1.35;
|
|
5
|
+
var ZOOM_DOUBLE = 2.5;
|
|
6
|
+
function clampScale(scale, limits = ZOOM_LIMITS) {
|
|
7
|
+
return Math.min(Math.max(scale, limits.min), limits.max);
|
|
8
|
+
}
|
|
9
|
+
function wheelFactor(deltaY, deltaMode = 0) {
|
|
10
|
+
const pixels = deltaMode === 1 ? deltaY * 16 : deltaMode === 2 ? deltaY * 400 : deltaY;
|
|
11
|
+
return Math.exp(-Math.min(Math.max(pixels, -240), 240) / 320);
|
|
12
|
+
}
|
|
13
|
+
function zoomAt(current, factor, point, box, limits = ZOOM_LIMITS) {
|
|
14
|
+
const scale = clampScale(current.scale * factor, limits);
|
|
15
|
+
if (scale === current.scale) return current;
|
|
16
|
+
const dx = point.x - (box.left + box.width / 2);
|
|
17
|
+
const dy = point.y - (box.top + box.height / 2);
|
|
18
|
+
const ratio = scale / current.scale;
|
|
19
|
+
return { scale, x: dx - (dx - current.x) * ratio, y: dy - (dy - current.y) * ratio };
|
|
20
|
+
}
|
|
21
|
+
function clampPan(transform, box, natural) {
|
|
22
|
+
const width = natural.width * transform.scale;
|
|
23
|
+
const height = natural.height * transform.scale;
|
|
24
|
+
const x = Math.max(0, (width - box.width) / 2);
|
|
25
|
+
const y = Math.max(0, (height - box.height) / 2);
|
|
26
|
+
return {
|
|
27
|
+
scale: transform.scale,
|
|
28
|
+
x: Math.min(Math.max(transform.x, -x), x),
|
|
29
|
+
y: Math.min(Math.max(transform.y, -y), y)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function fittedSize(natural, box) {
|
|
33
|
+
if (!natural.width || !natural.height) return { width: box.width, height: box.height };
|
|
34
|
+
const ratio = Math.min(box.width / natural.width, box.height / natural.height, 1);
|
|
35
|
+
return { width: natural.width * ratio, height: natural.height * ratio };
|
|
36
|
+
}
|
|
37
|
+
function pinchDistance(a, b) {
|
|
38
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
39
|
+
}
|
|
40
|
+
function nextIndex(index, length, step, options = {}) {
|
|
41
|
+
const { loop = true, skip } = options;
|
|
42
|
+
if (length <= 0) return -1;
|
|
43
|
+
let next = index;
|
|
44
|
+
for (let moved = 0; moved < length; moved += 1) {
|
|
45
|
+
next += step;
|
|
46
|
+
if (next < 0 || next >= length) {
|
|
47
|
+
if (!loop) return -1;
|
|
48
|
+
next = (next % length + length) % length;
|
|
49
|
+
}
|
|
50
|
+
if (!skip?.has(next)) return next;
|
|
51
|
+
}
|
|
52
|
+
return -1;
|
|
53
|
+
}
|
|
54
|
+
function filenameFrom(url, fallback = "image") {
|
|
55
|
+
try {
|
|
56
|
+
const { pathname } = new URL(url, typeof location === "undefined" ? "https://localhost" : location.href);
|
|
57
|
+
const name = pathname.split("/").filter(Boolean).pop();
|
|
58
|
+
return name && name.includes(".") ? decodeURIComponent(name) : fallback;
|
|
59
|
+
} catch {
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function downloadFile(url, filename) {
|
|
64
|
+
const name = filename ?? filenameFrom(url);
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch(url, { mode: "cors", credentials: "omit" });
|
|
67
|
+
if (!response.ok) throw new Error(`the file answered ${response.status}`);
|
|
68
|
+
const blob = await response.blob();
|
|
69
|
+
const href = URL.createObjectURL(blob);
|
|
70
|
+
saveAs(href, name);
|
|
71
|
+
setTimeout(() => URL.revokeObjectURL(href), 1e4);
|
|
72
|
+
} catch {
|
|
73
|
+
saveAs(url, name, true);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function saveAs(href, name, external = false) {
|
|
77
|
+
if (typeof document === "undefined") return;
|
|
78
|
+
const anchor = document.createElement("a");
|
|
79
|
+
anchor.href = href;
|
|
80
|
+
anchor.download = name;
|
|
81
|
+
anchor.rel = "noopener";
|
|
82
|
+
if (external) anchor.target = "_blank";
|
|
83
|
+
document.body.append(anchor);
|
|
84
|
+
anchor.click();
|
|
85
|
+
anchor.remove();
|
|
86
|
+
}
|
|
87
|
+
function dataUrlToBlob(url) {
|
|
88
|
+
const comma = url.indexOf(",");
|
|
89
|
+
if (!url.startsWith("data:") || comma === -1) throw new Error("not a data URL");
|
|
90
|
+
const head = url.slice("data:".length, comma);
|
|
91
|
+
const body = url.slice(comma + 1);
|
|
92
|
+
const base64 = head.endsWith(";base64");
|
|
93
|
+
const type = (base64 ? head.slice(0, -";base64".length) : head) || "text/plain";
|
|
94
|
+
if (!base64) return new Blob([decodeURIComponent(body)], { type });
|
|
95
|
+
const binary = atob(body);
|
|
96
|
+
const bytes = new Uint8Array(binary.length);
|
|
97
|
+
for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
|
|
98
|
+
return new Blob([bytes], { type });
|
|
99
|
+
}
|
|
100
|
+
function openInNewTab(url) {
|
|
101
|
+
if (typeof window === "undefined") return;
|
|
102
|
+
if (!url.startsWith("data:")) {
|
|
103
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const href = URL.createObjectURL(dataUrlToBlob(url));
|
|
108
|
+
window.open(href, "_blank", "noopener,noreferrer");
|
|
109
|
+
setTimeout(() => URL.revokeObjectURL(href), 6e4);
|
|
110
|
+
} catch {
|
|
111
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
var FLIGHT_MS = 300;
|
|
115
|
+
function flightMs(element) {
|
|
116
|
+
if (!element || typeof getComputedStyle === "undefined") return FLIGHT_MS;
|
|
117
|
+
const seconds = Number.parseFloat(getComputedStyle(element).transitionDuration);
|
|
118
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : FLIGHT_MS;
|
|
119
|
+
}
|
|
120
|
+
function flightFrom(from, to) {
|
|
121
|
+
if (!from.width || !from.height || !to.width || !to.height) return null;
|
|
122
|
+
return {
|
|
123
|
+
scale: from.width / to.width,
|
|
124
|
+
// Centre to centre: `translate(x, y) scale(s)` scales about the element's own centre
|
|
125
|
+
// first, so the offset that lands it on the thumbnail is the distance between them.
|
|
126
|
+
x: from.left + from.width / 2 - (to.left + to.width / 2),
|
|
127
|
+
y: from.top + from.height / 2 - (to.top + to.height / 2)
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function prefersReducedMotion() {
|
|
131
|
+
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
132
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export { FLIGHT_MS, IDENTITY, ZOOM_DOUBLE, ZOOM_LIMITS, ZOOM_STEP, clampPan, clampScale, dataUrlToBlob, downloadFile, filenameFrom, fittedSize, flightFrom, flightMs, nextIndex, openInNewTab, pinchDistance, prefersReducedMotion, wheelFactor, zoomAt };
|