@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/dist/esm/core/EventBus.d.ts +16 -0
  4. package/dist/esm/core/FocusTrap.d.ts +9 -0
  5. package/dist/esm/core/Gallery.d.ts +221 -0
  6. package/dist/esm/core/GestureController.d.ts +50 -0
  7. package/dist/esm/core/LiveRegion.d.ts +6 -0
  8. package/dist/esm/core/SlideManager.d.ts +83 -0
  9. package/dist/esm/core/bodyScrollLock.d.ts +2 -0
  10. package/dist/esm/core/dom.d.ts +27 -0
  11. package/dist/esm/core/icons.d.ts +6 -0
  12. package/dist/esm/core/index.d.ts +5 -0
  13. package/dist/esm/core/index.js +1966 -0
  14. package/dist/esm/core/index.js.map +1 -0
  15. package/dist/esm/core/plugin.d.ts +54 -0
  16. package/dist/esm/core/rotateFlipNormalize.d.ts +19 -0
  17. package/dist/esm/core/scan.d.ts +8 -0
  18. package/dist/esm/core/types.d.ts +268 -0
  19. package/dist/esm/core/zoomTransition.d.ts +33 -0
  20. package/dist/esm/gestures/GestureEngine.d.ts +78 -0
  21. package/dist/esm/index.css +475 -0
  22. package/dist/esm/index.d.ts +32 -0
  23. package/dist/esm/index.js +24 -0
  24. package/dist/esm/index.js.map +1 -0
  25. package/dist/esm/index2.css +22 -0
  26. package/dist/esm/index3.css +166 -0
  27. package/dist/esm/index4.css +27 -0
  28. package/dist/esm/plugins/activeThumbnail/index.d.ts +26 -0
  29. package/dist/esm/plugins/activeThumbnail/index.js +48 -0
  30. package/dist/esm/plugins/activeThumbnail/index.js.map +1 -0
  31. package/dist/esm/plugins/autoplay/icons.d.ts +3 -0
  32. package/dist/esm/plugins/autoplay/index.d.ts +17 -0
  33. package/dist/esm/plugins/autoplay/index.js +177 -0
  34. package/dist/esm/plugins/autoplay/index.js.map +1 -0
  35. package/dist/esm/plugins/fullscreen/icons.d.ts +3 -0
  36. package/dist/esm/plugins/fullscreen/index.d.ts +17 -0
  37. package/dist/esm/plugins/fullscreen/index.js +74 -0
  38. package/dist/esm/plugins/fullscreen/index.js.map +1 -0
  39. package/dist/esm/plugins/layout/index.d.ts +191 -0
  40. package/dist/esm/plugins/layout/index.js +746 -0
  41. package/dist/esm/plugins/layout/index.js.map +1 -0
  42. package/dist/esm/plugins/layout/justified.d.ts +68 -0
  43. package/dist/esm/plugins/layout/masonry.d.ts +92 -0
  44. package/dist/esm/plugins/rotateFlip/icons.d.ts +5 -0
  45. package/dist/esm/plugins/rotateFlip/index.d.ts +17 -0
  46. package/dist/esm/plugins/rotateFlip/index.js +138 -0
  47. package/dist/esm/plugins/rotateFlip/index.js.map +1 -0
  48. package/dist/esm/plugins/video/index.d.ts +13 -0
  49. package/dist/esm/plugins/video/index.js +95 -0
  50. package/dist/esm/plugins/video/index.js.map +1 -0
  51. package/dist/esm/plugins/video/youtube.d.ts +61 -0
  52. package/dist/esm/plugins/zoom/icons.d.ts +4 -0
  53. package/dist/esm/plugins/zoom/index.d.ts +30 -0
  54. package/dist/esm/plugins/zoom/index.js +274 -0
  55. package/dist/esm/plugins/zoom/index.js.map +1 -0
  56. package/dist/esm/plugins/zoom/zoomMath.d.ts +24 -0
  57. package/dist/esm/transitions/SlideTransition.d.ts +25 -0
  58. package/dist/esm/transitions/presets.d.ts +21 -0
  59. package/dist/esm/zoomTransition-bbKHpVpA.js +110 -0
  60. package/dist/esm/zoomTransition-bbKHpVpA.js.map +1 -0
  61. package/dist/shoji.css +690 -0
  62. package/dist/shoji.js +3605 -0
  63. package/dist/shoji.js.map +1 -0
  64. package/dist/shoji.min.css +1 -0
  65. package/dist/shoji.min.js +2 -0
  66. package/dist/shoji.min.js.map +1 -0
  67. 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;"}