@michaelyagi/shoji 0.1.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/dist/esm/core/EventBus.d.ts +16 -0
  4. package/dist/esm/core/FocusTrap.d.ts +9 -0
  5. package/dist/esm/core/Gallery.d.ts +221 -0
  6. package/dist/esm/core/GestureController.d.ts +50 -0
  7. package/dist/esm/core/LiveRegion.d.ts +6 -0
  8. package/dist/esm/core/SlideManager.d.ts +83 -0
  9. package/dist/esm/core/bodyScrollLock.d.ts +2 -0
  10. package/dist/esm/core/dom.d.ts +27 -0
  11. package/dist/esm/core/icons.d.ts +6 -0
  12. package/dist/esm/core/index.d.ts +5 -0
  13. package/dist/esm/core/index.js +1966 -0
  14. package/dist/esm/core/index.js.map +1 -0
  15. package/dist/esm/core/plugin.d.ts +54 -0
  16. package/dist/esm/core/rotateFlipNormalize.d.ts +19 -0
  17. package/dist/esm/core/scan.d.ts +8 -0
  18. package/dist/esm/core/types.d.ts +268 -0
  19. package/dist/esm/core/zoomTransition.d.ts +33 -0
  20. package/dist/esm/gestures/GestureEngine.d.ts +78 -0
  21. package/dist/esm/index.css +475 -0
  22. package/dist/esm/index.d.ts +32 -0
  23. package/dist/esm/index.js +24 -0
  24. package/dist/esm/index.js.map +1 -0
  25. package/dist/esm/index2.css +22 -0
  26. package/dist/esm/index3.css +166 -0
  27. package/dist/esm/index4.css +27 -0
  28. package/dist/esm/plugins/activeThumbnail/index.d.ts +26 -0
  29. package/dist/esm/plugins/activeThumbnail/index.js +48 -0
  30. package/dist/esm/plugins/activeThumbnail/index.js.map +1 -0
  31. package/dist/esm/plugins/autoplay/icons.d.ts +3 -0
  32. package/dist/esm/plugins/autoplay/index.d.ts +17 -0
  33. package/dist/esm/plugins/autoplay/index.js +177 -0
  34. package/dist/esm/plugins/autoplay/index.js.map +1 -0
  35. package/dist/esm/plugins/fullscreen/icons.d.ts +3 -0
  36. package/dist/esm/plugins/fullscreen/index.d.ts +17 -0
  37. package/dist/esm/plugins/fullscreen/index.js +74 -0
  38. package/dist/esm/plugins/fullscreen/index.js.map +1 -0
  39. package/dist/esm/plugins/layout/index.d.ts +191 -0
  40. package/dist/esm/plugins/layout/index.js +746 -0
  41. package/dist/esm/plugins/layout/index.js.map +1 -0
  42. package/dist/esm/plugins/layout/justified.d.ts +68 -0
  43. package/dist/esm/plugins/layout/masonry.d.ts +92 -0
  44. package/dist/esm/plugins/rotateFlip/icons.d.ts +5 -0
  45. package/dist/esm/plugins/rotateFlip/index.d.ts +17 -0
  46. package/dist/esm/plugins/rotateFlip/index.js +138 -0
  47. package/dist/esm/plugins/rotateFlip/index.js.map +1 -0
  48. package/dist/esm/plugins/video/index.d.ts +13 -0
  49. package/dist/esm/plugins/video/index.js +95 -0
  50. package/dist/esm/plugins/video/index.js.map +1 -0
  51. package/dist/esm/plugins/video/youtube.d.ts +61 -0
  52. package/dist/esm/plugins/zoom/icons.d.ts +4 -0
  53. package/dist/esm/plugins/zoom/index.d.ts +30 -0
  54. package/dist/esm/plugins/zoom/index.js +274 -0
  55. package/dist/esm/plugins/zoom/index.js.map +1 -0
  56. package/dist/esm/plugins/zoom/zoomMath.d.ts +24 -0
  57. package/dist/esm/transitions/SlideTransition.d.ts +25 -0
  58. package/dist/esm/transitions/presets.d.ts +21 -0
  59. package/dist/esm/zoomTransition-bbKHpVpA.js +110 -0
  60. package/dist/esm/zoomTransition-bbKHpVpA.js.map +1 -0
  61. package/dist/shoji.css +690 -0
  62. package/dist/shoji.js +3605 -0
  63. package/dist/shoji.js.map +1 -0
  64. package/dist/shoji.min.css +1 -0
  65. package/dist/shoji.min.js +2 -0
  66. package/dist/shoji.min.js.map +1 -0
  67. package/package.json +77 -0
