@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
package/dist/shoji.js ADDED
@@ -0,0 +1,3605 @@
1
+ (function(global, factory) {
2
+ typeof exports === "object" && typeof module !== "undefined" ? module.exports = factory() : typeof define === "function" && define.amd ? define(factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, global.Shoji = factory());
3
+ })(this, (function() {
4
+ "use strict";var __defProp = Object.defineProperty;
5
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
7
+
8
+ let lockCount = 0;
9
+ let savedOverflow = "";
10
+ let savedHtmlOverflow = "";
11
+ function lockBodyScroll() {
12
+ if (lockCount === 0) {
13
+ savedOverflow = document.body.style.overflow;
14
+ document.body.style.overflow = "hidden";
15
+ savedHtmlOverflow = document.documentElement.style.overflow;
16
+ document.documentElement.style.overflow = "hidden";
17
+ }
18
+ lockCount++;
19
+ }
20
+ function unlockBodyScroll() {
21
+ lockCount = Math.max(0, lockCount - 1);
22
+ if (lockCount === 0) {
23
+ document.body.style.overflow = savedOverflow;
24
+ document.documentElement.style.overflow = savedHtmlOverflow;
25
+ }
26
+ }
27
+ class EventBus {
28
+ constructor() {
29
+ __publicField(this, "listeners", /* @__PURE__ */ new Map());
30
+ }
31
+ on(event, fn) {
32
+ let set = this.listeners.get(event);
33
+ if (!set) {
34
+ set = /* @__PURE__ */ new Set();
35
+ this.listeners.set(event, set);
36
+ }
37
+ set.add(fn);
38
+ return () => this.off(event, fn);
39
+ }
40
+ off(event, fn) {
41
+ var _a;
42
+ (_a = this.listeners.get(event)) == null ? void 0 : _a.delete(fn);
43
+ }
44
+ emit(event, detail) {
45
+ const set = this.listeners.get(event);
46
+ if (!set) return;
47
+ for (const fn of set) fn(detail);
48
+ }
49
+ clear() {
50
+ this.listeners.clear();
51
+ }
52
+ }
53
+ const CLOSE_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>';
54
+ const PREV_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></svg>';
55
+ const NEXT_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>';
56
+ const PLAY_ICON$1 = '<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
57
+ const CAPTION_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="3" y="5" width="18" height="14" rx="2"/><path d="M7 10h10M7 14h6"/></svg>';
58
+ let idCounter = 0;
59
+ function iconButton(className, icon, label) {
60
+ const button = document.createElement("button");
61
+ button.type = "button";
62
+ button.className = className;
63
+ button.ariaLabel = button.title = label;
64
+ button.innerHTML = icon;
65
+ return button;
66
+ }
67
+ function buildLightboxDom(slides, labels) {
68
+ const labelId = `shoji-counter-${++idCounter}`;
69
+ const outer = document.createElement("div");
70
+ outer.className = "shoji-outer";
71
+ const backdrop = document.createElement("div");
72
+ backdrop.className = "shoji-backdrop";
73
+ outer.appendChild(backdrop);
74
+ const dialog = document.createElement("div");
75
+ dialog.className = "shoji-dialog";
76
+ dialog.setAttribute("role", "dialog");
77
+ dialog.setAttribute("aria-modal", "true");
78
+ dialog.setAttribute("aria-labelledby", labelId);
79
+ dialog.tabIndex = -1;
80
+ const toolbar = document.createElement("div");
81
+ toolbar.className = "shoji-toolbar";
82
+ const toolbarLeft = document.createElement("div");
83
+ toolbarLeft.className = "shoji-toolbar-slot shoji-toolbar-left";
84
+ const toolbarCenter = document.createElement("div");
85
+ toolbarCenter.className = "shoji-toolbar-slot shoji-toolbar-center";
86
+ const toolbarRight = document.createElement("div");
87
+ toolbarRight.className = "shoji-toolbar-slot shoji-toolbar-right";
88
+ const closeButton = iconButton("shoji-close", CLOSE_ICON, labels.close);
89
+ const captionToggleButton = iconButton(
90
+ "shoji-toolbar-button shoji-caption-toggle",
91
+ CAPTION_ICON,
92
+ labels.showCaption
93
+ );
94
+ captionToggleButton.setAttribute("aria-pressed", "false");
95
+ captionToggleButton.hidden = true;
96
+ toolbarRight.append(captionToggleButton, closeButton);
97
+ const counter = document.createElement("div");
98
+ counter.className = "shoji-counter";
99
+ counter.id = labelId;
100
+ toolbarLeft.appendChild(counter);
101
+ toolbar.append(toolbarLeft, toolbarCenter, toolbarRight);
102
+ dialog.appendChild(toolbar);
103
+ const prevButton = iconButton("shoji-nav shoji-nav-prev", PREV_ICON, labels.previous);
104
+ const nextButton = iconButton("shoji-nav shoji-nav-next", NEXT_ICON, labels.next);
105
+ dialog.appendChild(prevButton);
106
+ dialog.appendChild(nextButton);
107
+ dialog.appendChild(slides);
108
+ const caption = document.createElement("div");
109
+ caption.className = "shoji-caption";
110
+ dialog.appendChild(caption);
111
+ outer.appendChild(dialog);
112
+ return {
113
+ outer,
114
+ dialog,
115
+ counter,
116
+ caption,
117
+ closeButton,
118
+ prevButton,
119
+ nextButton,
120
+ captionToggleButton,
121
+ toolbarLeft,
122
+ toolbarCenter,
123
+ toolbarRight
124
+ };
125
+ }
126
+ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), video[controls], [tabindex]:not([tabindex="-1"])';
127
+ class FocusTrap {
128
+ constructor() {
129
+ __publicField(this, "container", null);
130
+ __publicField(this, "previouslyFocused", null);
131
+ __publicField(this, "onKeydown", (event) => {
132
+ if (event.key !== "Tab" || !this.container) return;
133
+ const focusable = this.getFocusable();
134
+ if (focusable.length === 0) {
135
+ event.preventDefault();
136
+ this.container.focus();
137
+ return;
138
+ }
139
+ const first = focusable[0];
140
+ const last = focusable[focusable.length - 1];
141
+ const active = document.activeElement;
142
+ const activeIsInside = active instanceof HTMLElement && focusable.includes(active);
143
+ if (event.shiftKey) {
144
+ if (!activeIsInside || active === first) {
145
+ event.preventDefault();
146
+ last.focus();
147
+ }
148
+ } else if (!activeIsInside || active === last) {
149
+ event.preventDefault();
150
+ first.focus();
151
+ }
152
+ });
153
+ }
154
+ getFocusable() {
155
+ if (!this.container) return [];
156
+ return Array.from(this.container.querySelectorAll(FOCUSABLE_SELECTOR));
157
+ }
158
+ activate(container) {
159
+ this.previouslyFocused = document.activeElement;
160
+ this.container = container;
161
+ document.addEventListener("keydown", this.onKeydown, true);
162
+ container.focus();
163
+ }
164
+ deactivate() {
165
+ var _a;
166
+ document.removeEventListener("keydown", this.onKeydown, true);
167
+ this.container = null;
168
+ (_a = this.previouslyFocused) == null ? void 0 : _a.focus();
169
+ this.previouslyFocused = null;
170
+ }
171
+ }
172
+ const DOUBLE_TAP_MAX_DELAY_MS = 300;
173
+ const DOUBLE_TAP_MAX_DISTANCE = 30;
174
+ const TAP_MAX_DELAY_MS = 500;
175
+ class GestureEngine {
176
+ constructor(target, callbacks, options) {
177
+ __publicField(this, "target");
178
+ __publicField(this, "options");
179
+ __publicField(this, "callbacks");
180
+ __publicField(this, "pointers", /* @__PURE__ */ new Map());
181
+ __publicField(this, "primaryPointerId", null);
182
+ __publicField(this, "direction", null);
183
+ __publicField(this, "dragStartDistance", 0);
184
+ // along the locked axis, at the moment direction locked — subtracted so onDragStart's delta is 0
185
+ __publicField(this, "pinching", false);
186
+ __publicField(this, "pinchStartDistance", 0);
187
+ // -Infinity, not 0: `event.timeStamp` is time-since-navigation-start, so a
188
+ // real first tap is always some large positive number and 0 would seem
189
+ // like a safe "no previous tap" sentinel in practice — but not always: a
190
+ // tap in the first 300ms of the page's life, near the viewport's top-left
191
+ // corner, would satisfy registerTap's isDouble check against this
192
+ // sentinel and get wrongly reported as a double-tap on nothing. -Infinity
193
+ // can never be within DOUBLE_TAP_MAX_DELAY_MS of any real timestamp.
194
+ __publicField(this, "lastTapTime", -Infinity);
195
+ __publicField(this, "lastTapX", 0);
196
+ __publicField(this, "lastTapY", 0);
197
+ __publicField(this, "onPointerDown", (event) => {
198
+ var _a, _b, _c, _d;
199
+ if (event.isPrimary === false && this.pointers.size === 0) return;
200
+ if (this.pointers.size === 0 && ((_b = (_a = this.callbacks).ignore) == null ? void 0 : _b.call(_a, event))) return;
201
+ this.pointers.set(event.pointerId, {
202
+ startX: event.clientX,
203
+ startY: event.clientY,
204
+ startTime: event.timeStamp,
205
+ lastX: event.clientX,
206
+ lastY: event.clientY,
207
+ lastTime: event.timeStamp
208
+ });
209
+ if (this.pointers.size === 1) {
210
+ this.primaryPointerId = event.pointerId;
211
+ this.direction = null;
212
+ } else if (this.pointers.size === 2) {
213
+ this.direction = null;
214
+ this.pinching = true;
215
+ const [a, b] = [...this.pointers.values()];
216
+ this.pinchStartDistance = distanceBetween(a, b);
217
+ const center = centerOf(a, b);
218
+ (_d = (_c = this.callbacks).onPinchStart) == null ? void 0 : _d.call(_c, center.x, center.y);
219
+ }
220
+ });
221
+ __publicField(this, "onPointerMove", (event) => {
222
+ var _a, _b, _c, _d, _e, _f, _g, _h;
223
+ const state = this.pointers.get(event.pointerId);
224
+ if (!state) return;
225
+ state.lastX = event.clientX;
226
+ state.lastY = event.clientY;
227
+ state.lastTime = event.timeStamp;
228
+ if (this.pinching) {
229
+ if (this.pointers.size < 2) return;
230
+ const [a, b] = [...this.pointers.values()];
231
+ const distance = distanceBetween(a, b);
232
+ const scale = this.pinchStartDistance > 0 ? distance / this.pinchStartDistance : 1;
233
+ const center = centerOf(a, b);
234
+ (_b = (_a = this.callbacks).onPinchMove) == null ? void 0 : _b.call(_a, scale, center.x, center.y);
235
+ return;
236
+ }
237
+ if (event.pointerId !== this.primaryPointerId) return;
238
+ const dx = event.clientX - state.startX;
239
+ const dy = event.clientY - state.startY;
240
+ if (this.direction === null) {
241
+ const absX = Math.abs(dx);
242
+ const absY = Math.abs(dy);
243
+ if (Math.max(absX, absY) < this.options.lockThreshold) return;
244
+ this.direction = absX > absY ? "horizontal" : "vertical";
245
+ this.dragStartDistance = this.direction === "horizontal" ? dx : dy;
246
+ if (((_d = (_c = this.callbacks).shouldCapture) == null ? void 0 : _d.call(_c)) ?? true) this.target.setPointerCapture(event.pointerId);
247
+ (_f = (_e = this.callbacks).onDragStart) == null ? void 0 : _f.call(_e, this.direction, event);
248
+ }
249
+ if (this.direction === "horizontal") event.preventDefault();
250
+ const delta = (this.direction === "horizontal" ? dx : dy) - this.dragStartDistance;
251
+ (_h = (_g = this.callbacks).onDragMove) == null ? void 0 : _h.call(_g, this.direction, delta, event);
252
+ });
253
+ __publicField(this, "onPointerUp", (event) => {
254
+ this.finishPointer(event, false);
255
+ });
256
+ __publicField(this, "onPointerCancel", (event) => {
257
+ this.finishPointer(event, true);
258
+ });
259
+ __publicField(this, "onWheel", (event) => {
260
+ var _a, _b;
261
+ if (!event.ctrlKey) return;
262
+ event.preventDefault();
263
+ const deltaScale = -event.deltaY * 0.01;
264
+ (_b = (_a = this.callbacks).onWheelZoom) == null ? void 0 : _b.call(_a, deltaScale, event.clientX, event.clientY, event);
265
+ });
266
+ this.target = target;
267
+ this.callbacks = callbacks;
268
+ this.options = {
269
+ lockThreshold: (options == null ? void 0 : options.lockThreshold) ?? 10,
270
+ swipeThreshold: (options == null ? void 0 : options.swipeThreshold) ?? 50,
271
+ swipeVelocity: (options == null ? void 0 : options.swipeVelocity) ?? 0.3
272
+ };
273
+ this.target.addEventListener("pointerdown", this.onPointerDown);
274
+ this.target.addEventListener("pointermove", this.onPointerMove);
275
+ this.target.addEventListener("pointerup", this.onPointerUp);
276
+ this.target.addEventListener("pointercancel", this.onPointerCancel);
277
+ this.target.addEventListener("wheel", this.onWheel, { passive: false });
278
+ }
279
+ destroy() {
280
+ this.target.removeEventListener("pointerdown", this.onPointerDown);
281
+ this.target.removeEventListener("pointermove", this.onPointerMove);
282
+ this.target.removeEventListener("pointerup", this.onPointerUp);
283
+ this.target.removeEventListener("pointercancel", this.onPointerCancel);
284
+ this.target.removeEventListener("wheel", this.onWheel);
285
+ this.pointers.clear();
286
+ }
287
+ finishPointer(event, cancelled) {
288
+ var _a, _b, _c, _d;
289
+ const state = this.pointers.get(event.pointerId);
290
+ if (!state) return;
291
+ state.lastX = event.clientX;
292
+ state.lastY = event.clientY;
293
+ state.lastTime = event.timeStamp;
294
+ this.pointers.delete(event.pointerId);
295
+ if (this.pinching) {
296
+ if (this.pointers.size < 2) {
297
+ this.pinching = false;
298
+ (_b = (_a = this.callbacks).onPinchEnd) == null ? void 0 : _b.call(_a);
299
+ }
300
+ return;
301
+ }
302
+ if (event.pointerId !== this.primaryPointerId) return;
303
+ if (this.direction === null) {
304
+ if (!cancelled && event.timeStamp - state.startTime <= TAP_MAX_DELAY_MS) {
305
+ this.registerTap(state.lastX, state.lastY, event);
306
+ }
307
+ } else {
308
+ const elapsed = Math.max(1, state.lastTime - state.startTime);
309
+ const totalDelta = (this.direction === "horizontal" ? state.lastX - state.startX : state.lastY - state.startY) - this.dragStartDistance;
310
+ const velocity = totalDelta / elapsed;
311
+ const completed = !cancelled && (Math.abs(totalDelta) >= this.options.swipeThreshold || Math.abs(velocity) >= this.options.swipeVelocity);
312
+ (_d = (_c = this.callbacks).onDragEnd) == null ? void 0 : _d.call(_c, this.direction, totalDelta, velocity, completed, cancelled);
313
+ }
314
+ this.primaryPointerId = null;
315
+ this.direction = null;
316
+ }
317
+ registerTap(x, y, event) {
318
+ var _a, _b, _c, _d;
319
+ const now = event.timeStamp;
320
+ const isDouble = now - this.lastTapTime <= DOUBLE_TAP_MAX_DELAY_MS && Math.hypot(x - this.lastTapX, y - this.lastTapY) <= DOUBLE_TAP_MAX_DISTANCE;
321
+ if (isDouble) {
322
+ (_b = (_a = this.callbacks).onDoubleTap) == null ? void 0 : _b.call(_a, x, y, event);
323
+ this.lastTapTime = 0;
324
+ } else {
325
+ (_d = (_c = this.callbacks).onTap) == null ? void 0 : _d.call(_c, x, y, event);
326
+ this.lastTapTime = now;
327
+ this.lastTapX = x;
328
+ this.lastTapY = y;
329
+ }
330
+ }
331
+ }
332
+ function distanceBetween(a, b) {
333
+ return Math.hypot(a.lastX - b.lastX, a.lastY - b.lastY);
334
+ }
335
+ function centerOf(a, b) {
336
+ return { x: (a.lastX + b.lastX) / 2, y: (a.lastY + b.lastY) / 2 };
337
+ }
338
+ function prefersReducedMotion$2() {
339
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
340
+ }
341
+ function parseCssTime$1(value) {
342
+ var _a;
343
+ const first = ((_a = value.split(",")[0]) == null ? void 0 : _a.trim()) ?? "";
344
+ if (first.endsWith("ms")) return parseFloat(first);
345
+ if (first.endsWith("s")) return parseFloat(first) * 1e3;
346
+ return 0;
347
+ }
348
+ function containedBox(container, aspectRatio) {
349
+ const containerRatio = container.width / container.height;
350
+ const width = aspectRatio > containerRatio ? container.width : container.height * aspectRatio;
351
+ const height = aspectRatio > containerRatio ? container.width / aspectRatio : container.height;
352
+ return {
353
+ left: container.left + (container.width - width) / 2,
354
+ top: container.top + (container.height - height) / 2,
355
+ width,
356
+ height
357
+ };
358
+ }
359
+ function effectiveTargetBox(target, aspectRatio) {
360
+ const child = target.firstElementChild;
361
+ const isPlaceholder = child instanceof HTMLElement && (child.classList.contains("shoji-slide-spinner") || child.classList.contains("shoji-slide-open-placeholder"));
362
+ if (child instanceof HTMLElement && !isPlaceholder) {
363
+ const rect = child.getBoundingClientRect();
364
+ if (rect.width > 0 && rect.height > 0) return rect;
365
+ }
366
+ const containerRect = target.getBoundingClientRect();
367
+ if (aspectRatio && containerRect.width > 0 && containerRect.height > 0) {
368
+ return containedBox(containerRect, aspectRatio);
369
+ }
370
+ return containerRect;
371
+ }
372
+ function computeTransform(origin, target, aspectRatio) {
373
+ const originRect = origin.getBoundingClientRect();
374
+ const targetRect = effectiveTargetBox(target, aspectRatio);
375
+ if (targetRect.width === 0 || targetRect.height === 0 || originRect.width === 0 || originRect.height === 0) {
376
+ return null;
377
+ }
378
+ const scale = Math.min(
379
+ originRect.width / targetRect.width,
380
+ originRect.height / targetRect.height
381
+ );
382
+ const translateX = originRect.left + originRect.width / 2 - (targetRect.left + targetRect.width / 2);
383
+ const translateY = originRect.top + originRect.height / 2 - (targetRect.top + targetRect.height / 2);
384
+ return `translate3d(${translateX}px, ${translateY}px, 0) scale3d(${scale}, ${scale}, 1)`;
385
+ }
386
+ function waitForTransitionEnd(target, cb) {
387
+ const durationMs = parseCssTime$1(getComputedStyle(target).transitionDuration);
388
+ let done = false;
389
+ const finish = () => {
390
+ if (done) return;
391
+ done = true;
392
+ target.removeEventListener("transitionend", onEnd);
393
+ cb();
394
+ };
395
+ const onEnd = (event) => {
396
+ if (event.target === target && event.propertyName === "transform")
397
+ finish();
398
+ };
399
+ target.addEventListener("transitionend", onEnd);
400
+ setTimeout(finish, durationMs + 100);
401
+ }
402
+ function clearInlineTransform$1(target, expectedTransform) {
403
+ target.style.transition = "";
404
+ target.style.transformOrigin = "";
405
+ target.style.willChange = "";
406
+ if (target.style.transform === expectedTransform) target.style.transform = "";
407
+ }
408
+ function zoomIn({ origin, target, aspectRatio }) {
409
+ if (prefersReducedMotion$2()) return;
410
+ const transform = computeTransform(origin, target, aspectRatio);
411
+ if (!transform) return;
412
+ target.style.transition = "none";
413
+ target.style.transformOrigin = "center";
414
+ target.style.willChange = "transform";
415
+ target.style.transform = transform;
416
+ void target.offsetHeight;
417
+ target.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
418
+ target.style.transform = "none";
419
+ waitForTransitionEnd(target, () => clearInlineTransform$1(target, "none"));
420
+ }
421
+ function zoomOut({ origin, target, aspectRatio }, onComplete) {
422
+ if (prefersReducedMotion$2()) {
423
+ onComplete();
424
+ return;
425
+ }
426
+ const transform = computeTransform(origin, target, aspectRatio);
427
+ if (!transform) {
428
+ onComplete();
429
+ return;
430
+ }
431
+ target.style.transformOrigin = "center";
432
+ target.style.willChange = "transform";
433
+ target.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
434
+ void target.offsetHeight;
435
+ target.style.transform = transform;
436
+ const appliedTransform = target.style.transform;
437
+ waitForTransitionEnd(target, () => {
438
+ clearInlineTransform$1(target, appliedTransform);
439
+ onComplete();
440
+ });
441
+ }
442
+ const DRAG_SETTLE_TRANSITION = "transform var(--shoji-duration) var(--shoji-momentum-easing)";
443
+ const DRAG_FEEDBACK_TRANSITION = "transform var(--shoji-duration) var(--shoji-momentum-easing), opacity var(--shoji-duration) var(--shoji-momentum-easing)";
444
+ const VERTICAL_FEEDBACK_DISTANCE = 160;
445
+ function shouldIgnoreGesture(event) {
446
+ return event.composedPath().some(
447
+ (node) => node instanceof Element && node.matches("button, video, input, select, textarea, a[href], [data-shoji-no-drag]")
448
+ );
449
+ }
450
+ class GestureController {
451
+ constructor(host, relay, options) {
452
+ __publicField(this, "engine");
453
+ /** Horizontal follows the finger 1:1; vertical drives close-feedback instead. Axis-locked, one branch per drag. */
454
+ __publicField(this, "onDragMove", (direction, delta) => {
455
+ if (this.host.isZoomed()) return;
456
+ if (direction === "horizontal") {
457
+ this.host.slides.setDragOffset(delta, null);
458
+ } else if (this.host.canClose()) {
459
+ this.applyVerticalDragFeedback(delta);
460
+ }
461
+ });
462
+ __publicField(this, "onDragEnd", (direction, delta, _velocity, completed) => {
463
+ if (this.host.isZoomed()) return;
464
+ if (direction === "horizontal") {
465
+ this.finishHorizontalDrag(delta, completed);
466
+ } else {
467
+ this.finishVerticalDrag(completed);
468
+ }
469
+ });
470
+ this.host = host;
471
+ this.engine = new GestureEngine(
472
+ host.dialog,
473
+ {
474
+ ignore: shouldIgnoreGesture,
475
+ shouldCapture: () => !host.isZoomed(),
476
+ onDragStart: () => host.onActivity(),
477
+ onDragMove: this.onDragMove,
478
+ onDragEnd: this.onDragEnd,
479
+ onTap: (x, y) => relay.onTap(x, y),
480
+ onDoubleTap: (x, y) => relay.onDoubleTap(x, y),
481
+ onPinchStart: (centerX, centerY) => relay.onPinchStart(centerX, centerY),
482
+ onPinchMove: (scale, centerX, centerY) => relay.onPinchMove(scale, centerX, centerY),
483
+ onPinchEnd: () => relay.onPinchEnd(),
484
+ onWheelZoom: (deltaScale, x, y) => relay.onWheelZoom(deltaScale, x, y)
485
+ },
486
+ options
487
+ );
488
+ }
489
+ destroy() {
490
+ this.engine.destroy();
491
+ }
492
+ /** `completed` decides intent; `canGoNext`/`canGoPrev` guard whether that's actually possible (`loop: false` at an end item). */
493
+ finishHorizontalDrag(delta, completed) {
494
+ const goingNext = delta < 0;
495
+ const canMove = goingNext ? this.host.canGoNext() : this.host.canGoPrev();
496
+ if (!completed || delta === 0 || !canMove) {
497
+ this.settleDragOffset(0, () => {
498
+ });
499
+ return;
500
+ }
501
+ const width = this.host.dialog.getBoundingClientRect().width || 1;
502
+ const target = goingNext ? -width : width;
503
+ this.settleDragOffset(target, () => {
504
+ this.host.slides.setDragOffset(0, null);
505
+ if (goingNext) this.host.next();
506
+ else this.host.prev();
507
+ });
508
+ }
509
+ settleDragOffset(targetPx, onSettled) {
510
+ const settleEl = this.host.slides.getSlotRoot(0);
511
+ this.host.slides.setDragOffset(targetPx, DRAG_SETTLE_TRANSITION);
512
+ if (!settleEl) {
513
+ onSettled();
514
+ return;
515
+ }
516
+ waitForTransitionEnd(settleEl, onSettled);
517
+ }
518
+ /** Purely presentational drag feedback — scales/fades the dialog toward `close()`'s target state as the viewer drags, without closing until release decides the outcome. */
519
+ applyVerticalDragFeedback(delta) {
520
+ const progress = Math.min(Math.abs(delta) / VERTICAL_FEEDBACK_DISTANCE, 1);
521
+ const dialog = this.host.dialog;
522
+ dialog.style.transition = "";
523
+ dialog.style.transform = `translateY(${delta}px) scale(${1 - progress * 0.15})`;
524
+ dialog.style.opacity = String(1 - progress * 0.6);
525
+ }
526
+ clearVerticalDragFeedback(animate) {
527
+ const dialog = this.host.dialog;
528
+ dialog.style.transition = animate ? DRAG_FEEDBACK_TRANSITION : "";
529
+ dialog.style.transform = "";
530
+ dialog.style.opacity = "";
531
+ }
532
+ finishVerticalDrag(completed) {
533
+ if (completed && this.host.canClose()) {
534
+ this.clearVerticalDragFeedback(false);
535
+ this.host.close();
536
+ } else {
537
+ this.clearVerticalDragFeedback(true);
538
+ }
539
+ }
540
+ }
541
+ class LiveRegion {
542
+ constructor() {
543
+ __publicField(this, "element");
544
+ this.element = document.createElement("div");
545
+ this.element.className = "shoji-live-region";
546
+ this.element.setAttribute("role", "status");
547
+ this.element.setAttribute("aria-live", "polite");
548
+ }
549
+ announce(text) {
550
+ this.element.textContent = "";
551
+ void this.element.offsetWidth;
552
+ this.element.textContent = text;
553
+ }
554
+ }
555
+ const DEFAULT_SELECTOR = ":scope > a, :scope > [data-shoji-src], :scope > [data-shoji-video]";
556
+ function scanContainer(container, selector = DEFAULT_SELECTOR) {
557
+ const elements = Array.from(container.querySelectorAll(selector));
558
+ const scanned = [];
559
+ for (const element of elements) {
560
+ const item = scanVideo(element) ?? scanImage(element);
561
+ if (item) scanned.push({ element, item });
562
+ }
563
+ return scanned;
564
+ }
565
+ function attr(el, name) {
566
+ return el.getAttribute(name) ?? void 0;
567
+ }
568
+ function numAttr(el, name) {
569
+ const raw = el.getAttribute(name);
570
+ if (raw === null) return void 0;
571
+ const n = Number(raw);
572
+ return Number.isFinite(n) ? n : void 0;
573
+ }
574
+ function applyCommon(element, item, src) {
575
+ const caption = attr(element, "data-shoji-caption");
576
+ if (caption) item.caption = caption;
577
+ item.id = attr(element, "data-shoji-id") ?? src;
578
+ let data;
579
+ for (const a of element.attributes) {
580
+ if (!a.name.startsWith("data-shoji-")) continue;
581
+ const key = a.name.slice(11);
582
+ if (key !== "no-drag" && !(key in item)) (data ?? (data = {}))[key] = a.value;
583
+ }
584
+ if (data) item.data = data;
585
+ }
586
+ function scanImage(element) {
587
+ const src = attr(element, "href") ?? attr(element, "data-shoji-src");
588
+ if (!src) return void 0;
589
+ const item = { src };
590
+ const img = element.querySelector("img");
591
+ const thumb = (img == null ? void 0 : img.getAttribute("src")) ?? attr(element, "data-shoji-thumb");
592
+ if (thumb) item.thumb = thumb;
593
+ const alt = (img == null ? void 0 : img.getAttribute("alt")) ?? attr(element, "data-shoji-alt");
594
+ if (alt) item.alt = alt;
595
+ const width = numAttr(element, "data-shoji-width");
596
+ if (width !== void 0) item.width = width;
597
+ const height = numAttr(element, "data-shoji-height");
598
+ if (height !== void 0) item.height = height;
599
+ const srcset = attr(element, "data-shoji-srcset");
600
+ if (srcset) item.srcset = srcset;
601
+ const sizes = attr(element, "data-shoji-sizes");
602
+ if (sizes) item.sizes = sizes;
603
+ applyCommon(element, item, src);
604
+ return item;
605
+ }
606
+ function scanVideo(element) {
607
+ var _a;
608
+ const videoEl = element.querySelector("video");
609
+ if (videoEl) {
610
+ const sources = Array.from(videoEl.querySelectorAll("source")).map((s) => ({ src: s.getAttribute("src") ?? "", type: s.getAttribute("type") ?? "" })).filter((s) => s.src);
611
+ const src = videoEl.getAttribute("src") ?? ((_a = sources[0]) == null ? void 0 : _a.src);
612
+ if (!src) return void 0;
613
+ const item = { src, video: { provider: "html5" } };
614
+ if (sources.length) item.sources = sources;
615
+ const poster = videoEl.getAttribute("poster");
616
+ if (poster) item.poster = poster;
617
+ applyCommon(element, item, src);
618
+ return item;
619
+ }
620
+ const videoUrl = attr(element, "data-shoji-video");
621
+ if (videoUrl) {
622
+ const youtubeId = detectYouTubeId(videoUrl);
623
+ const item = youtubeId ? { src: videoUrl, video: { provider: "youtube", id: youtubeId, url: videoUrl } } : {
624
+ src: videoUrl,
625
+ video: { provider: "html5" },
626
+ sources: [{ src: videoUrl, type: guessVideoType(videoUrl) }]
627
+ };
628
+ const poster = attr(element, "data-shoji-poster");
629
+ if (poster) item.poster = poster;
630
+ applyCommon(element, item, videoUrl);
631
+ return item;
632
+ }
633
+ return void 0;
634
+ }
635
+ function detectYouTubeId(url) {
636
+ let parsed;
637
+ try {
638
+ parsed = new URL(url, window.location.href);
639
+ } catch {
640
+ return void 0;
641
+ }
642
+ const host = parsed.hostname.replace(/^(www|m)\./, "");
643
+ if (host === "youtu.be") return parsed.pathname.slice(1).split("/")[0] || void 0;
644
+ if (host !== "youtube.com") return void 0;
645
+ if (parsed.pathname === "/watch") return parsed.searchParams.get("v") ?? void 0;
646
+ const match = parsed.pathname.match(/^\/(?:embed|shorts)\/([^/?]+)/);
647
+ return match == null ? void 0 : match[1];
648
+ }
649
+ function guessVideoType(url) {
650
+ const clean = url.split(/[?#]/)[0] ?? url;
651
+ const ext = clean.slice(clean.lastIndexOf(".") + 1).toLowerCase();
652
+ if (ext === "webm") return "video/webm";
653
+ if (ext === "ogg" || ext === "ogv") return "video/ogg";
654
+ return "video/mp4";
655
+ }
656
+ const PAUSE_OVERLAY_DELAY_MS = 200;
657
+ class SlideManager {
658
+ constructor(options) {
659
+ __publicField(this, "element");
660
+ __publicField(this, "slots");
661
+ __publicField(this, "preload");
662
+ __publicField(this, "playVideoLabel");
663
+ __publicField(this, "videoProviders");
664
+ __publicField(this, "dragOffsetPx", 0);
665
+ /**
666
+ * Ready nodes keyed by item index, not slot offset — a `Slot` only
667
+ * remembers its *own* previous index, so it can't tell that some *other*
668
+ * slot already decoded the exact index it's now asked to show (the
669
+ * routine case: stepping forward moves the +1 slot's content into the 0
670
+ * slot). Without this, already-decoded content is thrown away and
671
+ * redecoded on every step. Trimmed to `centerIndex ± preload` each
672
+ * `render()`, same window the slots cover.
673
+ */
674
+ __publicField(this, "cache", /* @__PURE__ */ new Map());
675
+ /**
676
+ * Image decodes in flight, keyed by item index, not by whichever slot
677
+ * started them — a real bug this fixes: navigating before a preload
678
+ * decode resolved used to reassign that slot, abandoning the decode and
679
+ * starting a duplicate elsewhere, so a slow preload could never finish,
680
+ * only restart. `reveal()` looks up which slot currently wants this
681
+ * index at resolve time, so a reshuffled pool still lands it correctly.
682
+ */
683
+ __publicField(this, "pending", /* @__PURE__ */ new Map());
684
+ this.element = document.createElement("div");
685
+ this.element.className = "shoji-slides";
686
+ this.preload = options.preload;
687
+ this.playVideoLabel = options.playVideoLabel;
688
+ this.videoProviders = options.videoProviders;
689
+ const count = options.preload * 2 + 1;
690
+ this.slots = Array.from({ length: count }, (_, i) => {
691
+ const offset = i - options.preload;
692
+ const root = document.createElement("div");
693
+ root.className = "shoji-slide";
694
+ const media = document.createElement("div");
695
+ media.className = "shoji-slide-media";
696
+ root.appendChild(media);
697
+ this.element.appendChild(root);
698
+ return { root, media, offset, assignedIndex: null, ready: false };
699
+ });
700
+ this.applyTransforms(null);
701
+ }
702
+ /** The active slot's `.shoji-slide-media` — not `.shoji-slide`, whose transform is owned by pool-offset positioning. */
703
+ getActiveMedia() {
704
+ var _a;
705
+ return ((_a = this.slots.find((slot) => slot.offset === 0)) == null ? void 0 : _a.media) ?? null;
706
+ }
707
+ /** False while the active slot's content is still decoding — Gallery.ts uses this right after `render()` to show the loading state immediately, without waiting for `onLoad`. */
708
+ isActiveReady() {
709
+ var _a;
710
+ return ((_a = this.slots.find((slot) => slot.offset === 0)) == null ? void 0 : _a.ready) ?? false;
711
+ }
712
+ /**
713
+ * DESIGN.md §2.4 — live drag-to-navigate feedback: every slot's
714
+ * structural `offset * 100%` position gets this same `px` added on top,
715
+ * so dragging left/right visually slides the whole pool together (the
716
+ * adjacent preloaded slot already exists at `±100%`) — no new DOM, no
717
+ * content re-render mid-drag. `transition`: `null` during the live drag
718
+ * (1:1 with the pointer); a real value only for the post-release settle
719
+ * animation, reset back to `null` once that transition ends.
720
+ */
721
+ setDragOffset(px, transition) {
722
+ this.dragOffsetPx = px;
723
+ this.applyTransforms(transition);
724
+ }
725
+ /** The pool slot's `.shoji-slide` root at a structural offset — what the drag settle animation (§2.4) waits for `transitionend` on. */
726
+ getSlotRoot(offset) {
727
+ var _a;
728
+ return ((_a = this.slots.find((slot) => slot.offset === offset)) == null ? void 0 : _a.root) ?? null;
729
+ }
730
+ applyTransforms(transition) {
731
+ for (const slot of this.slots) {
732
+ slot.root.style.transition = transition ?? "";
733
+ slot.root.style.transform = `translateX(calc(${slot.offset * 100}% + ${this.dragOffsetPx}px))`;
734
+ }
735
+ }
736
+ /** Re-renders whichever slots need a different item; `onLoad` fires per index once its media settles. `openPlaceholderSrc` (only from `Gallery.open()`) swaps the centerIndex slot's spinner for a low-res placeholder once it decodes, if not already ready. */
737
+ render(items, centerIndex, onLoad, openPlaceholderSrc) {
738
+ var _a;
739
+ for (const [index, entry] of this.cache) {
740
+ if (index < centerIndex - this.preload || index > centerIndex + this.preload) {
741
+ releaseVideoNode(entry.node);
742
+ this.cache.delete(index);
743
+ }
744
+ }
745
+ const targets = this.slots.map((slot) => {
746
+ const index = centerIndex + slot.offset;
747
+ const item = index >= 0 && index < items.length ? items[index] : void 0;
748
+ return { slot, index, item };
749
+ });
750
+ for (const { slot, index, item } of targets) {
751
+ if (!item || slot.assignedIndex === index) continue;
752
+ const cached = this.cache.get(index);
753
+ if (!cached) continue;
754
+ if (cached.node.classList.contains("shoji-slide-provider-video")) continue;
755
+ slot.assignedIndex = index;
756
+ this.moveIn(slot, cached, index);
757
+ onLoad(index);
758
+ }
759
+ for (const { slot, index, item } of targets) {
760
+ if (!item) {
761
+ slot.assignedIndex = null;
762
+ slot.ready = false;
763
+ releaseVideo(slot.media);
764
+ slot.media.replaceChildren();
765
+ continue;
766
+ }
767
+ if (slot.assignedIndex === index) continue;
768
+ slot.assignedIndex = index;
769
+ slot.ready = false;
770
+ releaseVideo(slot.media);
771
+ slot.media.replaceChildren(createSpinner());
772
+ if (index === centerIndex && openPlaceholderSrc) {
773
+ this.revealOpenPlaceholder(openPlaceholderSrc, slot, index);
774
+ }
775
+ if (((_a = item.video) == null ? void 0 : _a.provider) === "html5") {
776
+ this.renderVideo(item, slot, index, onLoad);
777
+ } else if (item.video) {
778
+ this.renderProviderVideo(item, slot, index, onLoad);
779
+ } else {
780
+ this.ensureImageDecoding(item, index, onLoad);
781
+ }
782
+ }
783
+ }
784
+ applyAspect(slot, item) {
785
+ if (item.width && item.height) {
786
+ slot.media.style.aspectRatio = `${item.width} / ${item.height}`;
787
+ } else {
788
+ slot.media.style.removeProperty("aspect-ratio");
789
+ }
790
+ }
791
+ /** Releases the slot's old content and swaps the new node in, caching it. `extra` rides as a second child. `ensureImageDecoding` goes through `moveIn` instead. */
792
+ swapIn(slot, node, item, index, extra) {
793
+ releaseVideo(slot.media);
794
+ this.applyAspect(slot, item);
795
+ if (extra) slot.media.replaceChildren(node, extra);
796
+ else slot.media.replaceChildren(node);
797
+ slot.ready = true;
798
+ this.cache.set(index, { node, item, extra });
799
+ }
800
+ /**
801
+ * Moves an already-cached, ready node into `slot` — skips `releaseVideo`,
802
+ * unlike `swapIn`: what `slot` holds might be a live cache entry another
803
+ * slot reclaims this same `render()` pass (Phase 1). Reparenting is
804
+ * always safe; only the later fresh/clear pass releases stale content.
805
+ */
806
+ moveIn(slot, entry, index) {
807
+ this.applyAspect(slot, entry.item);
808
+ if (entry.extra) slot.media.replaceChildren(entry.node, entry.extra);
809
+ else slot.media.replaceChildren(entry.node);
810
+ slot.ready = true;
811
+ this.cache.set(index, entry);
812
+ }
813
+ /** Starts decoding `item` for `index`, only if nothing already is (see `pending`). Resolves by looking up whichever slot currently wants this index, not the one active when the decode started. */
814
+ ensureImageDecoding(item, index, onLoad) {
815
+ if (this.pending.has(index)) return;
816
+ const img = document.createElement("img");
817
+ img.className = "shoji-slide-img";
818
+ img.alt = item.alt ?? "";
819
+ img.draggable = false;
820
+ if ("fetchPriority" in img) img.fetchPriority = "high";
821
+ if (item.srcset) img.srcset = item.srcset;
822
+ if (item.sizes) img.sizes = item.sizes;
823
+ img.src = item.src;
824
+ this.pending.set(index, img);
825
+ const reveal = () => {
826
+ this.pending.delete(index);
827
+ const entry = { node: img, item };
828
+ this.cache.set(index, entry);
829
+ const slot = this.slots.find((s) => s.assignedIndex === index);
830
+ if (!slot) return;
831
+ this.moveIn(slot, entry, index);
832
+ onLoad(index);
833
+ };
834
+ if (typeof img.decode === "function") {
835
+ img.decode().then(reveal, reveal);
836
+ } else {
837
+ img.addEventListener("load", reveal, { once: true });
838
+ img.addEventListener("error", reveal, { once: true });
839
+ }
840
+ }
841
+ /** DESIGN.md §2.3 — swaps the spinner for the placeholder once *it* decodes, not immediately: `item.thumb` is often just `item.src` again, so an undecoded placeholder can leave as long a blank gap as the spinner it replaces. */
842
+ revealOpenPlaceholder(src, slot, index) {
843
+ const img = createOpenPlaceholder(src);
844
+ const reveal = () => {
845
+ var _a;
846
+ if (slot.assignedIndex !== index || slot.ready) return;
847
+ (_a = slot.media.querySelector(".shoji-slide-spinner")) == null ? void 0 : _a.remove();
848
+ slot.media.appendChild(img);
849
+ };
850
+ if (typeof img.decode === "function") {
851
+ img.decode().then(reveal, reveal);
852
+ } else {
853
+ img.addEventListener("load", reveal, { once: true });
854
+ img.addEventListener("error", reveal, { once: true });
855
+ }
856
+ }
857
+ /** Native `<video controls>` — real playback, not just a poster. Not autoplayed/muted: a deliberate click-to-play, not a background loop. */
858
+ renderVideo(item, slot, index, onLoad) {
859
+ const hasSource = Boolean(item.src) || Boolean(item.sources && item.sources.length > 0);
860
+ if (!hasSource) {
861
+ const placeholder = document.createElement("div");
862
+ placeholder.className = "shoji-slide-placeholder";
863
+ placeholder.textContent = "Video";
864
+ this.swapIn(slot, placeholder, item, index);
865
+ onLoad(index);
866
+ return;
867
+ }
868
+ const video = document.createElement("video");
869
+ video.className = "shoji-slide-video";
870
+ video.controls = true;
871
+ video.playsInline = true;
872
+ if (item.poster) video.poster = item.poster;
873
+ if (item.sources && item.sources.length > 0) {
874
+ for (const s of item.sources) {
875
+ const source = document.createElement("source");
876
+ source.src = s.src;
877
+ source.type = s.type;
878
+ video.appendChild(source);
879
+ }
880
+ } else {
881
+ video.src = item.src;
882
+ }
883
+ this.swapIn(slot, video, item, index, this.createVideoPlayOverlay(video));
884
+ const reveal = () => onLoad(index);
885
+ video.addEventListener("loadedmetadata", reveal, { once: true });
886
+ video.addEventListener("error", reveal, { once: true });
887
+ }
888
+ /** Tracks the video's own play/pause/ended state. Hide `.shoji-video-play-overlay` in CSS to opt out. */
889
+ createVideoPlayOverlay(video) {
890
+ const overlay = document.createElement("button");
891
+ overlay.type = "button";
892
+ overlay.className = "shoji-video-play-overlay";
893
+ overlay.innerHTML = PLAY_ICON$1;
894
+ overlay.ariaLabel = overlay.title = this.playVideoLabel;
895
+ overlay.hidden = !video.paused;
896
+ let pauseTimer = null;
897
+ const clearPauseTimer = () => {
898
+ if (pauseTimer === null) return;
899
+ clearTimeout(pauseTimer);
900
+ pauseTimer = null;
901
+ };
902
+ overlay.addEventListener("click", (event) => {
903
+ event.stopPropagation();
904
+ video.play().catch(() => {
905
+ });
906
+ });
907
+ video.addEventListener("play", () => {
908
+ clearPauseTimer();
909
+ overlay.hidden = true;
910
+ });
911
+ video.addEventListener("pause", () => {
912
+ clearPauseTimer();
913
+ pauseTimer = setTimeout(() => {
914
+ pauseTimer = null;
915
+ if (video.paused) overlay.hidden = false;
916
+ }, PAUSE_OVERLAY_DELAY_MS);
917
+ });
918
+ video.addEventListener("ended", () => {
919
+ clearPauseTimer();
920
+ overlay.hidden = false;
921
+ });
922
+ return overlay;
923
+ }
924
+ /** DESIGN.md §4-video. Unregistered provider → same placeholder as no-source video. */
925
+ renderProviderVideo(item, slot, index, onLoad) {
926
+ const video = item.video;
927
+ const renderFn = video.provider === "custom" ? video.render : this.videoProviders.get(video.provider);
928
+ if (!renderFn) {
929
+ const placeholder = document.createElement("div");
930
+ placeholder.className = "shoji-slide-placeholder";
931
+ placeholder.textContent = "Video";
932
+ this.swapIn(slot, placeholder, item, index);
933
+ onLoad(index);
934
+ return;
935
+ }
936
+ const container = document.createElement("div");
937
+ container.className = "shoji-slide-provider-video";
938
+ container.hidden = true;
939
+ const controller = new AbortController();
940
+ providerAbortControllers.set(container, controller);
941
+ slot.media.appendChild(container);
942
+ let revealed = false;
943
+ const onReady = () => {
944
+ var _a;
945
+ if (revealed || controller.signal.aborted) return;
946
+ revealed = true;
947
+ container.hidden = false;
948
+ (_a = slot.media.querySelector(".shoji-slide-spinner, .shoji-slide-open-placeholder")) == null ? void 0 : _a.remove();
949
+ this.applyAspect(slot, item);
950
+ slot.ready = true;
951
+ this.cache.set(index, { node: container, item });
952
+ onLoad(index);
953
+ };
954
+ renderFn(container, item, onReady, controller.signal);
955
+ }
956
+ destroy() {
957
+ for (const slot of this.slots) {
958
+ releaseVideo(slot.media);
959
+ slot.media.replaceChildren();
960
+ slot.assignedIndex = null;
961
+ slot.ready = false;
962
+ }
963
+ this.cache.clear();
964
+ this.pending.clear();
965
+ this.element.remove();
966
+ }
967
+ }
968
+ function createOpenPlaceholder(src) {
969
+ const img = document.createElement("img");
970
+ img.className = "shoji-slide-img shoji-slide-open-placeholder";
971
+ img.alt = "";
972
+ img.draggable = false;
973
+ img.src = src;
974
+ return img;
975
+ }
976
+ function createSpinner() {
977
+ const spinner = document.createElement("div");
978
+ spinner.className = "shoji-slide-spinner";
979
+ spinner.setAttribute("aria-hidden", "true");
980
+ return spinner;
981
+ }
982
+ const providerAbortControllers = /* @__PURE__ */ new WeakMap();
983
+ function releaseVideo(media) {
984
+ const video = media.querySelector("video");
985
+ if (video) releaseVideoNode(video);
986
+ const provider = media.querySelector(".shoji-slide-provider-video");
987
+ if (provider) releaseProviderNode(provider);
988
+ }
989
+ function releaseVideoNode(node) {
990
+ if (node.tagName !== "VIDEO") {
991
+ releaseProviderNode(node);
992
+ return;
993
+ }
994
+ const video = node;
995
+ video.pause();
996
+ video.removeAttribute("src");
997
+ video.load();
998
+ }
999
+ function releaseProviderNode(node) {
1000
+ const controller = providerAbortControllers.get(node);
1001
+ if (!controller) return;
1002
+ providerAbortControllers.delete(node);
1003
+ controller.abort();
1004
+ }
1005
+ function resolveAxis(config, direction) {
1006
+ const sign = config.directional ? direction : 1;
1007
+ const parts = [];
1008
+ if (config.perspective) parts.push(`perspective(${config.perspective}px)`);
1009
+ const tx = config.translateX !== void 0 ? config.translateX * sign : 0;
1010
+ const ty = config.translateY ?? 0;
1011
+ if (tx !== 0 || ty !== 0) parts.push(`translate(${tx}%, ${ty}%)`);
1012
+ if (config.rotateY !== void 0) parts.push(`rotateY(${config.rotateY * sign}deg)`);
1013
+ if (config.rotateX !== void 0) parts.push(`rotateX(${config.rotateX}deg)`);
1014
+ if (config.rotateZ !== void 0) parts.push(`rotateZ(${config.rotateZ * sign}deg)`);
1015
+ if (config.scale !== void 0) parts.push(`scale(${config.scale})`);
1016
+ return { transform: parts.length > 0 ? parts.join(" ") : "none", opacity: config.opacity ?? 1 };
1017
+ }
1018
+ function preset(spec) {
1019
+ return {
1020
+ name: spec.name,
1021
+ enter: (direction) => resolveAxis(spec.enter, direction),
1022
+ leave: (direction) => resolveAxis(spec.leave, direction)
1023
+ };
1024
+ }
1025
+ const PRESET_LIST = [
1026
+ // The four DESIGN.md calls out by name: default mode, plus fade/zoom/deck.
1027
+ preset({
1028
+ name: "slide",
1029
+ enter: { directional: true, translateX: 100 },
1030
+ leave: { directional: true, translateX: -100 }
1031
+ }),
1032
+ preset({
1033
+ name: "fade",
1034
+ enter: { opacity: 0 },
1035
+ leave: { opacity: 0 }
1036
+ }),
1037
+ preset({
1038
+ name: "zoom",
1039
+ enter: { scale: 0.6, opacity: 0 },
1040
+ leave: { scale: 1.4, opacity: 0 }
1041
+ }),
1042
+ preset({
1043
+ // "lg-style deck": the incoming slide drops into place from slightly
1044
+ // above/small while the outgoing one recedes back and fades, like a
1045
+ // card being placed on top of a deck — not direction-dependent, a deck
1046
+ // doesn't care whether the card came from the left or right.
1047
+ name: "deck",
1048
+ enter: { translateY: 6, scale: 0.85, opacity: 0 },
1049
+ leave: { translateY: -4, scale: 0.92, opacity: 0 }
1050
+ }),
1051
+ // Positional siblings of `slide`.
1052
+ preset({
1053
+ name: "slideVertical",
1054
+ enter: { directional: true, translateY: 100 },
1055
+ leave: { directional: true, translateY: -100 }
1056
+ }),
1057
+ preset({
1058
+ name: "push",
1059
+ // Same geometry as `slide` but travels further (120% vs 100%) and with
1060
+ // no cross-fade at all — a harder, snappier "shove it off" feel.
1061
+ enter: { directional: true, translateX: 120 },
1062
+ leave: { directional: true, translateX: -120 }
1063
+ }),
1064
+ // Fixed-direction fades (not nav-direction-aware — a named entrance style).
1065
+ preset({
1066
+ name: "fadeUp",
1067
+ enter: { translateY: 12, opacity: 0 },
1068
+ leave: { translateY: -12, opacity: 0 }
1069
+ }),
1070
+ preset({
1071
+ name: "fadeDown",
1072
+ enter: { translateY: -12, opacity: 0 },
1073
+ leave: { translateY: 12, opacity: 0 }
1074
+ }),
1075
+ preset({
1076
+ name: "fadeLeft",
1077
+ enter: { translateX: -12, opacity: 0 },
1078
+ leave: { translateX: 12, opacity: 0 }
1079
+ }),
1080
+ preset({
1081
+ name: "fadeRight",
1082
+ enter: { translateX: 12, opacity: 0 },
1083
+ leave: { translateX: -12, opacity: 0 }
1084
+ }),
1085
+ // Zoom family.
1086
+ preset({ name: "zoomOut", enter: { scale: 1.4, opacity: 0 }, leave: { scale: 0.6, opacity: 0 } }),
1087
+ preset({ name: "scaleUp", enter: { scale: 0.7 }, leave: { scale: 1.3 } }),
1088
+ preset({ name: "scaleDown", enter: { scale: 1.3 }, leave: { scale: 0.7 } }),
1089
+ // Rotation family — `rotate` is fixed-direction; `rotateLeft`/`rotateRight`
1090
+ // tie their spin to nav direction instead.
1091
+ preset({
1092
+ name: "rotate",
1093
+ enter: { rotateZ: 15, opacity: 0 },
1094
+ leave: { rotateZ: -15, opacity: 0 }
1095
+ }),
1096
+ preset({
1097
+ name: "rotateLeft",
1098
+ enter: { directional: true, rotateZ: -25, opacity: 0 },
1099
+ leave: { directional: true, rotateZ: 25, opacity: 0 }
1100
+ }),
1101
+ preset({
1102
+ name: "rotateRight",
1103
+ enter: { directional: true, rotateZ: 25, opacity: 0 },
1104
+ leave: { directional: true, rotateZ: -25, opacity: 0 }
1105
+ }),
1106
+ // 3D family — needs `perspective()` in the same transform for real depth.
1107
+ preset({
1108
+ name: "flipX",
1109
+ enter: { rotateX: 90, perspective: 1200, opacity: 0 },
1110
+ leave: { rotateX: -90, perspective: 1200, opacity: 0 }
1111
+ }),
1112
+ preset({
1113
+ name: "flipY",
1114
+ enter: { directional: true, rotateY: 90, perspective: 1200, opacity: 0 },
1115
+ leave: { directional: true, rotateY: -90, perspective: 1200, opacity: 0 }
1116
+ }),
1117
+ preset({
1118
+ name: "cube",
1119
+ enter: { directional: true, rotateY: 70, translateX: 40, perspective: 1600, opacity: 1 },
1120
+ leave: { directional: true, rotateY: -70, translateX: -40, perspective: 1600, opacity: 1 }
1121
+ }),
1122
+ preset({
1123
+ name: "coverflow",
1124
+ enter: {
1125
+ directional: true,
1126
+ rotateY: 35,
1127
+ translateX: 60,
1128
+ scale: 0.8,
1129
+ perspective: 1400,
1130
+ opacity: 0
1131
+ },
1132
+ leave: {
1133
+ directional: true,
1134
+ rotateY: -35,
1135
+ translateX: -60,
1136
+ scale: 0.8,
1137
+ perspective: 1400,
1138
+ opacity: 0
1139
+ }
1140
+ }),
1141
+ // Fixed-direction drop.
1142
+ preset({
1143
+ name: "drop",
1144
+ enter: { translateY: -30, opacity: 0 },
1145
+ leave: { translateY: 20, opacity: 0 }
1146
+ })
1147
+ ];
1148
+ const TRANSITION_PRESETS = Object.fromEntries(
1149
+ PRESET_LIST.map((p) => [p.name, p])
1150
+ );
1151
+ function prefersReducedMotion$1() {
1152
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1153
+ }
1154
+ function parseCssTime(value) {
1155
+ var _a;
1156
+ const first = ((_a = value.split(",")[0]) == null ? void 0 : _a.trim()) ?? "";
1157
+ if (first.endsWith("ms")) return parseFloat(first);
1158
+ if (first.endsWith("s")) return parseFloat(first) * 1e3;
1159
+ return 0;
1160
+ }
1161
+ function waitForEnd(el, cb) {
1162
+ const style = getComputedStyle(el);
1163
+ const durationMs = Math.max(
1164
+ parseCssTime(style.transitionDuration),
1165
+ parseCssTime(style.animationDuration)
1166
+ );
1167
+ let done = false;
1168
+ const finish = () => {
1169
+ if (done) return;
1170
+ done = true;
1171
+ el.removeEventListener("transitionend", onEnd);
1172
+ el.removeEventListener("animationend", onEnd);
1173
+ cb();
1174
+ };
1175
+ const onEnd = (event) => {
1176
+ if (event.target === el) finish();
1177
+ };
1178
+ el.addEventListener("transitionend", onEnd);
1179
+ el.addEventListener("animationend", onEnd);
1180
+ setTimeout(finish, durationMs + 100);
1181
+ }
1182
+ function applyEnter(el, transform, opacity) {
1183
+ el.style.transition = "none";
1184
+ el.style.transformOrigin = "center";
1185
+ el.style.transform = transform;
1186
+ el.style.opacity = String(opacity);
1187
+ void el.offsetHeight;
1188
+ el.style.transition = "transform var(--shoji-duration) var(--shoji-easing), opacity var(--shoji-duration) var(--shoji-easing)";
1189
+ el.style.transform = "none";
1190
+ el.style.opacity = "1";
1191
+ }
1192
+ function applyLeave(el, transform, opacity) {
1193
+ el.style.transformOrigin = "center";
1194
+ el.style.transition = "transform var(--shoji-duration) var(--shoji-easing), opacity var(--shoji-duration) var(--shoji-easing)";
1195
+ void el.offsetHeight;
1196
+ el.style.transform = transform;
1197
+ el.style.opacity = String(opacity);
1198
+ }
1199
+ function clearInlineTransform(el) {
1200
+ el.style.transition = "";
1201
+ el.style.transform = "";
1202
+ el.style.opacity = "";
1203
+ el.style.transformOrigin = "";
1204
+ }
1205
+ class SlideTransition {
1206
+ constructor(slides) {
1207
+ this.slides = slides;
1208
+ }
1209
+ /** A recognized built-in preset (`TRANSITION_PRESETS`). */
1210
+ animate(preset2, direction, swapContent) {
1211
+ this.run(swapContent, (ghost, incoming) => {
1212
+ const leave = preset2.leave(direction);
1213
+ applyLeave(ghost, leave.transform, leave.opacity);
1214
+ const enter = preset2.enter(direction);
1215
+ applyEnter(incoming, enter.transform, enter.opacity);
1216
+ });
1217
+ }
1218
+ /** 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." */
1219
+ animateCustom(mode, direction, swapContent) {
1220
+ const dirAttr = direction === 1 ? "next" : "prev";
1221
+ this.run(
1222
+ swapContent,
1223
+ (ghost, incoming) => {
1224
+ ghost.dataset.shojiDirection = dirAttr;
1225
+ ghost.classList.add(`shoji-transition-${mode}-leave`);
1226
+ incoming.dataset.shojiDirection = dirAttr;
1227
+ incoming.classList.add(`shoji-transition-${mode}-enter`);
1228
+ },
1229
+ (incoming) => {
1230
+ incoming.classList.remove(`shoji-transition-${mode}-enter`);
1231
+ delete incoming.dataset.shojiDirection;
1232
+ }
1233
+ );
1234
+ }
1235
+ run(swapContent, kickOff, cleanupIncoming = clearInlineTransform) {
1236
+ if (prefersReducedMotion$1()) {
1237
+ swapContent();
1238
+ return;
1239
+ }
1240
+ const outgoing = this.slides.getActiveMedia();
1241
+ if (!outgoing) {
1242
+ swapContent();
1243
+ return;
1244
+ }
1245
+ const ghost = document.createElement("div");
1246
+ ghost.className = "shoji-slide-ghost";
1247
+ ghost.appendChild(outgoing.cloneNode(true));
1248
+ this.slides.element.appendChild(ghost);
1249
+ void ghost.offsetHeight;
1250
+ swapContent();
1251
+ const incoming = this.slides.getActiveMedia();
1252
+ if (!incoming) {
1253
+ ghost.remove();
1254
+ return;
1255
+ }
1256
+ kickOff(ghost, incoming);
1257
+ waitForEnd(ghost, () => ghost.remove());
1258
+ waitForEnd(incoming, () => cleanupIncoming(incoming));
1259
+ }
1260
+ }
1261
+ function itemKey(item) {
1262
+ return item.id ?? item.src;
1263
+ }
1264
+ function resolveElement(target) {
1265
+ if (typeof target !== "string") return target;
1266
+ const el = document.querySelector(target);
1267
+ if (!el) throw new Error(`Shoji: no element matches selector "${target}"`);
1268
+ return el;
1269
+ }
1270
+ const instanceRegistry = /* @__PURE__ */ new WeakMap();
1271
+ const allInstances = /* @__PURE__ */ new Set();
1272
+ const DEFAULT_LOCALE = {
1273
+ close: "Close",
1274
+ previous: "Previous image",
1275
+ next: "Next image",
1276
+ playVideo: "Play video",
1277
+ showCaption: "Show caption",
1278
+ hideCaption: "Hide caption"
1279
+ };
1280
+ function isBackdropClick(event) {
1281
+ return !event.composedPath().some(
1282
+ (node) => node instanceof Element && node.matches(".shoji-slide-img, button, video, .shoji-counter, .shoji-caption")
1283
+ );
1284
+ }
1285
+ function isDangerousHtmlCaption(value) {
1286
+ return typeof value === "object" && value !== null && typeof value.dangerouslySetInnerHTML === "string";
1287
+ }
1288
+ class Gallery {
1289
+ constructor(target, options = {}) {
1290
+ /** Not `readonly` — `reinit()` (§2.7) reassigns this + every option-derived field below; initializers here just satisfy strict-init. */
1291
+ __publicField(this, "options", {});
1292
+ __publicField(this, "element");
1293
+ __publicField(this, "bus", new EventBus());
1294
+ __publicField(this, "selector", DEFAULT_SELECTOR);
1295
+ __publicField(this, "isDynamicMode", false);
1296
+ __publicField(this, "preload", 1);
1297
+ __publicField(this, "locale", DEFAULT_LOCALE);
1298
+ __publicField(this, "showCounter", true);
1299
+ __publicField(this, "captionVisibleOnVideo", false);
1300
+ // DESIGN.md §2.3a
1301
+ __publicField(this, "loop", true);
1302
+ __publicField(this, "closable", true);
1303
+ __publicField(this, "autoHideDelay", 5e3);
1304
+ __publicField(this, "focusTrap", new FocusTrap());
1305
+ __publicField(this, "liveRegion", new LiveRegion());
1306
+ __publicField(this, "autoHideTimer", null);
1307
+ __publicField(this, "autoHidden", false);
1308
+ __publicField(this, "hoveredControlCount", 0);
1309
+ __publicField(this, "isClosing", false);
1310
+ __publicField(this, "itemList", []);
1311
+ __publicField(this, "scannedElements", []);
1312
+ __publicField(this, "activeIndex", 0);
1313
+ __publicField(this, "opened", false);
1314
+ __publicField(this, "destroyed", false);
1315
+ __publicField(this, "slides", null);
1316
+ __publicField(this, "dom", null);
1317
+ __publicField(this, "gesture", null);
1318
+ __publicField(this, "transition", null);
1319
+ __publicField(this, "shortcuts", /* @__PURE__ */ new Map());
1320
+ __publicField(this, "pluginStorage", /* @__PURE__ */ new Map());
1321
+ /** Backs `getActivePlugins()`. */
1322
+ __publicField(this, "activePluginNames", /* @__PURE__ */ new Set());
1323
+ __publicField(this, "videoProviders", /* @__PURE__ */ new Map());
1324
+ __publicField(this, "zoomGate", null);
1325
+ __publicField(this, "pluginCleanups", []);
1326
+ __publicField(this, "onContainerClick", (event) => {
1327
+ let node = event.target;
1328
+ while (node && node !== this.element) {
1329
+ if (node instanceof HTMLElement) {
1330
+ const index = this.scannedElements.indexOf(node);
1331
+ if (index !== -1) {
1332
+ event.preventDefault();
1333
+ this.open(index);
1334
+ return;
1335
+ }
1336
+ }
1337
+ node = node.parentNode;
1338
+ }
1339
+ });
1340
+ __publicField(this, "onOuterClick", (event) => {
1341
+ if (this.closable && isBackdropClick(event)) this.close();
1342
+ });
1343
+ /** DESIGN.md §2.8 — any interaction re-shows controls, restarts the idle clock. `autoHideDelay: 0` = "never show controls" — a no-op here. */
1344
+ __publicField(this, "onActivity", () => {
1345
+ if (this.autoHideDelay === 0) return;
1346
+ this.showControls();
1347
+ this.scheduleAutoHide();
1348
+ });
1349
+ __publicField(this, "onKeydown", (event) => {
1350
+ if (document.activeElement instanceof HTMLVideoElement && event.key !== "Escape") return;
1351
+ this.onActivity();
1352
+ switch (event.key) {
1353
+ case "Escape":
1354
+ if (this.closable) this.close();
1355
+ break;
1356
+ case "ArrowLeft":
1357
+ case "a":
1358
+ case "A":
1359
+ this.prev();
1360
+ break;
1361
+ case "ArrowRight":
1362
+ case "d":
1363
+ case "D":
1364
+ this.next();
1365
+ break;
1366
+ case "Home":
1367
+ this.goTo(0);
1368
+ break;
1369
+ case "End":
1370
+ this.goTo(this.itemList.length - 1);
1371
+ break;
1372
+ default: {
1373
+ const shortcut = this.shortcuts.get(event.key);
1374
+ if (!shortcut) return;
1375
+ shortcut(event);
1376
+ }
1377
+ }
1378
+ event.preventDefault();
1379
+ });
1380
+ this.element = resolveElement(target);
1381
+ instanceRegistry.set(this.element, this);
1382
+ allInstances.add(this);
1383
+ this.applyOptions(options);
1384
+ }
1385
+ /** DESIGN.md §2.7 — the live instance mounted on `el`, or `undefined` if none exists there (or it's since been destroyed). */
1386
+ static getInstance(el) {
1387
+ return instanceRegistry.get(el);
1388
+ }
1389
+ /** DESIGN.md §2.7 — every live instance, e.g. for page-wide teardown or devtools inspection. */
1390
+ static instances() {
1391
+ return allInstances.values();
1392
+ }
1393
+ /** Everything the constructor does after `this.element` is resolved — shared with `reinit()` (§2.7). */
1394
+ applyOptions(options) {
1395
+ this.options = options;
1396
+ this.selector = options.selector ?? DEFAULT_SELECTOR;
1397
+ this.isDynamicMode = options.items !== void 0;
1398
+ this.preload = options.preload ?? 1;
1399
+ this.locale = { ...DEFAULT_LOCALE, ...options.locale };
1400
+ this.showCounter = options.counter ?? true;
1401
+ this.loop = options.loop ?? true;
1402
+ this.closable = options.closable ?? true;
1403
+ this.autoHideDelay = options.autoHideDelay ?? 5e3;
1404
+ this.activeIndex = 0;
1405
+ this.scannedElements = [];
1406
+ if (this.isDynamicMode) {
1407
+ this.itemList = options.items ?? [];
1408
+ } else {
1409
+ const scanned = scanContainer(this.element, this.selector);
1410
+ this.itemList = scanned.map((s) => s.item);
1411
+ this.scannedElements = scanned.map((s) => s.element);
1412
+ this.element.addEventListener("click", this.onContainerClick);
1413
+ }
1414
+ this.ensureLightbox();
1415
+ this.dom.closeButton.hidden = !this.closable;
1416
+ if (options.openOnInit && this.itemList.length > 0) {
1417
+ this.open(options.index ?? 0);
1418
+ }
1419
+ }
1420
+ get items() {
1421
+ return this.itemList;
1422
+ }
1423
+ get currentIndex() {
1424
+ return this.activeIndex;
1425
+ }
1426
+ /** True from the first `open()` until `close()`/`destroy()`. */
1427
+ get isOpen() {
1428
+ return this.opened;
1429
+ }
1430
+ /** True once `destroy()` has run. */
1431
+ get isDestroyed() {
1432
+ return this.destroyed;
1433
+ }
1434
+ /** Whether auto-hide (§2.8) currently has controls faded. */
1435
+ get controlsHidden() {
1436
+ return this.autoHidden;
1437
+ }
1438
+ /** Names of plugins that actually initialized. */
1439
+ getActivePlugins() {
1440
+ return [...this.activePluginNames];
1441
+ }
1442
+ /** The active slide's `.shoji-slide-media` container. Empty before the first `open()`; `null` only after `destroy()`. */
1443
+ getActiveMedia() {
1444
+ var _a;
1445
+ return ((_a = this.slides) == null ? void 0 : _a.getActiveMedia()) ?? null;
1446
+ }
1447
+ /** DESIGN.md §4-zoom — suspends drag-to-navigate/close while zoomed. Single slot, not a multi-subscriber event. */
1448
+ registerZoomGate(isZoomed) {
1449
+ this.zoomGate = isZoomed;
1450
+ return () => {
1451
+ if (this.zoomGate === isZoomed) this.zoomGate = null;
1452
+ };
1453
+ }
1454
+ on(event, fn) {
1455
+ return this.bus.on(event, fn);
1456
+ }
1457
+ clampToRange(index) {
1458
+ return Math.min(Math.max(index, 0), Math.max(this.itemList.length - 1, 0));
1459
+ }
1460
+ ensureLightbox() {
1461
+ if (this.dom) return;
1462
+ this.slides = new SlideManager({
1463
+ preload: this.preload,
1464
+ playVideoLabel: this.locale.playVideo,
1465
+ videoProviders: this.videoProviders
1466
+ });
1467
+ this.transition = new SlideTransition(this.slides);
1468
+ const dom = buildLightboxDom(this.slides.element, this.locale);
1469
+ this.dom = dom;
1470
+ dom.outer.appendChild(this.liveRegion.element);
1471
+ dom.closeButton.addEventListener("click", () => this.close());
1472
+ dom.prevButton.addEventListener("click", () => this.prev());
1473
+ dom.nextButton.addEventListener("click", () => this.next());
1474
+ dom.captionToggleButton.addEventListener("click", () => {
1475
+ this.captionVisibleOnVideo = !this.captionVisibleOnVideo;
1476
+ this.updateCaptionVisibility();
1477
+ });
1478
+ dom.outer.addEventListener("click", this.onOuterClick);
1479
+ dom.outer.addEventListener("pointermove", this.onActivity, { passive: true });
1480
+ dom.outer.addEventListener("pointerdown", this.onActivity, { passive: true });
1481
+ dom.outer.addEventListener("touchstart", this.onActivity, { passive: true });
1482
+ dom.outer.addEventListener("wheel", this.onActivity, { passive: true });
1483
+ dom.outer.addEventListener("focusin", this.onActivity);
1484
+ dom.outer.addEventListener("focusout", () => this.scheduleAutoHide());
1485
+ for (const el of [
1486
+ dom.closeButton,
1487
+ dom.prevButton,
1488
+ dom.nextButton,
1489
+ dom.captionToggleButton,
1490
+ dom.caption,
1491
+ dom.counter,
1492
+ dom.toolbarLeft,
1493
+ dom.toolbarCenter,
1494
+ dom.toolbarRight
1495
+ ]) {
1496
+ this.wireControlHover(el);
1497
+ }
1498
+ document.body.appendChild(dom.outer);
1499
+ this.gesture = new GestureController(
1500
+ {
1501
+ dialog: dom.dialog,
1502
+ slides: this.slides,
1503
+ canGoNext: () => this.loop || this.activeIndex < this.itemList.length - 1,
1504
+ canGoPrev: () => this.loop || this.activeIndex > 0,
1505
+ // animate:false — a completed swipe already played its own
1506
+ // live-drag/settle animation (§2.4); running the §2.5 `mode`
1507
+ // transition on top would double-animate. See navigate()'s doc.
1508
+ next: () => this.navigate(this.nextIndex(), 1, false),
1509
+ prev: () => this.navigate(this.prevIndex(), -1, false),
1510
+ close: () => this.close(),
1511
+ canClose: () => this.closable,
1512
+ onActivity: () => this.onActivity(),
1513
+ isZoomed: () => {
1514
+ var _a;
1515
+ return ((_a = this.zoomGate) == null ? void 0 : _a.call(this)) ?? false;
1516
+ }
1517
+ },
1518
+ {
1519
+ onTap: (x, y) => this.bus.emit("tap", { x, y }),
1520
+ onDoubleTap: (x, y) => this.bus.emit("doubleTap", { x, y }),
1521
+ onPinchStart: (centerX, centerY) => this.bus.emit("pinchStart", { centerX, centerY }),
1522
+ onPinchMove: (scale, centerX, centerY) => this.bus.emit("pinchMove", { scale, centerX, centerY }),
1523
+ onPinchEnd: () => this.bus.emit("pinchEnd", {}),
1524
+ onWheelZoom: (deltaScale, x, y) => this.bus.emit("wheelZoom", { deltaScale, x, y })
1525
+ },
1526
+ this.options.gestures
1527
+ );
1528
+ this.initPlugins();
1529
+ }
1530
+ /** DESIGN.md §3 — plugins init here, not the constructor. `requires` checks names loaded earlier; an unmet one is skipped (logged), not thrown. Cleared up front so a `reinit()` doesn't inherit names from before. */
1531
+ initPlugins() {
1532
+ this.activePluginNames.clear();
1533
+ this.videoProviders.clear();
1534
+ for (const plugin of this.options.plugins ?? []) {
1535
+ if (!plugin || typeof plugin.init !== "function") {
1536
+ console.error(
1537
+ "Shoji: skipping an invalid plugins[] entry (not a ShojiPlugin object):",
1538
+ plugin
1539
+ );
1540
+ continue;
1541
+ }
1542
+ const missing = (plugin.requires ?? []).filter((name) => !this.activePluginNames.has(name));
1543
+ if (missing.length > 0) {
1544
+ console.error(
1545
+ `Shoji: plugin "${plugin.name}" requires [${missing.join(", ")}] to be registered first — skipping.`
1546
+ );
1547
+ continue;
1548
+ }
1549
+ const pluginOptions = {
1550
+ ...plugin.defaults,
1551
+ ...this.options[plugin.name]
1552
+ };
1553
+ const ctx = {
1554
+ gallery: this,
1555
+ options: pluginOptions,
1556
+ on: this.on.bind(this),
1557
+ emit: (event, detail) => this.bus.emit(event, detail),
1558
+ ui: {
1559
+ toolbar: (slot, el) => this.pluginToolbar(slot, el),
1560
+ overlay: (el, layer) => this.pluginOverlay(el, layer),
1561
+ outer: () => this.dom.outer,
1562
+ registerShortcut: (key, fn) => {
1563
+ this.shortcuts.set(key, fn);
1564
+ return () => this.shortcuts.delete(key);
1565
+ },
1566
+ registerVideoProvider: (name, render) => {
1567
+ this.videoProviders.set(name, render);
1568
+ return () => {
1569
+ if (this.videoProviders.get(name) === render) this.videoProviders.delete(name);
1570
+ };
1571
+ }
1572
+ },
1573
+ storage: {
1574
+ get: (key) => this.pluginStorage.get(key),
1575
+ set: (key, value) => {
1576
+ this.pluginStorage.set(key, value);
1577
+ }
1578
+ }
1579
+ };
1580
+ const cleanup = plugin.init(ctx);
1581
+ if (typeof cleanup === "function") this.pluginCleanups.push(cleanup);
1582
+ this.activePluginNames.add(plugin.name);
1583
+ }
1584
+ }
1585
+ /**
1586
+ * DESIGN.md §3.1 — 'right' inserts immediately before the close button
1587
+ * (never after), so close stays fixed rightmost and plugins cluster to
1588
+ * its left in registration order: `plugins: [A, B, C]` reads A, B, C,
1589
+ * close. 'left'/'center' are independent zones for a plugin that doesn't
1590
+ * want to sit next to close.
1591
+ */
1592
+ pluginToolbar(slot, el) {
1593
+ const dom = this.dom;
1594
+ const isRawElement = el instanceof HTMLElement;
1595
+ const node = isRawElement ? el : this.buildToolbarButton(el);
1596
+ const unhover = isRawElement ? this.wireControlHover(node) : null;
1597
+ if (slot === "right") {
1598
+ dom.toolbarRight.insertBefore(node, dom.closeButton);
1599
+ } else {
1600
+ (slot === "left" ? dom.toolbarLeft : dom.toolbarCenter).appendChild(node);
1601
+ }
1602
+ return () => {
1603
+ unhover == null ? void 0 : unhover();
1604
+ node.remove();
1605
+ };
1606
+ }
1607
+ buildToolbarButton(spec) {
1608
+ const button = document.createElement("button");
1609
+ button.type = "button";
1610
+ button.className = "shoji-toolbar-button";
1611
+ if (spec.icon) {
1612
+ button.ariaLabel = button.title = spec.label;
1613
+ button.innerHTML = spec.icon;
1614
+ } else {
1615
+ button.textContent = spec.label;
1616
+ }
1617
+ button.addEventListener("click", spec.onClick);
1618
+ this.wireControlHover(button);
1619
+ return button;
1620
+ }
1621
+ pluginOverlay(el, layer) {
1622
+ if (layer !== void 0) el.style.zIndex = String(layer);
1623
+ this.dom.dialog.appendChild(el);
1624
+ const unhover = this.wireControlHover(el);
1625
+ return () => {
1626
+ unhover();
1627
+ el.remove();
1628
+ };
1629
+ }
1630
+ /** DESIGN.md §2.8/§3 — pauses auto-hide while genuinely hovered: controls, caption, and any plugin overlay (`ctx.ui.overlay()`). Unsubscribe also corrects the count if removed mid-hover — a real risk for overlay content a plugin can toggle while the gallery stays open, unlike static buttons. */
1631
+ wireControlHover(el) {
1632
+ let hovering = false;
1633
+ const onEnter = () => {
1634
+ hovering = true;
1635
+ this.hoveredControlCount++;
1636
+ this.onActivity();
1637
+ };
1638
+ const onLeave = () => {
1639
+ hovering = false;
1640
+ this.hoveredControlCount--;
1641
+ this.scheduleAutoHide();
1642
+ };
1643
+ el.addEventListener("pointerenter", onEnter);
1644
+ el.addEventListener("pointerleave", onLeave);
1645
+ return () => {
1646
+ el.removeEventListener("pointerenter", onEnter);
1647
+ el.removeEventListener("pointerleave", onLeave);
1648
+ if (hovering) {
1649
+ hovering = false;
1650
+ this.hoveredControlCount--;
1651
+ this.scheduleAutoHide();
1652
+ }
1653
+ };
1654
+ }
1655
+ /**
1656
+ * DESIGN.md §2.8 — paused only by a real *hover*. Used to also treat a
1657
+ * *focused* control as active via `document.activeElement`, but a click
1658
+ * leaves a `<button>` focused indefinitely, permanently blocking
1659
+ * `hideControls()`. Focus still counts as activity (`focusin` →
1660
+ * `onActivity()`), it just no longer blocks the eventual hide.
1661
+ */
1662
+ isControlActive() {
1663
+ return this.hoveredControlCount > 0;
1664
+ }
1665
+ /**
1666
+ * The thumbnail for `index` — what the zoom transition animates to/from,
1667
+ * and what `activeThumbnail` marks/scrolls-to. `data-shoji-id` is an
1668
+ * explicit opt-in that works in any mode (needed for dynamic mode, which
1669
+ * has no scanned DOM); checked first so it can override selector mode's
1670
+ * default `scannedElements[index]` too, e.g. to target an inner element.
1671
+ */
1672
+ getOriginElement(index) {
1673
+ const item = this.itemList[index];
1674
+ if ((item == null ? void 0 : item.id) && typeof CSS !== "undefined" && typeof CSS.escape === "function") {
1675
+ const marked = document.querySelector(
1676
+ `[data-shoji-id="${CSS.escape(item.id)}"]`
1677
+ );
1678
+ if (marked) return marked;
1679
+ }
1680
+ return this.scannedElements[index] ?? null;
1681
+ }
1682
+ scheduleAutoHide() {
1683
+ if (this.autoHideTimer !== null) {
1684
+ clearTimeout(this.autoHideTimer);
1685
+ this.autoHideTimer = null;
1686
+ }
1687
+ if (!this.opened) return;
1688
+ if (this.autoHideDelay === 0) {
1689
+ this.hideControls();
1690
+ return;
1691
+ }
1692
+ this.autoHideTimer = setTimeout(() => this.hideControls(), this.autoHideDelay);
1693
+ }
1694
+ hideControls() {
1695
+ if (!this.dom || this.autoHidden || this.isControlActive()) return;
1696
+ this.autoHidden = true;
1697
+ this.dom.dialog.classList.add("shoji-controls-hidden");
1698
+ this.bus.emit("controls:hide", {});
1699
+ }
1700
+ showControls() {
1701
+ if (!this.dom || !this.autoHidden) return;
1702
+ this.autoHidden = false;
1703
+ this.dom.dialog.classList.remove("shoji-controls-hidden");
1704
+ this.bus.emit("controls:show", {});
1705
+ }
1706
+ /**
1707
+ * DESIGN.md §2.1 — `caption` is `string | HTMLElement | DangerousHtmlCaption`:
1708
+ * a plain string renders as text (`textContent`, auto-escaped, safe by
1709
+ * default); an `HTMLElement` is appended as real DOM (the host built it);
1710
+ * `{ dangerouslySetInnerHTML }` renders raw, unescaped HTML via `innerHTML`
1711
+ * — Shoji does not sanitize it, the host must, same contract as React's
1712
+ * identically-named escape hatch. Returns whether the caption element
1713
+ * should be hidden.
1714
+ */
1715
+ renderCaption(el, caption) {
1716
+ if (caption instanceof HTMLElement) {
1717
+ el.replaceChildren(caption);
1718
+ return false;
1719
+ }
1720
+ if (isDangerousHtmlCaption(caption)) {
1721
+ el.innerHTML = caption.dangerouslySetInnerHTML;
1722
+ return caption.dangerouslySetInnerHTML === "";
1723
+ }
1724
+ el.textContent = caption ?? "";
1725
+ return !caption;
1726
+ }
1727
+ /**
1728
+ * `.shoji-toolbar-button` only — never `.shoji-close`/`.shoji-nav`, which
1729
+ * are different classes entirely, so this can't accidentally block
1730
+ * closing or navigating away from a slow-loading slide. `tabIndex = -1`
1731
+ * (not just the CSS below) so a keyboard user tabbing through the
1732
+ * toolbar skips these entirely while disabled, not just visually dims
1733
+ * them — `pointer-events: none` alone wouldn't stop Enter/Space
1734
+ * activating an already-focused button.
1735
+ */
1736
+ setSlideLoading(loading) {
1737
+ if (!this.dom) return;
1738
+ this.dom.outer.classList.toggle("shoji-slide-loading", loading);
1739
+ for (const button of this.dom.outer.querySelectorAll(
1740
+ ".shoji-toolbar-button"
1741
+ )) {
1742
+ button.ariaDisabled = loading ? "true" : null;
1743
+ if (loading) button.tabIndex = -1;
1744
+ else button.removeAttribute("tabindex");
1745
+ }
1746
+ }
1747
+ /**
1748
+ * Content is always kept current (correct the instant loading finishes,
1749
+ * no text flash) — only `hidden` also gates on `isActiveReady()`, so a
1750
+ * caption for the *new* slide can't sit there readable while the
1751
+ * image/video it describes is still a spinner.
1752
+ */
1753
+ updateCaptionVisibility() {
1754
+ if (!this.dom || !this.slides) return;
1755
+ const item = this.itemList[this.activeIndex];
1756
+ const noCaption = this.renderCaption(this.dom.caption, item == null ? void 0 : item.caption);
1757
+ const isVideo = !!(item == null ? void 0 : item.video);
1758
+ this.dom.caption.classList.toggle("shoji-caption--video", isVideo);
1759
+ const hiddenByToggle = isVideo && !this.captionVisibleOnVideo;
1760
+ this.dom.caption.hidden = noCaption || !this.slides.isActiveReady() || hiddenByToggle;
1761
+ const btn = this.dom.captionToggleButton;
1762
+ btn.hidden = !isVideo || noCaption;
1763
+ if (!btn.hidden) {
1764
+ btn.setAttribute("aria-pressed", String(this.captionVisibleOnVideo));
1765
+ btn.ariaLabel = btn.title = this.captionVisibleOnVideo ? this.locale.hideCaption : this.locale.showCaption;
1766
+ }
1767
+ }
1768
+ renderCurrentSlide(openPlaceholderSrc) {
1769
+ if (!this.slides || !this.dom) return;
1770
+ const dom = this.dom;
1771
+ this.slides.render(
1772
+ this.itemList,
1773
+ this.activeIndex,
1774
+ (loadedIndex) => {
1775
+ if (loadedIndex === this.activeIndex) {
1776
+ this.bus.emit("slideItemLoad", { index: loadedIndex });
1777
+ this.setSlideLoading(false);
1778
+ this.updateCaptionVisibility();
1779
+ }
1780
+ },
1781
+ openPlaceholderSrc
1782
+ );
1783
+ this.setSlideLoading(!this.slides.isActiveReady());
1784
+ const item = this.itemList[this.activeIndex];
1785
+ const total = this.itemList.length;
1786
+ dom.counter.textContent = total > 0 ? `${this.activeIndex + 1} / ${total}` : "";
1787
+ dom.counter.hidden = !this.showCounter || total === 0;
1788
+ this.updateCaptionVisibility();
1789
+ const label = `Image ${this.activeIndex + 1} of ${total}${(item == null ? void 0 : item.alt) ? `: ${item.alt}` : ""}`;
1790
+ this.liveRegion.announce(label);
1791
+ dom.prevButton.hidden = total <= 1;
1792
+ dom.nextButton.hidden = total <= 1;
1793
+ dom.prevButton.disabled = !this.loop && this.activeIndex <= 0;
1794
+ dom.nextButton.disabled = !this.loop && this.activeIndex >= total - 1;
1795
+ }
1796
+ open(index = this.options.index ?? 0) {
1797
+ var _a;
1798
+ if (this.destroyed || this.opened) return;
1799
+ this.bus.emit("beforeOpen", { index });
1800
+ this.ensureLightbox();
1801
+ this.opened = true;
1802
+ this.activeIndex = index;
1803
+ this.captionVisibleOnVideo = !!this.options.showVideoCaption;
1804
+ lockBodyScroll();
1805
+ const origin = this.getOriginElement(index);
1806
+ this.renderCurrentSlide(this.resolveOpenPlaceholderSrc(this.itemList[index], origin));
1807
+ this.dom.outer.classList.add("shoji-open");
1808
+ document.addEventListener("keydown", this.onKeydown);
1809
+ this.focusTrap.activate(this.dom.dialog);
1810
+ this.scheduleAutoHide();
1811
+ this.applyMobileControlsSetting();
1812
+ const media = (_a = this.slides) == null ? void 0 : _a.getActiveMedia();
1813
+ if (media && origin) {
1814
+ zoomIn({ origin, target: media, aspectRatio: this.resolveAspectRatio(index, origin) });
1815
+ }
1816
+ this.bus.emit("open", { index });
1817
+ this.bus.emit("afterOpen", { index });
1818
+ }
1819
+ /** DESIGN.md §2.3 — low-res open() placeholder source, checked in order: item.thumb, a live data-shoji-thumb on origin, else origin's own rendered <img>. */
1820
+ resolveOpenPlaceholderSrc(item, origin) {
1821
+ if (item == null ? void 0 : item.thumb) return item.thumb;
1822
+ const dataThumb = origin == null ? void 0 : origin.getAttribute("data-shoji-thumb");
1823
+ if (dataThumb) return dataThumb;
1824
+ const img = origin == null ? void 0 : origin.querySelector("img");
1825
+ return (img == null ? void 0 : img.currentSrc) || (img == null ? void 0 : img.src) || void 0;
1826
+ }
1827
+ /**
1828
+ * DESIGN.md §2.2/§2.6 — navigates while open; always clamps an explicit
1829
+ * out-of-range index (this is a directed jump, not a step — `loop` only
1830
+ * affects `next()`/`prev()`, see below). No-op if not open. `animate`
1831
+ * (default `true`) runs the configured §2.5 transition; hosts that want
1832
+ * an instant jump — e.g. syncing to an external index without a visual
1833
+ * flourish — can pass `{ animate: false }`.
1834
+ */
1835
+ goTo(index, options) {
1836
+ if (this.destroyed || !this.opened || this.itemList.length === 0) return;
1837
+ const target = this.clampToRange(index);
1838
+ if (target === this.activeIndex) return;
1839
+ const direction = target > this.activeIndex ? 1 : -1;
1840
+ this.navigate(target, direction, (options == null ? void 0 : options.animate) ?? true);
1841
+ }
1842
+ next() {
1843
+ this.navigate(this.nextIndex(), 1, true);
1844
+ }
1845
+ prev() {
1846
+ this.navigate(this.prevIndex(), -1, true);
1847
+ }
1848
+ nextIndex() {
1849
+ const atEnd = this.activeIndex >= this.itemList.length - 1;
1850
+ return atEnd && this.loop ? 0 : this.activeIndex + 1;
1851
+ }
1852
+ prevIndex() {
1853
+ const atStart = this.activeIndex <= 0;
1854
+ return atStart && this.loop ? this.itemList.length - 1 : this.activeIndex - 1;
1855
+ }
1856
+ /**
1857
+ * DESIGN.md §2.5 — the shared path behind `goTo()`/`next()`/`prev()`.
1858
+ * `animate` is `false` only for `GestureController`'s own host callbacks
1859
+ * (see `ensureLightbox()`): a gesture-completed swipe already played its
1860
+ * own live-drag/settle animation, so running a *second*, `mode`-based
1861
+ * transition on top of it would visibly double-animate. Every other
1862
+ * caller — buttons, keyboard, autoplay, a plugin calling `goTo()` — gets
1863
+ * the real, configured transition.
1864
+ */
1865
+ navigate(target, direction, animate) {
1866
+ if (this.destroyed || !this.opened || this.itemList.length === 0) return;
1867
+ const clamped = this.clampToRange(target);
1868
+ if (clamped === this.activeIndex) return;
1869
+ const from = this.activeIndex;
1870
+ this.bus.emit("beforeSlide", { from, to: clamped });
1871
+ this.activeIndex = clamped;
1872
+ const swap = () => this.renderCurrentSlide();
1873
+ if (animate && this.transition) {
1874
+ const modeName = this.resolveTransitionMode();
1875
+ const builtin = TRANSITION_PRESETS[modeName];
1876
+ if (builtin) {
1877
+ this.transition.animate(builtin, direction, swap);
1878
+ } else {
1879
+ this.transition.animateCustom(modeName, direction, swap);
1880
+ }
1881
+ } else {
1882
+ swap();
1883
+ }
1884
+ this.bus.emit("afterSlide", { from, to: clamped });
1885
+ }
1886
+ /** DESIGN.md §2.5 — `mobileSettings.mode` overrides `mode` on a coarse-pointer device, evaluated fresh each navigation. */
1887
+ resolveTransitionMode() {
1888
+ var _a;
1889
+ if (this.isMobileQuery() && ((_a = this.options.mobileSettings) == null ? void 0 : _a.mode)) {
1890
+ return this.options.mobileSettings.mode;
1891
+ }
1892
+ return this.options.mode ?? "slide";
1893
+ }
1894
+ isMobileQuery() {
1895
+ return typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches;
1896
+ }
1897
+ /**
1898
+ * DESIGN.md §2.5 — `mobileSettings.controls: false` starts controls
1899
+ * hidden on a coarse-pointer device, reusing the existing §2.8 auto-hide
1900
+ * mechanism (`hideControls()`) rather than a separate permanent-hide
1901
+ * state: auto-hide is opacity-only, never removed from the tab order,
1902
+ * and already reveals on any activity — a genuinely *permanent* hide
1903
+ * would strand a touch user with no way to ever reach Close.
1904
+ */
1905
+ applyMobileControlsSetting() {
1906
+ var _a;
1907
+ if (this.isMobileQuery() && ((_a = this.options.mobileSettings) == null ? void 0 : _a.controls) === false) {
1908
+ this.hideControls();
1909
+ }
1910
+ }
1911
+ close() {
1912
+ var _a;
1913
+ if (this.destroyed || !this.opened || this.isClosing) return;
1914
+ this.bus.emit("beforeClose", {});
1915
+ this.isClosing = true;
1916
+ const media = (_a = this.slides) == null ? void 0 : _a.getActiveMedia();
1917
+ const origin = this.getOriginElement(this.activeIndex);
1918
+ if (media && origin) {
1919
+ const aspectRatio = this.resolveAspectRatio(this.activeIndex, origin);
1920
+ zoomOut({ origin, target: media, aspectRatio }, () => this.finishClose());
1921
+ } else {
1922
+ this.finishClose();
1923
+ }
1924
+ }
1925
+ /** `item.width`/`height`, else origin's `naturalWidth`/`naturalHeight` (accurate when `item.thumb` is unset). Feeds `computeTransform`'s letterbox-aware sizing. */
1926
+ resolveAspectRatio(index, origin) {
1927
+ const item = this.itemList[index];
1928
+ if ((item == null ? void 0 : item.width) && item.height) return item.width / item.height;
1929
+ const thumbImg = origin == null ? void 0 : origin.querySelector("img");
1930
+ if ((thumbImg == null ? void 0 : thumbImg.naturalWidth) && thumbImg.naturalHeight) {
1931
+ return thumbImg.naturalWidth / thumbImg.naturalHeight;
1932
+ }
1933
+ return void 0;
1934
+ }
1935
+ /**
1936
+ * Idempotent on purpose: a pending zoom-out's `transitionend`/fallback
1937
+ * timeout can still fire after `destroy()` has already force-finished the
1938
+ * close (§ destroy() below) — the `!this.opened` guard makes that a no-op
1939
+ * instead of a second `close`/`afterClose` emission on a torn-down gallery.
1940
+ */
1941
+ finishClose() {
1942
+ var _a, _b;
1943
+ if (!this.opened) return;
1944
+ this.isClosing = false;
1945
+ this.opened = false;
1946
+ unlockBodyScroll();
1947
+ document.removeEventListener("keydown", this.onKeydown);
1948
+ this.focusTrap.deactivate();
1949
+ (_a = this.dom) == null ? void 0 : _a.outer.classList.remove("shoji-open");
1950
+ if (this.autoHideTimer !== null) {
1951
+ clearTimeout(this.autoHideTimer);
1952
+ this.autoHideTimer = null;
1953
+ }
1954
+ this.autoHidden = false;
1955
+ this.hoveredControlCount = 0;
1956
+ (_b = this.dom) == null ? void 0 : _b.dialog.classList.remove("shoji-controls-hidden");
1957
+ this.bus.emit("close", {});
1958
+ this.bus.emit("afterClose", {});
1959
+ }
1960
+ /** DESIGN.md §2.1 — diffs by id (fallback src), preserving the active slide. */
1961
+ updateSlides(items, currentIndex) {
1962
+ if (this.destroyed) return;
1963
+ const activeItem = this.itemList[this.activeIndex];
1964
+ const activeKey = activeItem ? itemKey(activeItem) : void 0;
1965
+ this.itemList = items;
1966
+ let nextIndex = currentIndex;
1967
+ if (nextIndex === void 0 && activeKey !== void 0) {
1968
+ const preserved = items.findIndex((item) => itemKey(item) === activeKey);
1969
+ if (preserved !== -1) nextIndex = preserved;
1970
+ }
1971
+ this.activeIndex = Math.min(
1972
+ Math.max(nextIndex ?? this.activeIndex, 0),
1973
+ Math.max(items.length - 1, 0)
1974
+ );
1975
+ if (this.opened) this.renderCurrentSlide();
1976
+ this.bus.emit("itemsUpdated", { items: this.itemList });
1977
+ }
1978
+ /**
1979
+ * Sugar over `updateSlides()` for the common "insert some items" case —
1980
+ * splices `items` into a copy of the current list at `atIndex` (default:
1981
+ * append at the end) and hands the result to `updateSlides()`, which
1982
+ * still does all the real work (active-item preservation, live re-render
1983
+ * if open, `itemsUpdated`). `atIndex` follows `Array.prototype.splice`'s
1984
+ * own semantics (negative counts from the end, out-of-range clamps) —
1985
+ * no extra validation on top of that.
1986
+ */
1987
+ addSlides(items, atIndex) {
1988
+ if (this.destroyed) return;
1989
+ const next = [...this.itemList];
1990
+ next.splice(atIndex ?? next.length, 0, ...items);
1991
+ this.updateSlides(next);
1992
+ }
1993
+ /**
1994
+ * Sugar over `updateSlides()` for the common "remove some items" case.
1995
+ * Accepts a single id/index or an array mixing both: a `string` matches
1996
+ * the same `id ?? src` key `updateSlides()` already uses for active-item
1997
+ * preservation, a `number` matches that position in the *current*
1998
+ * `items` list (resolved against the list as it is now, before any
1999
+ * removal — passing `[0, 1]` removes the first two items, not "index 0,
2000
+ * then whatever became index 1 after that").
2001
+ */
2002
+ removeSlides(match) {
2003
+ if (this.destroyed) return;
2004
+ const matches = Array.isArray(match) ? match : [match];
2005
+ const indices = new Set(matches.filter((m) => typeof m === "number"));
2006
+ const keys = new Set(matches.filter((m) => typeof m === "string"));
2007
+ const next = this.itemList.filter((item, i) => !indices.has(i) && !keys.has(itemKey(item)));
2008
+ this.updateSlides(next);
2009
+ }
2010
+ /** DESIGN.md §2.7 — selector-mode only; sugar over updateSlides() sourced from a DOM rescan. */
2011
+ refresh() {
2012
+ if (this.destroyed) return;
2013
+ if (this.isDynamicMode) {
2014
+ console.warn("Shoji: refresh() is a no-op in dynamic mode — call updateSlides() instead.");
2015
+ return;
2016
+ }
2017
+ const scanned = scanContainer(this.element, this.selector);
2018
+ this.scannedElements = scanned.map((s) => s.element);
2019
+ this.updateSlides(scanned.map((s) => s.item));
2020
+ }
2021
+ /** Shared by `destroy()`/`reinit()` (§2.7): force-close, run plugin cleanups, drop gesture/transition/slide/dialog. */
2022
+ teardown() {
2023
+ var _a, _b, _c;
2024
+ if (this.opened) {
2025
+ if (!this.isClosing) this.bus.emit("beforeClose", {});
2026
+ this.finishClose();
2027
+ }
2028
+ for (const cleanup of this.pluginCleanups) cleanup();
2029
+ this.pluginCleanups = [];
2030
+ this.shortcuts.clear();
2031
+ this.pluginStorage.clear();
2032
+ this.activePluginNames.clear();
2033
+ this.videoProviders.clear();
2034
+ if (!this.isDynamicMode) {
2035
+ this.element.removeEventListener("click", this.onContainerClick);
2036
+ }
2037
+ (_a = this.gesture) == null ? void 0 : _a.destroy();
2038
+ this.gesture = null;
2039
+ this.transition = null;
2040
+ (_b = this.slides) == null ? void 0 : _b.destroy();
2041
+ (_c = this.dom) == null ? void 0 : _c.outer.remove();
2042
+ this.slides = null;
2043
+ this.dom = null;
2044
+ }
2045
+ destroy() {
2046
+ if (this.destroyed) return;
2047
+ if (instanceRegistry.get(this.element) === this) instanceRegistry.delete(this.element);
2048
+ allInstances.delete(this);
2049
+ this.teardown();
2050
+ this.bus.emit("destroy", {});
2051
+ this.bus.clear();
2052
+ this.destroyed = true;
2053
+ }
2054
+ /**
2055
+ * DESIGN.md §2.7 — full teardown + reconstruction on `this`, not a new
2056
+ * object: `Shoji.getInstance(el)`/any held reference keeps working across
2057
+ * the call, unlike `destroy()` + `new Shoji(...)`. Omit `options` to
2058
+ * rebuild unchanged (a hard reset); pass options to reconfigure
2059
+ * structurally. `gallery.on(...)` listeners do NOT survive — the event
2060
+ * bus is fully cleared, same as `destroy()` — re-`on()` anything needed.
2061
+ */
2062
+ reinit(options = this.options) {
2063
+ if (this.destroyed) return;
2064
+ this.teardown();
2065
+ this.bus.clear();
2066
+ this.applyOptions(options);
2067
+ }
2068
+ }
2069
+ function prefersReducedMotion() {
2070
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2071
+ }
2072
+ const ActiveThumbnail = {
2073
+ name: "activeThumbnail",
2074
+ defaults: {
2075
+ activeClass: "shoji-thumb-active",
2076
+ scrollIntoView: true
2077
+ },
2078
+ init(ctx) {
2079
+ const { gallery } = ctx;
2080
+ const activeClass = String(ctx.options.activeClass ?? "shoji-thumb-active");
2081
+ const scrollIntoView = ctx.options.scrollIntoView !== false;
2082
+ let current = null;
2083
+ function apply(index) {
2084
+ const el = gallery.getOriginElement(index);
2085
+ if (current && current !== el) current.classList.remove(activeClass);
2086
+ if (el) {
2087
+ el.classList.add(activeClass);
2088
+ if (scrollIntoView) {
2089
+ el.scrollIntoView({
2090
+ block: "nearest",
2091
+ inline: "nearest",
2092
+ behavior: prefersReducedMotion() ? "auto" : "smooth"
2093
+ });
2094
+ }
2095
+ }
2096
+ current = el;
2097
+ }
2098
+ function clear() {
2099
+ current == null ? void 0 : current.classList.remove(activeClass);
2100
+ current = null;
2101
+ }
2102
+ const offOpen = ctx.on("afterOpen", ({ index }) => apply(index));
2103
+ const offSlide = ctx.on("afterSlide", ({ to }) => apply(to));
2104
+ const offClose = ctx.on("close", clear);
2105
+ return () => {
2106
+ offOpen();
2107
+ offSlide();
2108
+ offClose();
2109
+ clear();
2110
+ };
2111
+ }
2112
+ };
2113
+ const PLAY_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
2114
+ const PAUSE_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>';
2115
+ function findPlayable(media) {
2116
+ const video = media == null ? void 0 : media.querySelector("video");
2117
+ if (video) return video;
2118
+ const provider = media == null ? void 0 : media.querySelector(
2119
+ ".shoji-slide-provider-video"
2120
+ );
2121
+ if (provider && typeof provider.play === "function") return provider;
2122
+ return null;
2123
+ }
2124
+ function isPendingProviderVideo(media) {
2125
+ const provider = media == null ? void 0 : media.querySelector(
2126
+ ".shoji-slide-provider-video"
2127
+ );
2128
+ return !!provider && typeof provider.play !== "function";
2129
+ }
2130
+ const PROVIDER_PLAY_RETRY_MS = 400;
2131
+ const MAX_PROVIDER_PLAY_ATTEMPTS = 8;
2132
+ const Autoplay = {
2133
+ name: "autoplay",
2134
+ defaults: { interval: 5e3, showProgress: true },
2135
+ init(ctx) {
2136
+ const { gallery } = ctx;
2137
+ const interval = Number(ctx.options.interval ?? 5e3);
2138
+ const showProgress = ctx.options.showProgress !== false;
2139
+ const locale = ctx.options.locale;
2140
+ const playLabel = (locale == null ? void 0 : locale.play) ?? "Play slideshow";
2141
+ const pauseLabel = (locale == null ? void 0 : locale.pause) ?? "Pause slideshow";
2142
+ let playing = false;
2143
+ let timer = null;
2144
+ let currentVideo = null;
2145
+ let awaitingProviderVideo = false;
2146
+ const media = gallery.getActiveMedia();
2147
+ function onVideoError() {
2148
+ if (playing) advance();
2149
+ }
2150
+ media == null ? void 0 : media.addEventListener("error", onVideoError);
2151
+ const button = document.createElement("button");
2152
+ button.type = "button";
2153
+ button.className = "shoji-toolbar-button";
2154
+ button.setAttribute("aria-label", playLabel);
2155
+ button.title = playLabel;
2156
+ button.innerHTML = PLAY_ICON;
2157
+ const progress = document.createElement("div");
2158
+ progress.className = "shoji-autoplay-progress";
2159
+ progress.hidden = true;
2160
+ const progressBar = document.createElement("div");
2161
+ progressBar.className = "shoji-autoplay-progress-bar";
2162
+ progress.appendChild(progressBar);
2163
+ function clearTimer() {
2164
+ if (timer !== null) {
2165
+ clearTimeout(timer);
2166
+ timer = null;
2167
+ }
2168
+ }
2169
+ function resetProgressBar() {
2170
+ if (!showProgress) return;
2171
+ progress.hidden = true;
2172
+ progressBar.style.transition = "none";
2173
+ progressBar.style.width = "0%";
2174
+ }
2175
+ function runProgressBar(ms) {
2176
+ if (!showProgress) return;
2177
+ progress.hidden = false;
2178
+ progressBar.style.transition = "none";
2179
+ progressBar.style.width = "0%";
2180
+ void progressBar.offsetWidth;
2181
+ progressBar.style.transition = `width ${ms}ms linear`;
2182
+ progressBar.style.width = "100%";
2183
+ }
2184
+ function setButtonState(isPlaying) {
2185
+ playing = isPlaying;
2186
+ button.innerHTML = isPlaying ? PAUSE_ICON : PLAY_ICON;
2187
+ button.setAttribute("aria-label", isPlaying ? pauseLabel : playLabel);
2188
+ button.title = isPlaying ? pauseLabel : playLabel;
2189
+ }
2190
+ function onVideoEnded() {
2191
+ if (playing) advance();
2192
+ }
2193
+ function onVideoPause() {
2194
+ if (currentVideo == null ? void 0 : currentVideo.ended) return;
2195
+ stop();
2196
+ }
2197
+ function detachVideo() {
2198
+ if (!currentVideo) return;
2199
+ currentVideo.removeEventListener("ended", onVideoEnded);
2200
+ currentVideo.removeEventListener("pause", onVideoPause);
2201
+ currentVideo = null;
2202
+ }
2203
+ function ensureProviderPlaying(video, attemptsLeft) {
2204
+ video.muted = true;
2205
+ video.play();
2206
+ setTimeout(() => {
2207
+ if (currentVideo !== video || !playing) return;
2208
+ if (!video.paused) return;
2209
+ if (attemptsLeft > 0) ensureProviderPlaying(video, attemptsLeft - 1);
2210
+ else stop();
2211
+ }, PROVIDER_PLAY_RETRY_MS);
2212
+ }
2213
+ function enterSlide() {
2214
+ clearTimer();
2215
+ detachVideo();
2216
+ resetProgressBar();
2217
+ awaitingProviderVideo = false;
2218
+ if (!playing) return;
2219
+ const media2 = gallery.getActiveMedia();
2220
+ const video = findPlayable(media2);
2221
+ if (video) {
2222
+ currentVideo = video;
2223
+ video.addEventListener("ended", onVideoEnded);
2224
+ video.addEventListener("pause", onVideoPause);
2225
+ if (video instanceof HTMLVideoElement) {
2226
+ const playResult = video.play();
2227
+ if (playResult && typeof playResult.catch === "function") {
2228
+ playResult.catch(() => stop());
2229
+ }
2230
+ } else {
2231
+ ensureProviderPlaying(video, MAX_PROVIDER_PLAY_ATTEMPTS);
2232
+ }
2233
+ return;
2234
+ }
2235
+ awaitingProviderVideo = isPendingProviderVideo(media2);
2236
+ runProgressBar(interval);
2237
+ timer = setTimeout(advance, interval);
2238
+ }
2239
+ function advance() {
2240
+ const before = gallery.currentIndex;
2241
+ gallery.next();
2242
+ if (gallery.currentIndex === before) stop();
2243
+ }
2244
+ function start() {
2245
+ if (playing) return;
2246
+ setButtonState(true);
2247
+ ctx.emit("autoplayStart", {});
2248
+ enterSlide();
2249
+ }
2250
+ function stop() {
2251
+ if (!playing) return;
2252
+ setButtonState(false);
2253
+ clearTimer();
2254
+ resetProgressBar();
2255
+ if (currentVideo && !currentVideo.paused) currentVideo.pause();
2256
+ detachVideo();
2257
+ ctx.emit("autoplayStop", {});
2258
+ }
2259
+ function toggle() {
2260
+ if (playing) stop();
2261
+ else start();
2262
+ }
2263
+ button.addEventListener("click", toggle);
2264
+ const removeButton = ctx.ui.toolbar("right", button);
2265
+ const removeProgress = showProgress ? ctx.ui.overlay(progress) : null;
2266
+ const removeShortcut = ctx.ui.registerShortcut(" ", toggle);
2267
+ const offSlide = ctx.on("afterSlide", () => {
2268
+ if (playing) enterSlide();
2269
+ });
2270
+ const offSlideItemLoad = ctx.on("slideItemLoad", ({ index }) => {
2271
+ if (playing && awaitingProviderVideo && index === gallery.currentIndex) enterSlide();
2272
+ });
2273
+ const offClose = ctx.on("close", () => stop());
2274
+ return () => {
2275
+ stop();
2276
+ media == null ? void 0 : media.removeEventListener("error", onVideoError);
2277
+ removeButton();
2278
+ removeProgress == null ? void 0 : removeProgress();
2279
+ removeShortcut();
2280
+ offSlide();
2281
+ offSlideItemLoad();
2282
+ offClose();
2283
+ };
2284
+ }
2285
+ };
2286
+ const EXPAND_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>';
2287
+ const COMPRESS_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg>';
2288
+ function isSupported() {
2289
+ return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled);
2290
+ }
2291
+ function currentFullscreenElement() {
2292
+ return document.fullscreenElement ?? document.webkitFullscreenElement ?? null;
2293
+ }
2294
+ function requestFullscreen(el) {
2295
+ var _a;
2296
+ const target = el;
2297
+ if (target.requestFullscreen) target.requestFullscreen().catch(() => {
2298
+ });
2299
+ else (_a = target.webkitRequestFullscreen) == null ? void 0 : _a.call(target);
2300
+ }
2301
+ function exitFullscreen() {
2302
+ var _a;
2303
+ const doc = document;
2304
+ if (document.exitFullscreen) document.exitFullscreen().catch(() => {
2305
+ });
2306
+ else (_a = doc.webkitExitFullscreen) == null ? void 0 : _a.call(doc);
2307
+ }
2308
+ const Fullscreen = {
2309
+ name: "fullscreen",
2310
+ init(ctx) {
2311
+ if (!isSupported()) return;
2312
+ const outer = ctx.ui.outer();
2313
+ const locale = ctx.gallery.options.locale ?? {};
2314
+ const enterLabel = locale.enterFullscreen ?? "Enter fullscreen";
2315
+ const exitLabel = locale.exitFullscreen ?? "Exit fullscreen";
2316
+ const button = document.createElement("button");
2317
+ button.type = "button";
2318
+ button.className = "shoji-toolbar-button";
2319
+ button.innerHTML = EXPAND_ICON;
2320
+ button.setAttribute("aria-label", enterLabel);
2321
+ button.title = enterLabel;
2322
+ button.setAttribute("aria-pressed", "false");
2323
+ function isActive() {
2324
+ return currentFullscreenElement() === outer;
2325
+ }
2326
+ function setState(active) {
2327
+ button.innerHTML = active ? COMPRESS_ICON : EXPAND_ICON;
2328
+ button.setAttribute("aria-label", active ? exitLabel : enterLabel);
2329
+ button.title = active ? exitLabel : enterLabel;
2330
+ button.setAttribute("aria-pressed", String(active));
2331
+ }
2332
+ button.addEventListener("click", () => {
2333
+ if (isActive()) exitFullscreen();
2334
+ else requestFullscreen(outer);
2335
+ });
2336
+ const onChange = () => {
2337
+ const active = isActive();
2338
+ setState(active);
2339
+ ctx.emit("fullscreenChange", { fullscreen: active });
2340
+ };
2341
+ document.addEventListener("fullscreenchange", onChange);
2342
+ document.addEventListener("webkitfullscreenchange", onChange);
2343
+ const removeButton = ctx.ui.toolbar("right", button);
2344
+ const offClose = ctx.on("close", () => {
2345
+ if (isActive()) exitFullscreen();
2346
+ });
2347
+ return () => {
2348
+ document.removeEventListener("fullscreenchange", onChange);
2349
+ document.removeEventListener("webkitfullscreenchange", onChange);
2350
+ removeButton();
2351
+ offClose();
2352
+ if (isActive()) exitFullscreen();
2353
+ };
2354
+ }
2355
+ };
2356
+ function computeColumnCount(options) {
2357
+ const { containerWidth, gutterX, columns } = options;
2358
+ if (typeof columns === "number") return Math.max(1, Math.floor(columns));
2359
+ const target = Math.max(
2360
+ 1,
2361
+ Math.floor((containerWidth + gutterX) / (options.columnWidth + gutterX))
2362
+ );
2363
+ const maxAllowedByMin = Math.max(
2364
+ 1,
2365
+ Math.floor((containerWidth + gutterX) / (options.minColumnWidth + gutterX))
2366
+ );
2367
+ const minRequiredByMax = Math.max(
2368
+ 1,
2369
+ Math.ceil((containerWidth + gutterX) / (options.maxColumnWidth + gutterX))
2370
+ );
2371
+ return Math.max(minRequiredByMax, Math.min(target, maxAllowedByMin));
2372
+ }
2373
+ function computeColumnWidth(containerWidth, gutterX, columnCount) {
2374
+ return (containerWidth - gutterX * (columnCount - 1)) / columnCount;
2375
+ }
2376
+ function layoutMasonry(tiles, options, startHeights) {
2377
+ const columnCount = computeColumnCount(options);
2378
+ const colWidth = computeColumnWidth(options.containerWidth, options.gutterX, columnCount);
2379
+ const columnHeights = startHeights && startHeights.length === columnCount ? [...startHeights] : new Array(columnCount).fill(0);
2380
+ const positions = tiles.map((tile, i) => {
2381
+ const colIndex = options.fill === "ordered" ? i % columnCount : indexOfShortest(columnHeights);
2382
+ const tileHeight = tile.width > 0 ? colWidth * (tile.height / tile.width) : colWidth;
2383
+ const x = colIndex * (colWidth + options.gutterX);
2384
+ const y = columnHeights[colIndex];
2385
+ columnHeights[colIndex] = y + tileHeight + options.gutterY;
2386
+ return { x, y, width: colWidth, height: tileHeight };
2387
+ });
2388
+ const containerHeight = tiles.length === 0 ? 0 : Math.max(...columnHeights) - options.gutterY;
2389
+ return { positions, columnCount, columnWidth: colWidth, columnHeights, containerHeight };
2390
+ }
2391
+ function indexOfShortest(heights) {
2392
+ let shortest = 0;
2393
+ for (let i = 1; i < heights.length; i++) {
2394
+ if (heights[i] < heights[shortest]) shortest = i;
2395
+ }
2396
+ return shortest;
2397
+ }
2398
+ function layoutMasonryHorizontal(tiles, options) {
2399
+ const { containerWidth, gutterX, gutterY, rowHeight } = options;
2400
+ const positions = new Array(tiles.length);
2401
+ let currentY = 0;
2402
+ let rowStart = 0;
2403
+ let rowWidth = 0;
2404
+ let rowCount = 0;
2405
+ function tileWidth(tile) {
2406
+ return tile.height > 0 ? rowHeight * (tile.width / tile.height) : rowHeight;
2407
+ }
2408
+ function placeRow(start, endExclusive) {
2409
+ let x = 0;
2410
+ for (let i = start; i < endExclusive; i++) {
2411
+ const w = tileWidth(tiles[i]);
2412
+ positions[i] = { x, y: currentY, width: w, height: rowHeight };
2413
+ x += w + gutterX;
2414
+ }
2415
+ rowCount++;
2416
+ currentY += rowHeight + gutterY;
2417
+ }
2418
+ for (let i = 0; i < tiles.length; i++) {
2419
+ const w = tileWidth(tiles[i]);
2420
+ const countInRow = i - rowStart;
2421
+ const widthIfIncluded = rowWidth + (countInRow > 0 ? gutterX : 0) + w;
2422
+ if (countInRow > 0 && widthIfIncluded > containerWidth) {
2423
+ placeRow(rowStart, i);
2424
+ rowStart = i;
2425
+ rowWidth = w;
2426
+ } else {
2427
+ rowWidth = widthIfIncluded;
2428
+ }
2429
+ }
2430
+ if (rowStart < tiles.length) placeRow(rowStart, tiles.length);
2431
+ const containerHeight = tiles.length === 0 ? 0 : currentY - gutterY;
2432
+ return { positions, rowCount, containerHeight };
2433
+ }
2434
+ function aspectRatioOf(tile) {
2435
+ return tile.width > 0 && tile.height > 0 ? tile.width / tile.height : 1;
2436
+ }
2437
+ function layoutJustified(tiles, options) {
2438
+ const { containerWidth, gutterX, gutterY, rowHeight, minRowHeight, maxRowHeight, lastRow } = options;
2439
+ const positions = new Array(tiles.length).fill(null);
2440
+ const rows = [];
2441
+ let currentY = 0;
2442
+ let rowStart = 0;
2443
+ let aspectSum = 0;
2444
+ function naturalHeightOf(startIndex, endExclusive, sum) {
2445
+ const availableWidth = containerWidth - gutterX * (endExclusive - startIndex - 1);
2446
+ return availableWidth / sum;
2447
+ }
2448
+ function placeRow(startIndex, endExclusive, height) {
2449
+ let x = 0;
2450
+ for (let i = startIndex; i < endExclusive; i++) {
2451
+ const width = height * aspectRatioOf(tiles[i]);
2452
+ positions[i] = { x, y: currentY, width, height };
2453
+ x += width + gutterX;
2454
+ }
2455
+ rows.push({ start: startIndex, end: endExclusive, y: currentY, height });
2456
+ currentY += height + gutterY;
2457
+ }
2458
+ function resolveScaledRow(startIndex, endExclusive, sum) {
2459
+ let end = endExclusive;
2460
+ let rowSum = sum;
2461
+ while (end - startIndex > 1 && naturalHeightOf(startIndex, end, rowSum) < minRowHeight) {
2462
+ end--;
2463
+ rowSum -= aspectRatioOf(tiles[end]);
2464
+ }
2465
+ const natural = naturalHeightOf(startIndex, end, rowSum);
2466
+ const height = end - startIndex === 1 && natural < minRowHeight ? natural : Math.min(Math.max(natural, minRowHeight), maxRowHeight);
2467
+ placeRow(startIndex, end, height);
2468
+ return end;
2469
+ }
2470
+ for (let i = 0; i < tiles.length; i++) {
2471
+ aspectSum += aspectRatioOf(tiles[i]);
2472
+ const count = i - rowStart + 1;
2473
+ const widthAtTargetHeight = rowHeight * aspectSum + gutterX * (count - 1);
2474
+ if (widthAtTargetHeight >= containerWidth) {
2475
+ const end = resolveScaledRow(rowStart, i + 1, aspectSum);
2476
+ rowStart = end;
2477
+ aspectSum = 0;
2478
+ for (let j = end; j <= i; j++) aspectSum += aspectRatioOf(tiles[j]);
2479
+ }
2480
+ }
2481
+ if (rowStart < tiles.length) {
2482
+ if (lastRow === "justify") {
2483
+ let start = rowStart;
2484
+ while (start < tiles.length) {
2485
+ let sum = 0;
2486
+ for (let j = start; j < tiles.length; j++) sum += aspectRatioOf(tiles[j]);
2487
+ start = resolveScaledRow(start, tiles.length, sum);
2488
+ }
2489
+ } else if (lastRow === "left") {
2490
+ placeRow(rowStart, tiles.length, rowHeight);
2491
+ }
2492
+ }
2493
+ const containerHeight = tiles.length === 0 ? 0 : Math.max(0, currentY - gutterY);
2494
+ return { positions, containerHeight, rows };
2495
+ }
2496
+ const DEFAULT_ASPECT = { width: 4, height: 3 };
2497
+ function resolveGutter(raw) {
2498
+ if (raw && typeof raw === "object") {
2499
+ const obj = raw;
2500
+ return { x: Number(obj.x ?? 8), y: Number(obj.y ?? 8) };
2501
+ }
2502
+ const n = Number(raw ?? 8);
2503
+ return { x: n, y: n };
2504
+ }
2505
+ function keyOf(item) {
2506
+ return item.id ?? item.src;
2507
+ }
2508
+ function isPureAppend(oldItems, newItems) {
2509
+ if (newItems.length <= oldItems.length) return false;
2510
+ for (let i = 0; i < oldItems.length; i++) {
2511
+ if (keyOf(oldItems[i]) !== keyOf(newItems[i])) return false;
2512
+ }
2513
+ return true;
2514
+ }
2515
+ const Layout = {
2516
+ name: "layout",
2517
+ defaults: {
2518
+ type: "justified",
2519
+ gutter: 8,
2520
+ columns: "auto",
2521
+ columnWidth: 240,
2522
+ minColumnWidth: 160,
2523
+ maxColumnWidth: 480,
2524
+ fill: "shortest",
2525
+ animate: true,
2526
+ autoMeasure: true
2527
+ },
2528
+ init(ctx) {
2529
+ const { gallery } = ctx;
2530
+ const type = ctx.options.type ?? "justified";
2531
+ const gutter = resolveGutter(ctx.options.gutter);
2532
+ const aspectRatio = Number(ctx.options.aspectRatio ?? 1);
2533
+ const orientation = ctx.options.orientation ?? "vertical";
2534
+ const columns = ctx.options.columns ?? "auto";
2535
+ const columnWidth = Number(ctx.options.columnWidth ?? 240);
2536
+ const minColumnWidth = Number(ctx.options.minColumnWidth ?? 160);
2537
+ const maxColumnWidth = Number(ctx.options.maxColumnWidth ?? 480);
2538
+ const fill = ctx.options.fill ?? "shortest";
2539
+ const rowHeight = Number(ctx.options.rowHeight ?? 220);
2540
+ const minRowHeight = Number(ctx.options.minRowHeight ?? 120);
2541
+ const maxRowHeight = Number(ctx.options.maxRowHeight ?? 360);
2542
+ const lastRow = ctx.options.lastRow ?? "justify";
2543
+ const animate = ctx.options.animate !== false;
2544
+ const autoMeasureEnabled = ctx.options.autoMeasure !== false;
2545
+ const horizontal = type === "masonry" && orientation === "horizontal";
2546
+ const groupByFn = ctx.options.groupBy;
2547
+ const renderHeadingFn = ctx.options.renderHeading;
2548
+ const headingOverflow = ctx.options.headingOverflow ?? "show";
2549
+ const stickyHeadingsRaw = ctx.options.stickyHeadings === true;
2550
+ const stickyHeadings = stickyHeadingsRaw && type === "grid";
2551
+ if (stickyHeadingsRaw && type !== "grid") {
2552
+ console.warn(
2553
+ "Shoji: layout plugin — stickyHeadings is only implemented for type: 'grid'; ignored (see the option's doc comment)."
2554
+ );
2555
+ }
2556
+ if (groupByFn && horizontal) {
2557
+ console.warn(
2558
+ "Shoji: layout plugin — groupBy isn't supported with orientation: 'horizontal'; ignored."
2559
+ );
2560
+ }
2561
+ const groupingEnabled = !!groupByFn && !horizontal;
2562
+ const baseConfig = {
2563
+ gutter,
2564
+ columns,
2565
+ columnWidth,
2566
+ minColumnWidth,
2567
+ maxColumnWidth,
2568
+ fill,
2569
+ rowHeight,
2570
+ minRowHeight,
2571
+ maxRowHeight,
2572
+ lastRow
2573
+ };
2574
+ const breakpointsRaw = ctx.options.breakpoints;
2575
+ const sortedBreakpoints = breakpointsRaw ? Object.entries(breakpointsRaw).map(([key, overrides]) => [Number(key), overrides]).sort((a, b) => a[0] - b[0]) : [];
2576
+ function resolveConfig(width) {
2577
+ const match = sortedBreakpoints.find(([maxWidth]) => width <= maxWidth);
2578
+ if (!match) return baseConfig;
2579
+ const overrides = match[1];
2580
+ return {
2581
+ gutter: overrides.gutter !== void 0 ? resolveGutter(overrides.gutter) : baseConfig.gutter,
2582
+ columns: overrides.columns ?? baseConfig.columns,
2583
+ columnWidth: overrides.columnWidth ?? baseConfig.columnWidth,
2584
+ minColumnWidth: overrides.minColumnWidth ?? baseConfig.minColumnWidth,
2585
+ maxColumnWidth: overrides.maxColumnWidth ?? baseConfig.maxColumnWidth,
2586
+ fill: overrides.fill ?? baseConfig.fill,
2587
+ rowHeight: overrides.rowHeight ?? baseConfig.rowHeight,
2588
+ minRowHeight: overrides.minRowHeight ?? baseConfig.minRowHeight,
2589
+ maxRowHeight: overrides.maxRowHeight ?? baseConfig.maxRowHeight,
2590
+ lastRow: overrides.lastRow ?? baseConfig.lastRow
2591
+ };
2592
+ }
2593
+ const container = gallery.element;
2594
+ container.classList.add("shoji-layout", `shoji-layout--${type}`);
2595
+ if (animate) container.classList.add("shoji-layout--animate");
2596
+ let tiles = [];
2597
+ let headings = [];
2598
+ let columnHeights;
2599
+ let previousItems = [];
2600
+ let warnedMissingDims = false;
2601
+ function computeSections(items) {
2602
+ const sections = [];
2603
+ for (let i = 0; i < items.length; i++) {
2604
+ const key = groupByFn(items[i]);
2605
+ const current = sections[sections.length - 1];
2606
+ if (!current || current.key !== key) {
2607
+ sections.push({ key, startIndex: i, endIndex: i + 1 });
2608
+ } else {
2609
+ current.endIndex = i + 1;
2610
+ }
2611
+ }
2612
+ return sections;
2613
+ }
2614
+ function createHeadingRoot(key, sectionItems) {
2615
+ const rendered = renderHeadingFn ? renderHeadingFn(key, sectionItems) : key;
2616
+ let root;
2617
+ let structured = null;
2618
+ if (typeof rendered === "string") {
2619
+ root = document.createElement("h2");
2620
+ root.textContent = rendered;
2621
+ } else if (rendered instanceof HTMLElement) {
2622
+ root = rendered;
2623
+ } else {
2624
+ root = document.createElement("h2");
2625
+ const titleEl = document.createElement("span");
2626
+ titleEl.className = "shoji-layout-heading-title";
2627
+ titleEl.textContent = rendered.title;
2628
+ root.appendChild(titleEl);
2629
+ let subtitleEl = null;
2630
+ if (rendered.subtitle) {
2631
+ subtitleEl = document.createElement("span");
2632
+ subtitleEl.className = "shoji-layout-heading-subtitle";
2633
+ subtitleEl.textContent = rendered.subtitle;
2634
+ root.appendChild(subtitleEl);
2635
+ }
2636
+ structured = { titleEl, subtitleEl };
2637
+ }
2638
+ root.classList.add("shoji-layout-heading");
2639
+ if (stickyHeadings) root.classList.add("shoji-layout-heading--sticky");
2640
+ return { root, structured };
2641
+ }
2642
+ function headingSegments() {
2643
+ return headings.map((heading, i) => ({
2644
+ heading,
2645
+ start: heading.tileStart,
2646
+ end: i + 1 < headings.length ? headings[i + 1].tileStart : tiles.length
2647
+ }));
2648
+ }
2649
+ function measurableSrc(item) {
2650
+ return item.thumb ?? item.poster ?? (item.video ? void 0 : item.src);
2651
+ }
2652
+ function aspectOf(item) {
2653
+ if ((item == null ? void 0 : item.width) && item.height) return { width: item.width, height: item.height };
2654
+ const selfCorrecting = autoMeasureEnabled && !!item && !!measurableSrc(item);
2655
+ if (!warnedMissingDims && !selfCorrecting) {
2656
+ warnedMissingDims = true;
2657
+ console.warn(
2658
+ autoMeasureEnabled ? "Shoji: layout plugin — item(s) missing width/height with no measurable thumb/poster/src to auto-measure from either; falling back to a 4:3 placeholder ratio permanently for those." : "Shoji: layout plugin — item(s) missing width/height and autoMeasure is disabled. Masonry/justified positions are computed from aspect ratios up front, never by measuring loaded images (DESIGN.md §5.2 — that causes layout-jump), so items without dimensions fall back to a 4:3 placeholder ratio instead."
2659
+ );
2660
+ }
2661
+ return DEFAULT_ASPECT;
2662
+ }
2663
+ function createTile(item, index) {
2664
+ const root = document.createElement("a");
2665
+ root.className = "shoji-layout-tile";
2666
+ if (item.id) root.dataset.shojiId = item.id;
2667
+ root.dataset.shojiIndex = String(index);
2668
+ const img = document.createElement("img");
2669
+ img.src = item.thumb ?? item.poster ?? item.src;
2670
+ img.loading = "lazy";
2671
+ img.alt = item.alt ?? "";
2672
+ root.appendChild(img);
2673
+ const tile = { index, root, img };
2674
+ root.addEventListener("click", (event) => {
2675
+ event.preventDefault();
2676
+ gallery.open(tile.index);
2677
+ });
2678
+ return tile;
2679
+ }
2680
+ function applyGrid(target) {
2681
+ const containerWidth = container.getBoundingClientRect().width;
2682
+ const config = resolveConfig(containerWidth);
2683
+ container.style.setProperty("--shoji-layout-column-width", `${config.columnWidth}px`);
2684
+ container.style.setProperty(
2685
+ "--shoji-layout-gutter",
2686
+ `${config.gutter.y}px ${config.gutter.x}px`
2687
+ );
2688
+ for (const tile of target) {
2689
+ tile.root.style.setProperty("--shoji-layout-tile-aspect", `${aspectRatio}`);
2690
+ }
2691
+ }
2692
+ function applyMasonry(appendOnly) {
2693
+ const containerWidth = container.getBoundingClientRect().width;
2694
+ if (containerWidth <= 0) return;
2695
+ const config = resolveConfig(containerWidth);
2696
+ const masonryOptions = {
2697
+ containerWidth,
2698
+ gutterX: config.gutter.x,
2699
+ gutterY: config.gutter.y,
2700
+ columns: config.columns,
2701
+ columnWidth: config.columnWidth,
2702
+ minColumnWidth: config.minColumnWidth,
2703
+ maxColumnWidth: config.maxColumnWidth,
2704
+ fill: config.fill
2705
+ };
2706
+ if (groupingEnabled && headings.length > 0) {
2707
+ const segments = headingSegments();
2708
+ const headingHeights = segments.map((s) => s.heading.root.getBoundingClientRect().height);
2709
+ let y = 0;
2710
+ segments.forEach(({ heading, start, end }, i) => {
2711
+ const segTiles = tiles.slice(start, end);
2712
+ heading.root.style.transform = `translate(0px, ${y}px)`;
2713
+ y += headingHeights[i] + config.gutter.y;
2714
+ const segResult = layoutMasonry(
2715
+ segTiles.map((t) => aspectOf(gallery.items[t.index])),
2716
+ masonryOptions
2717
+ );
2718
+ segTiles.forEach((tile, j) => {
2719
+ const pos = segResult.positions[j];
2720
+ tile.root.style.width = `${pos.width}px`;
2721
+ tile.root.style.height = `${pos.height}px`;
2722
+ tile.root.style.visibility = "visible";
2723
+ tile.root.style.transform = `translate(${pos.x}px, ${y + pos.y}px)`;
2724
+ });
2725
+ y += segResult.containerHeight + config.gutter.y;
2726
+ });
2727
+ columnHeights = void 0;
2728
+ container.style.height = `${Math.max(0, y - config.gutter.y)}px`;
2729
+ return;
2730
+ }
2731
+ const target = appendOnly ?? tiles;
2732
+ const masonryTiles = target.map((t) => aspectOf(gallery.items[t.index]));
2733
+ const startHeights = appendOnly ? columnHeights : void 0;
2734
+ const result = layoutMasonry(masonryTiles, masonryOptions, startHeights);
2735
+ target.forEach((tile, i) => {
2736
+ const pos = result.positions[i];
2737
+ tile.root.style.width = `${pos.width}px`;
2738
+ tile.root.style.height = `${pos.height}px`;
2739
+ tile.root.style.visibility = "visible";
2740
+ tile.root.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
2741
+ });
2742
+ columnHeights = result.columnHeights;
2743
+ container.style.height = `${result.containerHeight}px`;
2744
+ }
2745
+ function applyMasonryHorizontal() {
2746
+ const containerWidth = container.getBoundingClientRect().width;
2747
+ if (containerWidth <= 0) return;
2748
+ const config = resolveConfig(containerWidth);
2749
+ const masonryTiles = tiles.map((t) => aspectOf(gallery.items[t.index]));
2750
+ const result = layoutMasonryHorizontal(masonryTiles, {
2751
+ containerWidth,
2752
+ gutterX: config.gutter.x,
2753
+ gutterY: config.gutter.y,
2754
+ rowHeight: config.rowHeight
2755
+ });
2756
+ tiles.forEach((tile, i) => {
2757
+ const pos = result.positions[i];
2758
+ tile.root.style.width = `${pos.width}px`;
2759
+ tile.root.style.height = `${pos.height}px`;
2760
+ tile.root.style.visibility = "visible";
2761
+ tile.root.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
2762
+ });
2763
+ container.style.height = `${result.containerHeight}px`;
2764
+ }
2765
+ function applyJustifiedGrouped(justifiedOptions, config, containerWidth) {
2766
+ const fullWidths = /* @__PURE__ */ new Map();
2767
+ const titleOnlyWidths = /* @__PURE__ */ new Map();
2768
+ if (headingOverflow === "fit") {
2769
+ for (const heading of headings) {
2770
+ if (!heading.structured) continue;
2771
+ const { subtitleEl } = heading.structured;
2772
+ heading.root.style.maxWidth = "none";
2773
+ heading.root.style.whiteSpace = "nowrap";
2774
+ if (subtitleEl) subtitleEl.hidden = false;
2775
+ fullWidths.set(heading.tileStart, heading.root.getBoundingClientRect().width);
2776
+ if (subtitleEl) {
2777
+ subtitleEl.hidden = true;
2778
+ titleOnlyWidths.set(heading.tileStart, heading.root.getBoundingClientRect().width);
2779
+ subtitleEl.hidden = false;
2780
+ } else {
2781
+ titleOnlyWidths.set(heading.tileStart, fullWidths.get(heading.tileStart));
2782
+ }
2783
+ }
2784
+ }
2785
+ function tileAspects(tileStart, tileEnd) {
2786
+ return tiles.slice(tileStart, tileEnd).map((t) => aspectOf(gallery.items[t.index]));
2787
+ }
2788
+ function tryFit(startSection, endSection) {
2789
+ const tileStart = headings[startSection].tileStart;
2790
+ const tileEnd = endSection < headings.length ? headings[endSection].tileStart : tiles.length;
2791
+ const result = layoutJustified(tileAspects(tileStart, tileEnd), justifiedOptions);
2792
+ for (let k = startSection; k < endSection; k++) {
2793
+ const heading = headings[k];
2794
+ if (!heading.structured) continue;
2795
+ const pos = result.positions[heading.tileStart - tileStart];
2796
+ if (!pos) continue;
2797
+ const next = k + 1 < endSection ? headings[k + 1] : void 0;
2798
+ const nextPos = next ? result.positions[next.tileStart - tileStart] : null;
2799
+ const sameRow = !!nextPos && Math.abs(nextPos.y - pos.y) < 0.5;
2800
+ const available = (sameRow ? nextPos.x : containerWidth) - pos.x - config.gutter.x;
2801
+ const bestWidth = Math.min(
2802
+ fullWidths.get(heading.tileStart) ?? Infinity,
2803
+ titleOnlyWidths.get(heading.tileStart) ?? Infinity
2804
+ );
2805
+ if (bestWidth > available) return null;
2806
+ }
2807
+ return result;
2808
+ }
2809
+ function computeSegments() {
2810
+ if (headingOverflow !== "fit") {
2811
+ return [
2812
+ {
2813
+ startSection: 0,
2814
+ endSection: headings.length,
2815
+ tileStart: 0,
2816
+ tileEnd: tiles.length,
2817
+ result: layoutJustified(tileAspects(0, tiles.length), justifiedOptions)
2818
+ }
2819
+ ];
2820
+ }
2821
+ const segments2 = [];
2822
+ let segStart = 0;
2823
+ while (segStart < headings.length) {
2824
+ const base = tryFit(segStart, segStart + 1);
2825
+ const tileStart = headings[segStart].tileStart;
2826
+ if (base === null) {
2827
+ const tileEnd2 = segStart + 1 < headings.length ? headings[segStart + 1].tileStart : tiles.length;
2828
+ segments2.push({
2829
+ startSection: segStart,
2830
+ endSection: segStart + 1,
2831
+ tileStart,
2832
+ tileEnd: tileEnd2,
2833
+ result: layoutJustified(tileAspects(tileStart, tileEnd2), justifiedOptions)
2834
+ });
2835
+ segStart += 1;
2836
+ continue;
2837
+ }
2838
+ let end = segStart + 1;
2839
+ let best = base;
2840
+ while (end < headings.length) {
2841
+ const trial = tryFit(segStart, end + 1);
2842
+ if (trial === null) break;
2843
+ end += 1;
2844
+ best = trial;
2845
+ }
2846
+ const tileEnd = end < headings.length ? headings[end].tileStart : tiles.length;
2847
+ segments2.push({
2848
+ startSection: segStart,
2849
+ endSection: end,
2850
+ tileStart,
2851
+ tileEnd,
2852
+ result: best
2853
+ });
2854
+ segStart = end;
2855
+ }
2856
+ return segments2;
2857
+ }
2858
+ function applyContentDecisions(segment) {
2859
+ for (let k = segment.startSection; k < segment.endSection; k++) {
2860
+ const heading = headings[k];
2861
+ if (!heading.structured) continue;
2862
+ const pos = segment.result.positions[heading.tileStart - segment.tileStart];
2863
+ if (!pos) continue;
2864
+ const next = k + 1 < segment.endSection ? headings[k + 1] : void 0;
2865
+ const nextPos = next ? segment.result.positions[next.tileStart - segment.tileStart] : null;
2866
+ const sameRow = !!nextPos && Math.abs(nextPos.y - pos.y) < 0.5;
2867
+ const available = (sameRow ? nextPos.x : containerWidth) - pos.x - config.gutter.x;
2868
+ const { subtitleEl } = heading.structured;
2869
+ heading.root.style.whiteSpace = "nowrap";
2870
+ heading.root.style.maxWidth = "none";
2871
+ heading.root.style.overflow = "";
2872
+ heading.root.style.textOverflow = "";
2873
+ if (subtitleEl) subtitleEl.hidden = false;
2874
+ const full = fullWidths.get(heading.tileStart) ?? 0;
2875
+ if (full <= available) continue;
2876
+ if (subtitleEl) {
2877
+ subtitleEl.hidden = true;
2878
+ const titleOnly = titleOnlyWidths.get(heading.tileStart) ?? 0;
2879
+ if (titleOnly <= available) continue;
2880
+ }
2881
+ heading.root.style.maxWidth = `${Math.max(0, available)}px`;
2882
+ heading.root.style.overflow = "hidden";
2883
+ heading.root.style.textOverflow = "ellipsis";
2884
+ }
2885
+ }
2886
+ function applyWrapDecisions(segment) {
2887
+ for (let k = segment.startSection; k < segment.endSection; k++) {
2888
+ const heading = headings[k];
2889
+ if (!heading.structured) continue;
2890
+ const pos = segment.result.positions[heading.tileStart - segment.tileStart];
2891
+ if (!pos) continue;
2892
+ const next = k + 1 < segment.endSection ? headings[k + 1] : void 0;
2893
+ const nextPos = next ? segment.result.positions[next.tileStart - segment.tileStart] : null;
2894
+ const sameRow = !!nextPos && Math.abs(nextPos.y - pos.y) < 0.5;
2895
+ const available = (sameRow ? nextPos.x : containerWidth) - pos.x - config.gutter.x;
2896
+ heading.root.style.whiteSpace = "normal";
2897
+ heading.root.style.maxWidth = `${Math.max(0, available)}px`;
2898
+ const style = getComputedStyle(heading.root);
2899
+ const parsedLineHeight = parseFloat(style.lineHeight);
2900
+ const lineHeight = Number.isFinite(parsedLineHeight) ? parsedLineHeight : parseFloat(style.fontSize) * 1.2;
2901
+ const maxLines = Math.max(1, Math.floor(config.maxRowHeight / lineHeight));
2902
+ heading.root.style.display = "-webkit-box";
2903
+ heading.root.style.overflow = "hidden";
2904
+ heading.root.style.setProperty("-webkit-box-orient", "vertical");
2905
+ heading.root.style.setProperty("-webkit-line-clamp", `${maxLines}`);
2906
+ }
2907
+ }
2908
+ const segments = computeSegments();
2909
+ let cumulativeExtra = 0;
2910
+ let headingPtr = 0;
2911
+ for (const segment of segments) {
2912
+ if (headingOverflow === "fit") applyContentDecisions(segment);
2913
+ else if (headingOverflow === "wrap") applyWrapDecisions(segment);
2914
+ const headingHeights = /* @__PURE__ */ new Map();
2915
+ for (let k = segment.startSection; k < segment.endSection; k++) {
2916
+ const heading = headings[k];
2917
+ headingHeights.set(heading.tileStart, heading.root.getBoundingClientRect().height);
2918
+ }
2919
+ for (const row of segment.result.rows) {
2920
+ const globalStart = row.start + segment.tileStart;
2921
+ const globalEnd = row.end + segment.tileStart;
2922
+ let bandHeight = 0;
2923
+ while (headingPtr < segment.endSection && headings[headingPtr].tileStart < globalEnd) {
2924
+ const heading = headings[headingPtr];
2925
+ const pos = segment.result.positions[heading.tileStart - segment.tileStart];
2926
+ if (pos) {
2927
+ heading.root.hidden = false;
2928
+ heading.root.style.transform = `translate(${pos.x}px, ${row.y + cumulativeExtra}px)`;
2929
+ bandHeight = Math.max(bandHeight, headingHeights.get(heading.tileStart) ?? 0);
2930
+ }
2931
+ headingPtr++;
2932
+ }
2933
+ if (bandHeight > 0) cumulativeExtra += bandHeight + config.gutter.y;
2934
+ for (let i = globalStart; i < globalEnd; i++) {
2935
+ const pos = segment.result.positions[i - segment.tileStart];
2936
+ const tile = tiles[i];
2937
+ tile.root.hidden = pos === null;
2938
+ if (!pos) continue;
2939
+ tile.root.style.width = `${pos.width}px`;
2940
+ tile.root.style.height = `${pos.height}px`;
2941
+ tile.root.style.visibility = "visible";
2942
+ tile.root.style.transform = `translate(${pos.x}px, ${pos.y + cumulativeExtra}px)`;
2943
+ }
2944
+ }
2945
+ const segLastCovered = segment.result.rows.length > 0 ? segment.result.rows[segment.result.rows.length - 1].end + segment.tileStart : segment.tileStart;
2946
+ for (let i = segLastCovered; i < segment.tileEnd; i++) {
2947
+ tiles[i].root.hidden = true;
2948
+ }
2949
+ cumulativeExtra += segment.result.containerHeight + config.gutter.y;
2950
+ }
2951
+ while (headingPtr < headings.length) {
2952
+ headings[headingPtr].root.hidden = true;
2953
+ headingPtr++;
2954
+ }
2955
+ container.style.height = `${Math.max(0, cumulativeExtra - config.gutter.y)}px`;
2956
+ }
2957
+ function applyJustified() {
2958
+ const containerWidth = container.getBoundingClientRect().width;
2959
+ if (containerWidth <= 0) return;
2960
+ const config = resolveConfig(containerWidth);
2961
+ const justifiedOptions = {
2962
+ containerWidth,
2963
+ gutterX: config.gutter.x,
2964
+ gutterY: config.gutter.y,
2965
+ rowHeight: config.rowHeight,
2966
+ minRowHeight: config.minRowHeight,
2967
+ maxRowHeight: config.maxRowHeight,
2968
+ lastRow: config.lastRow
2969
+ };
2970
+ if (groupingEnabled && headings.length > 0) {
2971
+ applyJustifiedGrouped(justifiedOptions, config, containerWidth);
2972
+ return;
2973
+ }
2974
+ const justifiedTiles = tiles.map((t) => aspectOf(gallery.items[t.index]));
2975
+ const result = layoutJustified(justifiedTiles, justifiedOptions);
2976
+ tiles.forEach((tile, i) => {
2977
+ const pos = result.positions[i];
2978
+ tile.root.hidden = pos === null;
2979
+ if (!pos) return;
2980
+ tile.root.style.width = `${pos.width}px`;
2981
+ tile.root.style.height = `${pos.height}px`;
2982
+ tile.root.style.visibility = "visible";
2983
+ tile.root.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
2984
+ });
2985
+ container.style.height = `${result.containerHeight}px`;
2986
+ }
2987
+ function relayoutAll() {
2988
+ if (horizontal) applyMasonryHorizontal();
2989
+ else if (type === "masonry") applyMasonry(null);
2990
+ else if (type === "justified") applyJustified();
2991
+ else applyGrid(tiles);
2992
+ }
2993
+ let destroyed = false;
2994
+ let measureFrame = null;
2995
+ function scheduleMeasureRelayout() {
2996
+ if (measureFrame !== null || destroyed) return;
2997
+ measureFrame = requestAnimationFrame(() => {
2998
+ measureFrame = null;
2999
+ if (destroyed) return;
3000
+ if (groupingEnabled) fullRender(gallery.items);
3001
+ else relayoutAll();
3002
+ });
3003
+ }
3004
+ function autoMeasureTiles(newTiles) {
3005
+ if (!autoMeasureEnabled) return;
3006
+ for (const tile of newTiles) {
3007
+ const item = gallery.items[tile.index];
3008
+ if (!item || item.width && item.height || !measurableSrc(item)) continue;
3009
+ const onLoad = () => {
3010
+ if (destroyed) return;
3011
+ if (tile.img.naturalWidth > 0 && tile.img.naturalHeight > 0) {
3012
+ item.width = tile.img.naturalWidth;
3013
+ item.height = tile.img.naturalHeight;
3014
+ scheduleMeasureRelayout();
3015
+ }
3016
+ };
3017
+ if (tile.img.complete) onLoad();
3018
+ else tile.img.addEventListener("load", onLoad, { once: true });
3019
+ }
3020
+ }
3021
+ function emitLayoutRender(rendered) {
3022
+ ctx.emit("layoutRender", {
3023
+ tiles: rendered.map((tile) => ({ index: tile.index, element: tile.root }))
3024
+ });
3025
+ }
3026
+ function fullRender(items) {
3027
+ container.replaceChildren();
3028
+ tiles = items.map((item, i) => createTile(item, i));
3029
+ headings = [];
3030
+ if (groupingEnabled) {
3031
+ for (const section of computeSections(items)) {
3032
+ const sectionItems = items.slice(section.startIndex, section.endIndex);
3033
+ const { root: headingRoot, structured } = createHeadingRoot(section.key, sectionItems);
3034
+ container.appendChild(headingRoot);
3035
+ headings.push({ root: headingRoot, tileStart: section.startIndex, structured });
3036
+ for (let i = section.startIndex; i < section.endIndex; i++) {
3037
+ container.appendChild(tiles[i].root);
3038
+ }
3039
+ }
3040
+ } else {
3041
+ for (const tile of tiles) container.appendChild(tile.root);
3042
+ }
3043
+ columnHeights = void 0;
3044
+ relayoutAll();
3045
+ autoMeasureTiles(tiles);
3046
+ emitLayoutRender(tiles);
3047
+ }
3048
+ function appendRender(newItems, startIndex) {
3049
+ const newTiles = newItems.map((item, i) => createTile(item, startIndex + i));
3050
+ for (const tile of newTiles) container.appendChild(tile.root);
3051
+ tiles = [...tiles, ...newTiles];
3052
+ if (horizontal)
3053
+ applyMasonryHorizontal();
3054
+ else if (type === "masonry") applyMasonry(newTiles);
3055
+ else if (type === "justified")
3056
+ applyJustified();
3057
+ else applyGrid(newTiles);
3058
+ autoMeasureTiles(newTiles);
3059
+ emitLayoutRender(newTiles);
3060
+ }
3061
+ fullRender(gallery.items);
3062
+ previousItems = gallery.items;
3063
+ const offItemsUpdated = ctx.on("itemsUpdated", ({ items }) => {
3064
+ if (groupingEnabled) {
3065
+ fullRender(items);
3066
+ } else if (isPureAppend(previousItems, items)) {
3067
+ appendRender(items.slice(previousItems.length), previousItems.length);
3068
+ } else {
3069
+ fullRender(items);
3070
+ }
3071
+ previousItems = items;
3072
+ });
3073
+ let resizeFrame = null;
3074
+ let resizeObserver;
3075
+ if (type === "masonry" || type === "justified" || sortedBreakpoints.length > 0) {
3076
+ resizeObserver = new ResizeObserver(() => {
3077
+ if (resizeFrame !== null) return;
3078
+ resizeFrame = requestAnimationFrame(() => {
3079
+ resizeFrame = null;
3080
+ relayoutAll();
3081
+ });
3082
+ });
3083
+ resizeObserver.observe(container);
3084
+ }
3085
+ return () => {
3086
+ destroyed = true;
3087
+ if (measureFrame !== null) cancelAnimationFrame(measureFrame);
3088
+ resizeObserver == null ? void 0 : resizeObserver.disconnect();
3089
+ if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
3090
+ offItemsUpdated();
3091
+ container.classList.remove("shoji-layout", `shoji-layout--${type}`, "shoji-layout--animate");
3092
+ container.style.removeProperty("height");
3093
+ container.style.removeProperty("width");
3094
+ container.replaceChildren();
3095
+ };
3096
+ }
3097
+ };
3098
+ function normalizeRotateFlip(state) {
3099
+ const rotation = (state.rotation % 360 + 360) % 360;
3100
+ if (state.flipH && state.flipV) {
3101
+ return { flipH: false, flipV: false, rotation: (rotation + 180) % 360 };
3102
+ }
3103
+ return { flipH: state.flipH, flipV: state.flipV, rotation };
3104
+ }
3105
+ const ROTATE_LEFT_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 1 1 0 12H9"/></svg>';
3106
+ const ROTATE_RIGHT_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 14 20 9l-5-5"/><path d="M20 9H10a6 6 0 1 0 0 12h5"/></svg>';
3107
+ const FLIP_H_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M17 7l3 5-3 5"/><path d="M7 7l-3 5 3 5"/></svg>';
3108
+ const FLIP_V_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/><path d="M7 17l5 3 5-3"/><path d="M7 7l5-3 5 3"/></svg>';
3109
+ const NEUTRAL = { flipH: false, flipV: false, rotation: 0 };
3110
+ function transformFor(flipH, flipV, rotationDeg, fitScale) {
3111
+ if (!flipH && !flipV && rotationDeg === 0 && fitScale === 1) return "none";
3112
+ const scaleX = (flipH ? -1 : 1) * fitScale;
3113
+ const scaleY = (flipV ? -1 : 1) * fitScale;
3114
+ return `scaleX(${scaleX}) scaleY(${scaleY}) rotate(${rotationDeg}deg)`;
3115
+ }
3116
+ function fitScaleFor(mediaWidth, mediaHeight, rotationDeg, naturalWidth, naturalHeight) {
3117
+ if (!mediaWidth || !mediaHeight || !naturalWidth || !naturalHeight) return 1;
3118
+ if (rotationDeg / 90 % 2 === 0) return 1;
3119
+ const scaleAt0 = Math.min(1, mediaWidth / naturalWidth, mediaHeight / naturalHeight);
3120
+ const scaleAt90 = Math.min(1, mediaWidth / naturalHeight, mediaHeight / naturalWidth);
3121
+ return scaleAt90 / scaleAt0;
3122
+ }
3123
+ const RotateFlip = {
3124
+ name: "rotateFlip",
3125
+ init(ctx) {
3126
+ const { gallery } = ctx;
3127
+ const locale = gallery.options.locale ?? {};
3128
+ const rotateLeftLabel = locale.rotateLeft ?? "Rotate left";
3129
+ const rotateRightLabel = locale.rotateRight ?? "Rotate right";
3130
+ const flipHLabel = locale.flipHorizontal ?? "Flip horizontal";
3131
+ const flipVLabel = locale.flipVertical ?? "Flip vertical";
3132
+ let state = { ...NEUTRAL };
3133
+ let visualRotation = 0;
3134
+ let visualFlipH = false;
3135
+ let visualFlipV = false;
3136
+ function resolveNaturalSize(media) {
3137
+ const item = gallery.items[gallery.currentIndex];
3138
+ if ((item == null ? void 0 : item.width) && item.height) return { width: item.width, height: item.height };
3139
+ const img = media.querySelector("img");
3140
+ if ((img == null ? void 0 : img.naturalWidth) && img.naturalHeight) {
3141
+ return { width: img.naturalWidth, height: img.naturalHeight };
3142
+ }
3143
+ return void 0;
3144
+ }
3145
+ function apply(animate) {
3146
+ const media = gallery.getActiveMedia();
3147
+ if (!media) return;
3148
+ const natural = resolveNaturalSize(media);
3149
+ const fitScale = fitScaleFor(
3150
+ media.clientWidth,
3151
+ media.clientHeight,
3152
+ visualRotation,
3153
+ natural == null ? void 0 : natural.width,
3154
+ natural == null ? void 0 : natural.height
3155
+ );
3156
+ const transform = transformFor(visualFlipH, visualFlipV, visualRotation, fitScale);
3157
+ if (!animate) {
3158
+ media.style.transform = transform;
3159
+ return;
3160
+ }
3161
+ media.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
3162
+ media.style.transform = transform;
3163
+ waitForTransitionEnd(media, () => {
3164
+ media.style.transition = "";
3165
+ });
3166
+ }
3167
+ function buildButton(icon, label) {
3168
+ const button = document.createElement("button");
3169
+ button.type = "button";
3170
+ button.className = "shoji-toolbar-button";
3171
+ button.innerHTML = icon;
3172
+ button.setAttribute("aria-label", label);
3173
+ button.title = label;
3174
+ return button;
3175
+ }
3176
+ const rotateLeftBtn = buildButton(ROTATE_LEFT_ICON, rotateLeftLabel);
3177
+ const rotateRightBtn = buildButton(ROTATE_RIGHT_ICON, rotateRightLabel);
3178
+ const flipHBtn = buildButton(FLIP_H_ICON, flipHLabel);
3179
+ const flipVBtn = buildButton(FLIP_V_ICON, flipVLabel);
3180
+ flipHBtn.setAttribute("aria-pressed", "false");
3181
+ flipVBtn.setAttribute("aria-pressed", "false");
3182
+ function update(patch, visualRotationDelta) {
3183
+ state = normalizeRotateFlip({ ...state, ...patch });
3184
+ if (visualRotationDelta !== void 0) visualRotation += visualRotationDelta;
3185
+ apply(true);
3186
+ flipHBtn.setAttribute("aria-pressed", String(state.flipH));
3187
+ flipVBtn.setAttribute("aria-pressed", String(state.flipV));
3188
+ ctx.emit("rotateFlipChange", { index: gallery.currentIndex, ...state });
3189
+ }
3190
+ function rotateDelta(clockwise) {
3191
+ const flippedOnOneAxis = visualFlipH !== visualFlipV;
3192
+ return clockwise !== flippedOnOneAxis ? 90 : -90;
3193
+ }
3194
+ rotateLeftBtn.addEventListener("click", () => {
3195
+ const delta = rotateDelta(false);
3196
+ update({ rotation: state.rotation + delta }, delta);
3197
+ });
3198
+ rotateRightBtn.addEventListener("click", () => {
3199
+ const delta = rotateDelta(true);
3200
+ update({ rotation: state.rotation + delta }, delta);
3201
+ });
3202
+ flipHBtn.addEventListener("click", () => {
3203
+ visualFlipH = !visualFlipH;
3204
+ update({ flipH: !state.flipH });
3205
+ });
3206
+ flipVBtn.addEventListener("click", () => {
3207
+ visualFlipV = !visualFlipV;
3208
+ update({ flipV: !state.flipV });
3209
+ });
3210
+ function reset() {
3211
+ state = { ...NEUTRAL };
3212
+ visualRotation = 0;
3213
+ visualFlipH = false;
3214
+ visualFlipV = false;
3215
+ apply(false);
3216
+ flipHBtn.setAttribute("aria-pressed", "false");
3217
+ flipVBtn.setAttribute("aria-pressed", "false");
3218
+ }
3219
+ const removeButtons = [rotateLeftBtn, rotateRightBtn, flipHBtn, flipVBtn].map(
3220
+ (button) => ctx.ui.toolbar("right", button)
3221
+ );
3222
+ const offOpen = ctx.on("afterOpen", reset);
3223
+ const offSlide = ctx.on("afterSlide", reset);
3224
+ return () => {
3225
+ for (const remove of removeButtons) remove();
3226
+ offOpen();
3227
+ offSlide();
3228
+ };
3229
+ }
3230
+ };
3231
+ let apiPromise = null;
3232
+ function loadYouTubeApi() {
3233
+ var _a;
3234
+ if ((_a = window.YT) == null ? void 0 : _a.Player) return Promise.resolve(window.YT);
3235
+ if (apiPromise) return apiPromise;
3236
+ apiPromise = new Promise((resolve) => {
3237
+ const previous = window.onYouTubeIframeAPIReady;
3238
+ window.onYouTubeIframeAPIReady = () => {
3239
+ previous == null ? void 0 : previous();
3240
+ resolve(window.YT);
3241
+ };
3242
+ const script = document.createElement("script");
3243
+ script.src = "https://www.youtube.com/iframe_api";
3244
+ document.head.appendChild(script);
3245
+ });
3246
+ return apiPromise;
3247
+ }
3248
+ function wirePlayableContract(container, player) {
3249
+ const playable = container;
3250
+ playable.paused = true;
3251
+ playable.ended = false;
3252
+ playable.play = () => player.playVideo();
3253
+ playable.pause = () => player.pauseVideo();
3254
+ Object.defineProperty(playable, "muted", {
3255
+ configurable: true,
3256
+ get: () => player.isMuted(),
3257
+ set: (value) => value ? player.mute() : player.unMute()
3258
+ });
3259
+ return playable;
3260
+ }
3261
+ function handleStateChange(event, YT, playable) {
3262
+ if (event.data === YT.PlayerState.PLAYING) {
3263
+ playable.paused = false;
3264
+ playable.dispatchEvent(new Event("play"));
3265
+ } else if (event.data === YT.PlayerState.PAUSED) {
3266
+ playable.paused = true;
3267
+ playable.dispatchEvent(new Event("pause"));
3268
+ } else if (event.data === YT.PlayerState.ENDED) {
3269
+ playable.paused = true;
3270
+ playable.ended = true;
3271
+ playable.dispatchEvent(new Event("ended"));
3272
+ }
3273
+ }
3274
+ const renderYouTube = (container, item, onReady, signal) => {
3275
+ var _a;
3276
+ if (((_a = item.video) == null ? void 0 : _a.provider) !== "youtube") return;
3277
+ const videoId = item.video.id;
3278
+ const mount = document.createElement("div");
3279
+ container.appendChild(mount);
3280
+ loadYouTubeApi().then((YT) => {
3281
+ if (signal.aborted) return;
3282
+ const playable = container;
3283
+ const player = new YT.Player(mount, {
3284
+ videoId,
3285
+ playerVars: { playsinline: 1, rel: 0 },
3286
+ events: {
3287
+ // `new YT.Player()` returns before the embed's own internal bootstrap
3288
+ // (its postMessage handshake with youtube.com) finishes — calling
3289
+ // `player.mute()`/`playVideo()`/etc. before that completes can throw
3290
+ // ("player.isMuted is not a function") or silently no-op, even though
3291
+ // the constructor already returned an object. `onReady` is YouTube's
3292
+ // own signal that the real API surface is actually callable now, so
3293
+ // wiring the contract here — not synchronously after construction —
3294
+ // is what makes `findPlayable()`'s "is `.play` wired?" check in
3295
+ // Autoplay (DESIGN.md §4.1) an accurate proxy for "is it really
3296
+ // ready?" instead of just "did we build the wrapper closures?".
3297
+ onReady: () => {
3298
+ wirePlayableContract(container, player);
3299
+ onReady();
3300
+ },
3301
+ onStateChange: (event) => handleStateChange(event, YT, playable),
3302
+ // bubbles: true — reaches .shoji-slide-media (the pool slot, always
3303
+ // present regardless of readiness) without needing wirePlayableContract
3304
+ // to have run first; onError can fire instead of onReady entirely
3305
+ // (e.g. a removed/private video), so playable isn't a safe assumption
3306
+ // here the way it is in onStateChange. Autoplay (DESIGN.md §4.1)
3307
+ // listens for this to skip to the next slide instead of stalling.
3308
+ onError: (event) => playable.dispatchEvent(
3309
+ new CustomEvent("error", { bubbles: true, detail: { code: event.data } })
3310
+ )
3311
+ }
3312
+ });
3313
+ signal.addEventListener("abort", () => player.destroy(), { once: true });
3314
+ });
3315
+ };
3316
+ const Video = {
3317
+ name: "video",
3318
+ init(ctx) {
3319
+ return ctx.ui.registerVideoProvider("youtube", renderYouTube);
3320
+ }
3321
+ };
3322
+ 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>';
3323
+ 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>';
3324
+ 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>';
3325
+ function clampScale(scale, min, max) {
3326
+ return Math.min(max, Math.max(min, scale));
3327
+ }
3328
+ function clampAxis(naturalPos, naturalSize, scale, containerPos, containerSize, offset) {
3329
+ const scaledSize = naturalSize * scale;
3330
+ if (scaledSize <= containerSize) {
3331
+ return containerPos + (containerSize - scaledSize) / 2 - naturalPos;
3332
+ }
3333
+ const minPos = containerPos + containerSize - scaledSize;
3334
+ const maxPos = containerPos;
3335
+ const proposedPos = naturalPos + offset;
3336
+ return Math.min(maxPos, Math.max(minPos, proposedPos)) - naturalPos;
3337
+ }
3338
+ function clampPan(natural, container, scale, pan) {
3339
+ return {
3340
+ tx: clampAxis(natural.left, natural.width, scale, container.left, container.width, pan.tx),
3341
+ ty: clampAxis(natural.top, natural.height, scale, container.top, container.height, pan.ty)
3342
+ };
3343
+ }
3344
+ function zoomTowardPoint(natural, pan, oldScale, newScale, anchorX, anchorY) {
3345
+ const currentLeft = natural.left + pan.tx;
3346
+ const currentTop = natural.top + pan.ty;
3347
+ const dx = anchorX - currentLeft;
3348
+ const dy = anchorY - currentTop;
3349
+ const factor = 1 - newScale / oldScale;
3350
+ return {
3351
+ tx: pan.tx + dx * factor,
3352
+ ty: pan.ty + dy * factor
3353
+ };
3354
+ }
3355
+ const ZOOM_EPSILON = 1.001;
3356
+ function isRealControl(event) {
3357
+ return event.composedPath().some(
3358
+ (node) => node instanceof Element && node.matches("button, video, input, select, textarea, a[href], [data-shoji-no-drag]")
3359
+ );
3360
+ }
3361
+ const Zoom = {
3362
+ name: "zoom",
3363
+ defaults: {
3364
+ maxScale: 4,
3365
+ doubleTapScale: 2,
3366
+ buttonStep: 1.5
3367
+ },
3368
+ init(ctx) {
3369
+ const { gallery } = ctx;
3370
+ const maxScale = Number(ctx.options.maxScale ?? 4);
3371
+ const doubleTapScale = Number(ctx.options.doubleTapScale ?? 2);
3372
+ const buttonStep = Number(ctx.options.buttonStep ?? 1.5);
3373
+ const locale = gallery.options.locale ?? {};
3374
+ const zoomInLabel = locale.zoomIn ?? "Zoom in";
3375
+ const zoomOutLabel = locale.zoomOut ?? "Zoom out";
3376
+ const actualSizeLabel = locale.zoomActualSize ?? "Actual size";
3377
+ let scale = 1;
3378
+ let pan = { tx: 0, ty: 0 };
3379
+ let natural = null;
3380
+ let container = null;
3381
+ let pinchStartScale = 1;
3382
+ function getImg() {
3383
+ const media = gallery.getActiveMedia();
3384
+ const child = media == null ? void 0 : media.firstElementChild;
3385
+ return child instanceof HTMLImageElement ? child : null;
3386
+ }
3387
+ function boxOf(el) {
3388
+ const rect = el.getBoundingClientRect();
3389
+ return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
3390
+ }
3391
+ function ensureNatural(img) {
3392
+ if (natural && container) return true;
3393
+ if (scale !== 1) return false;
3394
+ natural = boxOf(img);
3395
+ container = boxOf(img.parentElement ?? img);
3396
+ return true;
3397
+ }
3398
+ function whenSettled(action) {
3399
+ const media = gallery.getActiveMedia();
3400
+ if (!media || media.style.transition === "") {
3401
+ action();
3402
+ return;
3403
+ }
3404
+ waitForTransitionEnd(media, action);
3405
+ }
3406
+ function apply() {
3407
+ const img = getImg();
3408
+ if (!img) return;
3409
+ img.style.transformOrigin = "0 0";
3410
+ img.style.transform = scale === 1 && pan.tx === 0 && pan.ty === 0 ? "none" : `translate3d(${pan.tx}px, ${pan.ty}px, 0) scale3d(${scale}, ${scale}, 1)`;
3411
+ img.classList.toggle("shoji-zoomed", scale > 1);
3412
+ }
3413
+ function emitChange() {
3414
+ ctx.emit("zoomChange", { index: gallery.currentIndex, scale });
3415
+ }
3416
+ function withTransition(img, run, afterEnd) {
3417
+ img.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
3418
+ run();
3419
+ waitForTransitionEnd(img, () => {
3420
+ img.style.transition = "";
3421
+ afterEnd == null ? void 0 : afterEnd();
3422
+ });
3423
+ }
3424
+ function zoomTo(targetScale, anchorX, anchorY, ceiling = maxScale, animate = false) {
3425
+ whenSettled(() => {
3426
+ const img = getImg();
3427
+ if (!img || !ensureNatural(img)) return;
3428
+ const clampedScale = clampScale(targetScale, 1, Math.max(ceiling, 1));
3429
+ pan = zoomTowardPoint(natural, pan, scale, clampedScale, anchorX, anchorY);
3430
+ scale = clampedScale;
3431
+ pan = clampPan(natural, container, scale, pan);
3432
+ if (animate) withTransition(img, apply);
3433
+ else apply();
3434
+ emitChange();
3435
+ });
3436
+ }
3437
+ function reset(animate = false) {
3438
+ scale = 1;
3439
+ pan = { tx: 0, ty: 0 };
3440
+ natural = null;
3441
+ container = null;
3442
+ const img = getImg();
3443
+ if (!img) return;
3444
+ const clearTransform = () => {
3445
+ img.style.transform = "";
3446
+ img.classList.remove("shoji-zoomed");
3447
+ };
3448
+ if (animate) {
3449
+ withTransition(img, clearTransform, () => {
3450
+ img.style.transformOrigin = "";
3451
+ });
3452
+ } else {
3453
+ clearTransform();
3454
+ img.style.transformOrigin = "";
3455
+ }
3456
+ }
3457
+ function markEnabled() {
3458
+ var _a;
3459
+ (_a = getImg()) == null ? void 0 : _a.classList.add("shoji-zoom-enabled");
3460
+ }
3461
+ function toggleZoom(x, y) {
3462
+ if (scale > ZOOM_EPSILON) reset(true);
3463
+ else zoomTo(doubleTapScale, x, y, maxScale, true);
3464
+ }
3465
+ const offPinchStart = ctx.on("pinchStart", () => {
3466
+ pinchStartScale = scale;
3467
+ });
3468
+ const offPinchMove = ctx.on("pinchMove", ({ scale: relative, centerX, centerY }) => {
3469
+ zoomTo(pinchStartScale * relative, centerX, centerY);
3470
+ });
3471
+ const offPinchEnd = ctx.on("pinchEnd", () => {
3472
+ if (scale <= ZOOM_EPSILON) reset();
3473
+ });
3474
+ const offDoubleTap = ctx.on("doubleTap", ({ x, y }) => toggleZoom(x, y));
3475
+ const offWheelZoom = ctx.on("wheelZoom", ({ deltaScale, x, y }) => {
3476
+ zoomTo(scale + deltaScale, x, y);
3477
+ });
3478
+ let panPointerId = null;
3479
+ let lastX = 0;
3480
+ let lastY = 0;
3481
+ const outer = ctx.ui.outer();
3482
+ function onPointerDown(event) {
3483
+ if (scale <= ZOOM_EPSILON || isRealControl(event)) return;
3484
+ panPointerId = event.pointerId;
3485
+ lastX = event.clientX;
3486
+ lastY = event.clientY;
3487
+ }
3488
+ function onPointerMove(event) {
3489
+ if (panPointerId !== event.pointerId || !natural || !container) return;
3490
+ event.preventDefault();
3491
+ const dx = event.clientX - lastX;
3492
+ const dy = event.clientY - lastY;
3493
+ lastX = event.clientX;
3494
+ lastY = event.clientY;
3495
+ pan = clampPan(natural, container, scale, { tx: pan.tx + dx, ty: pan.ty + dy });
3496
+ apply();
3497
+ }
3498
+ function onPointerUp(event) {
3499
+ if (panPointerId === event.pointerId) panPointerId = null;
3500
+ }
3501
+ outer.addEventListener("pointerdown", onPointerDown);
3502
+ outer.addEventListener("pointermove", onPointerMove, { passive: false });
3503
+ outer.addEventListener("pointerup", onPointerUp);
3504
+ outer.addEventListener("pointercancel", onPointerUp);
3505
+ function buildButton(icon, label) {
3506
+ const button = document.createElement("button");
3507
+ button.type = "button";
3508
+ button.className = "shoji-toolbar-button";
3509
+ button.innerHTML = icon;
3510
+ button.setAttribute("aria-label", label);
3511
+ button.title = label;
3512
+ return button;
3513
+ }
3514
+ function centerAnchor() {
3515
+ const media = gallery.getActiveMedia();
3516
+ const rect = media == null ? void 0 : media.getBoundingClientRect();
3517
+ return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : { x: 0, y: 0 };
3518
+ }
3519
+ function zoomInStep() {
3520
+ const { x, y } = centerAnchor();
3521
+ zoomTo(scale * buttonStep, x, y, maxScale, true);
3522
+ }
3523
+ function zoomOutStep() {
3524
+ const { x, y } = centerAnchor();
3525
+ if (scale / buttonStep <= ZOOM_EPSILON) reset(true);
3526
+ else zoomTo(scale / buttonStep, x, y, maxScale, true);
3527
+ }
3528
+ const zoomInBtn = buildButton(ZOOM_IN_ICON, zoomInLabel);
3529
+ const zoomOutBtn = buildButton(ZOOM_OUT_ICON, zoomOutLabel);
3530
+ const actualSizeBtn = buildButton(ZOOM_ACTUAL_SIZE_ICON, actualSizeLabel);
3531
+ zoomInBtn.addEventListener("click", zoomInStep);
3532
+ zoomOutBtn.addEventListener("click", zoomOutStep);
3533
+ actualSizeBtn.addEventListener("click", () => {
3534
+ whenSettled(() => {
3535
+ const img = getImg();
3536
+ if (!img || !img.naturalWidth) return;
3537
+ if (!ensureNatural(img)) return;
3538
+ if (scale > ZOOM_EPSILON) {
3539
+ reset(true);
3540
+ return;
3541
+ }
3542
+ const targetScale = img.naturalWidth / natural.width;
3543
+ const { x, y } = centerAnchor();
3544
+ zoomTo(targetScale, x, y, targetScale, true);
3545
+ });
3546
+ });
3547
+ const removeButtons = [zoomInBtn, zoomOutBtn, actualSizeBtn].map(
3548
+ (button) => ctx.ui.toolbar("right", button)
3549
+ );
3550
+ const removeShortcuts = [
3551
+ ctx.ui.registerShortcut("w", zoomInStep),
3552
+ ctx.ui.registerShortcut("W", zoomInStep),
3553
+ ctx.ui.registerShortcut("s", zoomOutStep),
3554
+ ctx.ui.registerShortcut("S", zoomOutStep)
3555
+ ];
3556
+ const offOpen = ctx.on("afterOpen", () => {
3557
+ reset();
3558
+ markEnabled();
3559
+ });
3560
+ const offBeforeSlide = ctx.on("beforeSlide", () => reset());
3561
+ const offSlide = ctx.on("afterSlide", () => {
3562
+ reset();
3563
+ markEnabled();
3564
+ });
3565
+ const offBeforeClose = ctx.on("beforeClose", () => reset());
3566
+ const unregisterGate = gallery.registerZoomGate(() => scale > ZOOM_EPSILON);
3567
+ markEnabled();
3568
+ return () => {
3569
+ var _a;
3570
+ for (const remove of removeButtons) remove();
3571
+ for (const remove of removeShortcuts) remove();
3572
+ offOpen();
3573
+ offBeforeSlide();
3574
+ offSlide();
3575
+ offBeforeClose();
3576
+ offPinchStart();
3577
+ offPinchMove();
3578
+ offPinchEnd();
3579
+ offDoubleTap();
3580
+ offWheelZoom();
3581
+ outer.removeEventListener("pointerdown", onPointerDown);
3582
+ outer.removeEventListener("pointermove", onPointerMove);
3583
+ outer.removeEventListener("pointerup", onPointerUp);
3584
+ outer.removeEventListener("pointercancel", onPointerUp);
3585
+ unregisterGate();
3586
+ reset();
3587
+ (_a = getImg()) == null ? void 0 : _a.classList.remove("shoji-zoom-enabled");
3588
+ };
3589
+ }
3590
+ };
3591
+ const version = "0.1.0-alpha.6";
3592
+ const Shoji = Object.assign(Gallery, {
3593
+ Autoplay,
3594
+ Layout,
3595
+ ActiveThumbnail,
3596
+ Fullscreen,
3597
+ RotateFlip,
3598
+ Video,
3599
+ Zoom,
3600
+ /** `package.json`'s own version — the single source of truth, read via a named JSON import so a bundler can tree-shake away everything else in package.json (devDependencies, scripts, ...) rather than embedding the whole file. */
3601
+ version
3602
+ });
3603
+ return Shoji;
3604
+ }));
3605
+ //# sourceMappingURL=shoji.js.map