@michaelyagi/shoji 0.1.0-alpha.38 → 0.1.0-alpha.39
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/esm/index.js +1 -1
- package/dist/esm/plugins/autoplay/index.js +14 -1
- package/dist/esm/plugins/autoplay/index.js.map +1 -1
- package/dist/esm/plugins/fullscreen/index.js +5 -2
- package/dist/esm/plugins/fullscreen/index.js.map +1 -1
- package/dist/esm/plugins/rotateFlip/index.js +20 -8
- package/dist/esm/plugins/rotateFlip/index.js.map +1 -1
- package/dist/esm/plugins/zoom/index.js +16 -7
- package/dist/esm/plugins/zoom/index.js.map +1 -1
- package/dist/shoji.js +56 -19
- package/dist/shoji.js.map +1 -1
- package/dist/shoji.min.js +1 -1
- package/dist/shoji.min.js.map +1 -1
- package/package.json +1 -1
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-alpha.
|
|
9
|
+
const version = "0.1.0-alpha.39";
|
|
10
10
|
const Shoji = Object.assign(Gallery, {
|
|
11
11
|
Autoplay,
|
|
12
12
|
Layout,
|
|
@@ -192,13 +192,16 @@ const Autoplay = {
|
|
|
192
192
|
detachVideo();
|
|
193
193
|
ctx.emit("autoplayStop", {});
|
|
194
194
|
}
|
|
195
|
+
function reCheckEngagedAfterManualStart() {
|
|
196
|
+
if (pauseOnZoom && zoomedIn || pauseOnRotateFlip && rotatedOrFlipped) stop();
|
|
197
|
+
}
|
|
195
198
|
function toggle() {
|
|
196
199
|
if (playing) {
|
|
197
200
|
stop();
|
|
198
201
|
return;
|
|
199
202
|
}
|
|
200
203
|
start();
|
|
201
|
-
|
|
204
|
+
reCheckEngagedAfterManualStart();
|
|
202
205
|
}
|
|
203
206
|
const offDragThreshold = ctx.on("dragCloseThreshold", ({ hidden }) => {
|
|
204
207
|
if (hidden) {
|
|
@@ -241,6 +244,14 @@ const Autoplay = {
|
|
|
241
244
|
const offSlideItemLoad = ctx.on("slideItemLoad", ({ index }) => {
|
|
242
245
|
if (playing && awaitingProviderVideo && index === gallery.currentIndex) enterSlide();
|
|
243
246
|
});
|
|
247
|
+
const offRequestPause = ctx.on("requestAutoplayPause", () => {
|
|
248
|
+
if (playing) stop();
|
|
249
|
+
});
|
|
250
|
+
const offRequestStart = ctx.on("requestAutoplayStart", () => {
|
|
251
|
+
if (playing) return;
|
|
252
|
+
start();
|
|
253
|
+
reCheckEngagedAfterManualStart();
|
|
254
|
+
});
|
|
244
255
|
const offClose = ctx.on("close", () => stop());
|
|
245
256
|
const offOpen = ctx.on("afterOpen", () => {
|
|
246
257
|
zoomedIn = false;
|
|
@@ -263,6 +274,8 @@ const Autoplay = {
|
|
|
263
274
|
offZoomChange();
|
|
264
275
|
offRotateFlipChange();
|
|
265
276
|
offCaptionModalChange();
|
|
277
|
+
offRequestPause();
|
|
278
|
+
offRequestStart();
|
|
266
279
|
};
|
|
267
280
|
}
|
|
268
281
|
};
|
|
@@ -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 /** Pauses the slideshow while the viewer is zoomed in on the active slide (Zoom plugin) — stays paused until Play is pressed again, even once un-zoomed back to neutral (no auto-resume). A no-op if Zoom isn't loaded. Default `true` — a viewer actively examining a zoomed-in detail is a clear signal the slideshow shouldn't advance out from under them; set `false` to let it keep running regardless. */\n pauseOnZoom?: boolean;\n /** Pauses the slideshow on any RotateFlip interaction — including one that lands back on the original orientation, since the click itself is still an active interruption, not just its end state. Stays paused until Play is pressed again. A no-op if RotateFlip isn't loaded. Default `true`, same reasoning as `pauseOnZoom` above — a viewer reaching for a rotate/flip control is actively engaging with the current slide, not passively watching a slideshow; set `false` to let it keep running regardless. */\n pauseOnRotateFlip?: boolean;\n /** Pauses the slideshow when the viewer expands a truncated caption to read the rest (core's own caption modal, DESIGN.md §2.3a) — stays paused until Play is pressed again, same as `pauseOnZoom`/`pauseOnRotateFlip`. No re-check on Play needed the way those two need one: the modal traps both pointer and keyboard input while open (core blocks every key, not just Escape), so Play is physically unreachable until the modal is already closed. Default `true`, same reasoning as the other two — expanding a caption to read the rest is a request for time, not an idle moment to advance past; set `false` to let it keep running regardless. */\n pauseOnCaptionExpand?: boolean;\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, same as the other `pauseOn*` options. Default `true`, same reasoning as `pauseOnZoom`/`pauseOnRotateFlip`/`pauseOnCaptionExpand` 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. */\n pauseOnManualNavigate?: boolean;\n}\n\n/**\n * DESIGN.md §4-autoplay. Advances on a fixed `interval` (default 5000ms) for\n * ordinary slides; for a video slide, plays it and waits for `ended` instead\n * — the interval never applies to video. A manual pause on that video pauses\n * the *slideshow* too (not just the video); manually resuming the video does\n * NOT resume the slideshow — that requires pressing the slideshow's own\n * play control again. See `enterSlide()`/`onVideoPause()` below for exactly\n * where each half of that rule lives.\n */\nexport const Autoplay: ShojiPlugin = {\n name: 'autoplay',\n defaults: {\n interval: 5000,\n showProgress: true,\n pauseOnZoom: true,\n pauseOnRotateFlip: true,\n pauseOnCaptionExpand: true,\n pauseOnManualNavigate: 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 pauseOnZoom = ctx.options.pauseOnZoom !== false;\n const pauseOnRotateFlip = ctx.options.pauseOnRotateFlip !== false;\n const pauseOnCaptionExpand = ctx.options.pauseOnCaptionExpand !== false;\n const pauseOnManualNavigate = ctx.options.pauseOnManualNavigate !== 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 // 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 pauseOnManualNavigate 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 `pauseOnZoom`/`pauseOnRotateFlip` 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 && ((pauseOnZoom && zoomedIn) || (pauseOnRotateFlip && 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 setButtonState(true);\n ctx.emit('autoplayStart', {});\n enterSlide();\n }\n\n function stop(): void {\n if (!playing) return;\n setButtonState(false);\n clearTimer();\n resetProgressBar();\n if (currentVideo && !currentVideo.paused) currentVideo.pause();\n detachVideo();\n ctx.emit('autoplayStop', {});\n }\n\n function toggle(): void {\n if (playing) {\n stop();\n return;\n }\n start();\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.\n if ((pauseOnZoom && zoomedIn) || (pauseOnRotateFlip && rotatedOrFlipped)) stop();\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) stop();\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\n * `pauseOnZoom: false` set explicitly (events over inheritance,\n * CLAUDE.md).\n *\n * Deliberately **stays paused** rather than auto-resuming once\n * un-zoomed back to neutral — the first design tried the opposite\n * (edge-tracked \"engaged\" state, auto-resume on disengage, matching\n * `dragCloseThreshold` below) and hit two real problems testing it:\n * (1) a manual restart while still zoomed left the edge-tracker stuck\n * \"already engaged,\" silently skipping the *next* re-pause; (2) once\n * fixed, un-zooming back to exactly scale 1 still auto-resumed even\n * when the viewer's very next action (confirmed directly for\n * RotateFlip's equivalent, below) was to keep interacting with the\n * view controls, not watch the slideshow. `scale > 1` still gates\n * *which* zoomChange events count (an ordinary scale-1 event can also\n * fire from an unrelated slide-change reset, not a real interaction —\n * `zoom/index.ts`'s `reset()` — so it can't unconditionally pause on\n * every event the way 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 (pauseOnZoom && zoomedIn && playing) stop();\n else updateToggleAvailability(); // stop() above already refreshes this; covers zooming in while already paused, which stop() wouldn't touch\n });\n /**\n * Same UX gap, RotateFlip's own equivalent (see `pauseOnRotateFlip`'s\n * own doc comment). Pauses on *any*\n * `rotateFlipChange` event unconditionally, including one that lands\n * back on the original orientation — confirmed directly, reported from\n * real usage: rotate four times back to 0deg still reads as an active\n * interruption of the slideshow, not \"nothing happened,\" so it must\n * stay paused too, the same as landing anywhere else. Every\n * `rotateFlipChange` (unlike zoomChange above) only ever fires from a\n * real button/shortcut click — `rotateFlip/index.ts`'s own per-slide\n * `reset()` never emits it — so no extra state check is needed here.\n */\n const offRotateFlipChange = ctx.on('rotateFlipChange', ({ flipH, flipV, rotation }) => {\n rotatedOrFlipped = flipH || flipV || rotation !== 0;\n if (pauseOnRotateFlip && playing) stop();\n else updateToggleAvailability(); // stop() above already refreshes this; covers rotating while already paused, which stop() wouldn't touch\n });\n /**\n * `pauseOnCaptionExpand`'s own doc comment above — unlike the two\n * listeners above, no `updateToggleAvailability()` companion call and\n * no `zoomedIn`/`rotatedOrFlipped`-style tracked flag: 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.\n */\n const offCaptionModalChange = ctx.on('captionModalChange', ({ open }) => {\n if (pauseOnCaptionExpand && open && playing) stop();\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.\n zoomedIn = false;\n rotatedOrFlipped = false;\n updateToggleAvailability();\n if (!playing) return;\n // pauseOnManualNavigate (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 (pauseOnManualNavigate && !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 const offClose = ctx.on('close', () => stop());\n const offOpen = ctx.on('afterOpen', () => {\n zoomedIn = false;\n rotatedOrFlipped = false;\n updateToggleAvailability();\n if (autoStart) start();\n });\n\n return () => {\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 };\n },\n};\n"],"names":[],"mappings":"AAEO,MAAM,YACX;AAEK,MAAM,aACX;ACaF,SAAS,aAAa,OAAiD;AACrE,QAAM,QAAQ,+BAAO,cAAc;AACnC,MAAI,MAAO,QAAO;AAClB,QAAM,WAAW,+BAAO;AAAA,IACtB;AAAA;AAEF,MAAI,YAAY,OAAO,SAAS,SAAS,WAAY,QAAO;AAC5D,SAAO;AACT;AAGA,SAAS,uBAAuB,OAAoC;AAClE,QAAM,WAAW,+BAAO;AAAA,IACtB;AAAA;AAEF,SAAO,CAAC,CAAC,YAAY,OAAO,SAAS,SAAS;AAChD;AAMA,MAAM,yBAAyB;AAC/B,MAAM,6BAA6B;AA4B5B,MAAM,WAAwB;AAAA,EACnC,MAAM;AAAA,EACN,UAAU;AAAA,IACR,UAAU;AAAA,IACV,cAAc;AAAA,IACd,aAAa;AAAA,IACb,mBAAmB;AAAA,IACnB,sBAAsB;AAAA,IACtB,uBAAuB;AAAA,EAAA;AAAA,EAGzB,KAAK,KAAgC;AACnC,UAAM,EAAE,YAAY;AACpB,UAAM,WAAW,OAAO,IAAI,QAAQ,YAAY,GAAI;AACpD,UAAM,eAAe,IAAI,QAAQ,iBAAiB;AAClD,UAAM,YAAY,IAAI,QAAQ,cAAc;AAC5C,UAAM,cAAc,IAAI,QAAQ,gBAAgB;AAChD,UAAM,oBAAoB,IAAI,QAAQ,sBAAsB;AAC5D,UAAM,uBAAuB,IAAI,QAAQ,yBAAyB;AAClE,UAAM,wBAAwB,IAAI,QAAQ,0BAA0B;AACpE,UAAM,SAAS,IAAI,QAAQ;AAC3B,UAAM,aAAY,iCAAQ,SAAQ;AAClC,UAAM,cAAa,iCAAQ,UAAS;AAEpC,QAAI,UAAU;AACd,QAAI,QAA8C;AAClD,QAAI,eAAqC;AACzC,QAAI,wBAAwB;AAC5B,QAAI,uBAAuB;AAM3B,QAAI,WAAW;AACf,QAAI,mBAAmB;AAMvB,QAAI,cAAc;AAiBlB,UAAM,QAAQ,IAAI,GAAG,MAAA;AACrB,aAAS,aAAa,OAAoB;AACxC,UAAI,CAAC,QAAS;AACd,YAAM,SAAS,QAAQ,eAAA;AACvB,UAAI,UAAU,MAAM,kBAAkB,QAAQ,OAAO,SAAS,MAAM,MAAM,EAAG,SAAA;AAAA,IAC/E;AACA,UAAM,iBAAiB,SAAS,YAAY;AAE5C,UAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,WAAO,OAAO;AAKd,WAAO,YAAY;AACnB,WAAO,aAAa,cAAc,SAAS;AAC3C,WAAO,QAAQ;AACf,WAAO,YAAY;AAEnB,UAAM,WAAW,SAAS,cAAc,KAAK;AAC7C,aAAS,YAAY;AACrB,aAAS,SAAS;AAClB,UAAM,cAAc,SAAS,cAAc,KAAK;AAChD,gBAAY,YAAY;AACxB,aAAS,YAAY,WAAW;AAEhC,aAAS,aAAmB;AAC1B,UAAI,UAAU,MAAM;AAClB,qBAAa,KAAK;AAClB,gBAAQ;AAAA,MACV;AAAA,IACF;AAEA,aAAS,mBAAyB;AAChC,UAAI,CAAC,aAAc;AACnB,eAAS,SAAS;AAClB,kBAAY,MAAM,aAAa;AAC/B,kBAAY,MAAM,QAAQ;AAAA,IAC5B;AAEA,aAAS,eAAe,IAAkB;AACxC,UAAI,CAAC,aAAc;AACnB,eAAS,SAAS;AAClB,kBAAY,MAAM,aAAa;AAC/B,kBAAY,MAAM,QAAQ;AAC1B,WAAK,YAAY;AACjB,kBAAY,MAAM,aAAa,SAAS,EAAE;AAC1C,kBAAY,MAAM,QAAQ;AAAA,IAC5B;AAgBA,aAAS,2BAAiC;AACxC,YAAM,UACJ,CAAC,YAAa,eAAe,YAAc,qBAAqB;AAClE,aAAO,eAAe,UAAU,SAAS;AACzC,UAAI,gBAAgB,WAAW;AAAA,UAC1B,QAAO,gBAAgB,UAAU;AAAA,IACxC;AAEA,aAAS,eAAe,WAA0B;AAChD,gBAAU;AACV,aAAO,YAAY,YAAY,aAAa;AAC5C,aAAO,aAAa,cAAc,YAAY,aAAa,SAAS;AACpE,aAAO,QAAQ,YAAY,aAAa;AACxC,+BAAA;AAAA,IACF;AAEA,aAAS,eAAqB;AAC5B,UAAI,QAAS,SAAA;AAAA,IACf;AAOA,aAAS,eAAqB;AAC5B,UAAI,6CAAc,MAAO;AACzB,WAAA;AAAA,IACF;AAEA,aAAS,cAAoB;AAC3B,UAAI,CAAC,aAAc;AACnB,mBAAa,oBAAoB,SAAS,YAAY;AACtD,mBAAa,oBAAoB,SAAS,YAAY;AACtD,qBAAe;AAAA,IACjB;AA6BA,aAAS,sBAAsB,OAAsB,cAA4B;AAC/E,YAAM,QAAQ;AACd,YAAM,aAAa,MAAM,KAAA;AACzB,UAAI,cAAc,OAAO,WAAW,SAAS,YAAY;AACvD,6BAAqB,OAAO,cAAc,UAAU;AAAA,MACtD,OAAO;AACL,2BAAmB,OAAO,YAAY;AAAA,MACxC;AAAA,IACF;AAGA,aAAS,mBAAmB,OAAsB,cAA4B;AAC5E,iBAAW,MAAM;AACf,YAAI,iBAAiB,SAAS,CAAC,QAAS;AACxC,YAAI,CAAC,MAAM,OAAQ;AACnB,YAAI,eAAe,EAAG,uBAAsB,OAAO,eAAe,CAAC;AAAA,YAQ9D,SAAA;AAAA,MACP,GAAG,sBAAsB;AAAA,IAC3B;AAGA,aAAS,qBACP,OACA,cACA,YACM;AAON,iBAAW,MAAM,MAAM;AAAA,MAAC,CAAC;AACzB,iBAAW,MAAM;AACf,YAAI,iBAAiB,SAAS,CAAC,QAAS;AACxC,YAAI,CAAC,MAAM,OAAQ;AACnB,YAAI,eAAe,EAAG,sBAAqB,OAAO,eAAe,GAAG,UAAU;AAAA,YACzE,SAAA;AAAA,MACP,GAAG,sBAAsB;AAAA,IAC3B;AAEA,aAAS,aAAmB;AAC1B,iBAAA;AACA,kBAAA;AACA,uBAAA;AACA,8BAAwB;AACxB,UAAI,CAAC,QAAS;AAEd,YAAM,QAAQ,QAAQ,eAAA;AACtB,YAAM,QAAQ,aAAa,KAAK;AAChC,UAAI,OAAO;AACT,uBAAe;AACf,cAAM,iBAAiB,SAAS,YAAY;AAC5C,cAAM,iBAAiB,SAAS,YAAY;AAC5C,YAAI,iBAAiB,kBAAkB;AACrC,gBAAM,aAAa,MAAM,KAAA;AASzB,cAAI,cAAc,OAAO,WAAW,UAAU,YAAY;AACxD,uBAAW,MAAM,CAAC,UAAmB;AACnC,kBAAI,iBAAiB,SAAS,CAAC,QAAS;AACxC,kBAAI,iBAAiB,gBAAgB,MAAM,SAAS,kBAAmB,MAAA;AAAA,kBAClE,SAAA;AAAA,YACP,CAAC;AAAA,UACH;AAAA,QACF,OAAO;AACL,gCAAsB,OAAO,0BAA0B;AAAA,QACzD;AACA;AAAA,MACF;AAOA,8BAAwB,uBAAuB,KAAK;AACpD,qBAAe,QAAQ;AACvB,cAAQ,WAAW,SAAS,QAAQ;AAAA,IACtC;AAEA,aAAS,UAAgB;AACvB,YAAM,SAAS,QAAQ;AACvB,oBAAc;AACd,cAAQ,KAAA;AACR,oBAAc;AAId,UAAI,QAAQ,iBAAiB,OAAQ,MAAA;AAAA,IACvC;AAEA,aAAS,QAAc;AACrB,UAAI,QAAS;AACb,qBAAe,IAAI;AACnB,UAAI,KAAK,iBAAiB,EAAE;AAC5B,iBAAA;AAAA,IACF;AAEA,aAAS,OAAa;AACpB,UAAI,CAAC,QAAS;AACd,qBAAe,KAAK;AACpB,iBAAA;AACA,uBAAA;AACA,UAAI,gBAAgB,CAAC,aAAa,qBAAqB,MAAA;AACvD,kBAAA;AACA,UAAI,KAAK,gBAAgB,EAAE;AAAA,IAC7B;AAEA,aAAS,SAAe;AACtB,UAAI,SAAS;AACX,aAAA;AACA;AAAA,MACF;AACA,YAAA;AAaA,UAAK,eAAe,YAAc,qBAAqB,iBAAmB,MAAA;AAAA,IAC5E;AAUA,UAAM,mBAAmB,IAAI,GAAG,sBAAsB,CAAC,EAAE,aAAa;AACpE,UAAI,QAAQ;AACV,+BAAuB;AACvB,YAAI,QAAS,MAAA;AAAA,MACf,WAAW,sBAAsB;AAC/B,+BAAuB;AACvB,cAAA;AAAA,MACF;AAAA,IACF,CAAC;AA0BD,UAAM,yBAAyB;AAC/B,UAAM,gBAAgB,IAAI,GAAG,cAAc,CAAC,EAAE,YAAY;AACxD,iBAAW,QAAQ;AACnB,UAAI,eAAe,YAAY,QAAS,MAAA;AAAA,UACnC,0BAAA;AAAA,IACP,CAAC;AAaD,UAAM,sBAAsB,IAAI,GAAG,oBAAoB,CAAC,EAAE,OAAO,OAAO,eAAe;AACrF,yBAAmB,SAAS,SAAS,aAAa;AAClD,UAAI,qBAAqB,QAAS,MAAA;AAAA,UAC7B,0BAAA;AAAA,IACP,CAAC;AAWD,UAAM,wBAAwB,IAAI,GAAG,sBAAsB,CAAC,EAAE,WAAW;AACvE,UAAI,wBAAwB,QAAQ,QAAS,MAAA;AAAA,IAC/C,CAAC;AAED,WAAO,iBAAiB,SAAS,MAAM;AAGvC,UAAM,eAAe,IAAI,GAAG,QAAQ,SAAS,MAAM;AACnD,UAAM,iBAAiB,eAAe,IAAI,GAAG,QAAQ,QAAQ,IAAI;AACjE,UAAM,iBAAiB,IAAI,GAAG,iBAAiB,KAAK,MAAM;AAQ1D,UAAM,iBAAiB,IAAI,GAAG,eAAe,MAAM;AACjD,kBAAA;AAAA,IACF,CAAC;AAOD,UAAM,WAAW,IAAI,GAAG,cAAc,MAAM;AAM1C,iBAAW;AACX,yBAAmB;AACnB,+BAAA;AACA,UAAI,CAAC,QAAS;AAKd,UAAI,yBAAyB,CAAC,YAAa,MAAA;AAAA,UACtC,YAAA;AAAA,IACP,CAAC;AAOD,UAAM,mBAAmB,IAAI,GAAG,iBAAiB,CAAC,EAAE,YAAY;AAC9D,UAAI,WAAW,yBAAyB,UAAU,QAAQ,aAAc,YAAA;AAAA,IAC1E,CAAC;AACD,UAAM,WAAW,IAAI,GAAG,SAAS,MAAM,MAAM;AAC7C,UAAM,UAAU,IAAI,GAAG,aAAa,MAAM;AACxC,iBAAW;AACX,yBAAmB;AACnB,+BAAA;AACA,UAAI,UAAW,OAAA;AAAA,IACjB,CAAC;AAED,WAAO,MAAM;AACX,WAAA;AACA,YAAM,oBAAoB,SAAS,YAAY;AAC/C,mBAAA;AACA;AACA,qBAAA;AACA,qBAAA;AACA,eAAA;AACA,uBAAA;AACA,cAAA;AACA,eAAA;AACA,uBAAA;AACA,oBAAA;AACA,0BAAA;AACA,4BAAA;AAAA,IACF;AAAA,EACF;AACF;"}
|
|
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 /** Pauses the slideshow while the viewer is zoomed in on the active slide (Zoom plugin) — stays paused until Play is pressed again, even once un-zoomed back to neutral (no auto-resume). A no-op if Zoom isn't loaded. Default `true` — a viewer actively examining a zoomed-in detail is a clear signal the slideshow shouldn't advance out from under them; set `false` to let it keep running regardless. */\n pauseOnZoom?: boolean;\n /** Pauses the slideshow on any RotateFlip interaction — including one that lands back on the original orientation, since the click itself is still an active interruption, not just its end state. Stays paused until Play is pressed again. A no-op if RotateFlip isn't loaded. Default `true`, same reasoning as `pauseOnZoom` above — a viewer reaching for a rotate/flip control is actively engaging with the current slide, not passively watching a slideshow; set `false` to let it keep running regardless. */\n pauseOnRotateFlip?: boolean;\n /** Pauses the slideshow when the viewer expands a truncated caption to read the rest (core's own caption modal, DESIGN.md §2.3a) — stays paused until Play is pressed again, same as `pauseOnZoom`/`pauseOnRotateFlip`. No re-check on Play needed the way those two need one: the modal traps both pointer and keyboard input while open (core blocks every key, not just Escape), so Play is physically unreachable until the modal is already closed. Default `true`, same reasoning as the other two — expanding a caption to read the rest is a request for time, not an idle moment to advance past; set `false` to let it keep running regardless. */\n pauseOnCaptionExpand?: boolean;\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, same as the other `pauseOn*` options. Default `true`, same reasoning as `pauseOnZoom`/`pauseOnRotateFlip`/`pauseOnCaptionExpand` 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. */\n pauseOnManualNavigate?: boolean;\n}\n\n/**\n * DESIGN.md §4-autoplay. Advances on a fixed `interval` (default 5000ms) for\n * ordinary slides; for a video slide, plays it and waits for `ended` instead\n * — the interval never applies to video. A manual pause on that video pauses\n * the *slideshow* too (not just the video); manually resuming the video does\n * NOT resume the slideshow — that requires pressing the slideshow's own\n * play control again. See `enterSlide()`/`onVideoPause()` below for exactly\n * where each half of that rule lives.\n */\nexport const Autoplay: ShojiPlugin = {\n name: 'autoplay',\n defaults: {\n interval: 5000,\n showProgress: true,\n pauseOnZoom: true,\n pauseOnRotateFlip: true,\n pauseOnCaptionExpand: true,\n pauseOnManualNavigate: 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 pauseOnZoom = ctx.options.pauseOnZoom !== false;\n const pauseOnRotateFlip = ctx.options.pauseOnRotateFlip !== false;\n const pauseOnCaptionExpand = ctx.options.pauseOnCaptionExpand !== false;\n const pauseOnManualNavigate = ctx.options.pauseOnManualNavigate !== 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 // 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 pauseOnManualNavigate 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 `pauseOnZoom`/`pauseOnRotateFlip` 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 && ((pauseOnZoom && zoomedIn) || (pauseOnRotateFlip && 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 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 if ((pauseOnZoom && zoomedIn) || (pauseOnRotateFlip && rotatedOrFlipped)) stop();\n }\n\n function toggle(): void {\n if (playing) {\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) stop();\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\n * `pauseOnZoom: false` set explicitly (events over inheritance,\n * CLAUDE.md).\n *\n * Deliberately **stays paused** rather than auto-resuming once\n * un-zoomed back to neutral — the first design tried the opposite\n * (edge-tracked \"engaged\" state, auto-resume on disengage, matching\n * `dragCloseThreshold` below) and hit two real problems testing it:\n * (1) a manual restart while still zoomed left the edge-tracker stuck\n * \"already engaged,\" silently skipping the *next* re-pause; (2) once\n * fixed, un-zooming back to exactly scale 1 still auto-resumed even\n * when the viewer's very next action (confirmed directly for\n * RotateFlip's equivalent, below) was to keep interacting with the\n * view controls, not watch the slideshow. `scale > 1` still gates\n * *which* zoomChange events count (an ordinary scale-1 event can also\n * fire from an unrelated slide-change reset, not a real interaction —\n * `zoom/index.ts`'s `reset()` — so it can't unconditionally pause on\n * every event the way 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 (pauseOnZoom && zoomedIn && playing) stop();\n else updateToggleAvailability(); // stop() above already refreshes this; covers zooming in while already paused, which stop() wouldn't touch\n });\n /**\n * Same UX gap, RotateFlip's own equivalent (see `pauseOnRotateFlip`'s\n * own doc comment). Pauses on *any*\n * `rotateFlipChange` event unconditionally, including one that lands\n * back on the original orientation — confirmed directly, reported from\n * real usage: rotate four times back to 0deg still reads as an active\n * interruption of the slideshow, not \"nothing happened,\" so it must\n * stay paused too, the same as landing anywhere else. Every\n * `rotateFlipChange` (unlike zoomChange above) only ever fires from a\n * real button/shortcut click — `rotateFlip/index.ts`'s own per-slide\n * `reset()` never emits it — so no extra state check is needed here.\n */\n const offRotateFlipChange = ctx.on('rotateFlipChange', ({ flipH, flipV, rotation }) => {\n rotatedOrFlipped = flipH || flipV || rotation !== 0;\n if (pauseOnRotateFlip && playing) stop();\n else updateToggleAvailability(); // stop() above already refreshes this; covers rotating while already paused, which stop() wouldn't touch\n });\n /**\n * `pauseOnCaptionExpand`'s own doc comment above — unlike the two\n * listeners above, no `updateToggleAvailability()` companion call and\n * no `zoomedIn`/`rotatedOrFlipped`-style tracked flag: 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.\n */\n const offCaptionModalChange = ctx.on('captionModalChange', ({ open }) => {\n if (pauseOnCaptionExpand && open && playing) stop();\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.\n zoomedIn = false;\n rotatedOrFlipped = false;\n updateToggleAvailability();\n if (!playing) return;\n // pauseOnManualNavigate (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 (pauseOnManualNavigate && !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('requestAutoplayPause', {})` 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 offRequestPause = ctx.on('requestAutoplayPause', () => {\n if (playing) stop();\n });\n const offRequestStart = ctx.on('requestAutoplayStart', () => {\n if (playing) return;\n start();\n reCheckEngagedAfterManualStart();\n });\n const offClose = ctx.on('close', () => stop());\n const offOpen = ctx.on('afterOpen', () => {\n zoomedIn = false;\n rotatedOrFlipped = false;\n updateToggleAvailability();\n if (autoStart) start();\n });\n\n return () => {\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 offRequestPause();\n offRequestStart();\n };\n },\n};\n"],"names":[],"mappings":"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"}
|
|
@@ -44,10 +44,11 @@ const Fullscreen = {
|
|
|
44
44
|
button.title = active ? exitLabel : enterLabel;
|
|
45
45
|
button.setAttribute("aria-pressed", String(active));
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
function toggleFullscreen() {
|
|
48
48
|
if (isActive()) exitFullscreen();
|
|
49
49
|
else requestFullscreen(outer);
|
|
50
|
-
}
|
|
50
|
+
}
|
|
51
|
+
button.addEventListener("click", toggleFullscreen);
|
|
51
52
|
const onChange = () => {
|
|
52
53
|
const active = isActive();
|
|
53
54
|
setState(active);
|
|
@@ -59,11 +60,13 @@ const Fullscreen = {
|
|
|
59
60
|
const offClose = ctx.on("close", () => {
|
|
60
61
|
if (isActive()) exitFullscreen();
|
|
61
62
|
});
|
|
63
|
+
const offRequestToggle = ctx.on("requestFullscreenToggle", toggleFullscreen);
|
|
62
64
|
return () => {
|
|
63
65
|
document.removeEventListener("fullscreenchange", onChange);
|
|
64
66
|
document.removeEventListener("webkitfullscreenchange", onChange);
|
|
65
67
|
removeButton();
|
|
66
68
|
offClose();
|
|
69
|
+
offRequestToggle();
|
|
67
70
|
if (isActive()) exitFullscreen();
|
|
68
71
|
};
|
|
69
72
|
}
|
|
@@ -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 button.innerHTML = EXPAND_ICON;\n button.setAttribute('aria-label', enterLabel);\n button.title = enterLabel;\n button.setAttribute('aria-pressed', 'false');\n\n function isActive(): boolean {\n return currentFullscreenElement() === outer;\n }\n\n function setState(active: boolean): void {\n button.innerHTML = active ? COMPRESS_ICON : EXPAND_ICON;\n button.setAttribute('aria-label', active ? exitLabel : enterLabel);\n button.title = active ? exitLabel : enterLabel;\n button.setAttribute('aria-pressed', String(active));\n }\n\n button.
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/plugins/fullscreen/icons.ts","../../../../src/plugins/fullscreen/index.ts"],"sourcesContent":["/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. Four corner-arrow glyphs, a generic \"expand\"/\"collapse\" pair, not tied to any particular icon set. */\nexport const EXPAND_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5\"/></svg>';\n\nexport const COMPRESS_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5\"/></svg>';\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { COMPRESS_ICON, EXPAND_ICON } from './icons';\n\n/**\n * Vendor-prefixed Fullscreen API surface (older Safari) — not in standard\n * `lib.dom.d.ts`, so this is a documented interop boundary (CLAUDE.md: `any`\n * only there). Everything else in this file uses the standard,\n * already-typed `Element.requestFullscreen`/`Document.exitFullscreen`/\n * `Document.fullscreenElement`/`Document.fullscreenEnabled`.\n */\ninterface WebkitFullscreenElement extends HTMLElement {\n webkitRequestFullscreen?: () => void;\n}\ninterface WebkitFullscreenDocument extends Document {\n webkitExitFullscreen?: () => void;\n webkitFullscreenElement?: Element | null;\n webkitFullscreenEnabled?: boolean;\n}\n\nfunction isSupported(): boolean {\n return !!(\n document.fullscreenEnabled || (document as WebkitFullscreenDocument).webkitFullscreenEnabled\n );\n}\n\nfunction currentFullscreenElement(): Element | null {\n return (\n document.fullscreenElement ??\n (document as WebkitFullscreenDocument).webkitFullscreenElement ??\n null\n );\n}\n\nfunction requestFullscreen(el: HTMLElement): void {\n const target = el as WebkitFullscreenElement;\n if (target.requestFullscreen) target.requestFullscreen().catch(() => {});\n else target.webkitRequestFullscreen?.();\n}\n\nfunction exitFullscreen(): void {\n const doc = document as WebkitFullscreenDocument;\n if (document.exitFullscreen) document.exitFullscreen().catch(() => {});\n else doc.webkitExitFullscreen?.();\n}\n\n/**\n * DESIGN.md §4 — native Fullscreen API toggle, `.shoji-outer` (`ctx.ui.outer()`,\n * the whole lightbox — backdrop, dialog, controls) is what goes fullscreen,\n * not just the slide media. No toolbar button at all — not a disabled one —\n * on a browser with no Fullscreen API support (checked once at `init()`;\n * historically Safari on iOS), rather than shipping a dead control.\n *\n * `fullscreenchange` (plus the `webkitfullscreenchange` vendor variant) is\n * the single source of truth for button state, not the click handler\n * directly — a real Fullscreen API `request`/`exit` call can be rejected\n * (permission, another element already fullscreen, etc.), and the browser's\n * own Escape-key fullscreen exit doesn't go through this plugin's click\n * handler at all. Reacting to the event instead of the request keeps the\n * button correct regardless of *why* fullscreen state changed.\n */\nexport const Fullscreen: ShojiPlugin = {\n name: 'fullscreen',\n\n init(ctx: PluginContext): (() => void) | void {\n if (!isSupported()) return;\n\n const outer = ctx.ui.outer();\n const locale = (ctx.gallery.options.locale ?? {}) as Record<string, string>;\n const enterLabel = locale.enterFullscreen ?? 'Enter fullscreen';\n const exitLabel = locale.exitFullscreen ?? 'Exit fullscreen';\n\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'shoji-toolbar-button';\n button.innerHTML = EXPAND_ICON;\n button.setAttribute('aria-label', enterLabel);\n button.title = enterLabel;\n button.setAttribute('aria-pressed', 'false');\n\n function isActive(): boolean {\n return currentFullscreenElement() === outer;\n }\n\n function setState(active: boolean): void {\n button.innerHTML = active ? COMPRESS_ICON : EXPAND_ICON;\n button.setAttribute('aria-label', active ? exitLabel : enterLabel);\n button.title = active ? exitLabel : enterLabel;\n button.setAttribute('aria-pressed', String(active));\n }\n\n /** 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;ACcF,SAAS,cAAuB;AAC9B,SAAO,CAAC,EACN,SAAS,qBAAsB,SAAsC;AAEzE;AAEA,SAAS,2BAA2C;AAClD,SACE,SAAS,qBACR,SAAsC,2BACvC;AAEJ;AAEA,SAAS,kBAAkB,IAAuB;ADhC3C;ACiCL,QAAM,SAAS;AACf,MAAI,OAAO,kBAAmB,QAAO,kBAAA,EAAoB,MAAM,MAAM;AAAA,EAAC,CAAC;AAAA,oBAC3D;AACd;AAEA,SAAS,iBAAuB;ADtCzB;ACuCL,QAAM,MAAM;AACZ,MAAI,SAAS,eAAgB,UAAS,eAAA,EAAiB,MAAM,MAAM;AAAA,EAAC,CAAC;AAAA,iBAC5D;AACX;AAiBO,MAAM,aAA0B;AAAA,EACrC,MAAM;AAAA,EAEN,KAAK,KAAyC;AAC5C,QAAI,CAAC,cAAe;AAEpB,UAAM,QAAQ,IAAI,GAAG,MAAA;AACrB,UAAM,SAAU,IAAI,QAAQ,QAAQ,UAAU,CAAA;AAC9C,UAAM,aAAa,OAAO,mBAAmB;AAC7C,UAAM,YAAY,OAAO,kBAAkB;AAE3C,UAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,WAAO,OAAO;AACd,WAAO,YAAY;AACnB,WAAO,YAAY;AACnB,WAAO,aAAa,cAAc,UAAU;AAC5C,WAAO,QAAQ;AACf,WAAO,aAAa,gBAAgB,OAAO;AAE3C,aAAS,WAAoB;AAC3B,aAAO,+BAA+B;AAAA,IACxC;AAEA,aAAS,SAAS,QAAuB;AACvC,aAAO,YAAY,SAAS,gBAAgB;AAC5C,aAAO,aAAa,cAAc,SAAS,YAAY,UAAU;AACjE,aAAO,QAAQ,SAAS,YAAY;AACpC,aAAO,aAAa,gBAAgB,OAAO,MAAM,CAAC;AAAA,IACpD;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;"}
|
|
@@ -95,22 +95,26 @@ const RotateFlip = {
|
|
|
95
95
|
const flippedOnOneAxis = visualFlipH !== visualFlipV;
|
|
96
96
|
return clockwise !== flippedOnOneAxis ? 90 : -90;
|
|
97
97
|
}
|
|
98
|
-
|
|
98
|
+
function rotateLeft() {
|
|
99
99
|
const delta = rotateDelta(false);
|
|
100
100
|
update({ rotation: state.rotation + delta }, delta);
|
|
101
|
-
}
|
|
102
|
-
|
|
101
|
+
}
|
|
102
|
+
function rotateRight() {
|
|
103
103
|
const delta = rotateDelta(true);
|
|
104
104
|
update({ rotation: state.rotation + delta }, delta);
|
|
105
|
-
}
|
|
106
|
-
|
|
105
|
+
}
|
|
106
|
+
function flipHorizontal() {
|
|
107
107
|
visualFlipH = !visualFlipH;
|
|
108
108
|
update({ flipH: !state.flipH });
|
|
109
|
-
}
|
|
110
|
-
|
|
109
|
+
}
|
|
110
|
+
function flipVertical() {
|
|
111
111
|
visualFlipV = !visualFlipV;
|
|
112
112
|
update({ flipV: !state.flipV });
|
|
113
|
-
}
|
|
113
|
+
}
|
|
114
|
+
rotateLeftBtn.addEventListener("click", rotateLeft);
|
|
115
|
+
rotateRightBtn.addEventListener("click", rotateRight);
|
|
116
|
+
flipHBtn.addEventListener("click", flipHorizontal);
|
|
117
|
+
flipVBtn.addEventListener("click", flipVertical);
|
|
114
118
|
function reset() {
|
|
115
119
|
state = { ...NEUTRAL };
|
|
116
120
|
visualRotation = 0;
|
|
@@ -129,6 +133,10 @@ const RotateFlip = {
|
|
|
129
133
|
const removeButtons = [rotateLeftBtn, rotateRightBtn, flipHBtn, flipVBtn].map(
|
|
130
134
|
(button) => ctx.ui.toolbar("right", button)
|
|
131
135
|
);
|
|
136
|
+
const offRequestRotateLeft = ctx.on("requestRotateLeft", rotateLeft);
|
|
137
|
+
const offRequestRotateRight = ctx.on("requestRotateRight", rotateRight);
|
|
138
|
+
const offRequestFlipHorizontal = ctx.on("requestFlipHorizontal", flipHorizontal);
|
|
139
|
+
const offRequestFlipVertical = ctx.on("requestFlipVertical", flipVertical);
|
|
132
140
|
const offOpen = ctx.on("afterOpen", reset);
|
|
133
141
|
const offBeforeSlide = ctx.on("beforeSlide", () => {
|
|
134
142
|
captureLeaveTransform();
|
|
@@ -147,6 +155,10 @@ const RotateFlip = {
|
|
|
147
155
|
});
|
|
148
156
|
return () => {
|
|
149
157
|
for (const remove of removeButtons) remove();
|
|
158
|
+
offRequestRotateLeft();
|
|
159
|
+
offRequestRotateRight();
|
|
160
|
+
offRequestFlipHorizontal();
|
|
161
|
+
offRequestFlipVertical();
|
|
150
162
|
offOpen();
|
|
151
163
|
offBeforeSlide();
|
|
152
164
|
offSlide();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../../../../src/core/rotateFlipNormalize.ts","../../../../src/plugins/rotateFlip/icons.ts","../../../../src/plugins/rotateFlip/index.ts"],"sourcesContent":["export interface RotateFlipState {\n flipH: boolean;\n flipV: boolean;\n /** Degrees; normalized to one of 0/90/180/270 by `normalizeRotateFlip`. */\n rotation: number;\n}\n\n/**\n * DESIGN.md §8.1's flip/rotation canonicalization table, verbatim — CLAUDE.md:\n * \"Flip + rotate compose non-commutatively. Use the normalization table...\n * don't re-derive it.\" `flipH && flipV` is always visually equivalent to a\n * 180°-rotated state with neither flip set; this collapses any\n * `flipH`/`flipV`/`rotation` combination down to that canonical form, so two\n * states reached via different sequences of clicks compare equal and never\n * accumulate redundant flip+flip-again or unbounded rotation values.\n * Shared between the standalone rotate/flip *view* plugin (§4) and the\n * future Editor plugin (§8), which both need the identical composition —\n * not duplicated per-plugin.\n */\nexport function normalizeRotateFlip(state: RotateFlipState): RotateFlipState {\n const rotation = ((state.rotation % 360) + 360) % 360;\n if (state.flipH && state.flipV) {\n return { flipH: false, flipV: false, rotation: (rotation + 180) % 360 };\n }\n return { flipH: state.flipH, flipV: state.flipV, rotation };\n}\n","/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. */\nexport const ROTATE_LEFT_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 14 4 9l5-5\"/><path d=\"M4 9h10a6 6 0 1 1 0 12H9\"/></svg>';\n\nexport const ROTATE_RIGHT_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=\"M15 14 20 9l-5-5\"/><path d=\"M20 9H10a6 6 0 1 0 0 12h5\"/></svg>';\n\nexport const FLIP_H_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=\"M12 3v18\"/><path d=\"M17 7l3 5-3 5\"/><path d=\"M7 7l-3 5 3 5\"/></svg>';\n\nexport const FLIP_V_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=\"M3 12h18\"/><path d=\"M7 17l5 3 5-3\"/><path d=\"M7 7l5-3 5 3\"/></svg>';\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { normalizeRotateFlip, type RotateFlipState } from '../../core/rotateFlipNormalize';\nimport { waitForTransitionEnd } from '../../core/zoomTransition';\nimport { FLIP_H_ICON, FLIP_V_ICON, ROTATE_LEFT_ICON, ROTATE_RIGHT_ICON } from './icons';\n\nconst NEUTRAL: RotateFlipState = { flipH: false, flipV: false, rotation: 0 };\n\n/**\n * Flip axes apply to the *currently visible* (already-rotated) orientation,\n * not the original unrotated image — `scaleX`/`scaleY` listed before\n * `rotate()` in the transform string is what makes that true: CSS transform\n * functions apply right-to-left, so `rotate()` (rightmost) affects the\n * content first, and the flip (leftmost) acts on that already-rotated\n * result.\n *\n * `flipH`/`flipV`/`rotationDeg` here are the *raw, uncanonicalized* visual\n * values (see `visualFlipH`/`visualFlipV`/`visualRotation` below) — never\n * `state`'s own normalized ones. Two real bugs came from feeding the\n * normalized state directly into this animated transform instead:\n *\n * 1. `rotationDeg` must be unbounded, not wrapped to `[0, 360)`: animating\n * straight to a wrapped value (e.g. 270° → 0° after a fourth\n * rotate-right click, instead of continuing on to 360°) makes the\n * browser interpolate a 270° *decrease*, spinning backward almost a\n * full turn instead of continuing the same 90° forward step being\n * clicked through.\n * 2. `flipH`/`flipV` must stay two independent, literal booleans, never\n * collapsed the way `normalizeRotateFlip` collapses `flipH && flipV`\n * into `rotation + 180`: flipping horizontal then vertical while\n * already flipped horizontal would otherwise animate `scaleX` back to\n * 1 *and* `rotate` up to 180° simultaneously (both values are in the\n * transform-function list, so the browser interpolates each\n * independently) — a compound squish-and-spin instead of the plain\n * vertical-flip motion \"Flip vertical\" implies. Keeping the raw\n * booleans means only `scaleY` ever changes for that click, since\n * flipping both axes is algebraically identical to a 180° rotation\n * regardless of what rotation is already applied — the *end* look\n * always matches the canonical state either way, only the *animated\n * path* differs.\n *\n * `rotationDeg === 0` is still a safe, unambiguous \"truly neutral\" check\n * for the shortcut below — every rotate click adds ±90, so it only lands\n * back on exactly `0` at genuine reset/initial state, never a masked\n * multiple of 360.\n *\n * `fitScale` (see `fitScaleFor` below) is folded directly into `scaleX`/\n * `scaleY` — a single uniform factor commutes with everything else already\n * in this transform (rotate, and flip's own -1), so multiplying it in here\n * is exactly equivalent to a separate trailing `scale()` function, without\n * adding a fourth transform function for the browser to interpolate.\n */\nfunction transformFor(\n flipH: boolean,\n flipV: boolean,\n rotationDeg: number,\n fitScale: number,\n): string {\n if (!flipH && !flipV && rotationDeg === 0 && fitScale === 1) return 'none';\n const scaleX = (flipH ? -1 : 1) * fitScale;\n const scaleY = (flipV ? -1 : 1) * fitScale;\n return `scaleX(${scaleX}) scaleY(${scaleY}) rotate(${rotationDeg}deg)`;\n}\n\n/**\n * DESIGN.md §4.5 — shrinks the *visible photo* exactly as much as needed to\n * keep it from getting its edges clipped away when rotated, and grows it\n * back up to fill newly-available space on rotation, but never past its\n * own native pixel resolution.\n *\n * **A real bug in the previous version of this fix, caught from real usage\n * on the docs site itself: it assumed `.shoji-slide-img` always scales to\n * *touch* the container on at least one axis (`object-fit: contain`'s\n * usual behavior), which is wrong.** The actual CSS is `max-width: 100%;\n * max-height: 100%` — a *cap*, not a forced fill (documented in\n * `shoji.css`'s own comment: real photos are assumed bigger than the\n * slide area, so never growing past natural size is the correct default).\n * A small placeholder photo, comfortably smaller than the dialog, simply\n * renders at its own native size — untouching every edge, nothing scaled.\n * The previous formula didn't know this: it computed an imagined\n * \"as if `object-fit: contain` always scales to fill\" pre-rotation size\n * (e.g. an 800×600 photo hypothetically stretched to ~1267×950 in a\n * 1920×950 window), then shrank *from that invented size* — a real,\n * visible shrink relative to what was actually on screen a moment\n * earlier, even though nothing should have changed at all. Confirmed\n * directly: measuring the real `<img>` on the real deployed docs page\n * showed it rendered at exactly its 800×600 native size, not the\n * \"contain-fit\" size the old formula assumed.\n *\n * **The fix: compute the photo's real render scale at each orientation\n * the same way the browser's own CSS does, then compare the two —\n * instead of computing an idealized target size and separately capping\n * it.** `scaleAt0`/`scaleAt90` are each `Math.min(1, mediaWidth /\n * relevantNaturalWidth, mediaHeight / relevantNaturalHeight)` — exactly\n * mirroring `max-width/max-height: 100%`'s own \"shrink to fit, never grow\n * past native size\" rule, once for the current (unrotated) orientation\n * and once for the rotated one (natural width/height swapped). The result\n * is simply their ratio: how much *more* (or less) of its own native\n * resolution the rotated orientation can use compared to what's already\n * on screen. This single ratio does everything the old two-step\n * idealFit-then-cap formula tried to do, correctly and for free: neither\n * `scaleAt0` nor `scaleAt90` can ever exceed `1` (native resolution is\n * never exceeded, in *either* orientation, not just relative to a\n * possibly-wrong assumed starting point), and whichever one is more\n * constrained by the container — rather than by native resolution —\n * still shrinks or grows the ratio exactly as far as that constraint\n * requires.\n *\n * `1` (a no-op) for 0°/180° — a rectangle's own bounding box is unchanged\n * by a half-turn, nothing to re-fit — and whenever either media dimension\n * isn't known yet (not yet laid out) or the photo's own natural dimensions\n * aren't known at all (video, or an image that hasn't decoded) — skipped\n * entirely rather than guessing.\n */\nfunction fitScaleFor(\n mediaWidth: number,\n mediaHeight: number,\n rotationDeg: number,\n naturalWidth: number | undefined,\n naturalHeight: number | undefined,\n): number {\n if (!mediaWidth || !mediaHeight || !naturalWidth || !naturalHeight) return 1;\n if ((rotationDeg / 90) % 2 === 0) return 1;\n const scaleAt0 = Math.min(1, mediaWidth / naturalWidth, mediaHeight / naturalHeight);\n const scaleAt90 = Math.min(1, mediaWidth / naturalHeight, mediaHeight / naturalWidth);\n return scaleAt90 / scaleAt0;\n}\n\n/**\n * DESIGN.md §4 — rotate ±90°/flip H/V of the *view*: a CSS transform on the\n * active `.shoji-slide-media`, non-destructive, resets to neutral on every\n * `afterOpen`/`afterSlide` (per-slide, not per-gallery — DESIGN.md's own\n * wording). Emits `rotateFlipChange` on every change so a host that wants\n * this to persist can store it themselves and re-apply later (e.g. by\n * feeding a starting orientation back in some other way) — this plugin\n * itself has no persistence of its own, matching \"resets per slide\".\n *\n * Distinct from the (unbuilt) Editor plugin's own rotate/flip (§8), which\n * is destructive/persisted server-side via `item.edits`. Both share the\n * same composition math (`normalizeRotateFlip`, `src/core/`) rather than\n * each re-deriving flip+rotate's non-commutative composition — a real bug\n * class CLAUDE.md calls out by name.\n */\nexport const RotateFlip: ShojiPlugin = {\n name: 'rotateFlip',\n\n init(ctx: PluginContext): () => void {\n const { gallery } = ctx;\n const locale = (gallery.options.locale ?? {}) as Record<string, string>;\n const rotateLeftLabel = locale.rotateLeft ?? 'Rotate left';\n const rotateRightLabel = locale.rotateRight ?? 'Rotate right';\n const flipHLabel = locale.flipHorizontal ?? 'Flip horizontal';\n const flipVLabel = locale.flipVertical ?? 'Flip vertical';\n\n let state: RotateFlipState = { ...NEUTRAL };\n /** Unbounded — never wrapped like `state.rotation` is. Drives only the CSS transform's rotate() degrees, so every rotate click continues smoothly in the same direction (90, 180, 270, 360, 450, ...) instead of snapping backward whenever the normalized state wraps past 0/360. See `transformFor`'s own doc comment for the full reasoning. */\n let visualRotation = 0;\n /** Raw, independent toggles — never collapsed into a rotation the way `state.flipH`/`state.flipV` are. See `transformFor`'s own doc comment. */\n let visualFlipH = false;\n let visualFlipV = false;\n\n /**\n * `animate` mirrors the Zoom plugin's own \"discrete jumps animate\"\n * pattern (`zoom/index.ts`'s `withTransition`) — a button click is a\n * one-shot state change, not a continuous gesture, so it eases instead\n * of snapping. `reset()` stays unanimated: the viewer never rotated the\n * new slide, so there's nothing to visibly animate *from*. The\n * transition is cleared once it ends so it doesn't linger onto the\n * open/close zoom transition's own later use of this same element's\n * `transform` (`zoomTransition.ts`, `.shoji-slide-media`).\n */\n /** `item.width`/`height` when known, else the active image's own natural dimensions — same fallback order the zoom transition (§2.3b) uses for its own aspect ratio, just sourced from the slide itself rather than the origin thumbnail, and kept as a real width/height pair (not collapsed to a ratio) since `fitScaleFor` needs the actual pixel counts for its resolution ceiling, not just their proportion. `undefined` for anything else (video, an image not yet decoded) — `fitScaleFor` treats that as nothing to fit, not a guess. */\n function resolveNaturalSize(media: HTMLElement): { width: number; height: number } | undefined {\n const item = gallery.items[gallery.currentIndex];\n if (item?.width && item.height) return { width: item.width, height: item.height };\n const img = media.querySelector('img');\n if (img?.naturalWidth && img.naturalHeight) {\n return { width: img.naturalWidth, height: img.naturalHeight };\n }\n return undefined;\n }\n\n function apply(animate: boolean): void {\n const media = gallery.getActiveMedia();\n if (!media) return;\n const natural = resolveNaturalSize(media);\n const fitScale = fitScaleFor(\n media.clientWidth,\n media.clientHeight,\n visualRotation,\n natural?.width,\n natural?.height,\n );\n const transform = transformFor(visualFlipH, visualFlipV, visualRotation, fitScale);\n if (!animate) {\n media.style.transform = transform;\n return;\n }\n media.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n media.style.transform = transform;\n waitForTransitionEnd(media, () => {\n media.style.transition = '';\n });\n }\n\n function buildButton(icon: string, label: string): HTMLButtonElement {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'shoji-toolbar-button';\n button.innerHTML = icon;\n button.setAttribute('aria-label', label);\n button.title = label;\n return button;\n }\n\n const rotateLeftBtn = buildButton(ROTATE_LEFT_ICON, rotateLeftLabel);\n const rotateRightBtn = buildButton(ROTATE_RIGHT_ICON, rotateRightLabel);\n const flipHBtn = buildButton(FLIP_H_ICON, flipHLabel);\n const flipVBtn = buildButton(FLIP_V_ICON, flipVLabel);\n flipHBtn.setAttribute('aria-pressed', 'false');\n flipVBtn.setAttribute('aria-pressed', 'false');\n\n /**\n * `visualRotationDelta`, when given, is a rotate click's own known\n * direction (±90) — applied as-is to the unbounded `visualRotation`.\n * Flip clicks omit it entirely: `visualFlipH`/`visualFlipV` are toggled\n * directly at each call site instead, completely independent of\n * `state`'s own canonicalized rotation/flip collapse (`transformFor`'s\n * doc comment has the full reasoning for why the two must stay\n * decoupled).\n */\n function update(patch: Partial<RotateFlipState>, visualRotationDelta?: number): void {\n state = normalizeRotateFlip({ ...state, ...patch });\n if (visualRotationDelta !== undefined) visualRotation += visualRotationDelta;\n apply(true);\n flipHBtn.setAttribute('aria-pressed', String(state.flipH));\n flipVBtn.setAttribute('aria-pressed', String(state.flipV));\n ctx.emit('rotateFlipChange', { index: gallery.currentIndex, ...state });\n }\n\n /**\n * A real bug, reported from real usage: with exactly one flip axis\n * active, \"Rotate right\" visually spun the image counter-clockwise\n * instead — a `+90` raw `rotate()` delta composed with a single\n * `scaleX(-1)`/`scaleY(-1)` mirror reverses the rotation's visual\n * handedness (a mirror is a reflection — determinant -1 — so a\n * clockwise turn *inside* it reads as counter-clockwise once mirrored\n * back onto the screen). Flipping *both* axes doesn't have this problem\n * — two reflections compose back into a rotation (determinant +1, same\n * as no flip at all), which is exactly why `normalizeRotateFlip`\n * already collapses that combination into a plain 180° rotation.\n * Inverting the raw delta whenever `visualFlipH !== visualFlipV` (XOR —\n * \"exactly one axis flipped\") makes the buttons always spin the image\n * the way they're visually labeled, regardless of flip state; `state`\n * (the canonicalized, emitted value) gets the same inverted delta, so\n * it stays an accurate description of what's actually on screen.\n */\n function rotateDelta(clockwise: boolean): number {\n const flippedOnOneAxis = visualFlipH !== visualFlipV;\n return clockwise !== flippedOnOneAxis ? 90 : -90;\n }\n\n rotateLeftBtn.addEventListener('click', () => {\n const delta = rotateDelta(false);\n update({ rotation: state.rotation + delta }, delta);\n });\n rotateRightBtn.addEventListener('click', () => {\n const delta = rotateDelta(true);\n update({ rotation: state.rotation + delta }, delta);\n });\n flipHBtn.addEventListener('click', () => {\n visualFlipH = !visualFlipH;\n update({ flipH: !state.flipH });\n });\n flipVBtn.addEventListener('click', () => {\n visualFlipV = !visualFlipV;\n update({ flipV: !state.flipV });\n });\n\n function reset(): void {\n state = { ...NEUTRAL };\n visualRotation = 0;\n visualFlipH = false;\n visualFlipV = false;\n apply(false);\n flipHBtn.setAttribute('aria-pressed', 'false');\n flipVBtn.setAttribute('aria-pressed', 'false');\n }\n\n /**\n * DESIGN.md §2.5/§4.5 — a real bug: clicking next/prev on a\n * rotated/flipped slide snapped it back to neutral *before* the slide\n * transition started, instead of un-rotating as part of it.\n * `beforeSlide`'s own `reset()` (below) still has to stay unanimated and\n * still has to run before `SlideManager.render()` reparents the\n * outgoing slide (the sixth real bug above) — that constraint doesn't\n * go away. What changes: the transform about to be wiped is captured\n * here first and handed to `SlideTransition` via\n * `registerSlideLeaveDecorator()` (Zoom registers its own, for the same\n * reason) — the decorator runs once, right after the ghost clones the\n * outgoing slide (the real node's already been reset by then, so the\n * clone alone wouldn't carry it), freezes the captured transform onto\n * that clone, and animates it back to neutral over the same window the\n * ghost itself is leaving in. Only the ghost animates away from it now.\n */\n let pendingLeaveTransform: string | null = null;\n function captureLeaveTransform(): void {\n const media = gallery.getActiveMedia();\n const transform = media?.style.transform;\n pendingLeaveTransform = transform && transform !== 'none' ? transform : null;\n }\n\n // 'right' — registered in this order, so they cluster left-to-right as\n // rotateLeft, rotateRight, flipH, flipV, then whatever later plugin (or\n // the close button) follows (DESIGN.md §3.1).\n const removeButtons = [rotateLeftBtn, rotateRightBtn, flipHBtn, flipVBtn].map((button) =>\n ctx.ui.toolbar('right', button),\n );\n\n const offOpen = ctx.on('afterOpen', reset);\n // Un-animated, and on beforeSlide rather than only afterSlide below —\n // same fix, same reasoning, as the Zoom plugin's own identical bug\n // (zoom/index.ts's own beforeSlide handler): SlideManager.render()\n // (called synchronously between the two) reuses a still-cached slide's\n // node via a plain reparent (moveIn(), no state clearing of its own)\n // into whichever pool slot its new offset needs — there is no code\n // path afterward that can still find *this* slide to reset it.\n // Reported from real usage: rotate, click next — the old, still-\n // rotated slide, now reparented into the (unclipped, per shoji.css)\n // neighboring slot, visibly bled into the new slide instead of\n // sitting invisibly off-screen the way an unrotated one always does.\n // Resetting here, while getActiveMedia() still resolves to the\n // about-to-move slide, clears it before that reparent ever happens.\n // afterSlide (below) still separately resets whichever *incoming*\n // slide becomes active — it may carry its own stale rotation from an\n // earlier visit, unrelated to whatever the outgoing slide had.\n // captureLeaveTransform() (see its own doc comment) runs first, while\n // the about-to-be-cleared transform is still readable.\n const offBeforeSlide = ctx.on('beforeSlide', () => {\n captureLeaveTransform();\n reset();\n });\n const offSlide = ctx.on('afterSlide', reset);\n const unregisterLeaveDecorator = gallery.registerSlideLeaveDecorator((clonedMedia) => {\n if (!pendingLeaveTransform) return;\n const transform = pendingLeaveTransform;\n pendingLeaveTransform = null;\n clonedMedia.style.transform = transform;\n return () => {\n clonedMedia.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n clonedMedia.style.transform = 'none';\n };\n });\n\n return () => {\n for (const remove of removeButtons) remove();\n offOpen();\n offBeforeSlide();\n offSlide();\n unregisterLeaveDecorator();\n };\n },\n};\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/core/rotateFlipNormalize.ts","../../../../src/plugins/rotateFlip/icons.ts","../../../../src/plugins/rotateFlip/index.ts"],"sourcesContent":["export interface RotateFlipState {\n flipH: boolean;\n flipV: boolean;\n /** Degrees; normalized to one of 0/90/180/270 by `normalizeRotateFlip`. */\n rotation: number;\n}\n\n/**\n * DESIGN.md §8.1's flip/rotation canonicalization table, verbatim — CLAUDE.md:\n * \"Flip + rotate compose non-commutatively. Use the normalization table...\n * don't re-derive it.\" `flipH && flipV` is always visually equivalent to a\n * 180°-rotated state with neither flip set; this collapses any\n * `flipH`/`flipV`/`rotation` combination down to that canonical form, so two\n * states reached via different sequences of clicks compare equal and never\n * accumulate redundant flip+flip-again or unbounded rotation values.\n * Shared between the standalone rotate/flip *view* plugin (§4) and the\n * future Editor plugin (§8), which both need the identical composition —\n * not duplicated per-plugin.\n */\nexport function normalizeRotateFlip(state: RotateFlipState): RotateFlipState {\n const rotation = ((state.rotation % 360) + 360) % 360;\n if (state.flipH && state.flipV) {\n return { flipH: false, flipV: false, rotation: (rotation + 180) % 360 };\n }\n return { flipH: state.flipH, flipV: state.flipV, rotation };\n}\n","/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. */\nexport const ROTATE_LEFT_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 14 4 9l5-5\"/><path d=\"M4 9h10a6 6 0 1 1 0 12H9\"/></svg>';\n\nexport const ROTATE_RIGHT_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=\"M15 14 20 9l-5-5\"/><path d=\"M20 9H10a6 6 0 1 0 0 12h5\"/></svg>';\n\nexport const FLIP_H_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=\"M12 3v18\"/><path d=\"M17 7l3 5-3 5\"/><path d=\"M7 7l-3 5 3 5\"/></svg>';\n\nexport const FLIP_V_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=\"M3 12h18\"/><path d=\"M7 17l5 3 5-3\"/><path d=\"M7 7l5-3 5 3\"/></svg>';\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { normalizeRotateFlip, type RotateFlipState } from '../../core/rotateFlipNormalize';\nimport { waitForTransitionEnd } from '../../core/zoomTransition';\nimport { FLIP_H_ICON, FLIP_V_ICON, ROTATE_LEFT_ICON, ROTATE_RIGHT_ICON } from './icons';\n\nconst NEUTRAL: RotateFlipState = { flipH: false, flipV: false, rotation: 0 };\n\n/**\n * Flip axes apply to the *currently visible* (already-rotated) orientation,\n * not the original unrotated image — `scaleX`/`scaleY` listed before\n * `rotate()` in the transform string is what makes that true: CSS transform\n * functions apply right-to-left, so `rotate()` (rightmost) affects the\n * content first, and the flip (leftmost) acts on that already-rotated\n * result.\n *\n * `flipH`/`flipV`/`rotationDeg` here are the *raw, uncanonicalized* visual\n * values (see `visualFlipH`/`visualFlipV`/`visualRotation` below) — never\n * `state`'s own normalized ones. Two real bugs came from feeding the\n * normalized state directly into this animated transform instead:\n *\n * 1. `rotationDeg` must be unbounded, not wrapped to `[0, 360)`: animating\n * straight to a wrapped value (e.g. 270° → 0° after a fourth\n * rotate-right click, instead of continuing on to 360°) makes the\n * browser interpolate a 270° *decrease*, spinning backward almost a\n * full turn instead of continuing the same 90° forward step being\n * clicked through.\n * 2. `flipH`/`flipV` must stay two independent, literal booleans, never\n * collapsed the way `normalizeRotateFlip` collapses `flipH && flipV`\n * into `rotation + 180`: flipping horizontal then vertical while\n * already flipped horizontal would otherwise animate `scaleX` back to\n * 1 *and* `rotate` up to 180° simultaneously (both values are in the\n * transform-function list, so the browser interpolates each\n * independently) — a compound squish-and-spin instead of the plain\n * vertical-flip motion \"Flip vertical\" implies. Keeping the raw\n * booleans means only `scaleY` ever changes for that click, since\n * flipping both axes is algebraically identical to a 180° rotation\n * regardless of what rotation is already applied — the *end* look\n * always matches the canonical state either way, only the *animated\n * path* differs.\n *\n * `rotationDeg === 0` is still a safe, unambiguous \"truly neutral\" check\n * for the shortcut below — every rotate click adds ±90, so it only lands\n * back on exactly `0` at genuine reset/initial state, never a masked\n * multiple of 360.\n *\n * `fitScale` (see `fitScaleFor` below) is folded directly into `scaleX`/\n * `scaleY` — a single uniform factor commutes with everything else already\n * in this transform (rotate, and flip's own -1), so multiplying it in here\n * is exactly equivalent to a separate trailing `scale()` function, without\n * adding a fourth transform function for the browser to interpolate.\n */\nfunction transformFor(\n flipH: boolean,\n flipV: boolean,\n rotationDeg: number,\n fitScale: number,\n): string {\n if (!flipH && !flipV && rotationDeg === 0 && fitScale === 1) return 'none';\n const scaleX = (flipH ? -1 : 1) * fitScale;\n const scaleY = (flipV ? -1 : 1) * fitScale;\n return `scaleX(${scaleX}) scaleY(${scaleY}) rotate(${rotationDeg}deg)`;\n}\n\n/**\n * DESIGN.md §4.5 — shrinks the *visible photo* exactly as much as needed to\n * keep it from getting its edges clipped away when rotated, and grows it\n * back up to fill newly-available space on rotation, but never past its\n * own native pixel resolution.\n *\n * **A real bug in the previous version of this fix, caught from real usage\n * on the docs site itself: it assumed `.shoji-slide-img` always scales to\n * *touch* the container on at least one axis (`object-fit: contain`'s\n * usual behavior), which is wrong.** The actual CSS is `max-width: 100%;\n * max-height: 100%` — a *cap*, not a forced fill (documented in\n * `shoji.css`'s own comment: real photos are assumed bigger than the\n * slide area, so never growing past natural size is the correct default).\n * A small placeholder photo, comfortably smaller than the dialog, simply\n * renders at its own native size — untouching every edge, nothing scaled.\n * The previous formula didn't know this: it computed an imagined\n * \"as if `object-fit: contain` always scales to fill\" pre-rotation size\n * (e.g. an 800×600 photo hypothetically stretched to ~1267×950 in a\n * 1920×950 window), then shrank *from that invented size* — a real,\n * visible shrink relative to what was actually on screen a moment\n * earlier, even though nothing should have changed at all. Confirmed\n * directly: measuring the real `<img>` on the real deployed docs page\n * showed it rendered at exactly its 800×600 native size, not the\n * \"contain-fit\" size the old formula assumed.\n *\n * **The fix: compute the photo's real render scale at each orientation\n * the same way the browser's own CSS does, then compare the two —\n * instead of computing an idealized target size and separately capping\n * it.** `scaleAt0`/`scaleAt90` are each `Math.min(1, mediaWidth /\n * relevantNaturalWidth, mediaHeight / relevantNaturalHeight)` — exactly\n * mirroring `max-width/max-height: 100%`'s own \"shrink to fit, never grow\n * past native size\" rule, once for the current (unrotated) orientation\n * and once for the rotated one (natural width/height swapped). The result\n * is simply their ratio: how much *more* (or less) of its own native\n * resolution the rotated orientation can use compared to what's already\n * on screen. This single ratio does everything the old two-step\n * idealFit-then-cap formula tried to do, correctly and for free: neither\n * `scaleAt0` nor `scaleAt90` can ever exceed `1` (native resolution is\n * never exceeded, in *either* orientation, not just relative to a\n * possibly-wrong assumed starting point), and whichever one is more\n * constrained by the container — rather than by native resolution —\n * still shrinks or grows the ratio exactly as far as that constraint\n * requires.\n *\n * `1` (a no-op) for 0°/180° — a rectangle's own bounding box is unchanged\n * by a half-turn, nothing to re-fit — and whenever either media dimension\n * isn't known yet (not yet laid out) or the photo's own natural dimensions\n * aren't known at all (video, or an image that hasn't decoded) — skipped\n * entirely rather than guessing.\n */\nfunction fitScaleFor(\n mediaWidth: number,\n mediaHeight: number,\n rotationDeg: number,\n naturalWidth: number | undefined,\n naturalHeight: number | undefined,\n): number {\n if (!mediaWidth || !mediaHeight || !naturalWidth || !naturalHeight) return 1;\n if ((rotationDeg / 90) % 2 === 0) return 1;\n const scaleAt0 = Math.min(1, mediaWidth / naturalWidth, mediaHeight / naturalHeight);\n const scaleAt90 = Math.min(1, mediaWidth / naturalHeight, mediaHeight / naturalWidth);\n return scaleAt90 / scaleAt0;\n}\n\n/**\n * DESIGN.md §4 — rotate ±90°/flip H/V of the *view*: a CSS transform on the\n * active `.shoji-slide-media`, non-destructive, resets to neutral on every\n * `afterOpen`/`afterSlide` (per-slide, not per-gallery — DESIGN.md's own\n * wording). Emits `rotateFlipChange` on every change so a host that wants\n * this to persist can store it themselves and re-apply later (e.g. by\n * feeding a starting orientation back in some other way) — this plugin\n * itself has no persistence of its own, matching \"resets per slide\".\n *\n * Distinct from the (unbuilt) Editor plugin's own rotate/flip (§8), which\n * is destructive/persisted server-side via `item.edits`. Both share the\n * same composition math (`normalizeRotateFlip`, `src/core/`) rather than\n * each re-deriving flip+rotate's non-commutative composition — a real bug\n * class CLAUDE.md calls out by name.\n */\nexport const RotateFlip: ShojiPlugin = {\n name: 'rotateFlip',\n\n init(ctx: PluginContext): () => void {\n const { gallery } = ctx;\n const locale = (gallery.options.locale ?? {}) as Record<string, string>;\n const rotateLeftLabel = locale.rotateLeft ?? 'Rotate left';\n const rotateRightLabel = locale.rotateRight ?? 'Rotate right';\n const flipHLabel = locale.flipHorizontal ?? 'Flip horizontal';\n const flipVLabel = locale.flipVertical ?? 'Flip vertical';\n\n let state: RotateFlipState = { ...NEUTRAL };\n /** Unbounded — never wrapped like `state.rotation` is. Drives only the CSS transform's rotate() degrees, so every rotate click continues smoothly in the same direction (90, 180, 270, 360, 450, ...) instead of snapping backward whenever the normalized state wraps past 0/360. See `transformFor`'s own doc comment for the full reasoning. */\n let visualRotation = 0;\n /** Raw, independent toggles — never collapsed into a rotation the way `state.flipH`/`state.flipV` are. See `transformFor`'s own doc comment. */\n let visualFlipH = false;\n let visualFlipV = false;\n\n /**\n * `animate` mirrors the Zoom plugin's own \"discrete jumps animate\"\n * pattern (`zoom/index.ts`'s `withTransition`) — a button click is a\n * one-shot state change, not a continuous gesture, so it eases instead\n * of snapping. `reset()` stays unanimated: the viewer never rotated the\n * new slide, so there's nothing to visibly animate *from*. The\n * transition is cleared once it ends so it doesn't linger onto the\n * open/close zoom transition's own later use of this same element's\n * `transform` (`zoomTransition.ts`, `.shoji-slide-media`).\n */\n /** `item.width`/`height` when known, else the active image's own natural dimensions — same fallback order the zoom transition (§2.3b) uses for its own aspect ratio, just sourced from the slide itself rather than the origin thumbnail, and kept as a real width/height pair (not collapsed to a ratio) since `fitScaleFor` needs the actual pixel counts for its resolution ceiling, not just their proportion. `undefined` for anything else (video, an image not yet decoded) — `fitScaleFor` treats that as nothing to fit, not a guess. */\n function resolveNaturalSize(media: HTMLElement): { width: number; height: number } | undefined {\n const item = gallery.items[gallery.currentIndex];\n if (item?.width && item.height) return { width: item.width, height: item.height };\n const img = media.querySelector('img');\n if (img?.naturalWidth && img.naturalHeight) {\n return { width: img.naturalWidth, height: img.naturalHeight };\n }\n return undefined;\n }\n\n function apply(animate: boolean): void {\n const media = gallery.getActiveMedia();\n if (!media) return;\n const natural = resolveNaturalSize(media);\n const fitScale = fitScaleFor(\n media.clientWidth,\n media.clientHeight,\n visualRotation,\n natural?.width,\n natural?.height,\n );\n const transform = transformFor(visualFlipH, visualFlipV, visualRotation, fitScale);\n if (!animate) {\n media.style.transform = transform;\n return;\n }\n media.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n media.style.transform = transform;\n waitForTransitionEnd(media, () => {\n media.style.transition = '';\n });\n }\n\n function buildButton(icon: string, label: string): HTMLButtonElement {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'shoji-toolbar-button';\n button.innerHTML = icon;\n button.setAttribute('aria-label', label);\n button.title = label;\n return button;\n }\n\n const rotateLeftBtn = buildButton(ROTATE_LEFT_ICON, rotateLeftLabel);\n const rotateRightBtn = buildButton(ROTATE_RIGHT_ICON, rotateRightLabel);\n const flipHBtn = buildButton(FLIP_H_ICON, flipHLabel);\n const flipVBtn = buildButton(FLIP_V_ICON, flipVLabel);\n flipHBtn.setAttribute('aria-pressed', 'false');\n flipVBtn.setAttribute('aria-pressed', 'false');\n\n /**\n * `visualRotationDelta`, when given, is a rotate click's own known\n * direction (±90) — applied as-is to the unbounded `visualRotation`.\n * Flip clicks omit it entirely: `visualFlipH`/`visualFlipV` are toggled\n * directly at each call site instead, completely independent of\n * `state`'s own canonicalized rotation/flip collapse (`transformFor`'s\n * doc comment has the full reasoning for why the two must stay\n * decoupled).\n */\n function update(patch: Partial<RotateFlipState>, visualRotationDelta?: number): void {\n state = normalizeRotateFlip({ ...state, ...patch });\n if (visualRotationDelta !== undefined) visualRotation += visualRotationDelta;\n apply(true);\n flipHBtn.setAttribute('aria-pressed', String(state.flipH));\n flipVBtn.setAttribute('aria-pressed', String(state.flipV));\n ctx.emit('rotateFlipChange', { index: gallery.currentIndex, ...state });\n }\n\n /**\n * A real bug, reported from real usage: with exactly one flip axis\n * active, \"Rotate right\" visually spun the image counter-clockwise\n * instead — a `+90` raw `rotate()` delta composed with a single\n * `scaleX(-1)`/`scaleY(-1)` mirror reverses the rotation's visual\n * handedness (a mirror is a reflection — determinant -1 — so a\n * clockwise turn *inside* it reads as counter-clockwise once mirrored\n * back onto the screen). Flipping *both* axes doesn't have this problem\n * — two reflections compose back into a rotation (determinant +1, same\n * as no flip at all), which is exactly why `normalizeRotateFlip`\n * already collapses that combination into a plain 180° rotation.\n * Inverting the raw delta whenever `visualFlipH !== visualFlipV` (XOR —\n * \"exactly one axis flipped\") makes the buttons always spin the image\n * the way they're visually labeled, regardless of flip state; `state`\n * (the canonicalized, emitted value) gets the same inverted delta, so\n * it stays an accurate description of what's actually on screen.\n */\n function rotateDelta(clockwise: boolean): number {\n const flippedOnOneAxis = visualFlipH !== visualFlipV;\n return clockwise !== flippedOnOneAxis ? 90 : -90;\n }\n\n /** Shared by the rotate-left toolbar button and `requestRotateLeft` below. */\n function rotateLeft(): void {\n const delta = rotateDelta(false);\n update({ rotation: state.rotation + delta }, delta);\n }\n /** Shared by the rotate-right toolbar button and `requestRotateRight` below. */\n function rotateRight(): void {\n const delta = rotateDelta(true);\n update({ rotation: state.rotation + delta }, delta);\n }\n /** Shared by the flip-horizontal toolbar button and `requestFlipHorizontal` below. */\n function flipHorizontal(): void {\n visualFlipH = !visualFlipH;\n update({ flipH: !state.flipH });\n }\n /** Shared by the flip-vertical toolbar button and `requestFlipVertical` below. */\n function flipVertical(): void {\n visualFlipV = !visualFlipV;\n update({ flipV: !state.flipV });\n }\n\n rotateLeftBtn.addEventListener('click', rotateLeft);\n rotateRightBtn.addEventListener('click', rotateRight);\n flipHBtn.addEventListener('click', flipHorizontal);\n flipVBtn.addEventListener('click', flipVertical);\n\n function reset(): void {\n state = { ...NEUTRAL };\n visualRotation = 0;\n visualFlipH = false;\n visualFlipV = false;\n apply(false);\n flipHBtn.setAttribute('aria-pressed', 'false');\n flipVBtn.setAttribute('aria-pressed', 'false');\n }\n\n /**\n * DESIGN.md §2.5/§4.5 — a real bug: clicking next/prev on a\n * rotated/flipped slide snapped it back to neutral *before* the slide\n * transition started, instead of un-rotating as part of it.\n * `beforeSlide`'s own `reset()` (below) still has to stay unanimated and\n * still has to run before `SlideManager.render()` reparents the\n * outgoing slide (the sixth real bug above) — that constraint doesn't\n * go away. What changes: the transform about to be wiped is captured\n * here first and handed to `SlideTransition` via\n * `registerSlideLeaveDecorator()` (Zoom registers its own, for the same\n * reason) — the decorator runs once, right after the ghost clones the\n * outgoing slide (the real node's already been reset by then, so the\n * clone alone wouldn't carry it), freezes the captured transform onto\n * that clone, and animates it back to neutral over the same window the\n * ghost itself is leaving in. Only the ghost animates away from it now.\n */\n let pendingLeaveTransform: string | null = null;\n function captureLeaveTransform(): void {\n const media = gallery.getActiveMedia();\n const transform = media?.style.transform;\n pendingLeaveTransform = transform && transform !== 'none' ? transform : null;\n }\n\n // 'right' — registered in this order, so they cluster left-to-right as\n // rotateLeft, rotateRight, flipH, flipV, then whatever later plugin (or\n // the close button) follows (DESIGN.md §3.1).\n const removeButtons = [rotateLeftBtn, rotateRightBtn, flipHBtn, flipVBtn].map((button) =>\n ctx.ui.toolbar('right', button),\n );\n\n /**\n * A generic command surface, requested directly (DESIGN.md §4.5), so a\n * *custom* (host-authored) plugin's own button can rotate/flip without\n * importing this plugin at all — same \"events over inheritance\"\n * decoupling every other listener here already uses.\n * `GalleryEvents` (`core/types.ts`) already extends `Record<string,\n * unknown>`, so `ctx.emit('requestRotateLeft', {})` from any plugin —\n * official or custom — type-checks with zero core changes; this is\n * just the listening half. Each mirrors its real toolbar button\n * exactly — same functions, same behavior.\n */\n const offRequestRotateLeft = ctx.on('requestRotateLeft', rotateLeft);\n const offRequestRotateRight = ctx.on('requestRotateRight', rotateRight);\n const offRequestFlipHorizontal = ctx.on('requestFlipHorizontal', flipHorizontal);\n const offRequestFlipVertical = ctx.on('requestFlipVertical', flipVertical);\n const offOpen = ctx.on('afterOpen', reset);\n // Un-animated, and on beforeSlide rather than only afterSlide below —\n // same fix, same reasoning, as the Zoom plugin's own identical bug\n // (zoom/index.ts's own beforeSlide handler): SlideManager.render()\n // (called synchronously between the two) reuses a still-cached slide's\n // node via a plain reparent (moveIn(), no state clearing of its own)\n // into whichever pool slot its new offset needs — there is no code\n // path afterward that can still find *this* slide to reset it.\n // Reported from real usage: rotate, click next — the old, still-\n // rotated slide, now reparented into the (unclipped, per shoji.css)\n // neighboring slot, visibly bled into the new slide instead of\n // sitting invisibly off-screen the way an unrotated one always does.\n // Resetting here, while getActiveMedia() still resolves to the\n // about-to-move slide, clears it before that reparent ever happens.\n // afterSlide (below) still separately resets whichever *incoming*\n // slide becomes active — it may carry its own stale rotation from an\n // earlier visit, unrelated to whatever the outgoing slide had.\n // captureLeaveTransform() (see its own doc comment) runs first, while\n // the about-to-be-cleared transform is still readable.\n const offBeforeSlide = ctx.on('beforeSlide', () => {\n captureLeaveTransform();\n reset();\n });\n const offSlide = ctx.on('afterSlide', reset);\n const unregisterLeaveDecorator = gallery.registerSlideLeaveDecorator((clonedMedia) => {\n if (!pendingLeaveTransform) return;\n const transform = pendingLeaveTransform;\n pendingLeaveTransform = null;\n clonedMedia.style.transform = transform;\n return () => {\n clonedMedia.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n clonedMedia.style.transform = 'none';\n };\n });\n\n return () => {\n for (const remove of removeButtons) remove();\n offRequestRotateLeft();\n offRequestRotateRight();\n offRequestFlipHorizontal();\n offRequestFlipVertical();\n offOpen();\n offBeforeSlide();\n offSlide();\n unregisterLeaveDecorator();\n };\n },\n};\n"],"names":[],"mappings":"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"}
|
|
@@ -250,6 +250,10 @@ const Zoom = {
|
|
|
250
250
|
const offWheelZoom = ctx.on("wheelZoom", ({ deltaScale, x, y }) => {
|
|
251
251
|
zoomTo(scale + deltaScale, x, y);
|
|
252
252
|
});
|
|
253
|
+
const offRequestZoomIn = ctx.on("requestZoomIn", zoomInStep);
|
|
254
|
+
const offRequestZoomOut = ctx.on("requestZoomOut", zoomOutStep);
|
|
255
|
+
const offRequestZoomActualSize = ctx.on("requestZoomActualSize", actualSizeToggle);
|
|
256
|
+
const offRequestZoomReset = ctx.on("requestZoomReset", () => reset(true));
|
|
253
257
|
let panPointerId = null;
|
|
254
258
|
let lastX = 0;
|
|
255
259
|
let lastY = 0;
|
|
@@ -313,12 +317,7 @@ const Zoom = {
|
|
|
313
317
|
if (scale / buttonStep <= ZOOM_EPSILON) reset(true);
|
|
314
318
|
else zoomTo(scale / buttonStep, x, y, maxScale, true);
|
|
315
319
|
}
|
|
316
|
-
|
|
317
|
-
const zoomOutBtn = buildButton(ZOOM_OUT_ICON, zoomOutLabel);
|
|
318
|
-
const actualSizeBtn = buildButton(ZOOM_ACTUAL_SIZE_ICON, actualSizeLabel);
|
|
319
|
-
zoomInBtn.addEventListener("click", zoomInStep);
|
|
320
|
-
zoomOutBtn.addEventListener("click", zoomOutStep);
|
|
321
|
-
actualSizeBtn.addEventListener("click", () => {
|
|
320
|
+
function actualSizeToggle() {
|
|
322
321
|
whenSettled(() => {
|
|
323
322
|
const img = getImg();
|
|
324
323
|
if (!img || !img.naturalWidth) return;
|
|
@@ -331,7 +330,13 @@ const Zoom = {
|
|
|
331
330
|
const { x, y } = centerAnchor();
|
|
332
331
|
zoomTo(targetScale, x, y, targetScale, true);
|
|
333
332
|
});
|
|
334
|
-
}
|
|
333
|
+
}
|
|
334
|
+
const zoomInBtn = buildButton(ZOOM_IN_ICON, zoomInLabel);
|
|
335
|
+
const zoomOutBtn = buildButton(ZOOM_OUT_ICON, zoomOutLabel);
|
|
336
|
+
const actualSizeBtn = buildButton(ZOOM_ACTUAL_SIZE_ICON, actualSizeLabel);
|
|
337
|
+
zoomInBtn.addEventListener("click", zoomInStep);
|
|
338
|
+
zoomOutBtn.addEventListener("click", zoomOutStep);
|
|
339
|
+
actualSizeBtn.addEventListener("click", actualSizeToggle);
|
|
335
340
|
const removeButtons = [zoomInBtn, zoomOutBtn, actualSizeBtn].map(
|
|
336
341
|
(button) => ctx.ui.toolbar("right", button)
|
|
337
342
|
);
|
|
@@ -399,6 +404,10 @@ const Zoom = {
|
|
|
399
404
|
offPinchEnd();
|
|
400
405
|
offDoubleTap();
|
|
401
406
|
offWheelZoom();
|
|
407
|
+
offRequestZoomIn();
|
|
408
|
+
offRequestZoomOut();
|
|
409
|
+
offRequestZoomActualSize();
|
|
410
|
+
offRequestZoomReset();
|
|
402
411
|
outer.removeEventListener("pointerdown", onPointerDown);
|
|
403
412
|
outer.removeEventListener("pointermove", onPointerMove);
|
|
404
413
|
outer.removeEventListener("pointerup", onPointerUp);
|