@@ -0,0 +1,95 @@
1
+ let apiPromise = null;
2
+ function loadYouTubeApi() {
3
+ var _a;
4
+ if ((_a = window.YT) == null ? void 0 : _a.Player) return Promise.resolve(window.YT);
5
+ if (apiPromise) return apiPromise;
6
+ apiPromise = new Promise((resolve) => {
7
+ const previous = window.onYouTubeIframeAPIReady;
8
+ window.onYouTubeIframeAPIReady = () => {
9
+ previous == null ? void 0 : previous();
10
+ resolve(window.YT);
11
+ };
12
+ const script = document.createElement("script");
13
+ script.src = "https://www.youtube.com/iframe_api";
14
+ document.head.appendChild(script);
15
+ });
16
+ return apiPromise;
17
+ }
18
+ function wirePlayableContract(container, player) {
19
+ const playable = container;
20
+ playable.paused = true;
21
+ playable.ended = false;
22
+ playable.play = () => player.playVideo();
23
+ playable.pause = () => player.pauseVideo();
24
+ Object.defineProperty(playable, "muted", {
25
+ configurable: true,
26
+ get: () => player.isMuted(),
27
+ set: (value) => value ? player.mute() : player.unMute()
28
+ });
29
+ return playable;
30
+ }
31
+ function handleStateChange(event, YT, playable) {
32
+ if (event.data === YT.PlayerState.PLAYING) {
33
+ playable.paused = false;
34
+ playable.dispatchEvent(new Event("play"));
35
+ } else if (event.data === YT.PlayerState.PAUSED) {
36
+ playable.paused = true;
37
+ playable.dispatchEvent(new Event("pause"));
38
+ } else if (event.data === YT.PlayerState.ENDED) {
39
+ playable.paused = true;
40
+ playable.ended = true;
41
+ playable.dispatchEvent(new Event("ended"));
42
+ }
43
+ }
44
+ const renderYouTube = (container, item, onReady, signal) => {
45
+ var _a;
46
+ if (((_a = item.video) == null ? void 0 : _a.provider) !== "youtube") return;
47
+ const videoId = item.video.id;
48
+ const mount = document.createElement("div");
49
+ container.appendChild(mount);
50
+ loadYouTubeApi().then((YT) => {
51
+ if (signal.aborted) return;
52
+ const playable = container;
53
+ const player = new YT.Player(mount, {
54
+ videoId,
55
+ playerVars: { playsinline: 1, rel: 0 },
56
+ events: {
57
+ // `new YT.Player()` returns before the embed's own internal bootstrap
58
+ // (its postMessage handshake with youtube.com) finishes — calling
59
+ // `player.mute()`/`playVideo()`/etc. before that completes can throw
60
+ // ("player.isMuted is not a function") or silently no-op, even though
61
+ // the constructor already returned an object. `onReady` is YouTube's
62
+ // own signal that the real API surface is actually callable now, so
63
+ // wiring the contract here — not synchronously after construction —
64
+ // is what makes `findPlayable()`'s "is `.play` wired?" check in
65
+ // Autoplay (DESIGN.md §4.1) an accurate proxy for "is it really
66
+ // ready?" instead of just "did we build the wrapper closures?".
67
+ onReady: () => {
68
+ wirePlayableContract(container, player);
69
+ onReady();
70
+ },
71
+ onStateChange: (event) => handleStateChange(event, YT, playable),
72
+ // bubbles: true — reaches .shoji-slide-media (the pool slot, always
73
+ // present regardless of readiness) without needing wirePlayableContract
74
+ // to have run first; onError can fire instead of onReady entirely
75
+ // (e.g. a removed/private video), so playable isn't a safe assumption
76
+ // here the way it is in onStateChange. Autoplay (DESIGN.md §4.1)
77
+ // listens for this to skip to the next slide instead of stalling.
78
+ onError: (event) => playable.dispatchEvent(
79
+ new CustomEvent("error", { bubbles: true, detail: { code: event.data } })
80
+ )
81
+ }
82
+ });
83
+ signal.addEventListener("abort", () => player.destroy(), { once: true });
84
+ });
85
+ };
86
+ const Video = {
87
+ name: "video",
88
+ init(ctx) {
89
+ return ctx.ui.registerVideoProvider("youtube", renderYouTube);
90
+ }
91
+ };
92
+ export {
93
+ Video
94
+ };
95
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../../../../src/plugins/video/youtube.ts","../../../../src/plugins/video/index.ts"],"sourcesContent":["import type { VideoProviderRenderer } from '../../core/plugin';\n\n/**\n * The bits of the YouTube IFrame Player API actually used here — not in\n * `lib.dom.d.ts` (a third-party global loaded on demand), so this is a\n * documented interop boundary (CLAUDE.md: `any` only there). Kept\n * intentionally minimal, not a full mirror of YouTube's own typings.\n */\ninterface YTPlayer {\n playVideo(): void;\n pauseVideo(): void;\n mute(): void;\n unMute(): void;\n isMuted(): boolean;\n destroy(): void;\n}\n\ninterface YTPlayerStateEvent {\n data: number;\n}\n\n/**\n * Same `{ data: number }` shape as `YTPlayerStateEvent` — `data` holds an\n * error code there (2 invalid parameter, 5 HTML5 player error, 100 video\n * not found/private, 101/150 embedding disabled by the owner; undocumented\n * codes like 153 are also reported in the wild) rather than a player state.\n * Named separately from `YTPlayerStateEvent` for readability at the call\n * site, not because the shape actually differs.\n */\ninterface YTPlayerErrorEvent {\n data: number;\n}\n\ninterface YTNamespace {\n Player: new (\n el: HTMLElement,\n opts: {\n videoId: string;\n playerVars?: { playsinline?: 0 | 1; rel?: 0 | 1 };\n events?: {\n onReady?: () => void;\n onStateChange?: (event: YTPlayerStateEvent) => void;\n onError?: (event: YTPlayerErrorEvent) => void;\n };\n },\n ) => YTPlayer;\n PlayerState: { ENDED: number; PLAYING: number; PAUSED: number };\n}\n\ndeclare global {\n interface Window {\n YT?: YTNamespace;\n onYouTubeIframeAPIReady?: () => void;\n }\n}\n\nlet apiPromise: Promise<YTNamespace> | null = null;\n\n/**\n * Loads `https://www.youtube.com/iframe_api` exactly once however many\n * YouTube slides/galleries end up on a page — the API itself is a single\n * global (`window.YT`), and re-injecting the script tag would be wasteful\n * and risks a second, conflicting `onYouTubeIframeAPIReady` firing. Composes\n * with a host's own `onYouTubeIframeAPIReady`, if one already exists\n * (e.g. the host embeds YouTube elsewhere too), instead of clobbering it.\n */\nfunction loadYouTubeApi(): Promise<YTNamespace> {\n if (window.YT?.Player) return Promise.resolve(window.YT);\n if (apiPromise) return apiPromise;\n\n apiPromise = new Promise((resolve) => {\n const previous = window.onYouTubeIframeAPIReady;\n window.onYouTubeIframeAPIReady = () => {\n previous?.();\n resolve(window.YT!);\n };\n const script = document.createElement('script');\n script.src = 'https://www.youtube.com/iframe_api';\n document.head.appendChild(script);\n });\n return apiPromise;\n}\n\n/**\n * DESIGN.md §4-autoplay — Autoplay looks for exactly this shape (`.play`/\n * `.pause`/`.paused`/`.ended`, plus real `play`/`pause`/`ended` DOM events)\n * on whatever's active, native `<video>` or not. Augmenting `container`\n * this way is what makes slideshow sync work without Autoplay ever having\n * to know YouTube — or any other provider — exists.\n */\ntype PlayableElement = HTMLElement & {\n play: () => void;\n pause: () => void;\n paused: boolean;\n ended: boolean;\n muted: boolean;\n};\n\n/**\n * `muted` is a real accessor (not a plain property) backed by the player's\n * own `mute()`/`unMute()`/`isMuted()` — DESIGN.md §4.1's autoplay-policy fix\n * sets `video.muted = true` right before an *automatic* play, the same way\n * it would for a native `<video>`; a plain property here would just be\n * inert state that never actually silences the embed.\n */\nfunction wirePlayableContract(container: HTMLElement, player: YTPlayer): PlayableElement {\n const playable = container as PlayableElement;\n playable.paused = true;\n playable.ended = false;\n playable.play = () => player.playVideo();\n playable.pause = () => player.pauseVideo();\n Object.defineProperty(playable, 'muted', {\n configurable: true,\n get: () => player.isMuted(),\n set: (value: boolean) => (value ? player.mute() : player.unMute()),\n });\n return playable;\n}\n\nfunction handleStateChange(\n event: YTPlayerStateEvent,\n YT: YTNamespace,\n playable: PlayableElement,\n): void {\n if (event.data === YT.PlayerState.PLAYING) {\n playable.paused = false;\n playable.dispatchEvent(new Event('play'));\n } else if (event.data === YT.PlayerState.PAUSED) {\n playable.paused = true;\n playable.dispatchEvent(new Event('pause'));\n } else if (event.data === YT.PlayerState.ENDED) {\n playable.paused = true;\n playable.ended = true;\n playable.dispatchEvent(new Event('ended'));\n }\n}\n\n/**\n * DESIGN.md §4-video — no poster/thumbnail handling at all: the slide shows\n * nothing until the embed itself is ready (same spinner-then-reveal every\n * other slide type gets), never an auto-fetched or guessed preview image.\n */\nexport const renderYouTube: VideoProviderRenderer = (container, item, onReady, signal) => {\n if (item.video?.provider !== 'youtube') return;\n const videoId = item.video.id;\n\n const mount = document.createElement('div');\n container.appendChild(mount);\n\n loadYouTubeApi().then((YT) => {\n if (signal.aborted) return; // navigated away before the (possibly first-ever, slow) API load resolved\n\n const playable = container as PlayableElement;\n const player = new YT.Player(mount, {\n videoId,\n playerVars: { playsinline: 1, rel: 0 },\n events: {\n // `new YT.Player()` returns before the embed's own internal bootstrap\n // (its postMessage handshake with youtube.com) finishes — calling\n // `player.mute()`/`playVideo()`/etc. before that completes can throw\n // (\"player.isMuted is not a function\") or silently no-op, even though\n // the constructor already returned an object. `onReady` is YouTube's\n // own signal that the real API surface is actually callable now, so\n // wiring the contract here — not synchronously after construction —\n // is what makes `findPlayable()`'s \"is `.play` wired?\" check in\n // Autoplay (DESIGN.md §4.1) an accurate proxy for \"is it really\n // ready?\" instead of just \"did we build the wrapper closures?\".\n onReady: () => {\n wirePlayableContract(container, player);\n onReady();\n },\n onStateChange: (event) => handleStateChange(event, YT, playable),\n // bubbles: true — reaches .shoji-slide-media (the pool slot, always\n // present regardless of readiness) without needing wirePlayableContract\n // to have run first; onError can fire instead of onReady entirely\n // (e.g. a removed/private video), so playable isn't a safe assumption\n // here the way it is in onStateChange. Autoplay (DESIGN.md §4.1)\n // listens for this to skip to the next slide instead of stalling.\n onError: (event) =>\n playable.dispatchEvent(\n new CustomEvent('error', { bubbles: true, detail: { code: event.data } }),\n ),\n },\n });\n signal.addEventListener('abort', () => player.destroy(), { once: true });\n });\n};\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { renderYouTube } from './youtube';\n\n/**\n * DESIGN.md §4-video — renders `{ video: { provider: 'youtube', id } }`\n * items (produced by `scan.ts`'s `data-shoji-video` host detection, or\n * authored directly in dynamic mode) as a real YouTube embed instead of\n * `SlideManager`'s native-`<video>` fallback, which can't play a YouTube\n * URL at all. Purely a renderer: no options, no toolbar button, no poster/\n * thumbnail handling — the slide shows nothing until the embed itself is\n * ready, same as every other slide type. Autoplay (§4-autoplay) picks up\n * real play/pause/ended sync automatically; see `youtube.ts`'s\n * `wirePlayableContract`.\n */\nexport const Video: ShojiPlugin = {\n name: 'video',\n\n init(ctx: PluginContext): () => void {\n return ctx.ui.registerVideoProvider('youtube', renderYouTube);\n },\n};\n"],"names":[],"mappings":"AAwDA,IAAI,aAA0C;AAU9C,SAAS,iBAAuC;AAVhD;AAWE,OAAI,YAAO,OAAP,mBAAW,eAAe,QAAQ,QAAQ,OAAO,EAAE;AACvD,MAAI,WAAY,QAAO;AAEvB,eAAa,IAAI,QAAQ,CAAC,YAAY;AACpC,UAAM,WAAW,OAAO;AACxB,WAAO,0BAA0B,MAAM;AACrC;AACA,cAAQ,OAAO,EAAG;AAAA,IACpB;AACA,UAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,WAAO,MAAM;AACb,aAAS,KAAK,YAAY,MAAM;AAAA,EAClC,CAAC;AACD,SAAO;AACT;AAwBA,SAAS,qBAAqB,WAAwB,QAAmC;AACvF,QAAM,WAAW;AACjB,WAAS,SAAS;AAClB,WAAS,QAAQ;AACjB,WAAS,OAAO,MAAM,OAAO,UAAA;AAC7B,WAAS,QAAQ,MAAM,OAAO,WAAA;AAC9B,SAAO,eAAe,UAAU,SAAS;AAAA,IACvC,cAAc;AAAA,IACd,KAAK,MAAM,OAAO,QAAA;AAAA,IAClB,KAAK,CAAC,UAAoB,QAAQ,OAAO,KAAA,IAAS,OAAO,OAAA;AAAA,EAAO,CACjE;AACD,SAAO;AACT;AAEA,SAAS,kBACP,OACA,IACA,UACM;AACN,MAAI,MAAM,SAAS,GAAG,YAAY,SAAS;AACzC,aAAS,SAAS;AAClB,aAAS,cAAc,IAAI,MAAM,MAAM,CAAC;AAAA,EAC1C,WAAW,MAAM,SAAS,GAAG,YAAY,QAAQ;AAC/C,aAAS,SAAS;AAClB,aAAS,cAAc,IAAI,MAAM,OAAO,CAAC;AAAA,EAC3C,WAAW,MAAM,SAAS,GAAG,YAAY,OAAO;AAC9C,aAAS,SAAS;AAClB,aAAS,QAAQ;AACjB,aAAS,cAAc,IAAI,MAAM,OAAO,CAAC;AAAA,EAC3C;AACF;AAOO,MAAM,gBAAuC,CAAC,WAAW,MAAM,SAAS,WAAW;AAtF1F;AAuFE,QAAI,UAAK,UAAL,mBAAY,cAAa,UAAW;AACxC,QAAM,UAAU,KAAK,MAAM;AAE3B,QAAM,QAAQ,SAAS,cAAc,KAAK;AAC1C,YAAU,YAAY,KAAK;AAE3B,iBAAA,EAAiB,KAAK,CAAC,OAAO;AAC5B,QAAI,OAAO,QAAS;AAEpB,UAAM,WAAW;AACjB,UAAM,SAAS,IAAI,GAAG,OAAO,OAAO;AAAA,MAClC;AAAA,MACA,YAAY,EAAE,aAAa,GAAG,KAAK,EAAA;AAAA,MACnC,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAWN,SAAS,MAAM;AACb,+BAAqB,WAAW,MAAM;AACtC,kBAAA;AAAA,QACF;AAAA,QACA,eAAe,CAAC,UAAU,kBAAkB,OAAO,IAAI,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAO/D,SAAS,CAAC,UACR,SAAS;AAAA,UACP,IAAI,YAAY,SAAS,EAAE,SAAS,MAAM,QAAQ,EAAE,MAAM,MAAM,OAAK,CAAG;AAAA,QAAA;AAAA,MAC1E;AAAA,IACJ,CACD;AACD,WAAO,iBAAiB,SAAS,MAAM,OAAO,WAAW,EAAE,MAAM,MAAM;AAAA,EACzE,CAAC;AACH;AC5KO,MAAM,QAAqB;AAAA,EAChC,MAAM;AAAA,EAEN,KAAK,KAAgC;AACnC,WAAO,IAAI,GAAG,sBAAsB,WAAW,aAAa;AAAA,EAC9D;AACF;"}
@@ -0,0 +1,61 @@
1
+ import { VideoProviderRenderer } from '../../core/plugin';
2
+ /**
3
+ * The bits of the YouTube IFrame Player API actually used here — not in
4
+ * `lib.dom.d.ts` (a third-party global loaded on demand), so this is a
5
+ * documented interop boundary (CLAUDE.md: `any` only there). Kept
6
+ * intentionally minimal, not a full mirror of YouTube's own typings.
7
+ */
8
+ interface YTPlayer {
9
+ playVideo(): void;
10
+ pauseVideo(): void;
11
+ mute(): void;
12
+ unMute(): void;
13
+ isMuted(): boolean;
14
+ destroy(): void;
15
+ }
16
+ interface YTPlayerStateEvent {
17
+ data: number;
18
+ }
19
+ /**
20
+ * Same `{ data: number }` shape as `YTPlayerStateEvent` — `data` holds an
21
+ * error code there (2 invalid parameter, 5 HTML5 player error, 100 video
22
+ * not found/private, 101/150 embedding disabled by the owner; undocumented
23
+ * codes like 153 are also reported in the wild) rather than a player state.
24
+ * Named separately from `YTPlayerStateEvent` for readability at the call
25
+ * site, not because the shape actually differs.
26
+ */
27
+ interface YTPlayerErrorEvent {
28
+ data: number;
29
+ }
30
+ interface YTNamespace {
31
+ Player: new (el: HTMLElement, opts: {
32
+ videoId: string;
33
+ playerVars?: {
34
+ playsinline?: 0 | 1;
35
+ rel?: 0 | 1;
36
+ };
37
+ events?: {
38
+ onReady?: () => void;
39
+ onStateChange?: (event: YTPlayerStateEvent) => void;
40
+ onError?: (event: YTPlayerErrorEvent) => void;
41
+ };
42
+ }) => YTPlayer;
43
+ PlayerState: {
44
+ ENDED: number;
45
+ PLAYING: number;
46
+ PAUSED: number;
47
+ };
48
+ }
49
+ declare global {
50
+ interface Window {
51
+ YT?: YTNamespace;
52
+ onYouTubeIframeAPIReady?: () => void;
53
+ }
54
+ }
55
+ /**
56
+ * DESIGN.md §4-video — no poster/thumbnail handling at all: the slide shows
57
+ * nothing until the embed itself is ready (same spinner-then-reveal every
58
+ * other slide type gets), never an auto-fetched or guessed preview image.
59
+ */
60
+ export declare const renderYouTube: VideoProviderRenderer;
61
+ export {};
@@ -0,0 +1,4 @@
1
+ /** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. A magnifying glass with a +/− in the lens, and a plain expand-corners glyph for "actual size" (distinct from fullscreen's EXPAND_ICON — no diagonal corner arrows, just a frame, so the two aren't visually confusable when both plugins are enabled). */
2
+ export declare const ZOOM_IN_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M21 21l-5.5-5.5M10 7v6M7 10h6\"/></svg>";
3
+ export declare const ZOOM_OUT_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M21 21l-5.5-5.5M7 10h6\"/></svg>";
4
+ export declare const ZOOM_ACTUAL_SIZE_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"1\"/><path d=\"M9 15l6-6M9 9h0M15 15h0\"/></svg>";
@@ -0,0 +1,30 @@
1
+ import { ShojiPlugin } from '../../core/plugin';
2
+ export interface ZoomOptions {
3
+ /** Multiplier cap for pinch/wheel/button zoom. "Actual size" can exceed this deliberately — it's an explicit action, not continuous gesture zoom. Default 4. */
4
+ maxScale?: number;
5
+ /** Scale a double-tap/double-click jumps to; a second one while already zoomed resets to 1 instead. Default 2. */
6
+ doubleTapScale?: number;
7
+ /** Multiplier applied per zoom-in/zoom-out toolbar button click. Default 1.5. */
8
+ buttonStep?: number;
9
+ }
10
+ /**
11
+ * DESIGN.md §4-zoom — pinch, double-tap/click, wheel+ctrl, and three toolbar
12
+ * buttons (zoom in/out/actual-size) all drive a single `scale`/pan state on
13
+ * the active slide's `<img>` — never `.shoji-slide-media` itself, which the
14
+ * rotateFlip plugin (§4) already transforms; nesting on the inner element
15
+ * instead of fighting over the same transform string means a rotated *and*
16
+ * zoomed photo behaves correctly for free (the outer rotate carries the
17
+ * inner pan/scale along with it as a rigid unit, which is also the visually
18
+ * expected result — see DESIGN.md's note on this plugin for the full
19
+ * reasoning). Pinch/double-tap/wheel are core's own gesture relay (§2.4) —
20
+ * scaffolding that existed specifically for this plugin to consume, not
21
+ * reimplemented here. Pan (single-pointer drag while zoomed) is the one
22
+ * piece core's relay doesn't cover — core's own drag-to-navigate/
23
+ * drag-to-close would otherwise fight over the same drag — so this plugin
24
+ * registers a zoom gate (`Gallery.registerZoomGate`, §4-zoom) that suspends
25
+ * core's drag handling entirely while zoomed, and tracks pan with its own
26
+ * minimal raw pointer listeners instead of reusing `GestureEngine` (whose
27
+ * axis-locked model — pick horizontal *or* vertical per gesture — is the
28
+ * wrong shape for a 2D pan that needs both at once).
29
+ */
30
+ export declare const Zoom: ShojiPlugin;
@@ -0,0 +1,274 @@
1
+ import { w as waitForTransitionEnd } from "../../zoomTransition-bbKHpVpA.js";
2
+ const ZOOM_IN_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="10" cy="10" r="7"/><path d="M21 21l-5.5-5.5M10 7v6M7 10h6"/></svg>';
3
+ const ZOOM_OUT_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="10" cy="10" r="7"/><path d="M21 21l-5.5-5.5M7 10h6"/></svg>';
4
+ const ZOOM_ACTUAL_SIZE_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1"/><path d="M9 15l6-6M9 9h0M15 15h0"/></svg>';
5
+ function clampScale(scale, min, max) {
6
+ return Math.min(max, Math.max(min, scale));
7
+ }
8
+ function clampAxis(naturalPos, naturalSize, scale, containerPos, containerSize, offset) {
9
+ const scaledSize = naturalSize * scale;
10
+ if (scaledSize <= containerSize) {
11
+ return containerPos + (containerSize - scaledSize) / 2 - naturalPos;
12
+ }
13
+ const minPos = containerPos + containerSize - scaledSize;
14
+ const maxPos = containerPos;
15
+ const proposedPos = naturalPos + offset;
16
+ return Math.min(maxPos, Math.max(minPos, proposedPos)) - naturalPos;
17
+ }
18
+ function clampPan(natural, container, scale, pan) {
19
+ return {
20
+ tx: clampAxis(natural.left, natural.width, scale, container.left, container.width, pan.tx),
21
+ ty: clampAxis(natural.top, natural.height, scale, container.top, container.height, pan.ty)
22
+ };
23
+ }
24
+ function zoomTowardPoint(natural, pan, oldScale, newScale, anchorX, anchorY) {
25
+ const currentLeft = natural.left + pan.tx;
26
+ const currentTop = natural.top + pan.ty;
27
+ const dx = anchorX - currentLeft;
28
+ const dy = anchorY - currentTop;
29
+ const factor = 1 - newScale / oldScale;
30
+ return {
31
+ tx: pan.tx + dx * factor,
32
+ ty: pan.ty + dy * factor
33
+ };
34
+ }
35
+ const ZOOM_EPSILON = 1.001;
36
+ function isRealControl(event) {
37
+ return event.composedPath().some(
38
+ (node) => node instanceof Element && node.matches("button, video, input, select, textarea, a[href], [data-shoji-no-drag]")
39
+ );
40
+ }
41
+ const Zoom = {
42
+ name: "zoom",
43
+ defaults: {
44
+ maxScale: 4,
45
+ doubleTapScale: 2,
46
+ buttonStep: 1.5
47
+ },
48
+ init(ctx) {
49
+ const { gallery } = ctx;
50
+ const maxScale = Number(ctx.options.maxScale ?? 4);
51
+ const doubleTapScale = Number(ctx.options.doubleTapScale ?? 2);
52
+ const buttonStep = Number(ctx.options.buttonStep ?? 1.5);
53
+ const locale = gallery.options.locale ?? {};
54
+ const zoomInLabel = locale.zoomIn ?? "Zoom in";
55
+ const zoomOutLabel = locale.zoomOut ?? "Zoom out";
56
+ const actualSizeLabel = locale.zoomActualSize ?? "Actual size";
57
+ let scale = 1;
58
+ let pan = { tx: 0, ty: 0 };
59
+ let natural = null;
60
+ let container = null;
61
+ let pinchStartScale = 1;
62
+ function getImg() {
63
+ const media = gallery.getActiveMedia();
64
+ const child = media == null ? void 0 : media.firstElementChild;
65
+ return child instanceof HTMLImageElement ? child : null;
66
+ }
67
+ function boxOf(el) {
68
+ const rect = el.getBoundingClientRect();
69
+ return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
70
+ }
71
+ function ensureNatural(img) {
72
+ if (natural && container) return true;
73
+ if (scale !== 1) return false;
74
+ natural = boxOf(img);
75
+ container = boxOf(img.parentElement ?? img);
76
+ return true;
77
+ }
78
+ function whenSettled(action) {
79
+ const media = gallery.getActiveMedia();
80
+ if (!media || media.style.transition === "") {
81
+ action();
82
+ return;
83
+ }
84
+ waitForTransitionEnd(media, action);
85
+ }
86
+ function apply() {
87
+ const img = getImg();
88
+ if (!img) return;
89
+ img.style.transformOrigin = "0 0";
90
+ img.style.transform = scale === 1 && pan.tx === 0 && pan.ty === 0 ? "none" : `translate3d(${pan.tx}px, ${pan.ty}px, 0) scale3d(${scale}, ${scale}, 1)`;
91
+ img.classList.toggle("shoji-zoomed", scale > 1);
92
+ }
93
+ function emitChange() {
94
+ ctx.emit("zoomChange", { index: gallery.currentIndex, scale });
95
+ }
96
+ function withTransition(img, run, afterEnd) {
97
+ img.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
98
+ run();
99
+ waitForTransitionEnd(img, () => {
100
+ img.style.transition = "";
101
+ afterEnd == null ? void 0 : afterEnd();
102
+ });
103
+ }
104
+ function zoomTo(targetScale, anchorX, anchorY, ceiling = maxScale, animate = false) {
105
+ whenSettled(() => {
106
+ const img = getImg();
107
+ if (!img || !ensureNatural(img)) return;
108
+ const clampedScale = clampScale(targetScale, 1, Math.max(ceiling, 1));
109
+ pan = zoomTowardPoint(natural, pan, scale, clampedScale, anchorX, anchorY);
110
+ scale = clampedScale;
111
+ pan = clampPan(natural, container, scale, pan);
112
+ if (animate) withTransition(img, apply);
113
+ else apply();
114
+ emitChange();
115
+ });
116
+ }
117
+ function reset(animate = false) {
118
+ scale = 1;
119
+ pan = { tx: 0, ty: 0 };
120
+ natural = null;
121
+ container = null;
122
+ const img = getImg();
123
+ if (!img) return;
124
+ const clearTransform = () => {
125
+ img.style.transform = "";
126
+ img.classList.remove("shoji-zoomed");
127
+ };
128
+ if (animate) {
129
+ withTransition(img, clearTransform, () => {
130
+ img.style.transformOrigin = "";
131
+ });
132
+ } else {
133
+ clearTransform();
134
+ img.style.transformOrigin = "";
135
+ }
136
+ }
137
+ function markEnabled() {
138
+ var _a;
139
+ (_a = getImg()) == null ? void 0 : _a.classList.add("shoji-zoom-enabled");
140
+ }
141
+ function toggleZoom(x, y) {
142
+ if (scale > ZOOM_EPSILON) reset(true);
143
+ else zoomTo(doubleTapScale, x, y, maxScale, true);
144
+ }
145
+ const offPinchStart = ctx.on("pinchStart", () => {
146
+ pinchStartScale = scale;
147
+ });
148
+ const offPinchMove = ctx.on("pinchMove", ({ scale: relative, centerX, centerY }) => {
149
+ zoomTo(pinchStartScale * relative, centerX, centerY);
150
+ });
151
+ const offPinchEnd = ctx.on("pinchEnd", () => {
152
+ if (scale <= ZOOM_EPSILON) reset();
153
+ });
154
+ const offDoubleTap = ctx.on("doubleTap", ({ x, y }) => toggleZoom(x, y));
155
+ const offWheelZoom = ctx.on("wheelZoom", ({ deltaScale, x, y }) => {
156
+ zoomTo(scale + deltaScale, x, y);
157
+ });
158
+ let panPointerId = null;
159
+ let lastX = 0;
160
+ let lastY = 0;
161
+ const outer = ctx.ui.outer();
162
+ function onPointerDown(event) {
163
+ if (scale <= ZOOM_EPSILON || isRealControl(event)) return;
164
+ panPointerId = event.pointerId;
165
+ lastX = event.clientX;
166
+ lastY = event.clientY;
167
+ }
168
+ function onPointerMove(event) {
169
+ if (panPointerId !== event.pointerId || !natural || !container) return;
170
+ event.preventDefault();
171
+ const dx = event.clientX - lastX;
172
+ const dy = event.clientY - lastY;
173
+ lastX = event.clientX;
174
+ lastY = event.clientY;
175
+ pan = clampPan(natural, container, scale, { tx: pan.tx + dx, ty: pan.ty + dy });
176
+ apply();
177
+ }
178
+ function onPointerUp(event) {
179
+ if (panPointerId === event.pointerId) panPointerId = null;
180
+ }
181
+ outer.addEventListener("pointerdown", onPointerDown);
182
+ outer.addEventListener("pointermove", onPointerMove, { passive: false });
183
+ outer.addEventListener("pointerup", onPointerUp);
184
+ outer.addEventListener("pointercancel", onPointerUp);
185
+ function buildButton(icon, label) {
186
+ const button = document.createElement("button");
187
+ button.type = "button";
188
+ button.className = "shoji-toolbar-button";
189
+ button.innerHTML = icon;
190
+ button.setAttribute("aria-label", label);
191
+ button.title = label;
192
+ return button;
193
+ }
194
+ function centerAnchor() {
195
+ const media = gallery.getActiveMedia();
196
+ const rect = media == null ? void 0 : media.getBoundingClientRect();
197
+ return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : { x: 0, y: 0 };
198
+ }
199
+ function zoomInStep() {
200
+ const { x, y } = centerAnchor();
201
+ zoomTo(scale * buttonStep, x, y, maxScale, true);
202
+ }
203
+ function zoomOutStep() {
204
+ const { x, y } = centerAnchor();
205
+ if (scale / buttonStep <= ZOOM_EPSILON) reset(true);
206
+ else zoomTo(scale / buttonStep, x, y, maxScale, true);
207
+ }
208
+ const zoomInBtn = buildButton(ZOOM_IN_ICON, zoomInLabel);
209
+ const zoomOutBtn = buildButton(ZOOM_OUT_ICON, zoomOutLabel);
210
+ const actualSizeBtn = buildButton(ZOOM_ACTUAL_SIZE_ICON, actualSizeLabel);
211
+ zoomInBtn.addEventListener("click", zoomInStep);
212
+ zoomOutBtn.addEventListener("click", zoomOutStep);
213
+ actualSizeBtn.addEventListener("click", () => {
214
+ whenSettled(() => {
215
+ const img = getImg();
216
+ if (!img || !img.naturalWidth) return;
217
+ if (!ensureNatural(img)) return;
218
+ if (scale > ZOOM_EPSILON) {
219
+ reset(true);
220
+ return;
221
+ }
222
+ const targetScale = img.naturalWidth / natural.width;
223
+ const { x, y } = centerAnchor();
224
+ zoomTo(targetScale, x, y, targetScale, true);
225
+ });
226
+ });
227
+ const removeButtons = [zoomInBtn, zoomOutBtn, actualSizeBtn].map(
228
+ (button) => ctx.ui.toolbar("right", button)
229
+ );
230
+ const removeShortcuts = [
231
+ ctx.ui.registerShortcut("w", zoomInStep),
232
+ ctx.ui.registerShortcut("W", zoomInStep),
233
+ ctx.ui.registerShortcut("s", zoomOutStep),
234
+ ctx.ui.registerShortcut("S", zoomOutStep)
235
+ ];
236
+ const offOpen = ctx.on("afterOpen", () => {
237
+ reset();
238
+ markEnabled();
239
+ });
240
+ const offBeforeSlide = ctx.on("beforeSlide", () => reset());
241
+ const offSlide = ctx.on("afterSlide", () => {
242
+ reset();
243
+ markEnabled();
244
+ });
245
+ const offBeforeClose = ctx.on("beforeClose", () => reset());
246
+ const unregisterGate = gallery.registerZoomGate(() => scale > ZOOM_EPSILON);
247
+ markEnabled();
248
+ return () => {
249
+ var _a;
250
+ for (const remove of removeButtons) remove();
251
+ for (const remove of removeShortcuts) remove();
252
+ offOpen();
253
+ offBeforeSlide();
254
+ offSlide();
255
+ offBeforeClose();
256
+ offPinchStart();
257
+ offPinchMove();
258
+ offPinchEnd();
259
+ offDoubleTap();
260
+ offWheelZoom();
261
+ outer.removeEventListener("pointerdown", onPointerDown);
262
+ outer.removeEventListener("pointermove", onPointerMove);
263
+ outer.removeEventListener("pointerup", onPointerUp);
264
+ outer.removeEventListener("pointercancel", onPointerUp);
265
+ unregisterGate();
266
+ reset();
267
+ (_a = getImg()) == null ? void 0 : _a.classList.remove("shoji-zoom-enabled");
268
+ };
269
+ }
270
+ };
271
+ export {
272
+ Zoom
273
+ };
274
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../../../../src/plugins/zoom/icons.ts","../../../../src/plugins/zoom/zoomMath.ts","../../../../src/plugins/zoom/index.ts"],"sourcesContent":["/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. A magnifying glass with a +/− in the lens, and a plain expand-corners glyph for \"actual size\" (distinct from fullscreen's EXPAND_ICON — no diagonal corner arrows, just a frame, so the two aren't visually confusable when both plugins are enabled). */\nexport const ZOOM_IN_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\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M21 21l-5.5-5.5M10 7v6M7 10h6\"/></svg>';\n\nexport const ZOOM_OUT_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\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M21 21l-5.5-5.5M7 10h6\"/></svg>';\n\nexport const ZOOM_ACTUAL_SIZE_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\"><rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"1\"/><path d=\"M9 15l6-6M9 9h0M15 15h0\"/></svg>';\n","/** A viewport-relative rect: `{left, top, width, height}`, same shape as `DOMRect` but plain-object so it's trivial to mock in tests. */\nexport interface ZoomBox {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\nexport interface PanOffset {\n tx: number;\n ty: number;\n}\n\nexport function clampScale(scale: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, scale));\n}\n\n/**\n * One axis of `clampPan` below. `natural*` is the element's own unscaled\n * rendered box (captured once at scale 1, before any transform — `apply()`\n * in `index.ts` renders `translate(tx, ty) scale(s)` with `transform-origin:\n * 0 0`, so the natural box's top-left is exactly where the scaled box's own\n * local origin sits on screen, and `tx`/`ty` are the *only* thing moving it\n * from there). If the scaled content is still smaller than the container on\n * this axis, no pan range can avoid a gap on *both* sides at once — centered\n * is the only sensible answer, not an inverted min>max range.\n */\nfunction clampAxis(\n naturalPos: number,\n naturalSize: number,\n scale: number,\n containerPos: number,\n containerSize: number,\n offset: number,\n): number {\n const scaledSize = naturalSize * scale;\n if (scaledSize <= containerSize) {\n return containerPos + (containerSize - scaledSize) / 2 - naturalPos;\n }\n const minPos = containerPos + containerSize - scaledSize;\n const maxPos = containerPos;\n const proposedPos = naturalPos + offset;\n return Math.min(maxPos, Math.max(minPos, proposedPos)) - naturalPos;\n}\n\n/** Keeps the scaled+panned content's edges from retreating inside `container`'s edges (no empty gap beyond the image), on both axes independently. */\nexport function clampPan(\n natural: ZoomBox,\n container: ZoomBox,\n scale: number,\n pan: PanOffset,\n): PanOffset {\n return {\n tx: clampAxis(natural.left, natural.width, scale, container.left, container.width, pan.tx),\n ty: clampAxis(natural.top, natural.height, scale, container.top, container.height, pan.ty),\n };\n}\n\n/**\n * The standard \"zoom toward a point\" formula: adjusts pan so the viewport\n * point `(anchorX, anchorY)` stays visually fixed as scale changes from\n * `oldScale` to `newScale`, given `transform-origin: 0 0` (see `natural`'s\n * doc comment on `clampAxis` above for why that makes this tractable —\n * scaling never moves the natural box's own top-left on screen, only `pan`\n * does). Callers still need to clamp the result with `clampPan` afterward;\n * this only solves the anchor-fixed part, not boundary containment.\n */\nexport function zoomTowardPoint(\n natural: ZoomBox,\n pan: PanOffset,\n oldScale: number,\n newScale: number,\n anchorX: number,\n anchorY: number,\n): PanOffset {\n const currentLeft = natural.left + pan.tx;\n const currentTop = natural.top + pan.ty;\n const dx = anchorX - currentLeft;\n const dy = anchorY - currentTop;\n const factor = 1 - newScale / oldScale;\n return {\n tx: pan.tx + dx * factor,\n ty: pan.ty + dy * factor,\n };\n}\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { waitForTransitionEnd } from '../../core/zoomTransition';\nimport { ZOOM_ACTUAL_SIZE_ICON, ZOOM_IN_ICON, ZOOM_OUT_ICON } from './icons';\nimport { clampPan, clampScale, zoomTowardPoint, type PanOffset, type ZoomBox } from './zoomMath';\nimport './zoom.css';\n\nexport interface ZoomOptions {\n /** Multiplier cap for pinch/wheel/button zoom. \"Actual size\" can exceed this deliberately — it's an explicit action, not continuous gesture zoom. Default 4. */\n maxScale?: number;\n /** Scale a double-tap/double-click jumps to; a second one while already zoomed resets to 1 instead. Default 2. */\n doubleTapScale?: number;\n /** Multiplier applied per zoom-in/zoom-out toolbar button click. Default 1.5. */\n buttonStep?: number;\n}\n\nconst ZOOM_EPSILON = 1.001; // treat \"just barely above 1\" as unzoomed — avoids float residue pinning isZoomed() true forever\n\n/** A click/drag starting on a real control shouldn't engage pan — same exclusion list GestureController's shouldIgnoreGesture uses, duplicated rather than imported since that function isn't part of core's exported surface. */\nfunction isRealControl(event: PointerEvent): boolean {\n return event\n .composedPath()\n .some(\n (node) =>\n node instanceof Element &&\n node.matches('button, video, input, select, textarea, a[href], [data-shoji-no-drag]'),\n );\n}\n\n/**\n * DESIGN.md §4-zoom — pinch, double-tap/click, wheel+ctrl, and three toolbar\n * buttons (zoom in/out/actual-size) all drive a single `scale`/pan state on\n * the active slide's `<img>` — never `.shoji-slide-media` itself, which the\n * rotateFlip plugin (§4) already transforms; nesting on the inner element\n * instead of fighting over the same transform string means a rotated *and*\n * zoomed photo behaves correctly for free (the outer rotate carries the\n * inner pan/scale along with it as a rigid unit, which is also the visually\n * expected result — see DESIGN.md's note on this plugin for the full\n * reasoning). Pinch/double-tap/wheel are core's own gesture relay (§2.4) —\n * scaffolding that existed specifically for this plugin to consume, not\n * reimplemented here. Pan (single-pointer drag while zoomed) is the one\n * piece core's relay doesn't cover — core's own drag-to-navigate/\n * drag-to-close would otherwise fight over the same drag — so this plugin\n * registers a zoom gate (`Gallery.registerZoomGate`, §4-zoom) that suspends\n * core's drag handling entirely while zoomed, and tracks pan with its own\n * minimal raw pointer listeners instead of reusing `GestureEngine` (whose\n * axis-locked model — pick horizontal *or* vertical per gesture — is the\n * wrong shape for a 2D pan that needs both at once).\n */\nexport const Zoom: ShojiPlugin = {\n name: 'zoom',\n defaults: {\n maxScale: 4,\n doubleTapScale: 2,\n buttonStep: 1.5,\n } satisfies ZoomOptions,\n\n init(ctx: PluginContext): () => void {\n const { gallery } = ctx;\n const maxScale = Number(ctx.options.maxScale ?? 4);\n const doubleTapScale = Number(ctx.options.doubleTapScale ?? 2);\n const buttonStep = Number(ctx.options.buttonStep ?? 1.5);\n const locale = (gallery.options.locale ?? {}) as Record<string, string>;\n const zoomInLabel = locale.zoomIn ?? 'Zoom in';\n const zoomOutLabel = locale.zoomOut ?? 'Zoom out';\n const actualSizeLabel = locale.zoomActualSize ?? 'Actual size';\n\n let scale = 1;\n let pan: PanOffset = { tx: 0, ty: 0 };\n let natural: ZoomBox | null = null;\n let container: ZoomBox | null = null;\n let pinchStartScale = 1;\n\n function getImg(): HTMLImageElement | null {\n const media = gallery.getActiveMedia();\n const child = media?.firstElementChild;\n return child instanceof HTMLImageElement ? child : null;\n }\n\n function boxOf(el: Element): ZoomBox {\n const rect = el.getBoundingClientRect();\n return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };\n }\n\n /** Only valid to measure while scale===1 (untransformed) — the very first zoom action on a slide; every subsequent action within the same slide reuses the cached box, since measuring an already-scaled element would capture the scaled size, not the natural one. */\n function ensureNatural(img: HTMLImageElement): boolean {\n if (natural && container) return true;\n if (scale !== 1) return false; // shouldn't happen — defensive\n natural = boxOf(img);\n container = boxOf(img.parentElement ?? img);\n return true;\n }\n\n /**\n * A real bug: `ensureNatural`'s first measurement of a slide is only\n * trustworthy once the lightbox's own open FLIP transition (`zoomIn`,\n * `core/zoomTransition.ts`) has actually settled — that transition\n * applies its own transform directly to `.shoji-slide-media`, the exact\n * element `ensureNatural` measures as `container`. A zoom action firing\n * before it settles (any interaction within `--shoji-duration` of\n * opening — a fast click, or a test that only waits for the dialog to\n * become visible) captured a wildly wrong, mid-animation rect,\n * permanently poisoning that slide's zoom math (every later action\n * reuses the same cached, wrong box). `zoomIn` is fire-and-forget by\n * design (nothing to await when opening) and clears this exact inline\n * style once its own transition ends — the one signal available for\n * \"is it still running.\" Runs `action` immediately once settled, which\n * is right away in the overwhelming common case (any real interaction\n * more than ~300ms after open).\n */\n function whenSettled(action: () => void): void {\n const media = gallery.getActiveMedia();\n if (!media || media.style.transition === '') {\n action();\n return;\n }\n waitForTransitionEnd(media, action);\n }\n\n /**\n * `translate3d`/`scale3d`, not the 2D `translate`/`scale` this used to\n * use — a real bug, reported from real usage: evenly-spaced horizontal\n * lines visible across a zoomed photo, at certain zoom levels, on real\n * GPU hardware (not reproducible in headless/software rendering, so\n * this can't be verified here). The regular spacing matches Chromium's\n * own raster-tile boundaries — a known quirk where scaling large\n * content via a 2D `scale()` transform can show seams between GPU\n * tiles. Forcing the fully 3D compositing path instead (functionally\n * identical — `scale3d(s, s, 1)` and `scale(s)` produce the same\n * on-screen result) is the commonly effective fix, since it takes a\n * different rasterization path than the 2D one.\n */\n function apply(): void {\n const img = getImg();\n if (!img) return;\n img.style.transformOrigin = '0 0';\n img.style.transform =\n scale === 1 && pan.tx === 0 && pan.ty === 0\n ? 'none'\n : `translate3d(${pan.tx}px, ${pan.ty}px, 0) scale3d(${scale}, ${scale}, 1)`;\n img.classList.toggle('shoji-zoomed', scale > 1);\n }\n\n function emitChange(): void {\n ctx.emit('zoomChange', { index: gallery.currentIndex, scale });\n }\n\n /** Wraps a transform-setting `run` in a transition, for discrete jumps (buttons, double-tap, actual-size) — never for pinch/pan/wheel, which already track the input 1:1 and would visibly lag behind it under a transition. `afterEnd`, if given, runs once the transition actually completes, not before — `reset()` uses it to clear `transformOrigin` only once it's safe to (see its own comment for why clearing it any earlier is a real bug). The transition itself is always cleared afterward, so it doesn't linger onto the next, possibly-continuous, zoom action. */\n function withTransition(img: HTMLImageElement, run: () => void, afterEnd?: () => void): void {\n img.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n run();\n waitForTransitionEnd(img, () => {\n img.style.transition = '';\n afterEnd?.();\n });\n }\n\n /** Shared by every zoom-in/out entry point (pinch, wheel, buttons, double-tap, actual-size) — anchors on (anchorX, anchorY), clamps scale to [1, ceiling] and pan to the container bounds. `ceiling` defaults to maxScale; actual-size passes its own (possibly larger) target so it isn't capped by the gesture-zoom limit. Deferred via `whenSettled` — see its doc comment — so a zoom action landing right as the lightbox opens doesn't measure mid-animation. `animate` — see `withTransition`. */\n function zoomTo(\n targetScale: number,\n anchorX: number,\n anchorY: number,\n ceiling = maxScale,\n animate = false,\n ): void {\n whenSettled(() => {\n const img = getImg();\n if (!img || !ensureNatural(img)) return;\n const clampedScale = clampScale(targetScale, 1, Math.max(ceiling, 1));\n pan = zoomTowardPoint(natural!, pan, scale, clampedScale, anchorX, anchorY);\n scale = clampedScale;\n pan = clampPan(natural!, container!, scale, pan);\n if (animate) withTransition(img, apply);\n else apply();\n emitChange();\n });\n }\n\n function reset(animate = false): void {\n scale = 1;\n pan = { tx: 0, ty: 0 };\n natural = null;\n container = null;\n const img = getImg();\n if (!img) return;\n const clearTransform = (): void => {\n img.style.transform = '';\n img.classList.remove('shoji-zoomed');\n };\n if (animate) {\n // transform-origin has to stay put (0 0) for the duration of the\n // transition — clearing it to the browser default (center) in the\n // same tick as starting the transition snaps the scale anchor\n // instantly, which visibly jumped the image before it eased down\n // to neutral. Deferred to `afterEnd`, once the transition is done\n // and transform-origin no longer affects anything visible.\n withTransition(img, clearTransform, () => {\n img.style.transformOrigin = '';\n });\n } else {\n clearTransform();\n img.style.transformOrigin = '';\n }\n }\n\n /** Each slide gets a freshly-created `<img>` (SlideManager never reuses elements across renders), so the cursor-affordance marker (`zoom.css`) needs reapplying every time the active media changes, not just once. */\n function markEnabled(): void {\n getImg()?.classList.add('shoji-zoom-enabled');\n }\n\n function toggleZoom(x: number, y: number): void {\n if (scale > ZOOM_EPSILON) reset(true);\n else zoomTo(doubleTapScale, x, y, maxScale, true);\n }\n\n // --- pinch (relayed by core, §2.4 — no built-in effect until this plugin exists) ---\n const offPinchStart = ctx.on('pinchStart', () => {\n pinchStartScale = scale;\n });\n const offPinchMove = ctx.on('pinchMove', ({ scale: relative, centerX, centerY }) => {\n zoomTo(pinchStartScale * relative, centerX, centerY);\n });\n const offPinchEnd = ctx.on('pinchEnd', () => {\n if (scale <= ZOOM_EPSILON) reset(); // snap fully back to neutral rather than leaving float residue\n });\n\n // --- double-tap / double-click (relayed by core; Pointer Events unify the two, see GestureEngine) ---\n const offDoubleTap = ctx.on('doubleTap', ({ x, y }) => toggleZoom(x, y));\n\n // --- ctrl+wheel / trackpad pinch (relayed by core) ---\n const offWheelZoom = ctx.on('wheelZoom', ({ deltaScale, x, y }) => {\n zoomTo(scale + deltaScale, x, y);\n });\n\n // --- pan while zoomed — the one gesture core's relay doesn't cover; see the plugin doc comment for why this can't reuse GestureEngine. ---\n let panPointerId: number | null = null;\n let lastX = 0;\n let lastY = 0;\n const outer = ctx.ui.outer();\n\n function onPointerDown(event: PointerEvent): void {\n if (scale <= ZOOM_EPSILON || isRealControl(event)) return;\n panPointerId = event.pointerId;\n lastX = event.clientX;\n lastY = event.clientY;\n }\n function onPointerMove(event: PointerEvent): void {\n if (panPointerId !== event.pointerId || !natural || !container) return;\n event.preventDefault();\n const dx = event.clientX - lastX;\n const dy = event.clientY - lastY;\n lastX = event.clientX;\n lastY = event.clientY;\n pan = clampPan(natural, container, scale, { tx: pan.tx + dx, ty: pan.ty + dy });\n apply();\n }\n function onPointerUp(event: PointerEvent): void {\n if (panPointerId === event.pointerId) panPointerId = null;\n }\n\n outer.addEventListener('pointerdown', onPointerDown);\n outer.addEventListener('pointermove', onPointerMove, { passive: false });\n outer.addEventListener('pointerup', onPointerUp);\n outer.addEventListener('pointercancel', onPointerUp);\n\n // --- toolbar buttons ---\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 function centerAnchor(): { x: number; y: number } {\n const media = gallery.getActiveMedia();\n const rect = media?.getBoundingClientRect();\n return rect\n ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }\n : { x: 0, y: 0 };\n }\n\n /** Shared by the zoom-in toolbar button and the `w` keyboard shortcut — same fixed `buttonStep` multiplier either way. */\n function zoomInStep(): void {\n const { x, y } = centerAnchor();\n zoomTo(scale * buttonStep, x, y, maxScale, true);\n }\n\n /** Shared by the zoom-out toolbar button and the `s` keyboard shortcut. */\n function zoomOutStep(): void {\n const { x, y } = centerAnchor();\n if (scale / buttonStep <= ZOOM_EPSILON) reset(true);\n else zoomTo(scale / buttonStep, x, y, maxScale, true);\n }\n\n const zoomInBtn = buildButton(ZOOM_IN_ICON, zoomInLabel);\n const zoomOutBtn = buildButton(ZOOM_OUT_ICON, zoomOutLabel);\n const actualSizeBtn = buildButton(ZOOM_ACTUAL_SIZE_ICON, actualSizeLabel);\n\n zoomInBtn.addEventListener('click', zoomInStep);\n zoomOutBtn.addEventListener('click', zoomOutStep);\n actualSizeBtn.addEventListener('click', () => {\n // Reads natural.width directly (unlike every other entry point, which\n // just hands zoomTo a target and lets it call ensureNatural itself) —\n // needs its own whenSettled wrap for that reason, not just zoomTo's.\n whenSettled(() => {\n const img = getImg();\n if (!img || !img.naturalWidth) return;\n if (!ensureNatural(img)) return;\n if (scale > ZOOM_EPSILON) {\n reset(true);\n return;\n }\n const targetScale = img.naturalWidth / natural!.width;\n const { x, y } = centerAnchor();\n zoomTo(targetScale, x, y, targetScale, true); // ceiling = targetScale — bypasses maxScale deliberately\n });\n });\n\n // 'right' — registered in this order, so they cluster left-to-right as\n // zoomIn, zoomOut, actualSize, then whatever later plugin (or the close\n // button) follows (DESIGN.md §3.1).\n const removeButtons = [zoomInBtn, zoomOutBtn, actualSizeBtn].map((button) =>\n ctx.ui.toolbar('right', button),\n );\n\n // w/s zoom in/out, same step as the toolbar buttons — both cases\n // registered explicitly (registerShortcut matches event.key verbatim,\n // no case-insensitive matching of its own) so Shift/CapsLock still work.\n const removeShortcuts = [\n ctx.ui.registerShortcut('w', zoomInStep),\n ctx.ui.registerShortcut('W', zoomInStep),\n ctx.ui.registerShortcut('s', zoomOutStep),\n ctx.ui.registerShortcut('S', zoomOutStep),\n ];\n\n const offOpen = ctx.on('afterOpen', () => {\n reset();\n markEnabled();\n });\n // Un-animated, and on beforeSlide rather than only afterSlide below:\n // SlideManager.render() (called synchronously between the two) reuses a\n // still-cached slide's node via a plain reparent (moveIn(), no state\n // clearing of its own) into whichever pool slot its new offset needs —\n // there is no code path afterward that can still find *this* image to\n // reset it. A real bug, reported from real usage: zoom in via \"Actual\n // size\", click next — the old, still-scaled image, now reparented into\n // the (unclipped, per shoji.css) neighboring slot, visibly bled into the\n // new slide instead of being invisible off-screen like an unzoomed one\n // always is. Resetting here, while getActiveMedia() still resolves to\n // the about-to-move image, clears it before that reparent ever happens.\n const offBeforeSlide = ctx.on('beforeSlide', () => reset());\n const offSlide = ctx.on('afterSlide', () => {\n reset();\n markEnabled();\n });\n // Fires synchronously, before Gallery.close() measures the active\n // media's rect to compute the zoom-out-to-thumbnail animation — reset\n // here (not just afterOpen/afterSlide) so that measurement sees the\n // image at its natural position/scale, not wherever it was left\n // zoomed/panned to. Skipping this made closing while zoomed animate\n // from the image's current (zoomed, often partly off-screen) rect\n // instead of its real thumbnail-relative size, landing \"closed\" at a\n // seemingly random spot instead of visibly shrinking into the thumbnail.\n const offBeforeClose = ctx.on('beforeClose', () => reset());\n const unregisterGate = gallery.registerZoomGate(() => scale > ZOOM_EPSILON);\n markEnabled(); // covers the (unusual but possible) case of the gallery already being open when this plugin initializes\n\n return () => {\n for (const remove of removeButtons) remove();\n for (const remove of removeShortcuts) remove();\n offOpen();\n offBeforeSlide();\n offSlide();\n offBeforeClose();\n offPinchStart();\n offPinchMove();\n offPinchEnd();\n offDoubleTap();\n offWheelZoom();\n outer.removeEventListener('pointerdown', onPointerDown);\n outer.removeEventListener('pointermove', onPointerMove);\n outer.removeEventListener('pointerup', onPointerUp);\n outer.removeEventListener('pointercancel', onPointerUp);\n unregisterGate();\n reset();\n getImg()?.classList.remove('shoji-zoom-enabled');\n };\n },\n};\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ /** A viewport-relative rect: `{left, top, width, height}`, same shape as `DOMRect` but plain-object so it's trivial to mock in tests. */
2
+ export interface ZoomBox {
3
+ left: number;
4
+ top: number;
5
+ width: number;
6
+ height: number;
7
+ }
8
+ export interface PanOffset {
9
+ tx: number;
10
+ ty: number;
11
+ }
12
+ export declare function clampScale(scale: number, min: number, max: number): number;
13
+ /** Keeps the scaled+panned content's edges from retreating inside `container`'s edges (no empty gap beyond the image), on both axes independently. */
14
+ export declare function clampPan(natural: ZoomBox, container: ZoomBox, scale: number, pan: PanOffset): PanOffset;
15
+ /**
16
+ * The standard "zoom toward a point" formula: adjusts pan so the viewport
17
+ * point `(anchorX, anchorY)` stays visually fixed as scale changes from
18
+ * `oldScale` to `newScale`, given `transform-origin: 0 0` (see `natural`'s
19
+ * doc comment on `clampAxis` above for why that makes this tractable —
20
+ * scaling never moves the natural box's own top-left on screen, only `pan`
21
+ * does). Callers still need to clamp the result with `clampPan` afterward;
22
+ * this only solves the anchor-fixed part, not boundary containment.
23
+ */
24
+ export declare function zoomTowardPoint(natural: ZoomBox, pan: PanOffset, oldScale: number, newScale: number, anchorX: number, anchorY: number): PanOffset;
@@ -0,0 +1,25 @@
1
+ import { SlideManager } from '../core/SlideManager';
2
+ import { TransitionPreset } from './presets';
3
+ /**
4
+ * DESIGN.md §2.5 — animates a slide-to-slide navigation via a temporary
5
+ * "ghost": a clone of the outgoing `.shoji-slide-media`, positioned over
6
+ * the real slot, playing the preset's `leave` keyframe while `swapContent`
7
+ * (the real `SlideManager.render()` reassignment) runs invisibly underneath
8
+ * it. The newly-swapped-in real active media then plays `enter`. Both
9
+ * animate concurrently.
10
+ *
11
+ * Deliberately separate from the gesture engine's own live-drag/settle
12
+ * animation (`GestureController.ts`, §2.4): `mode` is a discrete "jump" for
13
+ * programmatic navigation, not layered onto a continuous drag — a completed
14
+ * swipe always uses its own pool-shift regardless of `mode`. See
15
+ * `Gallery.navigate()`'s `animate` flag.
16
+ */
17
+ export declare class SlideTransition {
18
+ private readonly slides;
19
+ constructor(slides: SlideManager);
20
+ /** A recognized built-in preset (`TRANSITION_PRESETS`). */
21
+ animate(preset: TransitionPreset, direction: 1 | -1, swapContent: () => void): void;
22
+ /** An unrecognized `mode` string: a host-supplied CSS class pair (`shoji-transition-<mode>-leave`/`-enter`), not an error — §2.5's "custom animation = a CSS class pair, no JS needed." */
23
+ animateCustom(mode: string, direction: 1 | -1, swapContent: () => void): void;
24
+ private run;
25
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * DESIGN.md §2.5 — built-in presets, generated from a small config→transform
3
+ * DSL rather than ~20 hand-written keyframe functions. `enter(direction)` is
4
+ * where the incoming slide starts (then transitions to natural: `transform:
5
+ * none; opacity: 1`); `leave(direction)` is where the outgoing clone (see
6
+ * `SlideTransition.ts`) ends up (starts at natural, transitions to this).
7
+ * `direction` is `1` for next()-style, `-1` for prev()-style — presets that
8
+ * don't care (fade, zoom, flipX, deck, drop...) never read it. transform/
9
+ * opacity only, per CLAUDE.md's hardware-accelerated-CSS3 constraint.
10
+ */
11
+ export interface TransitionState {
12
+ transform: string;
13
+ opacity: number;
14
+ }
15
+ export interface TransitionPreset {
16
+ name: string;
17
+ enter(direction: 1 | -1): TransitionState;
18
+ leave(direction: 1 | -1): TransitionState;
19
+ }
20
+ /** Keyed by name for `Gallery`'s `mode`/`mobileSettings.mode` lookup — an unrecognized string is treated as a custom CSS-class-pair mode instead (see `SlideTransition.animateCustom`), not an error. */
21
+ export declare const TRANSITION_PRESETS: Readonly<Record<string, TransitionPreset>>;