@michaelyagi/shoji 0.1.0-beta.13 → 0.1.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/shoji-core.css +43 -0
- package/dist/core/shoji-core.min.css +1 -1
- package/dist/esm/core/iconSwap.d.ts +21 -0
- package/dist/esm/iconSwap-BAXTLihx.js +21 -0
- package/dist/esm/iconSwap-BAXTLihx.js.map +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/index2.css +43 -0
- package/dist/esm/plugins/autoplay/index.js +4 -2
- package/dist/esm/plugins/autoplay/index.js.map +1 -1
- package/dist/esm/plugins/fullscreen/index.js +4 -2
- package/dist/esm/plugins/fullscreen/index.js.map +1 -1
- package/dist/esm/plugins/zoom/icons.d.ts +14 -1
- package/dist/esm/plugins/zoom/index.js +18 -2
- package/dist/esm/plugins/zoom/index.js.map +1 -1
- package/dist/plugins/autoplay.js +20 -2
- package/dist/plugins/autoplay.js.map +1 -1
- package/dist/plugins/autoplay.min.js +1 -1
- package/dist/plugins/autoplay.min.js.map +1 -1
- package/dist/plugins/fullscreen.js +20 -2
- package/dist/plugins/fullscreen.js.map +1 -1
- package/dist/plugins/fullscreen.min.js +1 -1
- package/dist/plugins/fullscreen.min.js.map +1 -1
- package/dist/plugins/zoom.js +34 -2
- package/dist/plugins/zoom.js.map +1 -1
- package/dist/plugins/zoom.min.js +1 -1
- package/dist/plugins/zoom.min.js.map +1 -1
- package/dist/shoji.css +43 -0
- package/dist/shoji.js +41 -7
- package/dist/shoji.js.map +1 -1
- package/dist/shoji.min.css +1 -1
- package/dist/shoji.min.js +1 -1
- package/dist/shoji.min.js.map +1 -1
- package/package.json +1 -1
package/dist/core/shoji-core.css
CHANGED
|
@@ -51,6 +51,13 @@
|
|
|
51
51
|
|
|
52
52
|
--shoji-easing: cubic-bezier(0.4, 0, 0.2, 1);
|
|
53
53
|
--shoji-duration: 300ms;
|
|
54
|
+
/* A dedicated var, not a reuse of --shoji-duration: a toolbar icon
|
|
55
|
+
cross-fade (Fullscreen's enter/exit, Autoplay's play/pause, Zoom's
|
|
56
|
+
actual-size expand/contract, src/core/iconSwap.ts) reads as a snappy
|
|
57
|
+
state flip, not a UI transition on the same scale as opening/closing —
|
|
58
|
+
the same "reuse read as too slow/fast for a different purpose"
|
|
59
|
+
reasoning `--shoji-active-thumbnail-fade-duration` was split out for. */
|
|
60
|
+
--shoji-icon-swap-duration: 150ms;
|
|
54
61
|
/* The toolbar's own rendered height (44px button + top/bottom padding) —
|
|
55
62
|
defined before the caption sizing below since it now derives from
|
|
56
63
|
this. Also reserves space at the top of a provider embed (YouTube
|
|
@@ -125,6 +132,7 @@
|
|
|
125
132
|
@media (prefers-reduced-motion: reduce) {
|
|
126
133
|
:root {
|
|
127
134
|
--shoji-duration: 0ms;
|
|
135
|
+
--shoji-icon-swap-duration: 0ms;
|
|
128
136
|
}
|
|
129
137
|
}
|
|
130
138
|
|
|
@@ -358,6 +366,41 @@
|
|
|
358
366
|
pointer-events: none;
|
|
359
367
|
}
|
|
360
368
|
|
|
369
|
+
/* `src/core/iconSwap.ts` — a toolbar button whose icon reflects live state
|
|
370
|
+
(Fullscreen enter/exit, Autoplay play/pause, Zoom actual-size
|
|
371
|
+
expand/contract). Both icons stay in the DOM at once, exactly stacked;
|
|
372
|
+
the modifier class below only ever changes which one is opaque, so the
|
|
373
|
+
swap is a plain cross-fade with nothing being added/removed mid-transition
|
|
374
|
+
(no flash-of-no-icon between an old node's removal and a new one's
|
|
375
|
+
fill/stroke painting in). */
|
|
376
|
+
.shoji-icon-swap {
|
|
377
|
+
position: relative;
|
|
378
|
+
display: inline-flex;
|
|
379
|
+
width: 24px;
|
|
380
|
+
height: 24px;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.shoji-icon-swap-icon {
|
|
384
|
+
position: absolute;
|
|
385
|
+
inset: 0;
|
|
386
|
+
display: flex;
|
|
387
|
+
align-items: center;
|
|
388
|
+
justify-content: center;
|
|
389
|
+
transition: opacity var(--shoji-icon-swap-duration, 150ms) var(--shoji-easing);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.shoji-icon-swap-icon--on {
|
|
393
|
+
opacity: 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.shoji-icon-swap--on .shoji-icon-swap-icon--off {
|
|
397
|
+
opacity: 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.shoji-icon-swap--on .shoji-icon-swap-icon--on {
|
|
401
|
+
opacity: 1;
|
|
402
|
+
}
|
|
403
|
+
|
|
361
404
|
.shoji-slides {
|
|
362
405
|
position: relative;
|
|
363
406
|
flex: 1;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
:root{--shoji-z-base: 9000;--shoji-z-backdrop: 0;--shoji-z-slides: 10;--shoji-z-overlay: 20;--shoji-z-toolbar: 30;--shoji-z-toast: 40;--shoji-z-caption-modal: 50;--shoji-color-bg: #0b0b0d;--shoji-color-fg: #f5f5f5;--shoji-color-accent: #4da3ff;--shoji-color-backdrop: #000000;--shoji-color-toolbar-bg: rgba(20, 20, 22, .85);--shoji-color-disabled: rgba(245, 245, 245, .35);--shoji-backdrop-opacity: 1;--shoji-focus-ring: 2px solid var(--shoji-color-accent);--shoji-progress: var(--shoji-color-accent);--shoji-spacing-sm: .5rem;--shoji-spacing-md: 1rem;--shoji-spacing-lg: 1.5rem;--shoji-easing: cubic-bezier(.4, 0, .2, 1);--shoji-duration: .3s;--shoji-provider-video-top-inset: calc(44px + 2 * var(--shoji-spacing-sm));--shoji-video-gesture-margin: 56px;--shoji-caption-max-width: calc(100% - 2 * var(--shoji-spacing-md));--shoji-caption-collapsed-max-height: max( calc(50% - 22px - var(--shoji-spacing-md) - var(--shoji-spacing-sm)), calc(1.6em + 2 * var(--shoji-spacing-sm)) );--shoji-momentum-easing: cubic-bezier(.2, 0, 0, 1);--shoji-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"}.shoji-light{--shoji-color-bg: #fafafa;--shoji-color-fg: #111111;--shoji-color-backdrop: #ffffff;--shoji-color-toolbar-bg: rgba(255, 255, 255, .9);--shoji-color-disabled: rgba(17, 17, 17, .35)}@media(prefers-reduced-motion:reduce){:root{--shoji-duration: 0ms}}@media(min-width:768px){:root{--shoji-caption-max-width: min(calc(100% - 2 * var(--shoji-spacing-md)), max(24vw, 14rem))}}.shoji-outer{display:none;position:fixed;top:0;right:0;bottom:0;left:0;z-index:var(--shoji-z-base)}.shoji-outer.shoji-open{display:block}.shoji-backdrop{position:absolute;top:0;right:0;bottom:0;left:0;z-index:var(--shoji-z-backdrop);background:var(--shoji-color-backdrop);opacity:var(--shoji-backdrop-opacity);transition:opacity var(--shoji-duration) var(--shoji-easing)}.shoji-dialog{position:relative;z-index:var(--shoji-z-slides);display:flex;flex-direction:column;height:100%;color:var(--shoji-color-fg);font-family:var(--shoji-font-family);outline:none;touch-action:none;-webkit-user-select:none;user-select:none;--shoji-caption-max-height: calc( 100% - var(--shoji-toolbar-height, var(--shoji-provider-video-top-inset)) - var(--shoji-spacing-md) - var(--shoji-spacing-sm) )}.shoji-toolbar{position:absolute;inset:0 0 auto 0;z-index:var(--shoji-z-toolbar);display:flex;justify-content:space-between;padding:var(--shoji-spacing-sm);pointer-events:none;will-change:transform}.shoji-toolbar-slot{display:flex;flex-wrap:wrap;align-items:center;gap:var(--shoji-spacing-sm);pointer-events:auto}.shoji-toolbar-left{justify-content:flex-start}.shoji-toolbar-right{justify-content:flex-end}.shoji-close,.shoji-nav,.shoji-toolbar-button,.shoji-caption-modal-close{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:none;padding:0;width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:#0006;color:var(--shoji-color-fg);cursor:pointer}.shoji-close:hover,.shoji-nav:hover,.shoji-toolbar-button:hover,.shoji-caption-modal-close:hover{background:#0009}.shoji-close:focus-visible,.shoji-nav:focus-visible,.shoji-toolbar-button:focus-visible,.shoji-caption-modal-close:focus-visible{outline:var(--shoji-focus-ring);outline-offset:2px}.shoji-nav{position:absolute;top:50%;transform:translateY(-50%);z-index:var(--shoji-z-toolbar)}.shoji-nav-prev{left:var(--shoji-spacing-md)}.shoji-nav-next{right:var(--shoji-spacing-md)}.shoji-nav:disabled{opacity:.3;cursor:default;pointer-events:none}.shoji-close[hidden],.shoji-nav[hidden],.shoji-toolbar-button[hidden]{display:none}.shoji-outer.shoji-slide-loading .shoji-toolbar-button{opacity:.3;cursor:default;pointer-events:none}.shoji-slides{position:relative;flex:1;overflow:hidden}.shoji-slide{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center}.shoji-slide-media{width:100%;height:100%;display:flex;align-items:center;justify-content:center;position:relative;--shoji-provider-video-aspect: calc(16 / 9);container-type:size}.shoji-slide-img,.shoji-slide-video{display:block;max-width:100%;max-height:100%;object-fit:contain}.shoji-slide-open-placeholder{width:100%;height:100%}.shoji-slide-provider-poster{width:min(100cqw,calc(100cqh * var(--shoji-provider-video-aspect)));height:min(100cqh,calc(100cqw / var(--shoji-provider-video-aspect)))}.shoji-slide-provider-video{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding-top:var(--shoji-provider-video-top-inset);container-type:size}.shoji-slide-provider-video[hidden]{display:none}.shoji-slide-provider-video>iframe,.shoji-video-mount{width:min(100cqw,calc(100cqh * var(--shoji-provider-video-aspect)))!important;height:min(100cqh,calc(100cqw / var(--shoji-provider-video-aspect)))!important;margin:0!important;padding:0!important}.shoji-slide-provider-video iframe{display:block;border:0}.shoji-video-mount iframe{position:static!important;width:100%!important;height:100%!important}.shoji-slide-ghost{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:none}.shoji-slide-placeholder{color:var(--shoji-color-disabled)}.shoji-slide-spinner{width:var(--shoji-spinner-size, 40px);height:var(--shoji-spinner-size, 40px);border-radius:50%;border:var(--shoji-spinner-thickness, 3px) solid var(--shoji-color-disabled);border-top-color:var(--shoji-color-accent);animation:shoji-spin .8s linear infinite}@keyframes shoji-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.shoji-slide-spinner{animation:none}}.shoji-counter{padding:.25rem .6rem;border-radius:999px;background:#0006;font-size:.875rem;white-space:nowrap}.shoji-counter[hidden]{display:none}.shoji-caption{position:absolute;bottom:var(--shoji-spacing-md);left:var(--shoji-spacing-md);z-index:var(--shoji-z-toolbar);-webkit-user-select:text;user-select:text;width:fit-content;max-width:var(--shoji-caption-max-width);max-height:min(var(--shoji-caption-max-height),var(--shoji-caption-collapsed-max-height));overflow:hidden;padding:var(--shoji-spacing-sm) var(--shoji-spacing-md);box-sizing:border-box;border-radius:8px;background:var(--shoji-color-toolbar-bg);text-align:left;line-height:1.4}.shoji-caption--video{pointer-events:none}.shoji-caption--video>*{pointer-events:auto}.shoji-caption--truncated{cursor:pointer}.shoji-caption--truncated:after{content:"…";position:absolute;right:var(--shoji-spacing-sm);bottom:var(--shoji-spacing-sm);padding-left:.5em;background:var(--shoji-color-toolbar-bg)}.shoji-caption-modal{position:absolute;top:0;right:0;bottom:0;left:0;z-index:var(--shoji-z-caption-modal);display:flex;align-items:center;justify-content:center;padding:var(--shoji-spacing-lg);background:#0009}.shoji-caption-modal[hidden]{display:none}.shoji-caption-modal-panel{position:relative;max-width:32rem;max-height:100%;overflow-y:auto;overscroll-behavior:contain;padding:var(--shoji-spacing-lg);padding-inline-end:calc(44px + var(--shoji-spacing-lg));border-radius:8px;background:var(--shoji-color-bg);color:var(--shoji-color-fg)}.shoji-caption-modal-close{position:absolute;top:var(--shoji-spacing-sm);right:var(--shoji-spacing-sm)}.shoji-caption[hidden],.shoji-toolbar-overflow[hidden]{display:none}.shoji-toolbar-overflow-panel{position:absolute;top:var(--shoji-toolbar-height, 56px);right:var(--shoji-spacing-md);z-index:var(--shoji-z-toast);display:grid;grid-template-columns:repeat(3,44px);gap:var(--shoji-spacing-sm);max-height:calc(100% - var(--shoji-toolbar-height, 56px) - var(--shoji-spacing-md));overflow-y:auto;padding:var(--shoji-spacing-sm);border-radius:8px;background:var(--shoji-color-bg);color:var(--shoji-color-fg)}.shoji-toolbar-overflow-panel[hidden]{display:none}.shoji-toolbar,.shoji-nav,.shoji-counter,.shoji-caption{transition:opacity var(--shoji-duration) var(--shoji-easing)}.shoji-controls-hidden .shoji-toolbar,.shoji-controls-hidden .shoji-nav,.shoji-controls-hidden .shoji-counter,.shoji-controls-hidden .shoji-caption{opacity:0;pointer-events:none}.shoji-controls-hidden,.shoji-controls-hidden *{cursor:none!important}.shoji-cursor-visible.shoji-controls-hidden,.shoji-cursor-visible.shoji-controls-hidden *,.shoji-controls-hidden-for-drag.shoji-controls-hidden,.shoji-controls-hidden-for-drag.shoji-controls-hidden *{cursor:auto!important}.shoji-live-region{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
1
|
+
:root{--shoji-z-base: 9000;--shoji-z-backdrop: 0;--shoji-z-slides: 10;--shoji-z-overlay: 20;--shoji-z-toolbar: 30;--shoji-z-toast: 40;--shoji-z-caption-modal: 50;--shoji-color-bg: #0b0b0d;--shoji-color-fg: #f5f5f5;--shoji-color-accent: #4da3ff;--shoji-color-backdrop: #000000;--shoji-color-toolbar-bg: rgba(20, 20, 22, .85);--shoji-color-disabled: rgba(245, 245, 245, .35);--shoji-backdrop-opacity: 1;--shoji-focus-ring: 2px solid var(--shoji-color-accent);--shoji-progress: var(--shoji-color-accent);--shoji-spacing-sm: .5rem;--shoji-spacing-md: 1rem;--shoji-spacing-lg: 1.5rem;--shoji-easing: cubic-bezier(.4, 0, .2, 1);--shoji-duration: .3s;--shoji-icon-swap-duration: .15s;--shoji-provider-video-top-inset: calc(44px + 2 * var(--shoji-spacing-sm));--shoji-video-gesture-margin: 56px;--shoji-caption-max-width: calc(100% - 2 * var(--shoji-spacing-md));--shoji-caption-collapsed-max-height: max( calc(50% - 22px - var(--shoji-spacing-md) - var(--shoji-spacing-sm)), calc(1.6em + 2 * var(--shoji-spacing-sm)) );--shoji-momentum-easing: cubic-bezier(.2, 0, 0, 1);--shoji-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"}.shoji-light{--shoji-color-bg: #fafafa;--shoji-color-fg: #111111;--shoji-color-backdrop: #ffffff;--shoji-color-toolbar-bg: rgba(255, 255, 255, .9);--shoji-color-disabled: rgba(17, 17, 17, .35)}@media(prefers-reduced-motion:reduce){:root{--shoji-duration: 0ms;--shoji-icon-swap-duration: 0ms}}@media(min-width:768px){:root{--shoji-caption-max-width: min(calc(100% - 2 * var(--shoji-spacing-md)), max(24vw, 14rem))}}.shoji-outer{display:none;position:fixed;top:0;right:0;bottom:0;left:0;z-index:var(--shoji-z-base)}.shoji-outer.shoji-open{display:block}.shoji-backdrop{position:absolute;top:0;right:0;bottom:0;left:0;z-index:var(--shoji-z-backdrop);background:var(--shoji-color-backdrop);opacity:var(--shoji-backdrop-opacity);transition:opacity var(--shoji-duration) var(--shoji-easing)}.shoji-dialog{position:relative;z-index:var(--shoji-z-slides);display:flex;flex-direction:column;height:100%;color:var(--shoji-color-fg);font-family:var(--shoji-font-family);outline:none;touch-action:none;-webkit-user-select:none;user-select:none;--shoji-caption-max-height: calc( 100% - var(--shoji-toolbar-height, var(--shoji-provider-video-top-inset)) - var(--shoji-spacing-md) - var(--shoji-spacing-sm) )}.shoji-toolbar{position:absolute;inset:0 0 auto 0;z-index:var(--shoji-z-toolbar);display:flex;justify-content:space-between;padding:var(--shoji-spacing-sm);pointer-events:none;will-change:transform}.shoji-toolbar-slot{display:flex;flex-wrap:wrap;align-items:center;gap:var(--shoji-spacing-sm);pointer-events:auto}.shoji-toolbar-left{justify-content:flex-start}.shoji-toolbar-right{justify-content:flex-end}.shoji-close,.shoji-nav,.shoji-toolbar-button,.shoji-caption-modal-close{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:none;padding:0;width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:#0006;color:var(--shoji-color-fg);cursor:pointer}.shoji-close:hover,.shoji-nav:hover,.shoji-toolbar-button:hover,.shoji-caption-modal-close:hover{background:#0009}.shoji-close:focus-visible,.shoji-nav:focus-visible,.shoji-toolbar-button:focus-visible,.shoji-caption-modal-close:focus-visible{outline:var(--shoji-focus-ring);outline-offset:2px}.shoji-nav{position:absolute;top:50%;transform:translateY(-50%);z-index:var(--shoji-z-toolbar)}.shoji-nav-prev{left:var(--shoji-spacing-md)}.shoji-nav-next{right:var(--shoji-spacing-md)}.shoji-nav:disabled{opacity:.3;cursor:default;pointer-events:none}.shoji-close[hidden],.shoji-nav[hidden],.shoji-toolbar-button[hidden]{display:none}.shoji-outer.shoji-slide-loading .shoji-toolbar-button{opacity:.3;cursor:default;pointer-events:none}.shoji-icon-swap{position:relative;display:inline-flex;width:24px;height:24px}.shoji-icon-swap-icon{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;transition:opacity var(--shoji-icon-swap-duration, .15s) var(--shoji-easing)}.shoji-icon-swap-icon--on,.shoji-icon-swap--on .shoji-icon-swap-icon--off{opacity:0}.shoji-icon-swap--on .shoji-icon-swap-icon--on{opacity:1}.shoji-slides{position:relative;flex:1;overflow:hidden}.shoji-slide{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center}.shoji-slide-media{width:100%;height:100%;display:flex;align-items:center;justify-content:center;position:relative;--shoji-provider-video-aspect: calc(16 / 9);container-type:size}.shoji-slide-img,.shoji-slide-video{display:block;max-width:100%;max-height:100%;object-fit:contain}.shoji-slide-open-placeholder{width:100%;height:100%}.shoji-slide-provider-poster{width:min(100cqw,calc(100cqh * var(--shoji-provider-video-aspect)));height:min(100cqh,calc(100cqw / var(--shoji-provider-video-aspect)))}.shoji-slide-provider-video{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding-top:var(--shoji-provider-video-top-inset);container-type:size}.shoji-slide-provider-video[hidden]{display:none}.shoji-slide-provider-video>iframe,.shoji-video-mount{width:min(100cqw,calc(100cqh * var(--shoji-provider-video-aspect)))!important;height:min(100cqh,calc(100cqw / var(--shoji-provider-video-aspect)))!important;margin:0!important;padding:0!important}.shoji-slide-provider-video iframe{display:block;border:0}.shoji-video-mount iframe{position:static!important;width:100%!important;height:100%!important}.shoji-slide-ghost{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:none}.shoji-slide-placeholder{color:var(--shoji-color-disabled)}.shoji-slide-spinner{width:var(--shoji-spinner-size, 40px);height:var(--shoji-spinner-size, 40px);border-radius:50%;border:var(--shoji-spinner-thickness, 3px) solid var(--shoji-color-disabled);border-top-color:var(--shoji-color-accent);animation:shoji-spin .8s linear infinite}@keyframes shoji-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.shoji-slide-spinner{animation:none}}.shoji-counter{padding:.25rem .6rem;border-radius:999px;background:#0006;font-size:.875rem;white-space:nowrap}.shoji-counter[hidden]{display:none}.shoji-caption{position:absolute;bottom:var(--shoji-spacing-md);left:var(--shoji-spacing-md);z-index:var(--shoji-z-toolbar);-webkit-user-select:text;user-select:text;width:fit-content;max-width:var(--shoji-caption-max-width);max-height:min(var(--shoji-caption-max-height),var(--shoji-caption-collapsed-max-height));overflow:hidden;padding:var(--shoji-spacing-sm) var(--shoji-spacing-md);box-sizing:border-box;border-radius:8px;background:var(--shoji-color-toolbar-bg);text-align:left;line-height:1.4}.shoji-caption--video{pointer-events:none}.shoji-caption--video>*{pointer-events:auto}.shoji-caption--truncated{cursor:pointer}.shoji-caption--truncated:after{content:"…";position:absolute;right:var(--shoji-spacing-sm);bottom:var(--shoji-spacing-sm);padding-left:.5em;background:var(--shoji-color-toolbar-bg)}.shoji-caption-modal{position:absolute;top:0;right:0;bottom:0;left:0;z-index:var(--shoji-z-caption-modal);display:flex;align-items:center;justify-content:center;padding:var(--shoji-spacing-lg);background:#0009}.shoji-caption-modal[hidden]{display:none}.shoji-caption-modal-panel{position:relative;max-width:32rem;max-height:100%;overflow-y:auto;overscroll-behavior:contain;padding:var(--shoji-spacing-lg);padding-inline-end:calc(44px + var(--shoji-spacing-lg));border-radius:8px;background:var(--shoji-color-bg);color:var(--shoji-color-fg)}.shoji-caption-modal-close{position:absolute;top:var(--shoji-spacing-sm);right:var(--shoji-spacing-sm)}.shoji-caption[hidden],.shoji-toolbar-overflow[hidden]{display:none}.shoji-toolbar-overflow-panel{position:absolute;top:var(--shoji-toolbar-height, 56px);right:var(--shoji-spacing-md);z-index:var(--shoji-z-toast);display:grid;grid-template-columns:repeat(3,44px);gap:var(--shoji-spacing-sm);max-height:calc(100% - var(--shoji-toolbar-height, 56px) - var(--shoji-spacing-md));overflow-y:auto;padding:var(--shoji-spacing-sm);border-radius:8px;background:var(--shoji-color-bg);color:var(--shoji-color-fg)}.shoji-toolbar-overflow-panel[hidden]{display:none}.shoji-toolbar,.shoji-nav,.shoji-counter,.shoji-caption{transition:opacity var(--shoji-duration) var(--shoji-easing)}.shoji-controls-hidden .shoji-toolbar,.shoji-controls-hidden .shoji-nav,.shoji-controls-hidden .shoji-counter,.shoji-controls-hidden .shoji-caption{opacity:0;pointer-events:none}.shoji-controls-hidden,.shoji-controls-hidden *{cursor:none!important}.shoji-cursor-visible.shoji-controls-hidden,.shoji-cursor-visible.shoji-controls-hidden *,.shoji-controls-hidden-for-drag.shoji-controls-hidden,.shoji-controls-hidden-for-drag.shoji-controls-hidden *{cursor:auto!important}.shoji-live-region{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DESIGN.md §9 — a toolbar button whose icon reflects live state (Fullscreen's
|
|
3
|
+
* enter/exit, Autoplay's play/pause, Zoom's actual-size expand/contract) used
|
|
4
|
+
* to just replace `button.innerHTML` outright — an instant cut, no
|
|
5
|
+
* transition, and each plugin reimplementing the same two-icon swap
|
|
6
|
+
* independently. Requested directly: make every one of these a smooth
|
|
7
|
+
* cross-fade, and make them all go through one shared mechanism rather than
|
|
8
|
+
* three slightly different ones. Both icons are always in the DOM at once,
|
|
9
|
+
* stacked via `position: absolute` inside a fixed-size wrapper
|
|
10
|
+
* (`.shoji-icon-swap`, `shoji.css`) — swapping is just toggling which one is
|
|
11
|
+
* `opacity: 1` via a single modifier class, so there's nothing to actually
|
|
12
|
+
* replace, no risk of a flash-of-no-icon between the old node being removed
|
|
13
|
+
* and the new one's fill/stroke painting in.
|
|
14
|
+
*/
|
|
15
|
+
export interface IconSwap {
|
|
16
|
+
/** Append this into the button — it *is* the button's visible icon content. */
|
|
17
|
+
el: HTMLSpanElement;
|
|
18
|
+
/** `false` shows `iconOff`, `true` shows `iconOn` — cross-fades between them via CSS, respecting `prefers-reduced-motion` the same way every other transition in this codebase does (shoji.css zeroes `--shoji-icon-swap-duration` there). */
|
|
19
|
+
setState(on: boolean): void;
|
|
20
|
+
}
|
|
21
|
+
export declare function createIconSwap(iconOff: string, iconOn: string): IconSwap;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function createIconSwap(iconOff, iconOn) {
|
|
2
|
+
const el = document.createElement("span");
|
|
3
|
+
el.className = "shoji-icon-swap";
|
|
4
|
+
const off = document.createElement("span");
|
|
5
|
+
off.className = "shoji-icon-swap-icon shoji-icon-swap-icon--off";
|
|
6
|
+
off.innerHTML = iconOff;
|
|
7
|
+
const on = document.createElement("span");
|
|
8
|
+
on.className = "shoji-icon-swap-icon shoji-icon-swap-icon--on";
|
|
9
|
+
on.innerHTML = iconOn;
|
|
10
|
+
el.append(off, on);
|
|
11
|
+
return {
|
|
12
|
+
el,
|
|
13
|
+
setState(state) {
|
|
14
|
+
el.classList.toggle("shoji-icon-swap--on", state);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export {
|
|
19
|
+
createIconSwap as c
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=iconSwap-BAXTLihx.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"iconSwap-BAXTLihx.js","sources":["../../src/core/iconSwap.ts"],"sourcesContent":["/**\n * DESIGN.md §9 — a toolbar button whose icon reflects live state (Fullscreen's\n * enter/exit, Autoplay's play/pause, Zoom's actual-size expand/contract) used\n * to just replace `button.innerHTML` outright — an instant cut, no\n * transition, and each plugin reimplementing the same two-icon swap\n * independently. Requested directly: make every one of these a smooth\n * cross-fade, and make them all go through one shared mechanism rather than\n * three slightly different ones. Both icons are always in the DOM at once,\n * stacked via `position: absolute` inside a fixed-size wrapper\n * (`.shoji-icon-swap`, `shoji.css`) — swapping is just toggling which one is\n * `opacity: 1` via a single modifier class, so there's nothing to actually\n * replace, no risk of a flash-of-no-icon between the old node being removed\n * and the new one's fill/stroke painting in.\n */\nexport interface IconSwap {\n /** Append this into the button — it *is* the button's visible icon content. */\n el: HTMLSpanElement;\n /** `false` shows `iconOff`, `true` shows `iconOn` — cross-fades between them via CSS, respecting `prefers-reduced-motion` the same way every other transition in this codebase does (shoji.css zeroes `--shoji-icon-swap-duration` there). */\n setState(on: boolean): void;\n}\n\nexport function createIconSwap(iconOff: string, iconOn: string): IconSwap {\n const el = document.createElement('span');\n el.className = 'shoji-icon-swap';\n\n const off = document.createElement('span');\n off.className = 'shoji-icon-swap-icon shoji-icon-swap-icon--off';\n off.innerHTML = iconOff;\n\n const on = document.createElement('span');\n on.className = 'shoji-icon-swap-icon shoji-icon-swap-icon--on';\n on.innerHTML = iconOn;\n\n el.append(off, on);\n\n return {\n el,\n setState(state: boolean): void {\n el.classList.toggle('shoji-icon-swap--on', state);\n },\n };\n}\n"],"names":[],"mappings":"AAqBO,SAAS,eAAe,SAAiB,QAA0B;AACxE,QAAM,KAAK,SAAS,cAAc,MAAM;AACxC,KAAG,YAAY;AAEf,QAAM,MAAM,SAAS,cAAc,MAAM;AACzC,MAAI,YAAY;AAChB,MAAI,YAAY;AAEhB,QAAM,KAAK,SAAS,cAAc,MAAM;AACxC,KAAG,YAAY;AACf,KAAG,YAAY;AAEf,KAAG,OAAO,KAAK,EAAE;AAEjB,SAAO;AAAA,IACL;AAAA,IACA,SAAS,OAAsB;AAC7B,SAAG,UAAU,OAAO,uBAAuB,KAAK;AAAA,IAClD;AAAA,EAAA;AAEJ;"}
|
package/dist/esm/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Layout } from "./plugins/layout/index.js";
|
|
|
6
6
|
import { RotateFlip } from "./plugins/rotateFlip/index.js";
|
|
7
7
|
import { Video } from "./plugins/video/index.js";
|
|
8
8
|
import { Zoom } from "./plugins/zoom/index.js";
|
|
9
|
-
const version = "0.1.0-beta.
|
|
9
|
+
const version = "0.1.0-beta.14";
|
|
10
10
|
const Shoji = Object.assign(Gallery, {
|
|
11
11
|
Autoplay,
|
|
12
12
|
Layout,
|
package/dist/esm/index2.css
CHANGED
|
@@ -51,6 +51,13 @@
|
|
|
51
51
|
|
|
52
52
|
--shoji-easing: cubic-bezier(0.4, 0, 0.2, 1);
|
|
53
53
|
--shoji-duration: 300ms;
|
|
54
|
+
/* A dedicated var, not a reuse of --shoji-duration: a toolbar icon
|
|
55
|
+
cross-fade (Fullscreen's enter/exit, Autoplay's play/pause, Zoom's
|
|
56
|
+
actual-size expand/contract, src/core/iconSwap.ts) reads as a snappy
|
|
57
|
+
state flip, not a UI transition on the same scale as opening/closing —
|
|
58
|
+
the same "reuse read as too slow/fast for a different purpose"
|
|
59
|
+
reasoning `--shoji-active-thumbnail-fade-duration` was split out for. */
|
|
60
|
+
--shoji-icon-swap-duration: 150ms;
|
|
54
61
|
/* The toolbar's own rendered height (44px button + top/bottom padding) —
|
|
55
62
|
defined before the caption sizing below since it now derives from
|
|
56
63
|
this. Also reserves space at the top of a provider embed (YouTube
|
|
@@ -125,6 +132,7 @@
|
|
|
125
132
|
@media (prefers-reduced-motion: reduce) {
|
|
126
133
|
:root {
|
|
127
134
|
--shoji-duration: 0ms;
|
|
135
|
+
--shoji-icon-swap-duration: 0ms;
|
|
128
136
|
}
|
|
129
137
|
}
|
|
130
138
|
|
|
@@ -358,6 +366,41 @@
|
|
|
358
366
|
pointer-events: none;
|
|
359
367
|
}
|
|
360
368
|
|
|
369
|
+
/* `src/core/iconSwap.ts` — a toolbar button whose icon reflects live state
|
|
370
|
+
(Fullscreen enter/exit, Autoplay play/pause, Zoom actual-size
|
|
371
|
+
expand/contract). Both icons stay in the DOM at once, exactly stacked;
|
|
372
|
+
the modifier class below only ever changes which one is opaque, so the
|
|
373
|
+
swap is a plain cross-fade with nothing being added/removed mid-transition
|
|
374
|
+
(no flash-of-no-icon between an old node's removal and a new one's
|
|
375
|
+
fill/stroke painting in). */
|
|
376
|
+
.shoji-icon-swap {
|
|
377
|
+
position: relative;
|
|
378
|
+
display: inline-flex;
|
|
379
|
+
width: 24px;
|
|
380
|
+
height: 24px;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.shoji-icon-swap-icon {
|
|
384
|
+
position: absolute;
|
|
385
|
+
inset: 0;
|
|
386
|
+
display: flex;
|
|
387
|
+
align-items: center;
|
|
388
|
+
justify-content: center;
|
|
389
|
+
transition: opacity var(--shoji-icon-swap-duration, 150ms) var(--shoji-easing);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.shoji-icon-swap-icon--on {
|
|
393
|
+
opacity: 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.shoji-icon-swap--on .shoji-icon-swap-icon--off {
|
|
397
|
+
opacity: 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.shoji-icon-swap--on .shoji-icon-swap-icon--on {
|
|
401
|
+
opacity: 1;
|
|
402
|
+
}
|
|
403
|
+
|
|
361
404
|
.shoji-slides {
|
|
362
405
|
position: relative;
|
|
363
406
|
flex: 1;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { c as createIconSwap } from "../../iconSwap-BAXTLihx.js";
|
|
1
2
|
const PLAY_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
|
|
2
3
|
const PAUSE_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>';
|
|
3
4
|
function findPlayable(media) {
|
|
@@ -92,7 +93,8 @@ const Autoplay = {
|
|
|
92
93
|
button.className = "shoji-toolbar-button shoji-autoplay-toggle";
|
|
93
94
|
button.setAttribute("aria-label", playLabel);
|
|
94
95
|
button.title = playLabel;
|
|
95
|
-
|
|
96
|
+
const iconSwap = createIconSwap(PLAY_ICON, PAUSE_ICON);
|
|
97
|
+
button.appendChild(iconSwap.el);
|
|
96
98
|
const progress = document.createElement("div");
|
|
97
99
|
progress.className = "shoji-autoplay-progress";
|
|
98
100
|
progress.hidden = true;
|
|
@@ -128,7 +130,7 @@ const Autoplay = {
|
|
|
128
130
|
}
|
|
129
131
|
function setButtonState(isPlaying) {
|
|
130
132
|
playing = isPlaying;
|
|
131
|
-
|
|
133
|
+
iconSwap.setState(isPlaying);
|
|
132
134
|
button.setAttribute("aria-label", isPlaying ? pauseLabel : playLabel);
|
|
133
135
|
button.title = isPlaying ? pauseLabel : playLabel;
|
|
134
136
|
updateToggleAvailability();
|
|
@@ -1 +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 /** Starts the slideshow automatically as soon as the gallery opens — every `open()`, not just the first — instead of waiting for the toolbar button/`Space`. Default `false`. */\n autoStart?: boolean;\n /**\n * How the slideshow reacts to the viewer zooming in on the active slide\n * (Zoom plugin). `'stop'` (default): pauses immediately and stays paused\n * until Play is pressed again, even once un-zoomed back to neutral — no\n * auto-resume. `'pause'`: same immediate pause, but auto-resumes on its\n * own once genuinely disengaged (see the module-level doc comment on\n * `RESUME_DEBOUNCE_MS` for why that's debounced rather than instant).\n * `false`: a complete no-op, regardless of zoom state. A no-op either way\n * if Zoom isn't loaded. Was a plain `pauseOnZoom` boolean (`true`\n * meaning today's `'stop'`) before `'pause'` existed as an option at all.\n */\n onZoom?: 'stop' | 'pause' | false;\n /**\n * Same shape as `onZoom`, for RotateFlip: `'stop'` (default) pauses on\n * any interaction — including one that lands back on the original\n * orientation, since the click itself is still an active interruption,\n * not just its end state — and stays paused; `'pause'` does the same\n * immediate pause but auto-resumes once genuinely disengaged (debounced,\n * same as `onZoom`); `false` is a no-op. A no-op either way if\n * RotateFlip isn't loaded. Was `pauseOnRotateFlip` before `'pause'`\n * existed.\n */\n onRotateFlip?: 'stop' | 'pause' | false;\n /**\n * Same shape as `onZoom`/`onRotateFlip`, for the viewer expanding a\n * truncated caption to read the rest (core's own caption modal,\n * DESIGN.md §2.3a). `'stop'` (default) pauses and stays paused; `'pause'`\n * auto-resumes once the modal closes (debounced, same as the other two —\n * even though the modal itself makes no *further* engagement possible\n * once closed, the same short window still applies for consistency and\n * because it costs nothing here). `false` is a no-op. Was\n * `pauseOnCaptionExpand` before `'pause'` existed. Unlike `onZoom`/\n * `onRotateFlip`, no re-check on Play is needed either mode: the modal\n * traps both pointer and keyboard input while open (core blocks every\n * key, not just Escape), so Play is physically unreachable until the\n * modal is already closed.\n */\n onCaptionExpand?: 'stop' | 'pause' | false;\n /** Pauses the slideshow the moment the viewer navigates manually — arrow keys/buttons, a completed swipe, a thumbnail click, or any other `goTo()` not caused by Autoplay's own `advance()` — instead of silently re-timing itself on whatever slide they land on. Stays paused until Play is pressed again — always a hard stop, no `'pause'`-style auto-resume variant: unlike zoom/rotate-flip/caption-expand, navigating away has no \"returns to its original state\" to resume from, so there's nothing for an auto-resume to key off. Default `true`, same reasoning as `onZoom`/`onRotateFlip`/`onCaptionExpand` above — reaching for the navigation controls at all is a clear signal of active engagement, and a slideshow that keeps ticking underneath that reads as broken, not helpful. Set `false` to restore the original \"manual nav just re-times the current slide\" behavior. Was `pauseOnManualNavigate` before the other three options gained a `'pause'` mode — renamed alongside them so \"stop\" only ever means stop now, not a mix of stop and pause depending which option you're reading. */\n stopOnManualNavigate?: boolean;\n}\n\n/**\n * Only meaningful for `onZoom`/`onRotateFlip`/`onCaptionExpand`'s `'pause'`\n * mode. A real bug, reported from real usage against this exact plugin's\n * *first* attempt at auto-resume (long before `'pause'` existed as a named\n * option — that attempt just always auto-resumed the moment zoom/rotate-\n * flip reported \"back to neutral\"): un-zooming or un-rotating back to\n * exactly neutral doesn't mean the viewer is done — reaching neutral is\n * just as easily one step *through* on the way to further interaction\n * (e.g. rotating 90 -> 180 -> 270 -> 0, or zooming out then immediately\n * back in) as it is the actual end of one. Resuming instantly on that\n * first neutral reading sprung the slideshow back to life mid-interaction,\n * which reads as more broken than never auto-resuming at all — the\n * original fix for this was simply to remove auto-resume entirely (the\n * `'stop'`-only behavior every `pauseOn*` option had until now). A short\n * idle debounce after the *last* disengage is what actually distinguishes\n * \"done interacting\" from \"just passing through neutral\": each further\n * engagement (re-zooming, another rotate/flip click) cancels and restarts\n * the wait, so the slideshow only ever resumes once nothing has happened\n * for a real pause in activity, not on the instant a single event happens\n * to read as neutral. Not configurable — an internal implementation detail\n * of what \"genuinely disengaged\" means, not a knob a host needs.\n */\nconst RESUME_DEBOUNCE_MS = 1000;\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: {\n interval: 5000,\n showProgress: true,\n onZoom: 'stop',\n onRotateFlip: 'stop',\n onCaptionExpand: 'stop',\n stopOnManualNavigate: true,\n } 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 autoStart = ctx.options.autoStart === true;\n const readMode = (value: unknown): 'stop' | 'pause' | false =>\n value === false || value === 'pause' ? value : 'stop';\n const onZoom = readMode(ctx.options.onZoom);\n const onRotateFlip = readMode(ctx.options.onRotateFlip);\n const onCaptionExpand = readMode(ctx.options.onCaptionExpand);\n const stopOnManualNavigate = ctx.options.stopOnManualNavigate !== 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 let wasPlayingBeforeDrag = false;\n // Tracks only the *current* zoom/rotate-flip state, purely for\n // toggle()'s \"was it already engaged the moment Play was pressed\"\n // check below — not reused for the pause-on-event logic itself, which\n // reacts to each zoomChange/rotateFlipChange event directly instead of\n // consulting these.\n let zoomedIn = false;\n let rotatedOrFlipped = false;\n let captionOpen = false;\n // Set only when this plugin itself stopped playback because of an\n // `onX: 'pause'` trigger — never for a manual stop, a `'stop'`-mode\n // trigger, `requestAutoplayStop`, or anything else that calls stop()\n // for an unrelated reason (video pause/end, drag-close, close()). Once\n // any of those flags is true, `maybeScheduleResume()` (below) is what's\n // waiting for the *matching* engagement flag (`zoomedIn`/\n // `rotatedOrFlipped`/`captionOpen`) to clear before it'll actually\n // queue the debounced resume — see `RESUME_DEBOUNCE_MS`'s own doc\n // comment for why that's a debounce and not instant.\n let pausedByZoom = false;\n let pausedByRotateFlip = false;\n let pausedByCaptionExpand = false;\n let resumeTimer: ReturnType<typeof setTimeout> | null = null;\n\n function cancelPendingResume(): void {\n if (resumeTimer !== null) {\n clearTimeout(resumeTimer);\n resumeTimer = null;\n }\n }\n\n /** Called on every hard stop (manual, `'stop'`-mode trigger, `requestAutoplayStop`, manual navigation, or anything else) — a hard stop always wins outright over a softer pending `'pause'`-mode resume from a *different* trigger, otherwise un-rotating after a hard zoom-stop could still spring the slideshow back to life on its own. */\n function clearPauseFlags(): void {\n pausedByZoom = false;\n pausedByRotateFlip = false;\n pausedByCaptionExpand = false;\n cancelPendingResume();\n }\n\n /**\n * Queues the actual resume once every `'pause'`-mode trigger that\n * caused the current stop has genuinely disengaged — not on the first\n * instant all three read as disengaged, but after `RESUME_DEBOUNCE_MS`\n * of that holding true, restarting the wait on every call (so\n * `zoomChange`/`rotateFlipChange`/`captionModalChange` re-engaging\n * before it fires cancels and re-arms it rather than stacking a second\n * timer). A no-op if nothing is actually pending — called from every\n * disengage point regardless of whether anything's waiting, cheaper\n * than each call site checking first.\n */\n function isEngaged(): boolean {\n return (\n (onZoom !== false && zoomedIn) ||\n (onRotateFlip !== false && rotatedOrFlipped) ||\n (onCaptionExpand !== false && captionOpen)\n );\n }\n\n function maybeScheduleResume(): void {\n if (!(pausedByZoom || pausedByRotateFlip || pausedByCaptionExpand)) return;\n if (isEngaged()) return;\n cancelPendingResume();\n resumeTimer = setTimeout(() => {\n resumeTimer = null;\n // Re-checked here, not just at the top of this function above — a\n // real bug, caught by this file's own test: a *different* trigger\n // can re-engage while this timer is already ticking down without\n // ever calling this function again (its own handler only reaches\n // maybeScheduleResume() when *it* transitions to disengaged, not\n // when it transitions to engaged) — e.g. rotating while a zoom\n // resume is already pending. Firing start() unconditionally would\n // resume playback while still visibly rotated. Leaves the pending\n // flags alone rather than clearing them: whichever trigger\n // eventually disengages next calls maybeScheduleResume() again and\n // finds them still set.\n if (isEngaged()) return;\n clearPauseFlags();\n start();\n }, RESUME_DEBOUNCE_MS);\n }\n\n // Set for the exact duration of advance()'s own gallery.next() call —\n // the afterSlide handler below fires synchronously inside it, so it can\n // tell \"this slide change is autoplay's own advance()\" apart from any\n // other navigation (arrows/buttons/swipe/goTo()) without needing the\n // event itself to carry a source. See stopOnManualNavigate above.\n let isAdvancing = false;\n\n // A real bug, regression: this used to capture gallery.getActiveMedia()\n // once here and listen on that node directly, back when a pool slot's\n // own offset never changed after construction (only its *content*\n // moved between slots) — so \"whichever slot has offset 0\" was always\n // the same physical node, safe to capture once. SlideManager's pool now\n // relabels a slot's offset in place instead (§2.3) — the node that\n // happened to be offset 0 when this plugin initialized can become a\n // neighbor after even one navigation, while a *different* node becomes\n // the active one, silently going unheard by this listener. Listening on\n // `ctx.ui.outer()` instead (the whole lightbox, never relabeled) still\n // catches a provider's own error event (§4-video's `onError`, dispatched\n // with `bubbles: true`) regardless of which slide it came from — the\n // `contains()` check below is what actually scopes it to the currently\n // active slide, re-resolved fresh on every error rather than trusting a\n // stale reference.\n const outer = ctx.ui.outer();\n function onVideoError(event: Event): void {\n if (!playing) return;\n const active = gallery.getActiveMedia();\n if (active && event.target instanceof Node && active.contains(event.target)) advance();\n }\n outer.addEventListener('error', onVideoError);\n\n const button = document.createElement('button');\n button.type = 'button';\n // shoji-autoplay-toggle: a stable hook for host code that needs to find\n // this button from outside (e.g. to pause the slideshow before opening\n // its own UI on top) — `title`/`aria-label` swap with `locale`, so\n // matching on those breaks silently the moment a host customizes it.\n button.className = 'shoji-toolbar-button shoji-autoplay-toggle';\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 /**\n * DESIGN.md §4.1 — while `onZoom`/`onRotateFlip` is holding\n * playback paused, pressing Play would otherwise just silently\n * re-pause it in the same synchronous tick (`toggle()`'s own check,\n * below) — no `transitionend`, no paint in between the two state\n * flips, so the button never visibly shows \"Pause\" at all before\n * reverting. Reported as looking broken, not just quiet. Disabling\n * the button while blocked (same `aria-disabled`/`tabIndex`/CSS\n * pattern as core's own slide-loading disable, Gallery.ts's\n * `setSlideLoading()`) makes \"you can't resume yet\" an honest, visible\n * state instead of a click that does nothing. Only while *not*\n * playing — while playing, the button always shows/means Pause, and\n * must stay clickable to stop regardless of zoom/rotate state.\n */\n function updateToggleAvailability(): void {\n const blocked =\n !playing &&\n ((onZoom !== false && zoomedIn) || (onRotateFlip !== false && rotatedOrFlipped));\n button.ariaDisabled = blocked ? 'true' : null;\n if (blocked) button.tabIndex = -1;\n else button.removeAttribute('tabindex');\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 updateToggleAvailability();\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 // Provider videos (§4.3's renderers) are responsible for holding their\n // own 'pause' dispatch back if it might just be a natural end arriving\n // slightly early — vimeo.ts's `PAUSE_DISPATCH_DELAY_MS` is why.\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 //\n // A real bug, reported from real usage, found only once a second\n // promise-returning provider (Vimeo) existed to compare against:\n // re-issuing `play()`/`muted = true` on every retry — needed for\n // YouTube, whose fire-and-forget `playVideo()` can silently drop a\n // command issued before its postMessage bridge is fully settled — does\n // the opposite for a provider whose `play()` genuinely returns a\n // promise. Confirmed directly, isolated from Shoji entirely: a single,\n // uninterrupted `play()` call reliably started Vimeo playback in\n // ~1.6s, while calling it again every 400ms (this loop's original,\n // unconditional behavior) kept it stuck indefinitely — each new call\n // resets the progress the previous one had already made, so it never\n // gets an uninterrupted run long enough to actually start. (This is\n // also what point 12 below's retry-exhaustion was actually skipping\n // past: not a broken video, but this loop's own repeated calls\n // preventing it from ever finishing what the first one started.)\n // `video.play()`'s return type only *claims* to be a promise for every\n // provider (see the `PlayableMedia` doc comment above) — branching on\n // whether it genuinely is one is what lets each provider get the\n // retry behavior it actually needs from this one shared function.\n function ensureProviderPlaying(video: PlayableMedia, attemptsLeft: number): void {\n video.muted = true;\n const playResult = video.play();\n if (playResult && typeof playResult.then === 'function') {\n pollWithoutReissuing(video, attemptsLeft, playResult);\n } else {\n reissueOnEachRetry(video, attemptsLeft);\n }\n }\n\n /** Fire-and-forget `play()` (YouTube) — re-issues the command itself on every retry, per this function's own doc comment above. */\n function reissueOnEachRetry(video: PlayableMedia, attemptsLeft: number): void {\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 // Exhausted every retry after already muting first — muted\n // autoplay is essentially never blocked by policy, so this means\n // something is genuinely wrong with the embed, not that it's\n // waiting on a gesture. advance() (not stop()) so a slow/late\n // error report (real usage: YouTube's own error can arrive slower\n // than this retry window, e.g. Error 153) can't leave the\n // slideshow stuck if this fires first.\n else advance();\n }, PROVIDER_PLAY_RETRY_MS);\n }\n\n /** A genuine `play()` promise (e.g. Vimeo) — the command itself is only ever issued once (by the caller); this only re-checks `.paused` on the same schedule, over the same total budget, without ever calling `play()` again. */\n function pollWithoutReissuing(\n video: PlayableMedia,\n attemptsLeft: number,\n playResult: Promise<void>,\n ): void {\n // Swallowed deliberately: a rejection here isn't distinguished from\n // \"still pending\" — the .paused poll below is what actually decides\n // whether this took effect, on the same schedule regardless of how\n // the promise itself settles. An unhandled-rejection console warning\n // is the only cost, same tradeoff as the native <video> path below\n // taking a real .catch() instead when it needs to branch on *why*.\n playResult.catch(() => {});\n setTimeout(() => {\n if (currentVideo !== video || !playing) return;\n if (!video.paused) return;\n if (attemptsLeft > 0) pollWithoutReissuing(video, attemptsLeft - 1, playResult);\n else advance();\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 // A rejected play() has two different causes that look identical\n // here: the browser blocking an unmuted play() that isn't a direct\n // continuation of a user gesture (NotAllowedError — the video is\n // fine, it just needs the viewer's own click) or the video\n // genuinely being unplayable (NotSupportedError — a broken/missing\n // source, an unsupported format). Only the first pauses the\n // slideshow for the viewer to resolve by hand; anything else means\n // there's nothing to wait for, so it skips ahead instead.\n if (playResult && typeof playResult.catch === 'function') {\n playResult.catch((error: unknown) => {\n if (currentVideo !== video || !playing) return; // stale\n if (error instanceof DOMException && error.name === 'NotAllowedError') stop();\n else advance();\n });\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 isAdvancing = true;\n gallery.next();\n isAdvancing = false;\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 // A manual start succeeding makes any still-ticking 'pause'-mode\n // resume timer moot — harmless left alone (its own eventual start()\n // call would just no-op against the `if (playing) return` above), but\n // cancelling it here avoids a dangling timer doing nothing for up to\n // RESUME_DEBOUNCE_MS after this already resolved things.\n cancelPendingResume();\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 // A real bug, reported from real usage: zooming in *first*, then\n // pressing Play, never paused at all — clicking Play doesn't itself\n // fire zoomChange/rotateFlipChange, and a single-step \"toggle back to\n // neutral\" action afterward (Actual size, double-tap-to-reset) only\n // ever emits the *already-neutral* event, never one crossing the\n // engaged threshold — so nothing downstream would ever have caught it\n // either. Re-checked here instead, right after a genuine manual\n // start — the one path a viewer can actually reach a non-neutral view\n // state from *before* pressing Play. `zoomedIn`/`rotatedOrFlipped`\n // (below) track only the *current* state, purely for this check —\n // deliberately not reused for the pause-on-event logic itself, which\n // reacts to each event directly. Shared by toggle()'s manual-start path\n // and requestAutoplayStart below — both are \"a genuine manual start,\"\n // just triggered from a different control.\n function reCheckEngagedAfterManualStart(): void {\n const stillZoomed = onZoom !== false && zoomedIn;\n const stillRotated = onRotateFlip !== false && rotatedOrFlipped;\n if (!stillZoomed && !stillRotated) return;\n stop();\n // Threading pause-mode tracking through *this* re-stop too (not just\n // the zoomChange/rotateFlipChange handlers below) is what avoids\n // repeating the first auto-resume attempt's other real bug: a manual\n // restart while still engaged left that design's own edge-tracker\n // permanently \"already engaged,\" silently skipping the next\n // re-pause's resume tracking. Setting the flag here, at the actual\n // moment this stop happens, means it's never missed regardless of\n // which path (button/Space, or a custom plugin's requestAutoplayStart)\n // triggered the manual start.\n if (onZoom === 'pause' && stillZoomed) pausedByZoom = true;\n if (onRotateFlip === 'pause' && stillRotated) pausedByRotateFlip = true;\n }\n\n function toggle(): void {\n if (playing) {\n clearPauseFlags(); // a real manual pause is always a hard stop, never a pending soft-resume\n stop();\n return;\n }\n start();\n reCheckEngagedAfterManualStart();\n }\n\n /**\n * Pauses the slideshow the instant a vertical drag crosses the close\n * threshold, mirroring the controls' own live hide cue\n * (`dragCloseThreshold`) — asked for directly, otherwise the\n * interval/video kept running mid-drag. Resumes only if *this*\n * crossing paused it — a manual pause (toolbar, mid-drag tap) must\n * stay paused even if the drag retreats back under the threshold.\n */\n const offDragThreshold = ctx.on('dragCloseThreshold', ({ hidden }) => {\n if (hidden) {\n wasPlayingBeforeDrag = playing;\n if (playing) {\n clearPauseFlags(); // a hard stop, unrelated to onZoom/onRotateFlip/onCaptionExpand — must not resurrect a pending soft-resume from one of those\n stop();\n }\n } else if (wasPlayingBeforeDrag) {\n wasPlayingBeforeDrag = false;\n start();\n }\n });\n\n /**\n * DESIGN.md §4.1 — a real UX gap, not a reported bug: nothing stopped\n * the slideshow from auto-advancing out from under a viewer actively\n * zoomed into a detail on the current slide. Only reacts to\n * `zoomChange`'s event *shape* (`core/types.ts`) — never imports Zoom\n * directly, so this is a no-op with it not loaded, or with `onZoom:\n * false` set explicitly (events over inheritance, CLAUDE.md).\n *\n * `'stop'` mode never auto-resumes, matching this plugin's *first*\n * attempt at this feature — see `RESUME_DEBOUNCE_MS`'s own doc comment\n * for the two real problems that attempt hit and why `'pause'` mode\n * (below) is built differently, not just re-adding the same thing\n * under a new name. `scale > 1` still gates *which* zoomChange events\n * count (an ordinary scale-1 event can also fire from an unrelated\n * slide-change reset, not a real interaction — `zoom/index.ts`'s\n * `reset()` — so it can't unconditionally pause on every event the way\n * rotateFlipChange below safely can).\n */\n const ZOOM_ENGAGED_THRESHOLD = 1.001; // matches Zoom's own ZOOM_EPSILON — \"just barely above 1\" is float residue, not a real zoom\n const offZoomChange = ctx.on('zoomChange', ({ scale }) => {\n zoomedIn = scale > ZOOM_ENGAGED_THRESHOLD;\n if (onZoom !== false && zoomedIn && playing) {\n if (onZoom === 'stop') clearPauseFlags();\n stop();\n if (onZoom === 'pause') pausedByZoom = true;\n } else {\n // stop() above already refreshes toggle availability; this branch\n // covers zooming in while already paused (stop() wouldn't touch\n // it) and zooming back out, which may have just disengaged the\n // last thing a pending 'pause'-mode resume was waiting on.\n updateToggleAvailability();\n if (!zoomedIn) maybeScheduleResume();\n }\n });\n /**\n * Same UX gap, RotateFlip's own equivalent (see `onRotateFlip`'s own\n * doc comment). Pauses on *any* `rotateFlipChange` event\n * unconditionally, including one that lands back on the original\n * orientation — confirmed directly, reported from real usage: rotate\n * four times back to 0deg still reads as an active interruption of the\n * slideshow, not \"nothing happened,\" so it must still pause too, the\n * same as landing anywhere else, in *both* modes — `'pause'` mode's\n * auto-resume is debounced specifically so this repeated-neutral case\n * doesn't spring it back to life between clicks (`RESUME_DEBOUNCE_MS`).\n * Every `rotateFlipChange` (unlike zoomChange above) only ever fires\n * from a real button/shortcut click — `rotateFlip/index.ts`'s own\n * per-slide `reset()` never emits it — so no extra state check is\n * needed here the way zoomChange's `scale > 1` gate is.\n */\n const offRotateFlipChange = ctx.on('rotateFlipChange', ({ flipH, flipV, rotation }) => {\n rotatedOrFlipped = flipH || flipV || rotation !== 0;\n if (onRotateFlip !== false && playing) {\n if (onRotateFlip === 'stop') clearPauseFlags();\n stop();\n if (onRotateFlip === 'pause') pausedByRotateFlip = true;\n } else {\n updateToggleAvailability(); // stop() above already refreshes this; covers rotating while already paused, which stop() wouldn't touch\n if (onRotateFlip !== false && !rotatedOrFlipped) maybeScheduleResume();\n }\n });\n /**\n * `onCaptionExpand`'s own doc comment above — unlike the two listeners\n * above, no `updateToggleAvailability()` companion call: the caption\n * modal already makes Play physically unreachable while `open: true`\n * (core's own focus trap + capture-phase keydown blocking, not\n * anything this plugin has to enforce), so there's no \"pressed Play\n * while still blocked\" case to disable the button for or re-check on\n * toggle() the way zoom/rotateFlip both need. `captionOpen` is still\n * tracked (unlike before `'pause'` mode existed) purely so\n * `maybeScheduleResume()` can confirm nothing else is also still\n * engaged before it queues a resume.\n */\n const offCaptionModalChange = ctx.on('captionModalChange', ({ open }) => {\n captionOpen = open;\n if (onCaptionExpand !== false && open && playing) {\n if (onCaptionExpand === 'stop') clearPauseFlags();\n stop();\n if (onCaptionExpand === 'pause') pausedByCaptionExpand = true;\n } else if (!open) {\n maybeScheduleResume();\n }\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 // Detaches this plugin's own 'pause'/'ended' listeners from the outgoing\n // video before Gallery.navigate() pauses it (see Gallery.ts's comment on\n // this same event) — otherwise that pause is misread as the viewer\n // manually pausing, which stops the slideshow before the new slide's\n // afterSlide handler below ever runs. enterSlide() below also calls\n // detachVideo(), but by then it's too late for *this* transition; that\n // call is what handles the slide *after* this one instead.\n const offBeforeSlide = ctx.on('beforeSlide', () => {\n detachVideo();\n });\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 // Zoom/RotateFlip both reset unanimated on navigation (DESIGN.md\n // §2.5) without re-emitting zoomChange/rotateFlipChange — resynced\n // here so a stale \"was engaged\" reading from the *outgoing* slide\n // can't wrongly re-pause toggle()'s next Play press on a slide\n // that's actually neutral now. Also clears any pending 'pause'-mode\n // resume outright, not just the flags it was waiting on — the slide\n // it was paused *for* isn't even the active one anymore, so whether\n // to resume here is genuinely ambiguous; `stopOnManualNavigate`\n // (default true) already decides what actually happens next.\n zoomedIn = false;\n rotatedOrFlipped = false;\n clearPauseFlags();\n updateToggleAvailability();\n if (!playing) return;\n // stopOnManualNavigate (default true, see its own doc comment) —\n // isAdvancing is only ever true for the exact duration of advance()'s\n // own gallery.next() call above, so its absence here means something\n // other than autoplay itself moved the slide.\n if (stopOnManualNavigate && !isAdvancing) stop();\n else 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 /**\n * DESIGN.md §4.1 point 20 — a generic command surface, requested\n * directly, so a *custom* (host-authored) plugin's own toolbar button\n * can start/pause the slideshow without importing this plugin at all,\n * the same \"events over inheritance\" decoupling every other listener\n * here already uses for official plugins (`zoomChange`,\n * `rotateFlipChange`, `captionModalChange`, `dragCloseThreshold`).\n * `GalleryEvents` (`core/types.ts`) already extends `Record<string,\n * unknown>`, so `ctx.emit('requestAutoplayStop', {})` from any plugin\n * — official or custom — type-checks with zero core changes; this is\n * just the listening half. Deliberately two separate commands, not one\n * toggle — a custom plugin building its own distinct Play/Pause\n * controls (rather than one button that flips) needs to command each\n * state directly, not guess at the current one. Both no-op in the\n * already-there state (`stop()`'s own existing guard for pause;\n * `start()`'s own existing guard, plus the same manual-start re-check\n * `toggle()`'s button/Space path uses, for start).\n */\n const offRequestStop = ctx.on('requestAutoplayStop', () => {\n if (playing) {\n clearPauseFlags(); // a command from another plugin is treated like a manual pause, not a soft pending resume\n stop();\n }\n });\n const offRequestStart = ctx.on('requestAutoplayStart', () => {\n if (playing) return;\n start();\n reCheckEngagedAfterManualStart();\n });\n const offClose = ctx.on('close', () => {\n clearPauseFlags();\n stop();\n });\n const offOpen = ctx.on('afterOpen', () => {\n zoomedIn = false;\n rotatedOrFlipped = false;\n captionOpen = false;\n clearPauseFlags();\n updateToggleAvailability();\n if (autoStart) start();\n });\n\n return () => {\n cancelPendingResume(); // a pending 'pause'-mode resume must not fire start() after teardown\n stop();\n outer.removeEventListener('error', onVideoError);\n removeButton();\n removeProgress?.();\n removeShortcut();\n offBeforeSlide();\n offSlide();\n offSlideItemLoad();\n offOpen();\n offClose();\n offDragThreshold();\n offZoomChange();\n offRotateFlipChange();\n offCaptionModalChange();\n offRequestStop();\n offRequestStart();\n };\n },\n};\n"],"names":[],"mappings":"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"}
|
|
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 { createIconSwap } from '../../core/iconSwap';\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 /** Starts the slideshow automatically as soon as the gallery opens — every `open()`, not just the first — instead of waiting for the toolbar button/`Space`. Default `false`. */\n autoStart?: boolean;\n /**\n * How the slideshow reacts to the viewer zooming in on the active slide\n * (Zoom plugin). `'stop'` (default): pauses immediately and stays paused\n * until Play is pressed again, even once un-zoomed back to neutral — no\n * auto-resume. `'pause'`: same immediate pause, but auto-resumes on its\n * own once genuinely disengaged (see the module-level doc comment on\n * `RESUME_DEBOUNCE_MS` for why that's debounced rather than instant).\n * `false`: a complete no-op, regardless of zoom state. A no-op either way\n * if Zoom isn't loaded. Was a plain `pauseOnZoom` boolean (`true`\n * meaning today's `'stop'`) before `'pause'` existed as an option at all.\n */\n onZoom?: 'stop' | 'pause' | false;\n /**\n * Same shape as `onZoom`, for RotateFlip: `'stop'` (default) pauses on\n * any interaction — including one that lands back on the original\n * orientation, since the click itself is still an active interruption,\n * not just its end state — and stays paused; `'pause'` does the same\n * immediate pause but auto-resumes once genuinely disengaged (debounced,\n * same as `onZoom`); `false` is a no-op. A no-op either way if\n * RotateFlip isn't loaded. Was `pauseOnRotateFlip` before `'pause'`\n * existed.\n */\n onRotateFlip?: 'stop' | 'pause' | false;\n /**\n * Same shape as `onZoom`/`onRotateFlip`, for the viewer expanding a\n * truncated caption to read the rest (core's own caption modal,\n * DESIGN.md §2.3a). `'stop'` (default) pauses and stays paused; `'pause'`\n * auto-resumes once the modal closes (debounced, same as the other two —\n * even though the modal itself makes no *further* engagement possible\n * once closed, the same short window still applies for consistency and\n * because it costs nothing here). `false` is a no-op. Was\n * `pauseOnCaptionExpand` before `'pause'` existed. Unlike `onZoom`/\n * `onRotateFlip`, no re-check on Play is needed either mode: the modal\n * traps both pointer and keyboard input while open (core blocks every\n * key, not just Escape), so Play is physically unreachable until the\n * modal is already closed.\n */\n onCaptionExpand?: 'stop' | 'pause' | false;\n /** Pauses the slideshow the moment the viewer navigates manually — arrow keys/buttons, a completed swipe, a thumbnail click, or any other `goTo()` not caused by Autoplay's own `advance()` — instead of silently re-timing itself on whatever slide they land on. Stays paused until Play is pressed again — always a hard stop, no `'pause'`-style auto-resume variant: unlike zoom/rotate-flip/caption-expand, navigating away has no \"returns to its original state\" to resume from, so there's nothing for an auto-resume to key off. Default `true`, same reasoning as `onZoom`/`onRotateFlip`/`onCaptionExpand` above — reaching for the navigation controls at all is a clear signal of active engagement, and a slideshow that keeps ticking underneath that reads as broken, not helpful. Set `false` to restore the original \"manual nav just re-times the current slide\" behavior. Was `pauseOnManualNavigate` before the other three options gained a `'pause'` mode — renamed alongside them so \"stop\" only ever means stop now, not a mix of stop and pause depending which option you're reading. */\n stopOnManualNavigate?: boolean;\n}\n\n/**\n * Only meaningful for `onZoom`/`onRotateFlip`/`onCaptionExpand`'s `'pause'`\n * mode. A real bug, reported from real usage against this exact plugin's\n * *first* attempt at auto-resume (long before `'pause'` existed as a named\n * option — that attempt just always auto-resumed the moment zoom/rotate-\n * flip reported \"back to neutral\"): un-zooming or un-rotating back to\n * exactly neutral doesn't mean the viewer is done — reaching neutral is\n * just as easily one step *through* on the way to further interaction\n * (e.g. rotating 90 -> 180 -> 270 -> 0, or zooming out then immediately\n * back in) as it is the actual end of one. Resuming instantly on that\n * first neutral reading sprung the slideshow back to life mid-interaction,\n * which reads as more broken than never auto-resuming at all — the\n * original fix for this was simply to remove auto-resume entirely (the\n * `'stop'`-only behavior every `pauseOn*` option had until now). A short\n * idle debounce after the *last* disengage is what actually distinguishes\n * \"done interacting\" from \"just passing through neutral\": each further\n * engagement (re-zooming, another rotate/flip click) cancels and restarts\n * the wait, so the slideshow only ever resumes once nothing has happened\n * for a real pause in activity, not on the instant a single event happens\n * to read as neutral. Not configurable — an internal implementation detail\n * of what \"genuinely disengaged\" means, not a knob a host needs.\n */\nconst RESUME_DEBOUNCE_MS = 1000;\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: {\n interval: 5000,\n showProgress: true,\n onZoom: 'stop',\n onRotateFlip: 'stop',\n onCaptionExpand: 'stop',\n stopOnManualNavigate: true,\n } 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 autoStart = ctx.options.autoStart === true;\n const readMode = (value: unknown): 'stop' | 'pause' | false =>\n value === false || value === 'pause' ? value : 'stop';\n const onZoom = readMode(ctx.options.onZoom);\n const onRotateFlip = readMode(ctx.options.onRotateFlip);\n const onCaptionExpand = readMode(ctx.options.onCaptionExpand);\n const stopOnManualNavigate = ctx.options.stopOnManualNavigate !== 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 let wasPlayingBeforeDrag = false;\n // Tracks only the *current* zoom/rotate-flip state, purely for\n // toggle()'s \"was it already engaged the moment Play was pressed\"\n // check below — not reused for the pause-on-event logic itself, which\n // reacts to each zoomChange/rotateFlipChange event directly instead of\n // consulting these.\n let zoomedIn = false;\n let rotatedOrFlipped = false;\n let captionOpen = false;\n // Set only when this plugin itself stopped playback because of an\n // `onX: 'pause'` trigger — never for a manual stop, a `'stop'`-mode\n // trigger, `requestAutoplayStop`, or anything else that calls stop()\n // for an unrelated reason (video pause/end, drag-close, close()). Once\n // any of those flags is true, `maybeScheduleResume()` (below) is what's\n // waiting for the *matching* engagement flag (`zoomedIn`/\n // `rotatedOrFlipped`/`captionOpen`) to clear before it'll actually\n // queue the debounced resume — see `RESUME_DEBOUNCE_MS`'s own doc\n // comment for why that's a debounce and not instant.\n let pausedByZoom = false;\n let pausedByRotateFlip = false;\n let pausedByCaptionExpand = false;\n let resumeTimer: ReturnType<typeof setTimeout> | null = null;\n\n function cancelPendingResume(): void {\n if (resumeTimer !== null) {\n clearTimeout(resumeTimer);\n resumeTimer = null;\n }\n }\n\n /** Called on every hard stop (manual, `'stop'`-mode trigger, `requestAutoplayStop`, manual navigation, or anything else) — a hard stop always wins outright over a softer pending `'pause'`-mode resume from a *different* trigger, otherwise un-rotating after a hard zoom-stop could still spring the slideshow back to life on its own. */\n function clearPauseFlags(): void {\n pausedByZoom = false;\n pausedByRotateFlip = false;\n pausedByCaptionExpand = false;\n cancelPendingResume();\n }\n\n /**\n * Queues the actual resume once every `'pause'`-mode trigger that\n * caused the current stop has genuinely disengaged — not on the first\n * instant all three read as disengaged, but after `RESUME_DEBOUNCE_MS`\n * of that holding true, restarting the wait on every call (so\n * `zoomChange`/`rotateFlipChange`/`captionModalChange` re-engaging\n * before it fires cancels and re-arms it rather than stacking a second\n * timer). A no-op if nothing is actually pending — called from every\n * disengage point regardless of whether anything's waiting, cheaper\n * than each call site checking first.\n */\n function isEngaged(): boolean {\n return (\n (onZoom !== false && zoomedIn) ||\n (onRotateFlip !== false && rotatedOrFlipped) ||\n (onCaptionExpand !== false && captionOpen)\n );\n }\n\n function maybeScheduleResume(): void {\n if (!(pausedByZoom || pausedByRotateFlip || pausedByCaptionExpand)) return;\n if (isEngaged()) return;\n cancelPendingResume();\n resumeTimer = setTimeout(() => {\n resumeTimer = null;\n // Re-checked here, not just at the top of this function above — a\n // real bug, caught by this file's own test: a *different* trigger\n // can re-engage while this timer is already ticking down without\n // ever calling this function again (its own handler only reaches\n // maybeScheduleResume() when *it* transitions to disengaged, not\n // when it transitions to engaged) — e.g. rotating while a zoom\n // resume is already pending. Firing start() unconditionally would\n // resume playback while still visibly rotated. Leaves the pending\n // flags alone rather than clearing them: whichever trigger\n // eventually disengages next calls maybeScheduleResume() again and\n // finds them still set.\n if (isEngaged()) return;\n clearPauseFlags();\n start();\n }, RESUME_DEBOUNCE_MS);\n }\n\n // Set for the exact duration of advance()'s own gallery.next() call —\n // the afterSlide handler below fires synchronously inside it, so it can\n // tell \"this slide change is autoplay's own advance()\" apart from any\n // other navigation (arrows/buttons/swipe/goTo()) without needing the\n // event itself to carry a source. See stopOnManualNavigate above.\n let isAdvancing = false;\n\n // A real bug, regression: this used to capture gallery.getActiveMedia()\n // once here and listen on that node directly, back when a pool slot's\n // own offset never changed after construction (only its *content*\n // moved between slots) — so \"whichever slot has offset 0\" was always\n // the same physical node, safe to capture once. SlideManager's pool now\n // relabels a slot's offset in place instead (§2.3) — the node that\n // happened to be offset 0 when this plugin initialized can become a\n // neighbor after even one navigation, while a *different* node becomes\n // the active one, silently going unheard by this listener. Listening on\n // `ctx.ui.outer()` instead (the whole lightbox, never relabeled) still\n // catches a provider's own error event (§4-video's `onError`, dispatched\n // with `bubbles: true`) regardless of which slide it came from — the\n // `contains()` check below is what actually scopes it to the currently\n // active slide, re-resolved fresh on every error rather than trusting a\n // stale reference.\n const outer = ctx.ui.outer();\n function onVideoError(event: Event): void {\n if (!playing) return;\n const active = gallery.getActiveMedia();\n if (active && event.target instanceof Node && active.contains(event.target)) advance();\n }\n outer.addEventListener('error', onVideoError);\n\n const button = document.createElement('button');\n button.type = 'button';\n // shoji-autoplay-toggle: a stable hook for host code that needs to find\n // this button from outside (e.g. to pause the slideshow before opening\n // its own UI on top) — `title`/`aria-label` swap with `locale`, so\n // matching on those breaks silently the moment a host customizes it.\n button.className = 'shoji-toolbar-button shoji-autoplay-toggle';\n button.setAttribute('aria-label', playLabel);\n button.title = playLabel;\n const iconSwap = createIconSwap(PLAY_ICON, PAUSE_ICON);\n button.appendChild(iconSwap.el);\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 /**\n * DESIGN.md §4.1 — while `onZoom`/`onRotateFlip` is holding\n * playback paused, pressing Play would otherwise just silently\n * re-pause it in the same synchronous tick (`toggle()`'s own check,\n * below) — no `transitionend`, no paint in between the two state\n * flips, so the button never visibly shows \"Pause\" at all before\n * reverting. Reported as looking broken, not just quiet. Disabling\n * the button while blocked (same `aria-disabled`/`tabIndex`/CSS\n * pattern as core's own slide-loading disable, Gallery.ts's\n * `setSlideLoading()`) makes \"you can't resume yet\" an honest, visible\n * state instead of a click that does nothing. Only while *not*\n * playing — while playing, the button always shows/means Pause, and\n * must stay clickable to stop regardless of zoom/rotate state.\n */\n function updateToggleAvailability(): void {\n const blocked =\n !playing &&\n ((onZoom !== false && zoomedIn) || (onRotateFlip !== false && rotatedOrFlipped));\n button.ariaDisabled = blocked ? 'true' : null;\n if (blocked) button.tabIndex = -1;\n else button.removeAttribute('tabindex');\n }\n\n function setButtonState(isPlaying: boolean): void {\n playing = isPlaying;\n iconSwap.setState(isPlaying);\n button.setAttribute('aria-label', isPlaying ? pauseLabel : playLabel);\n button.title = isPlaying ? pauseLabel : playLabel;\n updateToggleAvailability();\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 // Provider videos (§4.3's renderers) are responsible for holding their\n // own 'pause' dispatch back if it might just be a natural end arriving\n // slightly early — vimeo.ts's `PAUSE_DISPATCH_DELAY_MS` is why.\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 //\n // A real bug, reported from real usage, found only once a second\n // promise-returning provider (Vimeo) existed to compare against:\n // re-issuing `play()`/`muted = true` on every retry — needed for\n // YouTube, whose fire-and-forget `playVideo()` can silently drop a\n // command issued before its postMessage bridge is fully settled — does\n // the opposite for a provider whose `play()` genuinely returns a\n // promise. Confirmed directly, isolated from Shoji entirely: a single,\n // uninterrupted `play()` call reliably started Vimeo playback in\n // ~1.6s, while calling it again every 400ms (this loop's original,\n // unconditional behavior) kept it stuck indefinitely — each new call\n // resets the progress the previous one had already made, so it never\n // gets an uninterrupted run long enough to actually start. (This is\n // also what point 12 below's retry-exhaustion was actually skipping\n // past: not a broken video, but this loop's own repeated calls\n // preventing it from ever finishing what the first one started.)\n // `video.play()`'s return type only *claims* to be a promise for every\n // provider (see the `PlayableMedia` doc comment above) — branching on\n // whether it genuinely is one is what lets each provider get the\n // retry behavior it actually needs from this one shared function.\n function ensureProviderPlaying(video: PlayableMedia, attemptsLeft: number): void {\n video.muted = true;\n const playResult = video.play();\n if (playResult && typeof playResult.then === 'function') {\n pollWithoutReissuing(video, attemptsLeft, playResult);\n } else {\n reissueOnEachRetry(video, attemptsLeft);\n }\n }\n\n /** Fire-and-forget `play()` (YouTube) — re-issues the command itself on every retry, per this function's own doc comment above. */\n function reissueOnEachRetry(video: PlayableMedia, attemptsLeft: number): void {\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 // Exhausted every retry after already muting first — muted\n // autoplay is essentially never blocked by policy, so this means\n // something is genuinely wrong with the embed, not that it's\n // waiting on a gesture. advance() (not stop()) so a slow/late\n // error report (real usage: YouTube's own error can arrive slower\n // than this retry window, e.g. Error 153) can't leave the\n // slideshow stuck if this fires first.\n else advance();\n }, PROVIDER_PLAY_RETRY_MS);\n }\n\n /** A genuine `play()` promise (e.g. Vimeo) — the command itself is only ever issued once (by the caller); this only re-checks `.paused` on the same schedule, over the same total budget, without ever calling `play()` again. */\n function pollWithoutReissuing(\n video: PlayableMedia,\n attemptsLeft: number,\n playResult: Promise<void>,\n ): void {\n // Swallowed deliberately: a rejection here isn't distinguished from\n // \"still pending\" — the .paused poll below is what actually decides\n // whether this took effect, on the same schedule regardless of how\n // the promise itself settles. An unhandled-rejection console warning\n // is the only cost, same tradeoff as the native <video> path below\n // taking a real .catch() instead when it needs to branch on *why*.\n playResult.catch(() => {});\n setTimeout(() => {\n if (currentVideo !== video || !playing) return;\n if (!video.paused) return;\n if (attemptsLeft > 0) pollWithoutReissuing(video, attemptsLeft - 1, playResult);\n else advance();\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 // A rejected play() has two different causes that look identical\n // here: the browser blocking an unmuted play() that isn't a direct\n // continuation of a user gesture (NotAllowedError — the video is\n // fine, it just needs the viewer's own click) or the video\n // genuinely being unplayable (NotSupportedError — a broken/missing\n // source, an unsupported format). Only the first pauses the\n // slideshow for the viewer to resolve by hand; anything else means\n // there's nothing to wait for, so it skips ahead instead.\n if (playResult && typeof playResult.catch === 'function') {\n playResult.catch((error: unknown) => {\n if (currentVideo !== video || !playing) return; // stale\n if (error instanceof DOMException && error.name === 'NotAllowedError') stop();\n else advance();\n });\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 isAdvancing = true;\n gallery.next();\n isAdvancing = false;\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 // A manual start succeeding makes any still-ticking 'pause'-mode\n // resume timer moot — harmless left alone (its own eventual start()\n // call would just no-op against the `if (playing) return` above), but\n // cancelling it here avoids a dangling timer doing nothing for up to\n // RESUME_DEBOUNCE_MS after this already resolved things.\n cancelPendingResume();\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 // A real bug, reported from real usage: zooming in *first*, then\n // pressing Play, never paused at all — clicking Play doesn't itself\n // fire zoomChange/rotateFlipChange, and a single-step \"toggle back to\n // neutral\" action afterward (Actual size, double-tap-to-reset) only\n // ever emits the *already-neutral* event, never one crossing the\n // engaged threshold — so nothing downstream would ever have caught it\n // either. Re-checked here instead, right after a genuine manual\n // start — the one path a viewer can actually reach a non-neutral view\n // state from *before* pressing Play. `zoomedIn`/`rotatedOrFlipped`\n // (below) track only the *current* state, purely for this check —\n // deliberately not reused for the pause-on-event logic itself, which\n // reacts to each event directly. Shared by toggle()'s manual-start path\n // and requestAutoplayStart below — both are \"a genuine manual start,\"\n // just triggered from a different control.\n function reCheckEngagedAfterManualStart(): void {\n const stillZoomed = onZoom !== false && zoomedIn;\n const stillRotated = onRotateFlip !== false && rotatedOrFlipped;\n if (!stillZoomed && !stillRotated) return;\n stop();\n // Threading pause-mode tracking through *this* re-stop too (not just\n // the zoomChange/rotateFlipChange handlers below) is what avoids\n // repeating the first auto-resume attempt's other real bug: a manual\n // restart while still engaged left that design's own edge-tracker\n // permanently \"already engaged,\" silently skipping the next\n // re-pause's resume tracking. Setting the flag here, at the actual\n // moment this stop happens, means it's never missed regardless of\n // which path (button/Space, or a custom plugin's requestAutoplayStart)\n // triggered the manual start.\n if (onZoom === 'pause' && stillZoomed) pausedByZoom = true;\n if (onRotateFlip === 'pause' && stillRotated) pausedByRotateFlip = true;\n }\n\n function toggle(): void {\n if (playing) {\n clearPauseFlags(); // a real manual pause is always a hard stop, never a pending soft-resume\n stop();\n return;\n }\n start();\n reCheckEngagedAfterManualStart();\n }\n\n /**\n * Pauses the slideshow the instant a vertical drag crosses the close\n * threshold, mirroring the controls' own live hide cue\n * (`dragCloseThreshold`) — asked for directly, otherwise the\n * interval/video kept running mid-drag. Resumes only if *this*\n * crossing paused it — a manual pause (toolbar, mid-drag tap) must\n * stay paused even if the drag retreats back under the threshold.\n */\n const offDragThreshold = ctx.on('dragCloseThreshold', ({ hidden }) => {\n if (hidden) {\n wasPlayingBeforeDrag = playing;\n if (playing) {\n clearPauseFlags(); // a hard stop, unrelated to onZoom/onRotateFlip/onCaptionExpand — must not resurrect a pending soft-resume from one of those\n stop();\n }\n } else if (wasPlayingBeforeDrag) {\n wasPlayingBeforeDrag = false;\n start();\n }\n });\n\n /**\n * DESIGN.md §4.1 — a real UX gap, not a reported bug: nothing stopped\n * the slideshow from auto-advancing out from under a viewer actively\n * zoomed into a detail on the current slide. Only reacts to\n * `zoomChange`'s event *shape* (`core/types.ts`) — never imports Zoom\n * directly, so this is a no-op with it not loaded, or with `onZoom:\n * false` set explicitly (events over inheritance, CLAUDE.md).\n *\n * `'stop'` mode never auto-resumes, matching this plugin's *first*\n * attempt at this feature — see `RESUME_DEBOUNCE_MS`'s own doc comment\n * for the two real problems that attempt hit and why `'pause'` mode\n * (below) is built differently, not just re-adding the same thing\n * under a new name. `scale > 1` still gates *which* zoomChange events\n * count (an ordinary scale-1 event can also fire from an unrelated\n * slide-change reset, not a real interaction — `zoom/index.ts`'s\n * `reset()` — so it can't unconditionally pause on every event the way\n * rotateFlipChange below safely can).\n */\n const ZOOM_ENGAGED_THRESHOLD = 1.001; // matches Zoom's own ZOOM_EPSILON — \"just barely above 1\" is float residue, not a real zoom\n const offZoomChange = ctx.on('zoomChange', ({ scale }) => {\n zoomedIn = scale > ZOOM_ENGAGED_THRESHOLD;\n if (onZoom !== false && zoomedIn && playing) {\n if (onZoom === 'stop') clearPauseFlags();\n stop();\n if (onZoom === 'pause') pausedByZoom = true;\n } else {\n // stop() above already refreshes toggle availability; this branch\n // covers zooming in while already paused (stop() wouldn't touch\n // it) and zooming back out, which may have just disengaged the\n // last thing a pending 'pause'-mode resume was waiting on.\n updateToggleAvailability();\n if (!zoomedIn) maybeScheduleResume();\n }\n });\n /**\n * Same UX gap, RotateFlip's own equivalent (see `onRotateFlip`'s own\n * doc comment). Pauses on *any* `rotateFlipChange` event\n * unconditionally, including one that lands back on the original\n * orientation — confirmed directly, reported from real usage: rotate\n * four times back to 0deg still reads as an active interruption of the\n * slideshow, not \"nothing happened,\" so it must still pause too, the\n * same as landing anywhere else, in *both* modes — `'pause'` mode's\n * auto-resume is debounced specifically so this repeated-neutral case\n * doesn't spring it back to life between clicks (`RESUME_DEBOUNCE_MS`).\n * Every `rotateFlipChange` (unlike zoomChange above) only ever fires\n * from a real button/shortcut click — `rotateFlip/index.ts`'s own\n * per-slide `reset()` never emits it — so no extra state check is\n * needed here the way zoomChange's `scale > 1` gate is.\n */\n const offRotateFlipChange = ctx.on('rotateFlipChange', ({ flipH, flipV, rotation }) => {\n rotatedOrFlipped = flipH || flipV || rotation !== 0;\n if (onRotateFlip !== false && playing) {\n if (onRotateFlip === 'stop') clearPauseFlags();\n stop();\n if (onRotateFlip === 'pause') pausedByRotateFlip = true;\n } else {\n updateToggleAvailability(); // stop() above already refreshes this; covers rotating while already paused, which stop() wouldn't touch\n if (onRotateFlip !== false && !rotatedOrFlipped) maybeScheduleResume();\n }\n });\n /**\n * `onCaptionExpand`'s own doc comment above — unlike the two listeners\n * above, no `updateToggleAvailability()` companion call: the caption\n * modal already makes Play physically unreachable while `open: true`\n * (core's own focus trap + capture-phase keydown blocking, not\n * anything this plugin has to enforce), so there's no \"pressed Play\n * while still blocked\" case to disable the button for or re-check on\n * toggle() the way zoom/rotateFlip both need. `captionOpen` is still\n * tracked (unlike before `'pause'` mode existed) purely so\n * `maybeScheduleResume()` can confirm nothing else is also still\n * engaged before it queues a resume.\n */\n const offCaptionModalChange = ctx.on('captionModalChange', ({ open }) => {\n captionOpen = open;\n if (onCaptionExpand !== false && open && playing) {\n if (onCaptionExpand === 'stop') clearPauseFlags();\n stop();\n if (onCaptionExpand === 'pause') pausedByCaptionExpand = true;\n } else if (!open) {\n maybeScheduleResume();\n }\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 // Detaches this plugin's own 'pause'/'ended' listeners from the outgoing\n // video before Gallery.navigate() pauses it (see Gallery.ts's comment on\n // this same event) — otherwise that pause is misread as the viewer\n // manually pausing, which stops the slideshow before the new slide's\n // afterSlide handler below ever runs. enterSlide() below also calls\n // detachVideo(), but by then it's too late for *this* transition; that\n // call is what handles the slide *after* this one instead.\n const offBeforeSlide = ctx.on('beforeSlide', () => {\n detachVideo();\n });\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 // Zoom/RotateFlip both reset unanimated on navigation (DESIGN.md\n // §2.5) without re-emitting zoomChange/rotateFlipChange — resynced\n // here so a stale \"was engaged\" reading from the *outgoing* slide\n // can't wrongly re-pause toggle()'s next Play press on a slide\n // that's actually neutral now. Also clears any pending 'pause'-mode\n // resume outright, not just the flags it was waiting on — the slide\n // it was paused *for* isn't even the active one anymore, so whether\n // to resume here is genuinely ambiguous; `stopOnManualNavigate`\n // (default true) already decides what actually happens next.\n zoomedIn = false;\n rotatedOrFlipped = false;\n clearPauseFlags();\n updateToggleAvailability();\n if (!playing) return;\n // stopOnManualNavigate (default true, see its own doc comment) —\n // isAdvancing is only ever true for the exact duration of advance()'s\n // own gallery.next() call above, so its absence here means something\n // other than autoplay itself moved the slide.\n if (stopOnManualNavigate && !isAdvancing) stop();\n else 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 /**\n * DESIGN.md §4.1 point 20 — a generic command surface, requested\n * directly, so a *custom* (host-authored) plugin's own toolbar button\n * can start/pause the slideshow without importing this plugin at all,\n * the same \"events over inheritance\" decoupling every other listener\n * here already uses for official plugins (`zoomChange`,\n * `rotateFlipChange`, `captionModalChange`, `dragCloseThreshold`).\n * `GalleryEvents` (`core/types.ts`) already extends `Record<string,\n * unknown>`, so `ctx.emit('requestAutoplayStop', {})` from any plugin\n * — official or custom — type-checks with zero core changes; this is\n * just the listening half. Deliberately two separate commands, not one\n * toggle — a custom plugin building its own distinct Play/Pause\n * controls (rather than one button that flips) needs to command each\n * state directly, not guess at the current one. Both no-op in the\n * already-there state (`stop()`'s own existing guard for pause;\n * `start()`'s own existing guard, plus the same manual-start re-check\n * `toggle()`'s button/Space path uses, for start).\n */\n const offRequestStop = ctx.on('requestAutoplayStop', () => {\n if (playing) {\n clearPauseFlags(); // a command from another plugin is treated like a manual pause, not a soft pending resume\n stop();\n }\n });\n const offRequestStart = ctx.on('requestAutoplayStart', () => {\n if (playing) return;\n start();\n reCheckEngagedAfterManualStart();\n });\n const offClose = ctx.on('close', () => {\n clearPauseFlags();\n stop();\n });\n const offOpen = ctx.on('afterOpen', () => {\n zoomedIn = false;\n rotatedOrFlipped = false;\n captionOpen = false;\n clearPauseFlags();\n updateToggleAvailability();\n if (autoStart) start();\n });\n\n return () => {\n cancelPendingResume(); // a pending 'pause'-mode resume must not fire start() after teardown\n stop();\n outer.removeEventListener('error', onVideoError);\n removeButton();\n removeProgress?.();\n removeShortcut();\n offBeforeSlide();\n offSlide();\n offSlideItemLoad();\n offOpen();\n offClose();\n offDragThreshold();\n offZoomChange();\n offRotateFlipChange();\n offCaptionModalChange();\n offRequestStop();\n offRequestStart();\n };\n },\n};\n"],"names":[],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { c as createIconSwap } from "../../iconSwap-BAXTLihx.js";
|
|
1
2
|
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
3
|
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
4
|
function isSupported() {
|
|
@@ -31,7 +32,8 @@ const Fullscreen = {
|
|
|
31
32
|
const button = document.createElement("button");
|
|
32
33
|
button.type = "button";
|
|
33
34
|
button.className = "shoji-toolbar-button";
|
|
34
|
-
|
|
35
|
+
const iconSwap = createIconSwap(EXPAND_ICON, COMPRESS_ICON);
|
|
36
|
+
button.appendChild(iconSwap.el);
|
|
35
37
|
button.setAttribute("aria-label", enterLabel);
|
|
36
38
|
button.title = enterLabel;
|
|
37
39
|
button.setAttribute("aria-pressed", "false");
|
|
@@ -39,7 +41,7 @@ const Fullscreen = {
|
|
|
39
41
|
return currentFullscreenElement() === outer;
|
|
40
42
|
}
|
|
41
43
|
function setState(active) {
|
|
42
|
-
|
|
44
|
+
iconSwap.setState(active);
|
|
43
45
|
button.setAttribute("aria-label", active ? exitLabel : enterLabel);
|
|
44
46
|
button.title = active ? exitLabel : enterLabel;
|
|
45
47
|
button.setAttribute("aria-pressed", String(active));
|
|
@@ -1 +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
|
|
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 { createIconSwap } from '../../core/iconSwap';\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 const iconSwap = createIconSwap(EXPAND_ICON, COMPRESS_ICON);\n button.appendChild(iconSwap.el);\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 iconSwap.setState(active);\n button.setAttribute('aria-label', active ? exitLabel : enterLabel);\n button.title = active ? exitLabel : enterLabel;\n button.setAttribute('aria-pressed', String(active));\n }\n\n /** Shared by the toolbar button and `requestFullscreenToggle` below. */\n function toggleFullscreen(): void {\n if (isActive()) exitFullscreen();\n else requestFullscreen(outer);\n }\n button.addEventListener('click', toggleFullscreen);\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 * A generic command surface, requested directly (DESIGN.md §4.4), so a\n * *custom* (host-authored) plugin's own button can toggle fullscreen\n * without importing this plugin at all — same \"events over\n * inheritance\" decoupling `close` above already uses.\n * `GalleryEvents` (`core/types.ts`) already extends `Record<string,\n * unknown>`, so `ctx.emit('requestFullscreenToggle', {})` from any\n * plugin — official or custom — type-checks with zero core changes;\n * this is just the listening half.\n */\n const offRequestToggle = ctx.on('requestFullscreenToggle', toggleFullscreen);\n\n return () => {\n document.removeEventListener('fullscreenchange', onChange);\n document.removeEventListener('webkitfullscreenchange', onChange);\n removeButton();\n offClose();\n offRequestToggle();\n if (isActive()) exitFullscreen();\n };\n },\n};\n"],"names":[],"mappings":";AACO,MAAM,cACX;AAEK,MAAM,gBACX;ACeF,SAAS,cAAuB;AAC9B,SAAO,CAAC,EACN,SAAS,qBAAsB,SAAsC;AAEzE;AAEA,SAAS,2BAA2C;AAClD,SACE,SAAS,qBACR,SAAsC,2BACvC;AAEJ;AAEA,SAAS,kBAAkB,IAAuB;;AAChD,QAAM,SAAS;AACf,MAAI,OAAO,kBAAmB,QAAO,kBAAA,EAAoB,MAAM,MAAM;AAAA,EAAC,CAAC;AAAA,oBAC3D;AACd;AAEA,SAAS,iBAAuB;;AAC9B,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,UAAM,WAAW,eAAe,aAAa,aAAa;AAC1D,WAAO,YAAY,SAAS,EAAE;AAC9B,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,eAAS,SAAS,MAAM;AACxB,aAAO,aAAa,cAAc,SAAS,YAAY,UAAU;AACjE,aAAO,QAAQ,SAAS,YAAY;AACpC,aAAO,aAAa,gBAAgB,OAAO,MAAM,CAAC;AAAA,IACpD;AAGA,aAAS,mBAAyB;AAChC,UAAI,SAAA,EAAY,gBAAA;AAAA,6BACO,KAAK;AAAA,IAC9B;AACA,WAAO,iBAAiB,SAAS,gBAAgB;AAEjD,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;AAWD,UAAM,mBAAmB,IAAI,GAAG,2BAA2B,gBAAgB;AAE3E,WAAO,MAAM;AACX,eAAS,oBAAoB,oBAAoB,QAAQ;AACzD,eAAS,oBAAoB,0BAA0B,QAAQ;AAC/D,mBAAA;AACA,eAAA;AACA,uBAAA;AACA,UAAI,SAAA,EAAY,gBAAA;AAAA,IAClB;AAAA,EACF;AACF;"}
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. A magnifying glass with a +/− in the lens, and a plain expand-corners glyph for "actual size" (distinct from fullscreen's EXPAND_ICON — no diagonal corner arrows, just a frame, so the two aren't visually confusable when both plugins are enabled). */
|
|
2
2
|
export declare const ZOOM_IN_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M21 21l-5.5-5.5M10 7v6M7 10h6\"/></svg>";
|
|
3
3
|
export declare const ZOOM_OUT_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M21 21l-5.5-5.5M7 10h6\"/></svg>";
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Live state icons for the actual-size button (index.ts's icon-swap wiring)
|
|
6
|
+
* — a diagonal double-arrow pair, matching the shape of Bootstrap Icons'
|
|
7
|
+
* `arrows-angle-expand`/`arrows-angle-contract` (requested directly), not
|
|
8
|
+
* literally that icon set's own path data. Still distinct from Fullscreen's
|
|
9
|
+
* own EXPAND_ICON/COMPRESS_ICON (fullscreen/icons.ts) despite both being
|
|
10
|
+
* diagonal-corner glyphs: Fullscreen draws four independent corner brackets
|
|
11
|
+
* with no connecting line between them; these draw one continuous diagonal
|
|
12
|
+
* shaft with an arrowhead-style bracket at each end, a different enough
|
|
13
|
+
* shape that the two read as separate icons at a glance, not near-copies of
|
|
14
|
+
* each other.
|
|
15
|
+
*/
|
|
16
|
+
export declare const ZOOM_ACTUAL_SIZE_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=\"M9 9L3 3M3 8V3h5M15 15l6 6M21 16v5h-5\"/></svg>";
|
|
17
|
+
export declare const ZOOM_ACTUAL_SIZE_CONTRACT_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=\"M3 3l6 6M9 3v6H3M21 21l-6-6M15 21v-6h6\"/></svg>";
|