@michaelyagi/shoji 0.1.0-alpha.6
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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/esm/core/EventBus.d.ts +16 -0
- package/dist/esm/core/FocusTrap.d.ts +9 -0
- package/dist/esm/core/Gallery.d.ts +221 -0
- package/dist/esm/core/GestureController.d.ts +50 -0
- package/dist/esm/core/LiveRegion.d.ts +6 -0
- package/dist/esm/core/SlideManager.d.ts +83 -0
- package/dist/esm/core/bodyScrollLock.d.ts +2 -0
- package/dist/esm/core/dom.d.ts +27 -0
- package/dist/esm/core/icons.d.ts +6 -0
- package/dist/esm/core/index.d.ts +5 -0
- package/dist/esm/core/index.js +1966 -0
- package/dist/esm/core/index.js.map +1 -0
- package/dist/esm/core/plugin.d.ts +54 -0
- package/dist/esm/core/rotateFlipNormalize.d.ts +19 -0
- package/dist/esm/core/scan.d.ts +8 -0
- package/dist/esm/core/types.d.ts +268 -0
- package/dist/esm/core/zoomTransition.d.ts +33 -0
- package/dist/esm/gestures/GestureEngine.d.ts +78 -0
- package/dist/esm/index.css +475 -0
- package/dist/esm/index.d.ts +32 -0
- package/dist/esm/index.js +24 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/index2.css +22 -0
- package/dist/esm/index3.css +166 -0
- package/dist/esm/index4.css +27 -0
- package/dist/esm/plugins/activeThumbnail/index.d.ts +26 -0
- package/dist/esm/plugins/activeThumbnail/index.js +48 -0
- package/dist/esm/plugins/activeThumbnail/index.js.map +1 -0
- package/dist/esm/plugins/autoplay/icons.d.ts +3 -0
- package/dist/esm/plugins/autoplay/index.d.ts +17 -0
- package/dist/esm/plugins/autoplay/index.js +177 -0
- package/dist/esm/plugins/autoplay/index.js.map +1 -0
- package/dist/esm/plugins/fullscreen/icons.d.ts +3 -0
- package/dist/esm/plugins/fullscreen/index.d.ts +17 -0
- package/dist/esm/plugins/fullscreen/index.js +74 -0
- package/dist/esm/plugins/fullscreen/index.js.map +1 -0
- package/dist/esm/plugins/layout/index.d.ts +191 -0
- package/dist/esm/plugins/layout/index.js +746 -0
- package/dist/esm/plugins/layout/index.js.map +1 -0
- package/dist/esm/plugins/layout/justified.d.ts +68 -0
- package/dist/esm/plugins/layout/masonry.d.ts +92 -0
- package/dist/esm/plugins/rotateFlip/icons.d.ts +5 -0
- package/dist/esm/plugins/rotateFlip/index.d.ts +17 -0
- package/dist/esm/plugins/rotateFlip/index.js +138 -0
- package/dist/esm/plugins/rotateFlip/index.js.map +1 -0
- package/dist/esm/plugins/video/index.d.ts +13 -0
- package/dist/esm/plugins/video/index.js +95 -0
- package/dist/esm/plugins/video/index.js.map +1 -0
- package/dist/esm/plugins/video/youtube.d.ts +61 -0
- package/dist/esm/plugins/zoom/icons.d.ts +4 -0
- package/dist/esm/plugins/zoom/index.d.ts +30 -0
- package/dist/esm/plugins/zoom/index.js +274 -0
- package/dist/esm/plugins/zoom/index.js.map +1 -0
- package/dist/esm/plugins/zoom/zoomMath.d.ts +24 -0
- package/dist/esm/transitions/SlideTransition.d.ts +25 -0
- package/dist/esm/transitions/presets.d.ts +21 -0
- package/dist/esm/zoomTransition-bbKHpVpA.js +110 -0
- package/dist/esm/zoomTransition-bbKHpVpA.js.map +1 -0
- package/dist/shoji.css +690 -0
- package/dist/shoji.js +3605 -0
- package/dist/shoji.js.map +1 -0
- package/dist/shoji.min.css +1 -0
- package/dist/shoji.min.js +2 -0
- package/dist/shoji.min.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/* DESIGN.md §5 — layout takes over the container entirely once enabled
|
|
2
|
+
(any host markup that was there at scan time is replaced by its own
|
|
3
|
+
generated tiles), so all of its structural styling lives here rather than
|
|
4
|
+
depending on host CSS. */
|
|
5
|
+
|
|
6
|
+
.shoji-layout {
|
|
7
|
+
position: relative;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* masonry & justified always give the container an explicit
|
|
11
|
+
`container.style.height` (index.ts) — never intrinsic/content-driven —
|
|
12
|
+
so CSS layout containment is safe here and doesn't collapse or misjudge
|
|
13
|
+
its size. A real, reported slowdown: opening/closing the lightbox reads
|
|
14
|
+
layout (getBoundingClientRect/offsetHeight — zoomTransition.ts's FLIP
|
|
15
|
+
technique) which forces the browser to flush *any* pending layout on the
|
|
16
|
+
page first, including this container's own (often large — hundreds of
|
|
17
|
+
individually absolutely-positioned tiles) subtree, even though none of
|
|
18
|
+
that subtree's layout ever changed. `contain: layout` tells the browser
|
|
19
|
+
this subtree's internal layout can't affect, and isn't affected by,
|
|
20
|
+
anything outside it, so that flush can skip descending into it. Not
|
|
21
|
+
applied to grid: its height is intrinsic (content-driven auto-sized
|
|
22
|
+
rows), which layout containment doesn't safely decouple the same way. */
|
|
23
|
+
.shoji-layout--masonry,
|
|
24
|
+
.shoji-layout--justified {
|
|
25
|
+
contain: layout style;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.shoji-layout-tile {
|
|
29
|
+
display: block;
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.shoji-layout-tile img {
|
|
34
|
+
display: block;
|
|
35
|
+
width: 100%;
|
|
36
|
+
height: 100%;
|
|
37
|
+
object-fit: cover;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* masonry & justified: both JS-positioned, absolutely placed, GPU-composited
|
|
41
|
+
moves only (transform), per CLAUDE.md — no top/left animation.
|
|
42
|
+
visibility: hidden until the JS layout pass actually sets a real
|
|
43
|
+
width/height/transform — masonry/justified skip that pass entirely
|
|
44
|
+
(index.ts's containerWidth <= 0 guard) if the container isn't sized yet
|
|
45
|
+
at construction, correcting only once the ResizeObserver's own
|
|
46
|
+
(asynchronous) initial notification fires. In that gap, an unstyled
|
|
47
|
+
tile falls back to CSS auto-sizing from its <img> child, which can be a
|
|
48
|
+
genuinely 0×0 or wildly wrong-shaped rect depending on image load
|
|
49
|
+
timing — clickable, but a click landing on it computes the zoom
|
|
50
|
+
transition's origin from that same bad rect, silently skipping the
|
|
51
|
+
animation (a 0×0 origin) or animating from the wrong place (a
|
|
52
|
+
wrong-shaped one). visibility: hidden (not display: none — keeps the
|
|
53
|
+
space-not-taking difference irrelevant here, but note it also removes
|
|
54
|
+
the tile from hit-testing, unlike opacity: 0) makes an unpositioned
|
|
55
|
+
tile un-clickable for that entire window instead — the JS layout
|
|
56
|
+
functions clear it back to visible the moment they actually set real
|
|
57
|
+
geometry. See DESIGN.md §2.3b. */
|
|
58
|
+
.shoji-layout--masonry .shoji-layout-tile,
|
|
59
|
+
.shoji-layout--justified .shoji-layout-tile {
|
|
60
|
+
position: absolute;
|
|
61
|
+
top: 0;
|
|
62
|
+
left: 0;
|
|
63
|
+
visibility: hidden;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.shoji-layout--masonry.shoji-layout--animate .shoji-layout-tile,
|
|
67
|
+
.shoji-layout--justified.shoji-layout--animate .shoji-layout-tile {
|
|
68
|
+
transition: transform var(--shoji-duration) var(--shoji-easing);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* grid: native CSS grid does the positioning, no JS math or transforms. */
|
|
72
|
+
.shoji-layout--grid {
|
|
73
|
+
display: grid;
|
|
74
|
+
grid-template-columns: repeat(auto-fill, minmax(var(--shoji-layout-column-width, 240px), 1fr));
|
|
75
|
+
gap: var(--shoji-layout-gutter, 8px);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.shoji-layout--grid .shoji-layout-tile {
|
|
79
|
+
aspect-ratio: var(--shoji-layout-tile-aspect, 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* headings (groupBy) — grid: a real grid row of its own, native flow, no JS
|
|
83
|
+
positioning needed (a full-span item naturally starts a new row after
|
|
84
|
+
it). masonry/justified: JS-positioned same as tiles, since those modes
|
|
85
|
+
don't have a document-flow "row" to insert into. */
|
|
86
|
+
.shoji-layout-heading {
|
|
87
|
+
margin: 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.shoji-layout--grid .shoji-layout-heading {
|
|
91
|
+
grid-column: 1 / -1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.shoji-layout--masonry .shoji-layout-heading,
|
|
95
|
+
.shoji-layout--justified .shoji-layout-heading {
|
|
96
|
+
position: absolute;
|
|
97
|
+
top: 0;
|
|
98
|
+
left: 0;
|
|
99
|
+
width: 100%;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* justified headings are compact, inline labels, not full-width blocking
|
|
103
|
+
elements (see the groupBy option's doc comment in index.ts) — sized to
|
|
104
|
+
their own content by default, and to their own JS-computed max-width
|
|
105
|
+
only when the content-fitting pass decides a label needs to wrap. This
|
|
106
|
+
overrides the shared width: 100% rule above by source order (equal
|
|
107
|
+
specificity), not by a stronger selector. */
|
|
108
|
+
.shoji-layout--justified .shoji-layout-heading {
|
|
109
|
+
width: auto;
|
|
110
|
+
white-space: nowrap;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.shoji-layout--masonry.shoji-layout--animate .shoji-layout-heading,
|
|
114
|
+
.shoji-layout--justified.shoji-layout--animate .shoji-layout-heading {
|
|
115
|
+
transition: transform var(--shoji-duration) var(--shoji-easing);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Font customization points, per CLAUDE.md ("all styling through --shoji-*
|
|
119
|
+
custom properties with sane defaults"). .shoji-layout-heading itself (the
|
|
120
|
+
plain-string/default heading, and the shared parent of title+subtitle)
|
|
121
|
+
deliberately keeps its browser-default <h2> font-size untouched rather
|
|
122
|
+
than gaining its own --shoji-layout-heading-font-size — overriding that
|
|
123
|
+
here to "inherit" would shrink it from the UA stylesheet's own (larger)
|
|
124
|
+
h2 default, a visible regression for anyone not using the structured
|
|
125
|
+
form. Host CSS targeting .shoji-layout-heading directly already fully
|
|
126
|
+
controls it.
|
|
127
|
+
|
|
128
|
+
The title/subtitle spans below intentionally do NOT size off that same
|
|
129
|
+
(oversized, unreset) h2 default — title and subtitle both get an
|
|
130
|
+
explicit shared default size instead (matching .shoji-counter's own
|
|
131
|
+
0.875rem elsewhere in core, for visual consistency), decoupled from
|
|
132
|
+
whatever the plain-string heading's own size happens to be. The label
|
|
133
|
+
(title) reads as muted/normal-weight, the value (subtitle) as bold/
|
|
134
|
+
full-color — e.g. "Taken" vs a bolded date — the inverse of an earlier
|
|
135
|
+
version of this default that bolded the label instead. */
|
|
136
|
+
.shoji-layout-heading {
|
|
137
|
+
font-family: var(--shoji-layout-heading-font-family, inherit);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.shoji-layout-heading-title {
|
|
141
|
+
font-size: var(--shoji-layout-heading-title-font-size, 0.875rem);
|
|
142
|
+
font-weight: var(--shoji-layout-heading-title-font-weight, 400);
|
|
143
|
+
color: var(--shoji-layout-heading-title-color, #767676);
|
|
144
|
+
/* headingOverflow: 'fit''s ellipsis fallback (the one unavoidable case —
|
|
145
|
+
a single label wider than the container even alone) still needs
|
|
146
|
+
overflow-wrap: break-word so a single long unbroken token (a URL, a
|
|
147
|
+
hashtag) doesn't itself force the ellipsis box wider than intended. */
|
|
148
|
+
overflow-wrap: break-word;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.shoji-layout-heading-subtitle {
|
|
152
|
+
margin-left: 0.6em;
|
|
153
|
+
font-size: var(--shoji-layout-heading-subtitle-font-size, 0.875rem);
|
|
154
|
+
font-weight: var(--shoji-layout-heading-subtitle-font-weight, 700);
|
|
155
|
+
color: var(--shoji-layout-heading-subtitle-color, inherit);
|
|
156
|
+
overflow-wrap: break-word;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* stickyHeadings — grid mode only; masonry/justified headings are already
|
|
160
|
+
absolutely positioned by JS, which sticky can't meaningfully layer onto
|
|
161
|
+
(see the option's own doc comment in index.ts). */
|
|
162
|
+
.shoji-layout--grid .shoji-layout-heading--sticky {
|
|
163
|
+
position: sticky;
|
|
164
|
+
top: 0;
|
|
165
|
+
z-index: 1;
|
|
166
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* DESIGN.md §4-zoom — cursor affordances only; the transform itself is set
|
|
2
|
+
inline (per-slide, computed from gesture/button state, not a static
|
|
3
|
+
rule). Scoped to .shoji-zoom-enabled (added by the plugin itself, per
|
|
4
|
+
slide) rather than the bare .shoji-slide-img class — that class exists on
|
|
5
|
+
every slide image regardless of whether this plugin is even enabled for
|
|
6
|
+
a given gallery, and this CSS ships in the single-file bundle alongside
|
|
7
|
+
every other official plugin's, so an unscoped rule would leak a zoom-in
|
|
8
|
+
cursor onto galleries that never opted into this plugin at all. */
|
|
9
|
+
.shoji-zoom-enabled {
|
|
10
|
+
cursor: zoom-in;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.shoji-zoom-enabled.shoji-zoomed {
|
|
14
|
+
cursor: grab;
|
|
15
|
+
/* Reported from real usage: a scaled-up image could show GPU tile-seam
|
|
16
|
+
lines through its own content while zoomed — the same rasterization
|
|
17
|
+
quirk as the toolbar's own (shoji.css), just from the image's frequent
|
|
18
|
+
scale() updates instead of a neighbor's. Scoped to only while actually
|
|
19
|
+
zoomed (not the whole time this plugin is enabled) — at scale 1 there's
|
|
20
|
+
no scale-up to tile-seam in the first place, so no reason to spend the
|
|
21
|
+
extra memory a promoted layer costs. */
|
|
22
|
+
will-change: transform;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.shoji-zoom-enabled.shoji-zoomed:active {
|
|
26
|
+
cursor: grabbing;
|
|
27
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ShojiPlugin } from '../../core/plugin';
|
|
2
|
+
export interface ActiveThumbnailOptions {
|
|
3
|
+
/** CSS class applied to the origin thumbnail for whichever slide is currently active. Default `'shoji-thumb-active'` — Shoji ships no default styling for it (the host's own thumbnail markup, or the layout plugin's tiles, define what "active" looks like), only the class toggling. */
|
|
4
|
+
activeClass?: string;
|
|
5
|
+
/** Scrolls the active thumbnail into view (`block: 'nearest'`) whenever it changes. Default `true`. */
|
|
6
|
+
scrollIntoView?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Opt-in: keeps the host's thumbnail grid in sync with whichever slide is
|
|
10
|
+
* currently open — highlighting it and, by default, scrolling it into view —
|
|
11
|
+
* as the viewer moves through the lightbox by any means (arrow keys,
|
|
12
|
+
* prev/next buttons, a completed swipe, autoplay ticking forward, a plugin
|
|
13
|
+
* calling `goTo()`). Not core: CLAUDE.md's "everything that can be a plugin
|
|
14
|
+
* is a plugin" — a host whose thumbnail grid is short enough to always be
|
|
15
|
+
* fully visible, or who doesn't want their page scrolling out from under
|
|
16
|
+
* them mid-slideshow, should be able to leave this off entirely.
|
|
17
|
+
*
|
|
18
|
+
* Reuses `Gallery.getOriginElement()` (the same index → thumbnail lookup the
|
|
19
|
+
* zoom transition already relies on, §2.3b) rather than re-deriving it, so
|
|
20
|
+
* this plugin's coverage exactly matches whatever the zoom transition
|
|
21
|
+
* already zooms to/from: `scannedElements[index]` in selector mode,
|
|
22
|
+
* `data-shoji-id="<item.id>"` markers elsewhere (including the layout
|
|
23
|
+
* plugin's own tiles, which set that attribute automatically when
|
|
24
|
+
* `item.id` is present).
|
|
25
|
+
*/
|
|
26
|
+
export declare const ActiveThumbnail: ShojiPlugin;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function prefersReducedMotion() {
|
|
2
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
3
|
+
}
|
|
4
|
+
const ActiveThumbnail = {
|
|
5
|
+
name: "activeThumbnail",
|
|
6
|
+
defaults: {
|
|
7
|
+
activeClass: "shoji-thumb-active",
|
|
8
|
+
scrollIntoView: true
|
|
9
|
+
},
|
|
10
|
+
init(ctx) {
|
|
11
|
+
const { gallery } = ctx;
|
|
12
|
+
const activeClass = String(ctx.options.activeClass ?? "shoji-thumb-active");
|
|
13
|
+
const scrollIntoView = ctx.options.scrollIntoView !== false;
|
|
14
|
+
let current = null;
|
|
15
|
+
function apply(index) {
|
|
16
|
+
const el = gallery.getOriginElement(index);
|
|
17
|
+
if (current && current !== el) current.classList.remove(activeClass);
|
|
18
|
+
if (el) {
|
|
19
|
+
el.classList.add(activeClass);
|
|
20
|
+
if (scrollIntoView) {
|
|
21
|
+
el.scrollIntoView({
|
|
22
|
+
block: "nearest",
|
|
23
|
+
inline: "nearest",
|
|
24
|
+
behavior: prefersReducedMotion() ? "auto" : "smooth"
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
current = el;
|
|
29
|
+
}
|
|
30
|
+
function clear() {
|
|
31
|
+
current == null ? void 0 : current.classList.remove(activeClass);
|
|
32
|
+
current = null;
|
|
33
|
+
}
|
|
34
|
+
const offOpen = ctx.on("afterOpen", ({ index }) => apply(index));
|
|
35
|
+
const offSlide = ctx.on("afterSlide", ({ to }) => apply(to));
|
|
36
|
+
const offClose = ctx.on("close", clear);
|
|
37
|
+
return () => {
|
|
38
|
+
offOpen();
|
|
39
|
+
offSlide();
|
|
40
|
+
offClose();
|
|
41
|
+
clear();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
export {
|
|
46
|
+
ActiveThumbnail
|
|
47
|
+
};
|
|
48
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/plugins/activeThumbnail/index.ts"],"sourcesContent":["import type { PluginContext, ShojiPlugin } from '../../core/plugin';\n\nexport interface ActiveThumbnailOptions {\n /** CSS class applied to the origin thumbnail for whichever slide is currently active. Default `'shoji-thumb-active'` — Shoji ships no default styling for it (the host's own thumbnail markup, or the layout plugin's tiles, define what \"active\" looks like), only the class toggling. */\n activeClass?: string;\n /** Scrolls the active thumbnail into view (`block: 'nearest'`) whenever it changes. Default `true`. */\n scrollIntoView?: boolean;\n}\n\nfunction prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n );\n}\n\n/**\n * Opt-in: keeps the host's thumbnail grid in sync with whichever slide is\n * currently open — highlighting it and, by default, scrolling it into view —\n * as the viewer moves through the lightbox by any means (arrow keys,\n * prev/next buttons, a completed swipe, autoplay ticking forward, a plugin\n * calling `goTo()`). Not core: CLAUDE.md's \"everything that can be a plugin\n * is a plugin\" — a host whose thumbnail grid is short enough to always be\n * fully visible, or who doesn't want their page scrolling out from under\n * them mid-slideshow, should be able to leave this off entirely.\n *\n * Reuses `Gallery.getOriginElement()` (the same index → thumbnail lookup the\n * zoom transition already relies on, §2.3b) rather than re-deriving it, so\n * this plugin's coverage exactly matches whatever the zoom transition\n * already zooms to/from: `scannedElements[index]` in selector mode,\n * `data-shoji-id=\"<item.id>\"` markers elsewhere (including the layout\n * plugin's own tiles, which set that attribute automatically when\n * `item.id` is present).\n */\nexport const ActiveThumbnail: ShojiPlugin = {\n name: 'activeThumbnail',\n defaults: {\n activeClass: 'shoji-thumb-active',\n scrollIntoView: true,\n } satisfies ActiveThumbnailOptions,\n\n init(ctx: PluginContext): () => void {\n const { gallery } = ctx;\n const activeClass = String(ctx.options.activeClass ?? 'shoji-thumb-active');\n const scrollIntoView = ctx.options.scrollIntoView !== false;\n\n let current: HTMLElement | null = null;\n\n function apply(index: number): void {\n const el = gallery.getOriginElement(index);\n if (current && current !== el) current.classList.remove(activeClass);\n if (el) {\n el.classList.add(activeClass);\n if (scrollIntoView) {\n el.scrollIntoView({\n block: 'nearest',\n inline: 'nearest',\n behavior: prefersReducedMotion() ? 'auto' : 'smooth',\n });\n }\n }\n current = el;\n }\n\n // Cleared on close (not left highlighting a thumbnail the viewer isn't\n // looking at anymore) — reopening re-applies it from whatever index\n // open() lands on, via the 'afterOpen' listener below.\n function clear(): void {\n current?.classList.remove(activeClass);\n current = null;\n }\n\n const offOpen = ctx.on('afterOpen', ({ index }) => apply(index));\n const offSlide = ctx.on('afterSlide', ({ to }) => apply(to));\n const offClose = ctx.on('close', clear);\n\n return () => {\n offOpen();\n offSlide();\n offClose();\n clear();\n };\n },\n};\n"],"names":[],"mappings":"AASA,SAAS,uBAAgC;AACvC,SACE,OAAO,OAAO,eAAe,cAC7B,OAAO,WAAW,kCAAkC,EAAE;AAE1D;AAoBO,MAAM,kBAA+B;AAAA,EAC1C,MAAM;AAAA,EACN,UAAU;AAAA,IACR,aAAa;AAAA,IACb,gBAAgB;AAAA,EAAA;AAAA,EAGlB,KAAK,KAAgC;AACnC,UAAM,EAAE,YAAY;AACpB,UAAM,cAAc,OAAO,IAAI,QAAQ,eAAe,oBAAoB;AAC1E,UAAM,iBAAiB,IAAI,QAAQ,mBAAmB;AAEtD,QAAI,UAA8B;AAElC,aAAS,MAAM,OAAqB;AAClC,YAAM,KAAK,QAAQ,iBAAiB,KAAK;AACzC,UAAI,WAAW,YAAY,GAAI,SAAQ,UAAU,OAAO,WAAW;AACnE,UAAI,IAAI;AACN,WAAG,UAAU,IAAI,WAAW;AAC5B,YAAI,gBAAgB;AAClB,aAAG,eAAe;AAAA,YAChB,OAAO;AAAA,YACP,QAAQ;AAAA,YACR,UAAU,qBAAA,IAAyB,SAAS;AAAA,UAAA,CAC7C;AAAA,QACH;AAAA,MACF;AACA,gBAAU;AAAA,IACZ;AAKA,aAAS,QAAc;AACrB,yCAAS,UAAU,OAAO;AAC1B,gBAAU;AAAA,IACZ;AAEA,UAAM,UAAU,IAAI,GAAG,aAAa,CAAC,EAAE,MAAA,MAAY,MAAM,KAAK,CAAC;AAC/D,UAAM,WAAW,IAAI,GAAG,cAAc,CAAC,EAAE,GAAA,MAAS,MAAM,EAAE,CAAC;AAC3D,UAAM,WAAW,IAAI,GAAG,SAAS,KAAK;AAEtC,WAAO,MAAM;AACX,cAAA;AACA,eAAA;AACA,eAAA;AACA,YAAA;AAAA,IACF;AAAA,EACF;AACF;"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** DESIGN.md §9 — inline SVG, stroke/fill = currentColor, matches src/core/icons.ts's convention. */
|
|
2
|
+
export declare const PLAY_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\"/></svg>";
|
|
3
|
+
export declare const PAUSE_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"currentColor\"><path d=\"M7 5h4v14H7zM13 5h4v14h-4z\"/></svg>";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ShojiPlugin } from '../../core/plugin';
|
|
2
|
+
export interface AutoplayOptions {
|
|
3
|
+
/** Milliseconds between advances for timed (photo) slides. Default `5000`. */
|
|
4
|
+
interval?: number;
|
|
5
|
+
/** Shows the thin progress bar (`--shoji-progress`) tracking time-to-next-advance along the dialog's bottom edge, for timed slides only — never shown during video slides, whose own runtime drives advancement instead. Default `true`. Purely presentational: turning it off doesn't change any timing, only whether it's drawn. */
|
|
6
|
+
showProgress?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* DESIGN.md §4-autoplay. Advances on a fixed `interval` (default 5000ms) for
|
|
10
|
+
* ordinary slides; for a video slide, plays it and waits for `ended` instead
|
|
11
|
+
* — the interval never applies to video. A manual pause on that video pauses
|
|
12
|
+
* the *slideshow* too (not just the video); manually resuming the video does
|
|
13
|
+
* NOT resume the slideshow — that requires pressing the slideshow's own
|
|
14
|
+
* play control again. See `enterSlide()`/`onVideoPause()` below for exactly
|
|
15
|
+
* where each half of that rule lives.
|
|
16
|
+
*/
|
|
17
|
+
export declare const Autoplay: ShojiPlugin;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const PLAY_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
|
|
2
|
+
const PAUSE_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>';
|
|
3
|
+
function findPlayable(media) {
|
|
4
|
+
const video = media == null ? void 0 : media.querySelector("video");
|
|
5
|
+
if (video) return video;
|
|
6
|
+
const provider = media == null ? void 0 : media.querySelector(
|
|
7
|
+
".shoji-slide-provider-video"
|
|
8
|
+
);
|
|
9
|
+
if (provider && typeof provider.play === "function") return provider;
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
function isPendingProviderVideo(media) {
|
|
13
|
+
const provider = media == null ? void 0 : media.querySelector(
|
|
14
|
+
".shoji-slide-provider-video"
|
|
15
|
+
);
|
|
16
|
+
return !!provider && typeof provider.play !== "function";
|
|
17
|
+
}
|
|
18
|
+
const PROVIDER_PLAY_RETRY_MS = 400;
|
|
19
|
+
const MAX_PROVIDER_PLAY_ATTEMPTS = 8;
|
|
20
|
+
const Autoplay = {
|
|
21
|
+
name: "autoplay",
|
|
22
|
+
defaults: { interval: 5e3, showProgress: true },
|
|
23
|
+
init(ctx) {
|
|
24
|
+
const { gallery } = ctx;
|
|
25
|
+
const interval = Number(ctx.options.interval ?? 5e3);
|
|
26
|
+
const showProgress = ctx.options.showProgress !== false;
|
|
27
|
+
const locale = ctx.options.locale;
|
|
28
|
+
const playLabel = (locale == null ? void 0 : locale.play) ?? "Play slideshow";
|
|
29
|
+
const pauseLabel = (locale == null ? void 0 : locale.pause) ?? "Pause slideshow";
|
|
30
|
+
let playing = false;
|
|
31
|
+
let timer = null;
|
|
32
|
+
let currentVideo = null;
|
|
33
|
+
let awaitingProviderVideo = false;
|
|
34
|
+
const media = gallery.getActiveMedia();
|
|
35
|
+
function onVideoError() {
|
|
36
|
+
if (playing) advance();
|
|
37
|
+
}
|
|
38
|
+
media == null ? void 0 : media.addEventListener("error", onVideoError);
|
|
39
|
+
const button = document.createElement("button");
|
|
40
|
+
button.type = "button";
|
|
41
|
+
button.className = "shoji-toolbar-button";
|
|
42
|
+
button.setAttribute("aria-label", playLabel);
|
|
43
|
+
button.title = playLabel;
|
|
44
|
+
button.innerHTML = PLAY_ICON;
|
|
45
|
+
const progress = document.createElement("div");
|
|
46
|
+
progress.className = "shoji-autoplay-progress";
|
|
47
|
+
progress.hidden = true;
|
|
48
|
+
const progressBar = document.createElement("div");
|
|
49
|
+
progressBar.className = "shoji-autoplay-progress-bar";
|
|
50
|
+
progress.appendChild(progressBar);
|
|
51
|
+
function clearTimer() {
|
|
52
|
+
if (timer !== null) {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
timer = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function resetProgressBar() {
|
|
58
|
+
if (!showProgress) return;
|
|
59
|
+
progress.hidden = true;
|
|
60
|
+
progressBar.style.transition = "none";
|
|
61
|
+
progressBar.style.width = "0%";
|
|
62
|
+
}
|
|
63
|
+
function runProgressBar(ms) {
|
|
64
|
+
if (!showProgress) return;
|
|
65
|
+
progress.hidden = false;
|
|
66
|
+
progressBar.style.transition = "none";
|
|
67
|
+
progressBar.style.width = "0%";
|
|
68
|
+
void progressBar.offsetWidth;
|
|
69
|
+
progressBar.style.transition = `width ${ms}ms linear`;
|
|
70
|
+
progressBar.style.width = "100%";
|
|
71
|
+
}
|
|
72
|
+
function setButtonState(isPlaying) {
|
|
73
|
+
playing = isPlaying;
|
|
74
|
+
button.innerHTML = isPlaying ? PAUSE_ICON : PLAY_ICON;
|
|
75
|
+
button.setAttribute("aria-label", isPlaying ? pauseLabel : playLabel);
|
|
76
|
+
button.title = isPlaying ? pauseLabel : playLabel;
|
|
77
|
+
}
|
|
78
|
+
function onVideoEnded() {
|
|
79
|
+
if (playing) advance();
|
|
80
|
+
}
|
|
81
|
+
function onVideoPause() {
|
|
82
|
+
if (currentVideo == null ? void 0 : currentVideo.ended) return;
|
|
83
|
+
stop();
|
|
84
|
+
}
|
|
85
|
+
function detachVideo() {
|
|
86
|
+
if (!currentVideo) return;
|
|
87
|
+
currentVideo.removeEventListener("ended", onVideoEnded);
|
|
88
|
+
currentVideo.removeEventListener("pause", onVideoPause);
|
|
89
|
+
currentVideo = null;
|
|
90
|
+
}
|
|
91
|
+
function ensureProviderPlaying(video, attemptsLeft) {
|
|
92
|
+
video.muted = true;
|
|
93
|
+
video.play();
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
if (currentVideo !== video || !playing) return;
|
|
96
|
+
if (!video.paused) return;
|
|
97
|
+
if (attemptsLeft > 0) ensureProviderPlaying(video, attemptsLeft - 1);
|
|
98
|
+
else stop();
|
|
99
|
+
}, PROVIDER_PLAY_RETRY_MS);
|
|
100
|
+
}
|
|
101
|
+
function enterSlide() {
|
|
102
|
+
clearTimer();
|
|
103
|
+
detachVideo();
|
|
104
|
+
resetProgressBar();
|
|
105
|
+
awaitingProviderVideo = false;
|
|
106
|
+
if (!playing) return;
|
|
107
|
+
const media2 = gallery.getActiveMedia();
|
|
108
|
+
const video = findPlayable(media2);
|
|
109
|
+
if (video) {
|
|
110
|
+
currentVideo = video;
|
|
111
|
+
video.addEventListener("ended", onVideoEnded);
|
|
112
|
+
video.addEventListener("pause", onVideoPause);
|
|
113
|
+
if (video instanceof HTMLVideoElement) {
|
|
114
|
+
const playResult = video.play();
|
|
115
|
+
if (playResult && typeof playResult.catch === "function") {
|
|
116
|
+
playResult.catch(() => stop());
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
ensureProviderPlaying(video, MAX_PROVIDER_PLAY_ATTEMPTS);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
awaitingProviderVideo = isPendingProviderVideo(media2);
|
|
124
|
+
runProgressBar(interval);
|
|
125
|
+
timer = setTimeout(advance, interval);
|
|
126
|
+
}
|
|
127
|
+
function advance() {
|
|
128
|
+
const before = gallery.currentIndex;
|
|
129
|
+
gallery.next();
|
|
130
|
+
if (gallery.currentIndex === before) stop();
|
|
131
|
+
}
|
|
132
|
+
function start() {
|
|
133
|
+
if (playing) return;
|
|
134
|
+
setButtonState(true);
|
|
135
|
+
ctx.emit("autoplayStart", {});
|
|
136
|
+
enterSlide();
|
|
137
|
+
}
|
|
138
|
+
function stop() {
|
|
139
|
+
if (!playing) return;
|
|
140
|
+
setButtonState(false);
|
|
141
|
+
clearTimer();
|
|
142
|
+
resetProgressBar();
|
|
143
|
+
if (currentVideo && !currentVideo.paused) currentVideo.pause();
|
|
144
|
+
detachVideo();
|
|
145
|
+
ctx.emit("autoplayStop", {});
|
|
146
|
+
}
|
|
147
|
+
function toggle() {
|
|
148
|
+
if (playing) stop();
|
|
149
|
+
else start();
|
|
150
|
+
}
|
|
151
|
+
button.addEventListener("click", toggle);
|
|
152
|
+
const removeButton = ctx.ui.toolbar("right", button);
|
|
153
|
+
const removeProgress = showProgress ? ctx.ui.overlay(progress) : null;
|
|
154
|
+
const removeShortcut = ctx.ui.registerShortcut(" ", toggle);
|
|
155
|
+
const offSlide = ctx.on("afterSlide", () => {
|
|
156
|
+
if (playing) enterSlide();
|
|
157
|
+
});
|
|
158
|
+
const offSlideItemLoad = ctx.on("slideItemLoad", ({ index }) => {
|
|
159
|
+
if (playing && awaitingProviderVideo && index === gallery.currentIndex) enterSlide();
|
|
160
|
+
});
|
|
161
|
+
const offClose = ctx.on("close", () => stop());
|
|
162
|
+
return () => {
|
|
163
|
+
stop();
|
|
164
|
+
media == null ? void 0 : media.removeEventListener("error", onVideoError);
|
|
165
|
+
removeButton();
|
|
166
|
+
removeProgress == null ? void 0 : removeProgress();
|
|
167
|
+
removeShortcut();
|
|
168
|
+
offSlide();
|
|
169
|
+
offSlideItemLoad();
|
|
170
|
+
offClose();
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
export {
|
|
175
|
+
Autoplay
|
|
176
|
+
};
|
|
177
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/plugins/autoplay/icons.ts","../../../../src/plugins/autoplay/index.ts"],"sourcesContent":["/** DESIGN.md §9 — inline SVG, stroke/fill = currentColor, matches src/core/icons.ts's convention. */\n\nexport const PLAY_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\"/></svg>';\n\nexport const PAUSE_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"currentColor\"><path d=\"M7 5h4v14H7zM13 5h4v14h-4z\"/></svg>';\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { PAUSE_ICON, PLAY_ICON } from './icons';\nimport './autoplay.css';\n\n/**\n * A native `<video>` or a `.shoji-slide-provider-video` container a video\n * provider plugin (§4-video) has augmented with the same shape — Autoplay\n * never needs to know which. `play()`'s return type is technically a lie\n * for the provider case (that `.play` is a synchronous fire-and-forget, no\n * `HTMLVideoElement.play()`-style promise) — harmless, `enterSlide()`'s own\n * `typeof playResult.catch === 'function'` guard already treats a\n * non-promise `play()` result as \"nothing to await,\" same as `undefined`.\n */\ntype PlayableMedia = Pick<\n HTMLVideoElement,\n 'play' | 'pause' | 'paused' | 'ended' | 'muted' | 'addEventListener' | 'removeEventListener'\n>;\n\n/** `.shoji-slide-provider-video` only counts if it's actually been wired up as playable (§4-video's `wirePlayableContract`) — a provider still mid-async-setup, or one that never opted into Autoplay sync at all, isn't. */\nfunction findPlayable(media: HTMLElement | null): PlayableMedia | null {\n const video = media?.querySelector('video');\n if (video) return video;\n const provider = media?.querySelector<HTMLElement & Partial<PlayableMedia>>(\n '.shoji-slide-provider-video',\n );\n if (provider && typeof provider.play === 'function') return provider as PlayableMedia;\n return null;\n}\n\n/** A provider container is attached to the DOM immediately (§4-video), well before its async setup (loading an SDK, constructing a player) finishes wiring `.play` — this is what tells `enterSlide()` \"there's a video here, it's just not playable yet\" apart from \"no video at all,\" so it doesn't mistreat a still-loading video as an ordinary timed slide. */\nfunction isPendingProviderVideo(media: HTMLElement | null): boolean {\n const provider = media?.querySelector<HTMLElement & Partial<PlayableMedia>>(\n '.shoji-slide-provider-video',\n );\n return !!provider && typeof provider.play !== 'function';\n}\n\n// A provider embed's postMessage bridge (DESIGN.md §4.3) can need more real\n// time after \"ready\" before it reliably processes its first command — a\n// play() issued too soon can silently no-op with nothing to catch. Retries\n// a few times with a short delay instead of a single best-effort attempt.\nconst PROVIDER_PLAY_RETRY_MS = 400;\nconst MAX_PROVIDER_PLAY_ATTEMPTS = 8; // + the initial attempt = 9 total, ~3.6s before giving up\n\nexport interface AutoplayOptions {\n /** Milliseconds between advances for timed (photo) slides. Default `5000`. */\n interval?: number;\n /** Shows the thin progress bar (`--shoji-progress`) tracking time-to-next-advance along the dialog's bottom edge, for timed slides only — never shown during video slides, whose own runtime drives advancement instead. Default `true`. Purely presentational: turning it off doesn't change any timing, only whether it's drawn. */\n showProgress?: boolean;\n}\n\n/**\n * DESIGN.md §4-autoplay. Advances on a fixed `interval` (default 5000ms) for\n * ordinary slides; for a video slide, plays it and waits for `ended` instead\n * — the interval never applies to video. A manual pause on that video pauses\n * the *slideshow* too (not just the video); manually resuming the video does\n * NOT resume the slideshow — that requires pressing the slideshow's own\n * play control again. See `enterSlide()`/`onVideoPause()` below for exactly\n * where each half of that rule lives.\n */\nexport const Autoplay: ShojiPlugin = {\n name: 'autoplay',\n defaults: { interval: 5000, showProgress: true } satisfies AutoplayOptions,\n\n init(ctx: PluginContext): () => void {\n const { gallery } = ctx;\n const interval = Number(ctx.options.interval ?? 5000);\n const showProgress = ctx.options.showProgress !== false;\n const locale = ctx.options.locale as Partial<Record<'play' | 'pause', string>> | undefined;\n const playLabel = locale?.play ?? 'Play slideshow';\n const pauseLabel = locale?.pause ?? 'Pause slideshow';\n\n let playing = false;\n let timer: ReturnType<typeof setTimeout> | null = null;\n let currentVideo: PlayableMedia | null = null;\n let awaitingProviderVideo = false;\n\n // .shoji-slide-media (offset 0) is a stable node for the gallery's whole\n // lifetime (SlideManager's pool, DESIGN.md §2.3) — registered once here,\n // not per enterSlide(), rather than tracked/detached alongside\n // currentVideo. A provider's own error event (§4-video, e.g. YouTube's\n // onError) bubbles up to it regardless of which slide is currently\n // showing there, or whether findPlayable() would even consider it\n // \"ready\" yet — a video that errors out before ever becoming playable\n // would otherwise just sit through the full `interval` fallback timer\n // instead of skipping ahead immediately.\n const media = gallery.getActiveMedia();\n function onVideoError(): void {\n if (playing) advance();\n }\n media?.addEventListener('error', onVideoError);\n\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'shoji-toolbar-button';\n button.setAttribute('aria-label', playLabel);\n button.title = playLabel;\n button.innerHTML = PLAY_ICON;\n\n const progress = document.createElement('div');\n progress.className = 'shoji-autoplay-progress';\n progress.hidden = true;\n const progressBar = document.createElement('div');\n progressBar.className = 'shoji-autoplay-progress-bar';\n progress.appendChild(progressBar);\n\n function clearTimer(): void {\n if (timer !== null) {\n clearTimeout(timer);\n timer = null;\n }\n }\n\n function resetProgressBar(): void {\n if (!showProgress) return;\n progress.hidden = true;\n progressBar.style.transition = 'none';\n progressBar.style.width = '0%';\n }\n\n function runProgressBar(ms: number): void {\n if (!showProgress) return;\n progress.hidden = false;\n progressBar.style.transition = 'none';\n progressBar.style.width = '0%';\n void progressBar.offsetWidth; // commit the reset before transitioning\n progressBar.style.transition = `width ${ms}ms linear`;\n progressBar.style.width = '100%';\n }\n\n function setButtonState(isPlaying: boolean): void {\n playing = isPlaying;\n button.innerHTML = isPlaying ? PAUSE_ICON : PLAY_ICON;\n button.setAttribute('aria-label', isPlaying ? pauseLabel : playLabel);\n button.title = isPlaying ? pauseLabel : playLabel;\n }\n\n function onVideoEnded(): void {\n if (playing) advance();\n }\n\n // A real user pause, as opposed to the pause some browsers fire\n // immediately alongside 'ended' — `.ended` disambiguates the two.\n function onVideoPause(): void {\n if (currentVideo?.ended) return;\n stop();\n }\n\n function detachVideo(): void {\n if (!currentVideo) return;\n currentVideo.removeEventListener('ended', onVideoEnded);\n currentVideo.removeEventListener('pause', onVideoPause);\n currentVideo = null;\n }\n\n // A provider embed (e.g. YouTube) is cross-origin — unlike native\n // <video>, its own autoplay policy requires a *direct* user gesture on\n // the embed itself, which an automatic play() arriving via this\n // timer/'ended'/slideItemLoad chain never has; it silently no-ops\n // rather than rejecting, so there's nothing to catch. Muting first is\n // what actually gets it to play — the viewer can still unmute via the\n // embed's own controls.\n function ensureProviderPlaying(video: PlayableMedia, attemptsLeft: number): void {\n video.muted = true;\n video.play();\n setTimeout(() => {\n if (currentVideo !== video || !playing) return; // stale — slide changed, or already stopped\n if (!video.paused) return; // took effect\n if (attemptsLeft > 0) ensureProviderPlaying(video, attemptsLeft - 1);\n else stop(); // exhausted retries — don't leave the slideshow silently stuck\n }, PROVIDER_PLAY_RETRY_MS);\n }\n\n function enterSlide(): void {\n clearTimer();\n detachVideo();\n resetProgressBar();\n awaitingProviderVideo = false;\n if (!playing) return;\n\n const media = gallery.getActiveMedia();\n const video = findPlayable(media);\n if (video) {\n currentVideo = video;\n video.addEventListener('ended', onVideoEnded);\n video.addEventListener('pause', onVideoPause);\n if (video instanceof HTMLVideoElement) {\n const playResult = video.play();\n // Browsers can block an unmuted native <video> play() that isn't a\n // direct continuation of a user gesture (e.g. one arriving via this\n // setTimeout/'ended' chain rather than the toggle button's click) —\n // pause the slideshow and wait for the viewer rather than getting\n // stuck with a video that silently never plays or advances.\n if (playResult && typeof playResult.catch === 'function') {\n playResult.catch(() => stop());\n }\n } else {\n ensureProviderPlaying(video, MAX_PROVIDER_PLAY_ATTEMPTS);\n }\n return;\n }\n\n // A provider video (e.g. YouTube) still mid-setup isn't an ordinary\n // slide either — the slideItemLoad listener below re-enters once it's\n // actually playable. This timer is a fallback in case that never\n // happens (network failure, blocked, ...), so the slideshow can't\n // stall on it forever.\n awaitingProviderVideo = isPendingProviderVideo(media);\n runProgressBar(interval);\n timer = setTimeout(advance, interval);\n }\n\n function advance(): void {\n const before = gallery.currentIndex;\n gallery.next();\n // loop:false and next() was already at the last item — nothing left\n // to advance to; the 'afterSlide' handler below won't fire for a\n // no-op goTo(), so this is the only place that can catch it.\n if (gallery.currentIndex === before) stop();\n }\n\n function start(): void {\n if (playing) return;\n setButtonState(true);\n ctx.emit('autoplayStart', {});\n enterSlide();\n }\n\n function stop(): void {\n if (!playing) return;\n setButtonState(false);\n clearTimer();\n resetProgressBar();\n if (currentVideo && !currentVideo.paused) currentVideo.pause();\n detachVideo();\n ctx.emit('autoplayStop', {});\n }\n\n function toggle(): void {\n if (playing) stop();\n else start();\n }\n\n button.addEventListener('click', toggle);\n\n // 'right' — clusters immediately before the close button, per DESIGN.md §3.1.\n const removeButton = ctx.ui.toolbar('right', button);\n const removeProgress = showProgress ? ctx.ui.overlay(progress) : null;\n const removeShortcut = ctx.ui.registerShortcut(' ', toggle);\n // Any slide change — autoplay's own next(), or the viewer manually\n // navigating mid-slideshow via arrows/buttons/goTo() — re-enters here,\n // tearing down the previous slide's timer/video listeners and setting\n // up fresh ones for whatever is active now. advance() deliberately does\n // NOT call enterSlide() itself: next() already triggers this listener\n // synchronously, so calling it twice would double up the timer/video wiring.\n const offSlide = ctx.on('afterSlide', () => {\n if (playing) enterSlide();\n });\n // A provider video (§4-video) that was still mid-setup when enterSlide()\n // last ran — see awaitingProviderVideo there — becomes playable some\n // time after afterSlide already fired and gave up on it for this pass.\n // Re-enter once it's genuinely ready, scoped to the still-active index\n // so a slide the viewer has already moved past doesn't retroactively\n // hijack the timer.\n const offSlideItemLoad = ctx.on('slideItemLoad', ({ index }) => {\n if (playing && awaitingProviderVideo && index === gallery.currentIndex) enterSlide();\n });\n const offClose = ctx.on('close', () => stop());\n\n return () => {\n stop();\n media?.removeEventListener('error', onVideoError);\n removeButton();\n removeProgress?.();\n removeShortcut();\n offSlide();\n offSlideItemLoad();\n offClose();\n };\n },\n};\n"],"names":["media"],"mappings":"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"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. Four corner-arrow glyphs, a generic "expand"/"collapse" pair, not tied to any particular icon set. */
|
|
2
|
+
export declare const EXPAND_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5\"/></svg>";
|
|
3
|
+
export declare const COMPRESS_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5\"/></svg>";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ShojiPlugin } from '../../core/plugin';
|
|
2
|
+
/**
|
|
3
|
+
* DESIGN.md §4 — native Fullscreen API toggle, `.shoji-outer` (`ctx.ui.outer()`,
|
|
4
|
+
* the whole lightbox — backdrop, dialog, controls) is what goes fullscreen,
|
|
5
|
+
* not just the slide media. No toolbar button at all — not a disabled one —
|
|
6
|
+
* on a browser with no Fullscreen API support (checked once at `init()`;
|
|
7
|
+
* historically Safari on iOS), rather than shipping a dead control.
|
|
8
|
+
*
|
|
9
|
+
* `fullscreenchange` (plus the `webkitfullscreenchange` vendor variant) is
|
|
10
|
+
* the single source of truth for button state, not the click handler
|
|
11
|
+
* directly — a real Fullscreen API `request`/`exit` call can be rejected
|
|
12
|
+
* (permission, another element already fullscreen, etc.), and the browser's
|
|
13
|
+
* own Escape-key fullscreen exit doesn't go through this plugin's click
|
|
14
|
+
* handler at all. Reacting to the event instead of the request keeps the
|
|
15
|
+
* button correct regardless of *why* fullscreen state changed.
|
|
16
|
+
*/
|
|
17
|
+
export declare const Fullscreen: ShojiPlugin;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const EXPAND_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>';
|
|
2
|
+
const COMPRESS_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg>';
|
|
3
|
+
function isSupported() {
|
|
4
|
+
return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled);
|
|
5
|
+
}
|
|
6
|
+
function currentFullscreenElement() {
|
|
7
|
+
return document.fullscreenElement ?? document.webkitFullscreenElement ?? null;
|
|
8
|
+
}
|
|
9
|
+
function requestFullscreen(el) {
|
|
10
|
+
var _a;
|
|
11
|
+
const target = el;
|
|
12
|
+
if (target.requestFullscreen) target.requestFullscreen().catch(() => {
|
|
13
|
+
});
|
|
14
|
+
else (_a = target.webkitRequestFullscreen) == null ? void 0 : _a.call(target);
|
|
15
|
+
}
|
|
16
|
+
function exitFullscreen() {
|
|
17
|
+
var _a;
|
|
18
|
+
const doc = document;
|
|
19
|
+
if (document.exitFullscreen) document.exitFullscreen().catch(() => {
|
|
20
|
+
});
|
|
21
|
+
else (_a = doc.webkitExitFullscreen) == null ? void 0 : _a.call(doc);
|
|
22
|
+
}
|
|
23
|
+
const Fullscreen = {
|
|
24
|
+
name: "fullscreen",
|
|
25
|
+
init(ctx) {
|
|
26
|
+
if (!isSupported()) return;
|
|
27
|
+
const outer = ctx.ui.outer();
|
|
28
|
+
const locale = ctx.gallery.options.locale ?? {};
|
|
29
|
+
const enterLabel = locale.enterFullscreen ?? "Enter fullscreen";
|
|
30
|
+
const exitLabel = locale.exitFullscreen ?? "Exit fullscreen";
|
|
31
|
+
const button = document.createElement("button");
|
|
32
|
+
button.type = "button";
|
|
33
|
+
button.className = "shoji-toolbar-button";
|
|
34
|
+
button.innerHTML = EXPAND_ICON;
|
|
35
|
+
button.setAttribute("aria-label", enterLabel);
|
|
36
|
+
button.title = enterLabel;
|
|
37
|
+
button.setAttribute("aria-pressed", "false");
|
|
38
|
+
function isActive() {
|
|
39
|
+
return currentFullscreenElement() === outer;
|
|
40
|
+
}
|
|
41
|
+
function setState(active) {
|
|
42
|
+
button.innerHTML = active ? COMPRESS_ICON : EXPAND_ICON;
|
|
43
|
+
button.setAttribute("aria-label", active ? exitLabel : enterLabel);
|
|
44
|
+
button.title = active ? exitLabel : enterLabel;
|
|
45
|
+
button.setAttribute("aria-pressed", String(active));
|
|
46
|
+
}
|
|
47
|
+
button.addEventListener("click", () => {
|
|
48
|
+
if (isActive()) exitFullscreen();
|
|
49
|
+
else requestFullscreen(outer);
|
|
50
|
+
});
|
|
51
|
+
const onChange = () => {
|
|
52
|
+
const active = isActive();
|
|
53
|
+
setState(active);
|
|
54
|
+
ctx.emit("fullscreenChange", { fullscreen: active });
|
|
55
|
+
};
|
|
56
|
+
document.addEventListener("fullscreenchange", onChange);
|
|
57
|
+
document.addEventListener("webkitfullscreenchange", onChange);
|
|
58
|
+
const removeButton = ctx.ui.toolbar("right", button);
|
|
59
|
+
const offClose = ctx.on("close", () => {
|
|
60
|
+
if (isActive()) exitFullscreen();
|
|
61
|
+
});
|
|
62
|
+
return () => {
|
|
63
|
+
document.removeEventListener("fullscreenchange", onChange);
|
|
64
|
+
document.removeEventListener("webkitfullscreenchange", onChange);
|
|
65
|
+
removeButton();
|
|
66
|
+
offClose();
|
|
67
|
+
if (isActive()) exitFullscreen();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
export {
|
|
72
|
+
Fullscreen
|
|
73
|
+
};
|
|
74
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/plugins/fullscreen/icons.ts","../../../../src/plugins/fullscreen/index.ts"],"sourcesContent":["/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. Four corner-arrow glyphs, a generic \"expand\"/\"collapse\" pair, not tied to any particular icon set. */\nexport const EXPAND_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5\"/></svg>';\n\nexport const COMPRESS_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5\"/></svg>';\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { COMPRESS_ICON, EXPAND_ICON } from './icons';\n\n/**\n * Vendor-prefixed Fullscreen API surface (older Safari) — not in standard\n * `lib.dom.d.ts`, so this is a documented interop boundary (CLAUDE.md: `any`\n * only there). Everything else in this file uses the standard,\n * already-typed `Element.requestFullscreen`/`Document.exitFullscreen`/\n * `Document.fullscreenElement`/`Document.fullscreenEnabled`.\n */\ninterface WebkitFullscreenElement extends HTMLElement {\n webkitRequestFullscreen?: () => void;\n}\ninterface WebkitFullscreenDocument extends Document {\n webkitExitFullscreen?: () => void;\n webkitFullscreenElement?: Element | null;\n webkitFullscreenEnabled?: boolean;\n}\n\nfunction isSupported(): boolean {\n return !!(\n document.fullscreenEnabled || (document as WebkitFullscreenDocument).webkitFullscreenEnabled\n );\n}\n\nfunction currentFullscreenElement(): Element | null {\n return (\n document.fullscreenElement ??\n (document as WebkitFullscreenDocument).webkitFullscreenElement ??\n null\n );\n}\n\nfunction requestFullscreen(el: HTMLElement): void {\n const target = el as WebkitFullscreenElement;\n if (target.requestFullscreen) target.requestFullscreen().catch(() => {});\n else target.webkitRequestFullscreen?.();\n}\n\nfunction exitFullscreen(): void {\n const doc = document as WebkitFullscreenDocument;\n if (document.exitFullscreen) document.exitFullscreen().catch(() => {});\n else doc.webkitExitFullscreen?.();\n}\n\n/**\n * DESIGN.md §4 — native Fullscreen API toggle, `.shoji-outer` (`ctx.ui.outer()`,\n * the whole lightbox — backdrop, dialog, controls) is what goes fullscreen,\n * not just the slide media. No toolbar button at all — not a disabled one —\n * on a browser with no Fullscreen API support (checked once at `init()`;\n * historically Safari on iOS), rather than shipping a dead control.\n *\n * `fullscreenchange` (plus the `webkitfullscreenchange` vendor variant) is\n * the single source of truth for button state, not the click handler\n * directly — a real Fullscreen API `request`/`exit` call can be rejected\n * (permission, another element already fullscreen, etc.), and the browser's\n * own Escape-key fullscreen exit doesn't go through this plugin's click\n * handler at all. Reacting to the event instead of the request keeps the\n * button correct regardless of *why* fullscreen state changed.\n */\nexport const Fullscreen: ShojiPlugin = {\n name: 'fullscreen',\n\n init(ctx: PluginContext): (() => void) | void {\n if (!isSupported()) return;\n\n const outer = ctx.ui.outer();\n const locale = (ctx.gallery.options.locale ?? {}) as Record<string, string>;\n const enterLabel = locale.enterFullscreen ?? 'Enter fullscreen';\n const exitLabel = locale.exitFullscreen ?? 'Exit fullscreen';\n\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'shoji-toolbar-button';\n button.innerHTML = EXPAND_ICON;\n button.setAttribute('aria-label', enterLabel);\n button.title = enterLabel;\n button.setAttribute('aria-pressed', 'false');\n\n function isActive(): boolean {\n return currentFullscreenElement() === outer;\n }\n\n function setState(active: boolean): void {\n button.innerHTML = active ? COMPRESS_ICON : EXPAND_ICON;\n button.setAttribute('aria-label', active ? exitLabel : enterLabel);\n button.title = active ? exitLabel : enterLabel;\n button.setAttribute('aria-pressed', String(active));\n }\n\n button.addEventListener('click', () => {\n if (isActive()) exitFullscreen();\n else requestFullscreen(outer);\n });\n\n const onChange = (): void => {\n const active = isActive();\n setState(active);\n ctx.emit('fullscreenChange', { fullscreen: active });\n };\n document.addEventListener('fullscreenchange', onChange);\n document.addEventListener('webkitfullscreenchange', onChange);\n\n // 'right' — clusters immediately before the close button (DESIGN.md §3.1).\n const removeButton = ctx.ui.toolbar('right', button);\n\n // Browser-level Escape-to-exit-fullscreen behavior is inconsistent\n // across browsers (some exit fullscreen and still deliver the page its\n // own Escape keydown, some don't) — closing the gallery explicitly\n // exits fullscreen too, rather than risking the page getting stuck\n // fullscreen-on-nothing after the lightbox itself is gone.\n const offClose = ctx.on('close', () => {\n if (isActive()) exitFullscreen();\n });\n\n return () => {\n document.removeEventListener('fullscreenchange', onChange);\n document.removeEventListener('webkitfullscreenchange', onChange);\n removeButton();\n offClose();\n if (isActive()) exitFullscreen();\n };\n },\n};\n"],"names":[],"mappings":"AACO,MAAM,cACX;AAEK,MAAM,gBACX;ACcF,SAAS,cAAuB;AAC9B,SAAO,CAAC,EACN,SAAS,qBAAsB,SAAsC;AAEzE;AAEA,SAAS,2BAA2C;AAClD,SACE,SAAS,qBACR,SAAsC,2BACvC;AAEJ;AAEA,SAAS,kBAAkB,IAAuB;ADhC3C;ACiCL,QAAM,SAAS;AACf,MAAI,OAAO,kBAAmB,QAAO,kBAAA,EAAoB,MAAM,MAAM;AAAA,EAAC,CAAC;AAAA,oBAC3D;AACd;AAEA,SAAS,iBAAuB;ADtCzB;ACuCL,QAAM,MAAM;AACZ,MAAI,SAAS,eAAgB,UAAS,eAAA,EAAiB,MAAM,MAAM;AAAA,EAAC,CAAC;AAAA,iBAC5D;AACX;AAiBO,MAAM,aAA0B;AAAA,EACrC,MAAM;AAAA,EAEN,KAAK,KAAyC;AAC5C,QAAI,CAAC,cAAe;AAEpB,UAAM,QAAQ,IAAI,GAAG,MAAA;AACrB,UAAM,SAAU,IAAI,QAAQ,QAAQ,UAAU,CAAA;AAC9C,UAAM,aAAa,OAAO,mBAAmB;AAC7C,UAAM,YAAY,OAAO,kBAAkB;AAE3C,UAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,WAAO,OAAO;AACd,WAAO,YAAY;AACnB,WAAO,YAAY;AACnB,WAAO,aAAa,cAAc,UAAU;AAC5C,WAAO,QAAQ;AACf,WAAO,aAAa,gBAAgB,OAAO;AAE3C,aAAS,WAAoB;AAC3B,aAAO,+BAA+B;AAAA,IACxC;AAEA,aAAS,SAAS,QAAuB;AACvC,aAAO,YAAY,SAAS,gBAAgB;AAC5C,aAAO,aAAa,cAAc,SAAS,YAAY,UAAU;AACjE,aAAO,QAAQ,SAAS,YAAY;AACpC,aAAO,aAAa,gBAAgB,OAAO,MAAM,CAAC;AAAA,IACpD;AAEA,WAAO,iBAAiB,SAAS,MAAM;AACrC,UAAI,SAAA,EAAY,gBAAA;AAAA,6BACO,KAAK;AAAA,IAC9B,CAAC;AAED,UAAM,WAAW,MAAY;AAC3B,YAAM,SAAS,SAAA;AACf,eAAS,MAAM;AACf,UAAI,KAAK,oBAAoB,EAAE,YAAY,QAAQ;AAAA,IACrD;AACA,aAAS,iBAAiB,oBAAoB,QAAQ;AACtD,aAAS,iBAAiB,0BAA0B,QAAQ;AAG5D,UAAM,eAAe,IAAI,GAAG,QAAQ,SAAS,MAAM;AAOnD,UAAM,WAAW,IAAI,GAAG,SAAS,MAAM;AACrC,UAAI,SAAA,EAAY,gBAAA;AAAA,IAClB,CAAC;AAED,WAAO,MAAM;AACX,eAAS,oBAAoB,oBAAoB,QAAQ;AACzD,eAAS,oBAAoB,0BAA0B,QAAQ;AAC/D,mBAAA;AACA,eAAA;AACA,UAAI,SAAA,EAAY,gBAAA;AAAA,IAClB;AAAA,EACF;AACF;"}
|