@michaelyagi/shoji 0.1.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/dist/esm/core/EventBus.d.ts +16 -0
  4. package/dist/esm/core/FocusTrap.d.ts +9 -0
  5. package/dist/esm/core/Gallery.d.ts +221 -0
  6. package/dist/esm/core/GestureController.d.ts +50 -0
  7. package/dist/esm/core/LiveRegion.d.ts +6 -0
  8. package/dist/esm/core/SlideManager.d.ts +83 -0
  9. package/dist/esm/core/bodyScrollLock.d.ts +2 -0
  10. package/dist/esm/core/dom.d.ts +27 -0
  11. package/dist/esm/core/icons.d.ts +6 -0
  12. package/dist/esm/core/index.d.ts +5 -0
  13. package/dist/esm/core/index.js +1966 -0
  14. package/dist/esm/core/index.js.map +1 -0
  15. package/dist/esm/core/plugin.d.ts +54 -0
  16. package/dist/esm/core/rotateFlipNormalize.d.ts +19 -0
  17. package/dist/esm/core/scan.d.ts +8 -0
  18. package/dist/esm/core/types.d.ts +268 -0
  19. package/dist/esm/core/zoomTransition.d.ts +33 -0
  20. package/dist/esm/gestures/GestureEngine.d.ts +78 -0
  21. package/dist/esm/index.css +475 -0
  22. package/dist/esm/index.d.ts +32 -0
  23. package/dist/esm/index.js +24 -0
  24. package/dist/esm/index.js.map +1 -0
  25. package/dist/esm/index2.css +22 -0
  26. package/dist/esm/index3.css +166 -0
  27. package/dist/esm/index4.css +27 -0
  28. package/dist/esm/plugins/activeThumbnail/index.d.ts +26 -0
  29. package/dist/esm/plugins/activeThumbnail/index.js +48 -0
  30. package/dist/esm/plugins/activeThumbnail/index.js.map +1 -0
  31. package/dist/esm/plugins/autoplay/icons.d.ts +3 -0
  32. package/dist/esm/plugins/autoplay/index.d.ts +17 -0
  33. package/dist/esm/plugins/autoplay/index.js +177 -0
  34. package/dist/esm/plugins/autoplay/index.js.map +1 -0
  35. package/dist/esm/plugins/fullscreen/icons.d.ts +3 -0
  36. package/dist/esm/plugins/fullscreen/index.d.ts +17 -0
  37. package/dist/esm/plugins/fullscreen/index.js +74 -0
  38. package/dist/esm/plugins/fullscreen/index.js.map +1 -0
  39. package/dist/esm/plugins/layout/index.d.ts +191 -0
  40. package/dist/esm/plugins/layout/index.js +746 -0
  41. package/dist/esm/plugins/layout/index.js.map +1 -0
  42. package/dist/esm/plugins/layout/justified.d.ts +68 -0
  43. package/dist/esm/plugins/layout/masonry.d.ts +92 -0
  44. package/dist/esm/plugins/rotateFlip/icons.d.ts +5 -0
  45. package/dist/esm/plugins/rotateFlip/index.d.ts +17 -0
  46. package/dist/esm/plugins/rotateFlip/index.js +138 -0
  47. package/dist/esm/plugins/rotateFlip/index.js.map +1 -0
  48. package/dist/esm/plugins/video/index.d.ts +13 -0
  49. package/dist/esm/plugins/video/index.js +95 -0
  50. package/dist/esm/plugins/video/index.js.map +1 -0
  51. package/dist/esm/plugins/video/youtube.d.ts +61 -0
  52. package/dist/esm/plugins/zoom/icons.d.ts +4 -0
  53. package/dist/esm/plugins/zoom/index.d.ts +30 -0
  54. package/dist/esm/plugins/zoom/index.js +274 -0
  55. package/dist/esm/plugins/zoom/index.js.map +1 -0
  56. package/dist/esm/plugins/zoom/zoomMath.d.ts +24 -0
  57. package/dist/esm/transitions/SlideTransition.d.ts +25 -0
  58. package/dist/esm/transitions/presets.d.ts +21 -0
  59. package/dist/esm/zoomTransition-bbKHpVpA.js +110 -0
  60. package/dist/esm/zoomTransition-bbKHpVpA.js.map +1 -0
  61. package/dist/shoji.css +690 -0
  62. package/dist/shoji.js +3605 -0
  63. package/dist/shoji.js.map +1 -0
  64. package/dist/shoji.min.css +1 -0
  65. package/dist/shoji.min.js +2 -0
  66. package/dist/shoji.min.js.map +1 -0
  67. package/package.json +77 -0
@@ -0,0 +1,1966 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+ import { w as waitForTransitionEnd, z as zoomIn, a as zoomOut } from "../zoomTransition-bbKHpVpA.js";
5
+ let lockCount = 0;
6
+ let savedOverflow = "";
7
+ let savedHtmlOverflow = "";
8
+ function lockBodyScroll() {
9
+ if (lockCount === 0) {
10
+ savedOverflow = document.body.style.overflow;
11
+ document.body.style.overflow = "hidden";
12
+ savedHtmlOverflow = document.documentElement.style.overflow;
13
+ document.documentElement.style.overflow = "hidden";
14
+ }
15
+ lockCount++;
16
+ }
17
+ function unlockBodyScroll() {
18
+ lockCount = Math.max(0, lockCount - 1);
19
+ if (lockCount === 0) {
20
+ document.body.style.overflow = savedOverflow;
21
+ document.documentElement.style.overflow = savedHtmlOverflow;
22
+ }
23
+ }
24
+ class EventBus {
25
+ constructor() {
26
+ __publicField(this, "listeners", /* @__PURE__ */ new Map());
27
+ }
28
+ on(event, fn) {
29
+ let set = this.listeners.get(event);
30
+ if (!set) {
31
+ set = /* @__PURE__ */ new Set();
32
+ this.listeners.set(event, set);
33
+ }
34
+ set.add(fn);
35
+ return () => this.off(event, fn);
36
+ }
37
+ off(event, fn) {
38
+ var _a;
39
+ (_a = this.listeners.get(event)) == null ? void 0 : _a.delete(fn);
40
+ }
41
+ emit(event, detail) {
42
+ const set = this.listeners.get(event);
43
+ if (!set) return;
44
+ for (const fn of set) fn(detail);
45
+ }
46
+ clear() {
47
+ this.listeners.clear();
48
+ }
49
+ }
50
+ 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>';
51
+ 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>';
52
+ 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>';
53
+ const PLAY_ICON = '<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
54
+ 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>';
55
+ let idCounter = 0;
56
+ function iconButton(className, icon, label) {
57
+ const button = document.createElement("button");
58
+ button.type = "button";
59
+ button.className = className;
60
+ button.ariaLabel = button.title = label;
61
+ button.innerHTML = icon;
62
+ return button;
63
+ }
64
+ function buildLightboxDom(slides, labels) {
65
+ const labelId = `shoji-counter-${++idCounter}`;
66
+ const outer = document.createElement("div");
67
+ outer.className = "shoji-outer";
68
+ const backdrop = document.createElement("div");
69
+ backdrop.className = "shoji-backdrop";
70
+ outer.appendChild(backdrop);
71
+ const dialog = document.createElement("div");
72
+ dialog.className = "shoji-dialog";
73
+ dialog.setAttribute("role", "dialog");
74
+ dialog.setAttribute("aria-modal", "true");
75
+ dialog.setAttribute("aria-labelledby", labelId);
76
+ dialog.tabIndex = -1;
77
+ const toolbar = document.createElement("div");
78
+ toolbar.className = "shoji-toolbar";
79
+ const toolbarLeft = document.createElement("div");
80
+ toolbarLeft.className = "shoji-toolbar-slot shoji-toolbar-left";
81
+ const toolbarCenter = document.createElement("div");
82
+ toolbarCenter.className = "shoji-toolbar-slot shoji-toolbar-center";
83
+ const toolbarRight = document.createElement("div");
84
+ toolbarRight.className = "shoji-toolbar-slot shoji-toolbar-right";
85
+ const closeButton = iconButton("shoji-close", CLOSE_ICON, labels.close);
86
+ const captionToggleButton = iconButton(
87
+ "shoji-toolbar-button shoji-caption-toggle",
88
+ CAPTION_ICON,
89
+ labels.showCaption
90
+ );
91
+ captionToggleButton.setAttribute("aria-pressed", "false");
92
+ captionToggleButton.hidden = true;
93
+ toolbarRight.append(captionToggleButton, closeButton);
94
+ const counter = document.createElement("div");
95
+ counter.className = "shoji-counter";
96
+ counter.id = labelId;
97
+ toolbarLeft.appendChild(counter);
98
+ toolbar.append(toolbarLeft, toolbarCenter, toolbarRight);
99
+ dialog.appendChild(toolbar);
100
+ const prevButton = iconButton("shoji-nav shoji-nav-prev", PREV_ICON, labels.previous);
101
+ const nextButton = iconButton("shoji-nav shoji-nav-next", NEXT_ICON, labels.next);
102
+ dialog.appendChild(prevButton);
103
+ dialog.appendChild(nextButton);
104
+ dialog.appendChild(slides);
105
+ const caption = document.createElement("div");
106
+ caption.className = "shoji-caption";
107
+ dialog.appendChild(caption);
108
+ outer.appendChild(dialog);
109
+ return {
110
+ outer,
111
+ dialog,
112
+ counter,
113
+ caption,
114
+ closeButton,
115
+ prevButton,
116
+ nextButton,
117
+ captionToggleButton,
118
+ toolbarLeft,
119
+ toolbarCenter,
120
+ toolbarRight
121
+ };
122
+ }
123
+ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), video[controls], [tabindex]:not([tabindex="-1"])';
124
+ class FocusTrap {
125
+ constructor() {
126
+ __publicField(this, "container", null);
127
+ __publicField(this, "previouslyFocused", null);
128
+ __publicField(this, "onKeydown", (event) => {
129
+ if (event.key !== "Tab" || !this.container) return;
130
+ const focusable = this.getFocusable();
131
+ if (focusable.length === 0) {
132
+ event.preventDefault();
133
+ this.container.focus();
134
+ return;
135
+ }
136
+ const first = focusable[0];
137
+ const last = focusable[focusable.length - 1];
138
+ const active = document.activeElement;
139
+ const activeIsInside = active instanceof HTMLElement && focusable.includes(active);
140
+ if (event.shiftKey) {
141
+ if (!activeIsInside || active === first) {
142
+ event.preventDefault();
143
+ last.focus();
144
+ }
145
+ } else if (!activeIsInside || active === last) {
146
+ event.preventDefault();
147
+ first.focus();
148
+ }
149
+ });
150
+ }
151
+ getFocusable() {
152
+ if (!this.container) return [];
153
+ return Array.from(this.container.querySelectorAll(FOCUSABLE_SELECTOR));
154
+ }
155
+ activate(container) {
156
+ this.previouslyFocused = document.activeElement;
157
+ this.container = container;
158
+ document.addEventListener("keydown", this.onKeydown, true);
159
+ container.focus();
160
+ }
161
+ deactivate() {
162
+ var _a;
163
+ document.removeEventListener("keydown", this.onKeydown, true);
164
+ this.container = null;
165
+ (_a = this.previouslyFocused) == null ? void 0 : _a.focus();
166
+ this.previouslyFocused = null;
167
+ }
168
+ }
169
+ const DOUBLE_TAP_MAX_DELAY_MS = 300;
170
+ const DOUBLE_TAP_MAX_DISTANCE = 30;
171
+ const TAP_MAX_DELAY_MS = 500;
172
+ class GestureEngine {
173
+ constructor(target, callbacks, options) {
174
+ __publicField(this, "target");
175
+ __publicField(this, "options");
176
+ __publicField(this, "callbacks");
177
+ __publicField(this, "pointers", /* @__PURE__ */ new Map());
178
+ __publicField(this, "primaryPointerId", null);
179
+ __publicField(this, "direction", null);
180
+ __publicField(this, "dragStartDistance", 0);
181
+ // along the locked axis, at the moment direction locked — subtracted so onDragStart's delta is 0
182
+ __publicField(this, "pinching", false);
183
+ __publicField(this, "pinchStartDistance", 0);
184
+ // -Infinity, not 0: `event.timeStamp` is time-since-navigation-start, so a
185
+ // real first tap is always some large positive number and 0 would seem
186
+ // like a safe "no previous tap" sentinel in practice — but not always: a
187
+ // tap in the first 300ms of the page's life, near the viewport's top-left
188
+ // corner, would satisfy registerTap's isDouble check against this
189
+ // sentinel and get wrongly reported as a double-tap on nothing. -Infinity
190
+ // can never be within DOUBLE_TAP_MAX_DELAY_MS of any real timestamp.
191
+ __publicField(this, "lastTapTime", -Infinity);
192
+ __publicField(this, "lastTapX", 0);
193
+ __publicField(this, "lastTapY", 0);
194
+ __publicField(this, "onPointerDown", (event) => {
195
+ var _a, _b, _c, _d;
196
+ if (event.isPrimary === false && this.pointers.size === 0) return;
197
+ if (this.pointers.size === 0 && ((_b = (_a = this.callbacks).ignore) == null ? void 0 : _b.call(_a, event))) return;
198
+ this.pointers.set(event.pointerId, {
199
+ startX: event.clientX,
200
+ startY: event.clientY,
201
+ startTime: event.timeStamp,
202
+ lastX: event.clientX,
203
+ lastY: event.clientY,
204
+ lastTime: event.timeStamp
205
+ });
206
+ if (this.pointers.size === 1) {
207
+ this.primaryPointerId = event.pointerId;
208
+ this.direction = null;
209
+ } else if (this.pointers.size === 2) {
210
+ this.direction = null;
211
+ this.pinching = true;
212
+ const [a, b] = [...this.pointers.values()];
213
+ this.pinchStartDistance = distanceBetween(a, b);
214
+ const center = centerOf(a, b);
215
+ (_d = (_c = this.callbacks).onPinchStart) == null ? void 0 : _d.call(_c, center.x, center.y);
216
+ }
217
+ });
218
+ __publicField(this, "onPointerMove", (event) => {
219
+ var _a, _b, _c, _d, _e, _f, _g, _h;
220
+ const state = this.pointers.get(event.pointerId);
221
+ if (!state) return;
222
+ state.lastX = event.clientX;
223
+ state.lastY = event.clientY;
224
+ state.lastTime = event.timeStamp;
225
+ if (this.pinching) {
226
+ if (this.pointers.size < 2) return;
227
+ const [a, b] = [...this.pointers.values()];
228
+ const distance = distanceBetween(a, b);
229
+ const scale = this.pinchStartDistance > 0 ? distance / this.pinchStartDistance : 1;
230
+ const center = centerOf(a, b);
231
+ (_b = (_a = this.callbacks).onPinchMove) == null ? void 0 : _b.call(_a, scale, center.x, center.y);
232
+ return;
233
+ }
234
+ if (event.pointerId !== this.primaryPointerId) return;
235
+ const dx = event.clientX - state.startX;
236
+ const dy = event.clientY - state.startY;
237
+ if (this.direction === null) {
238
+ const absX = Math.abs(dx);
239
+ const absY = Math.abs(dy);
240
+ if (Math.max(absX, absY) < this.options.lockThreshold) return;
241
+ this.direction = absX > absY ? "horizontal" : "vertical";
242
+ this.dragStartDistance = this.direction === "horizontal" ? dx : dy;
243
+ if (((_d = (_c = this.callbacks).shouldCapture) == null ? void 0 : _d.call(_c)) ?? true) this.target.setPointerCapture(event.pointerId);
244
+ (_f = (_e = this.callbacks).onDragStart) == null ? void 0 : _f.call(_e, this.direction, event);
245
+ }
246
+ if (this.direction === "horizontal") event.preventDefault();
247
+ const delta = (this.direction === "horizontal" ? dx : dy) - this.dragStartDistance;
248
+ (_h = (_g = this.callbacks).onDragMove) == null ? void 0 : _h.call(_g, this.direction, delta, event);
249
+ });
250
+ __publicField(this, "onPointerUp", (event) => {
251
+ this.finishPointer(event, false);
252
+ });
253
+ __publicField(this, "onPointerCancel", (event) => {
254
+ this.finishPointer(event, true);
255
+ });
256
+ __publicField(this, "onWheel", (event) => {
257
+ var _a, _b;
258
+ if (!event.ctrlKey) return;
259
+ event.preventDefault();
260
+ const deltaScale = -event.deltaY * 0.01;
261
+ (_b = (_a = this.callbacks).onWheelZoom) == null ? void 0 : _b.call(_a, deltaScale, event.clientX, event.clientY, event);
262
+ });
263
+ this.target = target;
264
+ this.callbacks = callbacks;
265
+ this.options = {
266
+ lockThreshold: (options == null ? void 0 : options.lockThreshold) ?? 10,
267
+ swipeThreshold: (options == null ? void 0 : options.swipeThreshold) ?? 50,
268
+ swipeVelocity: (options == null ? void 0 : options.swipeVelocity) ?? 0.3
269
+ };
270
+ this.target.addEventListener("pointerdown", this.onPointerDown);
271
+ this.target.addEventListener("pointermove", this.onPointerMove);
272
+ this.target.addEventListener("pointerup", this.onPointerUp);
273
+ this.target.addEventListener("pointercancel", this.onPointerCancel);
274
+ this.target.addEventListener("wheel", this.onWheel, { passive: false });
275
+ }
276
+ destroy() {
277
+ this.target.removeEventListener("pointerdown", this.onPointerDown);
278
+ this.target.removeEventListener("pointermove", this.onPointerMove);
279
+ this.target.removeEventListener("pointerup", this.onPointerUp);
280
+ this.target.removeEventListener("pointercancel", this.onPointerCancel);
281
+ this.target.removeEventListener("wheel", this.onWheel);
282
+ this.pointers.clear();
283
+ }
284
+ finishPointer(event, cancelled) {
285
+ var _a, _b, _c, _d;
286
+ const state = this.pointers.get(event.pointerId);
287
+ if (!state) return;
288
+ state.lastX = event.clientX;
289
+ state.lastY = event.clientY;
290
+ state.lastTime = event.timeStamp;
291
+ this.pointers.delete(event.pointerId);
292
+ if (this.pinching) {
293
+ if (this.pointers.size < 2) {
294
+ this.pinching = false;
295
+ (_b = (_a = this.callbacks).onPinchEnd) == null ? void 0 : _b.call(_a);
296
+ }
297
+ return;
298
+ }
299
+ if (event.pointerId !== this.primaryPointerId) return;
300
+ if (this.direction === null) {
301
+ if (!cancelled && event.timeStamp - state.startTime <= TAP_MAX_DELAY_MS) {
302
+ this.registerTap(state.lastX, state.lastY, event);
303
+ }
304
+ } else {
305
+ const elapsed = Math.max(1, state.lastTime - state.startTime);
306
+ const totalDelta = (this.direction === "horizontal" ? state.lastX - state.startX : state.lastY - state.startY) - this.dragStartDistance;
307
+ const velocity = totalDelta / elapsed;
308
+ const completed = !cancelled && (Math.abs(totalDelta) >= this.options.swipeThreshold || Math.abs(velocity) >= this.options.swipeVelocity);
309
+ (_d = (_c = this.callbacks).onDragEnd) == null ? void 0 : _d.call(_c, this.direction, totalDelta, velocity, completed, cancelled);
310
+ }
311
+ this.primaryPointerId = null;
312
+ this.direction = null;
313
+ }
314
+ registerTap(x, y, event) {
315
+ var _a, _b, _c, _d;
316
+ const now = event.timeStamp;
317
+ const isDouble = now - this.lastTapTime <= DOUBLE_TAP_MAX_DELAY_MS && Math.hypot(x - this.lastTapX, y - this.lastTapY) <= DOUBLE_TAP_MAX_DISTANCE;
318
+ if (isDouble) {
319
+ (_b = (_a = this.callbacks).onDoubleTap) == null ? void 0 : _b.call(_a, x, y, event);
320
+ this.lastTapTime = 0;
321
+ } else {
322
+ (_d = (_c = this.callbacks).onTap) == null ? void 0 : _d.call(_c, x, y, event);
323
+ this.lastTapTime = now;
324
+ this.lastTapX = x;
325
+ this.lastTapY = y;
326
+ }
327
+ }
328
+ }
329
+ function distanceBetween(a, b) {
330
+ return Math.hypot(a.lastX - b.lastX, a.lastY - b.lastY);
331
+ }
332
+ function centerOf(a, b) {
333
+ return { x: (a.lastX + b.lastX) / 2, y: (a.lastY + b.lastY) / 2 };
334
+ }
335
+ const DRAG_SETTLE_TRANSITION = "transform var(--shoji-duration) var(--shoji-momentum-easing)";
336
+ const DRAG_FEEDBACK_TRANSITION = "transform var(--shoji-duration) var(--shoji-momentum-easing), opacity var(--shoji-duration) var(--shoji-momentum-easing)";
337
+ const VERTICAL_FEEDBACK_DISTANCE = 160;
338
+ function shouldIgnoreGesture(event) {
339
+ return event.composedPath().some(
340
+ (node) => node instanceof Element && node.matches("button, video, input, select, textarea, a[href], [data-shoji-no-drag]")
341
+ );
342
+ }
343
+ class GestureController {
344
+ constructor(host, relay, options) {
345
+ __publicField(this, "engine");
346
+ /** Horizontal follows the finger 1:1; vertical drives close-feedback instead. Axis-locked, one branch per drag. */
347
+ __publicField(this, "onDragMove", (direction, delta) => {
348
+ if (this.host.isZoomed()) return;
349
+ if (direction === "horizontal") {
350
+ this.host.slides.setDragOffset(delta, null);
351
+ } else if (this.host.canClose()) {
352
+ this.applyVerticalDragFeedback(delta);
353
+ }
354
+ });
355
+ __publicField(this, "onDragEnd", (direction, delta, _velocity, completed) => {
356
+ if (this.host.isZoomed()) return;
357
+ if (direction === "horizontal") {
358
+ this.finishHorizontalDrag(delta, completed);
359
+ } else {
360
+ this.finishVerticalDrag(completed);
361
+ }
362
+ });
363
+ this.host = host;
364
+ this.engine = new GestureEngine(
365
+ host.dialog,
366
+ {
367
+ ignore: shouldIgnoreGesture,
368
+ shouldCapture: () => !host.isZoomed(),
369
+ onDragStart: () => host.onActivity(),
370
+ onDragMove: this.onDragMove,
371
+ onDragEnd: this.onDragEnd,
372
+ onTap: (x, y) => relay.onTap(x, y),
373
+ onDoubleTap: (x, y) => relay.onDoubleTap(x, y),
374
+ onPinchStart: (centerX, centerY) => relay.onPinchStart(centerX, centerY),
375
+ onPinchMove: (scale, centerX, centerY) => relay.onPinchMove(scale, centerX, centerY),
376
+ onPinchEnd: () => relay.onPinchEnd(),
377
+ onWheelZoom: (deltaScale, x, y) => relay.onWheelZoom(deltaScale, x, y)
378
+ },
379
+ options
380
+ );
381
+ }
382
+ destroy() {
383
+ this.engine.destroy();
384
+ }
385
+ /** `completed` decides intent; `canGoNext`/`canGoPrev` guard whether that's actually possible (`loop: false` at an end item). */
386
+ finishHorizontalDrag(delta, completed) {
387
+ const goingNext = delta < 0;
388
+ const canMove = goingNext ? this.host.canGoNext() : this.host.canGoPrev();
389
+ if (!completed || delta === 0 || !canMove) {
390
+ this.settleDragOffset(0, () => {
391
+ });
392
+ return;
393
+ }
394
+ const width = this.host.dialog.getBoundingClientRect().width || 1;
395
+ const target = goingNext ? -width : width;
396
+ this.settleDragOffset(target, () => {
397
+ this.host.slides.setDragOffset(0, null);
398
+ if (goingNext) this.host.next();
399
+ else this.host.prev();
400
+ });
401
+ }
402
+ settleDragOffset(targetPx, onSettled) {
403
+ const settleEl = this.host.slides.getSlotRoot(0);
404
+ this.host.slides.setDragOffset(targetPx, DRAG_SETTLE_TRANSITION);
405
+ if (!settleEl) {
406
+ onSettled();
407
+ return;
408
+ }
409
+ waitForTransitionEnd(settleEl, onSettled);
410
+ }
411
+ /** Purely presentational drag feedback — scales/fades the dialog toward `close()`'s target state as the viewer drags, without closing until release decides the outcome. */
412
+ applyVerticalDragFeedback(delta) {
413
+ const progress = Math.min(Math.abs(delta) / VERTICAL_FEEDBACK_DISTANCE, 1);
414
+ const dialog = this.host.dialog;
415
+ dialog.style.transition = "";
416
+ dialog.style.transform = `translateY(${delta}px) scale(${1 - progress * 0.15})`;
417
+ dialog.style.opacity = String(1 - progress * 0.6);
418
+ }
419
+ clearVerticalDragFeedback(animate) {
420
+ const dialog = this.host.dialog;
421
+ dialog.style.transition = animate ? DRAG_FEEDBACK_TRANSITION : "";
422
+ dialog.style.transform = "";
423
+ dialog.style.opacity = "";
424
+ }
425
+ finishVerticalDrag(completed) {
426
+ if (completed && this.host.canClose()) {
427
+ this.clearVerticalDragFeedback(false);
428
+ this.host.close();
429
+ } else {
430
+ this.clearVerticalDragFeedback(true);
431
+ }
432
+ }
433
+ }
434
+ class LiveRegion {
435
+ constructor() {
436
+ __publicField(this, "element");
437
+ this.element = document.createElement("div");
438
+ this.element.className = "shoji-live-region";
439
+ this.element.setAttribute("role", "status");
440
+ this.element.setAttribute("aria-live", "polite");
441
+ }
442
+ announce(text) {
443
+ this.element.textContent = "";
444
+ void this.element.offsetWidth;
445
+ this.element.textContent = text;
446
+ }
447
+ }
448
+ const DEFAULT_SELECTOR = ":scope > a, :scope > [data-shoji-src], :scope > [data-shoji-video]";
449
+ function scanContainer(container, selector = DEFAULT_SELECTOR) {
450
+ const elements = Array.from(container.querySelectorAll(selector));
451
+ const scanned = [];
452
+ for (const element of elements) {
453
+ const item = scanVideo(element) ?? scanImage(element);
454
+ if (item) scanned.push({ element, item });
455
+ }
456
+ return scanned;
457
+ }
458
+ function attr(el, name) {
459
+ return el.getAttribute(name) ?? void 0;
460
+ }
461
+ function numAttr(el, name) {
462
+ const raw = el.getAttribute(name);
463
+ if (raw === null) return void 0;
464
+ const n = Number(raw);
465
+ return Number.isFinite(n) ? n : void 0;
466
+ }
467
+ function applyCommon(element, item, src) {
468
+ const caption = attr(element, "data-shoji-caption");
469
+ if (caption) item.caption = caption;
470
+ item.id = attr(element, "data-shoji-id") ?? src;
471
+ let data;
472
+ for (const a of element.attributes) {
473
+ if (!a.name.startsWith("data-shoji-")) continue;
474
+ const key = a.name.slice(11);
475
+ if (key !== "no-drag" && !(key in item)) (data ?? (data = {}))[key] = a.value;
476
+ }
477
+ if (data) item.data = data;
478
+ }
479
+ function scanImage(element) {
480
+ const src = attr(element, "href") ?? attr(element, "data-shoji-src");
481
+ if (!src) return void 0;
482
+ const item = { src };
483
+ const img = element.querySelector("img");
484
+ const thumb = (img == null ? void 0 : img.getAttribute("src")) ?? attr(element, "data-shoji-thumb");
485
+ if (thumb) item.thumb = thumb;
486
+ const alt = (img == null ? void 0 : img.getAttribute("alt")) ?? attr(element, "data-shoji-alt");
487
+ if (alt) item.alt = alt;
488
+ const width = numAttr(element, "data-shoji-width");
489
+ if (width !== void 0) item.width = width;
490
+ const height = numAttr(element, "data-shoji-height");
491
+ if (height !== void 0) item.height = height;
492
+ const srcset = attr(element, "data-shoji-srcset");
493
+ if (srcset) item.srcset = srcset;
494
+ const sizes = attr(element, "data-shoji-sizes");
495
+ if (sizes) item.sizes = sizes;
496
+ applyCommon(element, item, src);
497
+ return item;
498
+ }
499
+ function scanVideo(element) {
500
+ var _a;
501
+ const videoEl = element.querySelector("video");
502
+ if (videoEl) {
503
+ const sources = Array.from(videoEl.querySelectorAll("source")).map((s) => ({ src: s.getAttribute("src") ?? "", type: s.getAttribute("type") ?? "" })).filter((s) => s.src);
504
+ const src = videoEl.getAttribute("src") ?? ((_a = sources[0]) == null ? void 0 : _a.src);
505
+ if (!src) return void 0;
506
+ const item = { src, video: { provider: "html5" } };
507
+ if (sources.length) item.sources = sources;
508
+ const poster = videoEl.getAttribute("poster");
509
+ if (poster) item.poster = poster;
510
+ applyCommon(element, item, src);
511
+ return item;
512
+ }
513
+ const videoUrl = attr(element, "data-shoji-video");
514
+ if (videoUrl) {
515
+ const youtubeId = detectYouTubeId(videoUrl);
516
+ const item = youtubeId ? { src: videoUrl, video: { provider: "youtube", id: youtubeId, url: videoUrl } } : {
517
+ src: videoUrl,
518
+ video: { provider: "html5" },
519
+ sources: [{ src: videoUrl, type: guessVideoType(videoUrl) }]
520
+ };
521
+ const poster = attr(element, "data-shoji-poster");
522
+ if (poster) item.poster = poster;
523
+ applyCommon(element, item, videoUrl);
524
+ return item;
525
+ }
526
+ return void 0;
527
+ }
528
+ function detectYouTubeId(url) {
529
+ let parsed;
530
+ try {
531
+ parsed = new URL(url, window.location.href);
532
+ } catch {
533
+ return void 0;
534
+ }
535
+ const host = parsed.hostname.replace(/^(www|m)\./, "");
536
+ if (host === "youtu.be") return parsed.pathname.slice(1).split("/")[0] || void 0;
537
+ if (host !== "youtube.com") return void 0;
538
+ if (parsed.pathname === "/watch") return parsed.searchParams.get("v") ?? void 0;
539
+ const match = parsed.pathname.match(/^\/(?:embed|shorts)\/([^/?]+)/);
540
+ return match == null ? void 0 : match[1];
541
+ }
542
+ function guessVideoType(url) {
543
+ const clean = url.split(/[?#]/)[0] ?? url;
544
+ const ext = clean.slice(clean.lastIndexOf(".") + 1).toLowerCase();
545
+ if (ext === "webm") return "video/webm";
546
+ if (ext === "ogg" || ext === "ogv") return "video/ogg";
547
+ return "video/mp4";
548
+ }
549
+ const PAUSE_OVERLAY_DELAY_MS = 200;
550
+ class SlideManager {
551
+ constructor(options) {
552
+ __publicField(this, "element");
553
+ __publicField(this, "slots");
554
+ __publicField(this, "preload");
555
+ __publicField(this, "playVideoLabel");
556
+ __publicField(this, "videoProviders");
557
+ __publicField(this, "dragOffsetPx", 0);
558
+ /**
559
+ * Ready nodes keyed by item index, not slot offset — a `Slot` only
560
+ * remembers its *own* previous index, so it can't tell that some *other*
561
+ * slot already decoded the exact index it's now asked to show (the
562
+ * routine case: stepping forward moves the +1 slot's content into the 0
563
+ * slot). Without this, already-decoded content is thrown away and
564
+ * redecoded on every step. Trimmed to `centerIndex ± preload` each
565
+ * `render()`, same window the slots cover.
566
+ */
567
+ __publicField(this, "cache", /* @__PURE__ */ new Map());
568
+ /**
569
+ * Image decodes in flight, keyed by item index, not by whichever slot
570
+ * started them — a real bug this fixes: navigating before a preload
571
+ * decode resolved used to reassign that slot, abandoning the decode and
572
+ * starting a duplicate elsewhere, so a slow preload could never finish,
573
+ * only restart. `reveal()` looks up which slot currently wants this
574
+ * index at resolve time, so a reshuffled pool still lands it correctly.
575
+ */
576
+ __publicField(this, "pending", /* @__PURE__ */ new Map());
577
+ this.element = document.createElement("div");
578
+ this.element.className = "shoji-slides";
579
+ this.preload = options.preload;
580
+ this.playVideoLabel = options.playVideoLabel;
581
+ this.videoProviders = options.videoProviders;
582
+ const count = options.preload * 2 + 1;
583
+ this.slots = Array.from({ length: count }, (_, i) => {
584
+ const offset = i - options.preload;
585
+ const root = document.createElement("div");
586
+ root.className = "shoji-slide";
587
+ const media = document.createElement("div");
588
+ media.className = "shoji-slide-media";
589
+ root.appendChild(media);
590
+ this.element.appendChild(root);
591
+ return { root, media, offset, assignedIndex: null, ready: false };
592
+ });
593
+ this.applyTransforms(null);
594
+ }
595
+ /** The active slot's `.shoji-slide-media` — not `.shoji-slide`, whose transform is owned by pool-offset positioning. */
596
+ getActiveMedia() {
597
+ var _a;
598
+ return ((_a = this.slots.find((slot) => slot.offset === 0)) == null ? void 0 : _a.media) ?? null;
599
+ }
600
+ /** 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`. */
601
+ isActiveReady() {
602
+ var _a;
603
+ return ((_a = this.slots.find((slot) => slot.offset === 0)) == null ? void 0 : _a.ready) ?? false;
604
+ }
605
+ /**
606
+ * DESIGN.md §2.4 — live drag-to-navigate feedback: every slot's
607
+ * structural `offset * 100%` position gets this same `px` added on top,
608
+ * so dragging left/right visually slides the whole pool together (the
609
+ * adjacent preloaded slot already exists at `±100%`) — no new DOM, no
610
+ * content re-render mid-drag. `transition`: `null` during the live drag
611
+ * (1:1 with the pointer); a real value only for the post-release settle
612
+ * animation, reset back to `null` once that transition ends.
613
+ */
614
+ setDragOffset(px, transition) {
615
+ this.dragOffsetPx = px;
616
+ this.applyTransforms(transition);
617
+ }
618
+ /** The pool slot's `.shoji-slide` root at a structural offset — what the drag settle animation (§2.4) waits for `transitionend` on. */
619
+ getSlotRoot(offset) {
620
+ var _a;
621
+ return ((_a = this.slots.find((slot) => slot.offset === offset)) == null ? void 0 : _a.root) ?? null;
622
+ }
623
+ applyTransforms(transition) {
624
+ for (const slot of this.slots) {
625
+ slot.root.style.transition = transition ?? "";
626
+ slot.root.style.transform = `translateX(calc(${slot.offset * 100}% + ${this.dragOffsetPx}px))`;
627
+ }
628
+ }
629
+ /** 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. */
630
+ render(items, centerIndex, onLoad, openPlaceholderSrc) {
631
+ var _a;
632
+ for (const [index, entry] of this.cache) {
633
+ if (index < centerIndex - this.preload || index > centerIndex + this.preload) {
634
+ releaseVideoNode(entry.node);
635
+ this.cache.delete(index);
636
+ }
637
+ }
638
+ const targets = this.slots.map((slot) => {
639
+ const index = centerIndex + slot.offset;
640
+ const item = index >= 0 && index < items.length ? items[index] : void 0;
641
+ return { slot, index, item };
642
+ });
643
+ for (const { slot, index, item } of targets) {
644
+ if (!item || slot.assignedIndex === index) continue;
645
+ const cached = this.cache.get(index);
646
+ if (!cached) continue;
647
+ if (cached.node.classList.contains("shoji-slide-provider-video")) continue;
648
+ slot.assignedIndex = index;
649
+ this.moveIn(slot, cached, index);
650
+ onLoad(index);
651
+ }
652
+ for (const { slot, index, item } of targets) {
653
+ if (!item) {
654
+ slot.assignedIndex = null;
655
+ slot.ready = false;
656
+ releaseVideo(slot.media);
657
+ slot.media.replaceChildren();
658
+ continue;
659
+ }
660
+ if (slot.assignedIndex === index) continue;
661
+ slot.assignedIndex = index;
662
+ slot.ready = false;
663
+ releaseVideo(slot.media);
664
+ slot.media.replaceChildren(createSpinner());
665
+ if (index === centerIndex && openPlaceholderSrc) {
666
+ this.revealOpenPlaceholder(openPlaceholderSrc, slot, index);
667
+ }
668
+ if (((_a = item.video) == null ? void 0 : _a.provider) === "html5") {
669
+ this.renderVideo(item, slot, index, onLoad);
670
+ } else if (item.video) {
671
+ this.renderProviderVideo(item, slot, index, onLoad);
672
+ } else {
673
+ this.ensureImageDecoding(item, index, onLoad);
674
+ }
675
+ }
676
+ }
677
+ applyAspect(slot, item) {
678
+ if (item.width && item.height) {
679
+ slot.media.style.aspectRatio = `${item.width} / ${item.height}`;
680
+ } else {
681
+ slot.media.style.removeProperty("aspect-ratio");
682
+ }
683
+ }
684
+ /** 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. */
685
+ swapIn(slot, node, item, index, extra) {
686
+ releaseVideo(slot.media);
687
+ this.applyAspect(slot, item);
688
+ if (extra) slot.media.replaceChildren(node, extra);
689
+ else slot.media.replaceChildren(node);
690
+ slot.ready = true;
691
+ this.cache.set(index, { node, item, extra });
692
+ }
693
+ /**
694
+ * Moves an already-cached, ready node into `slot` — skips `releaseVideo`,
695
+ * unlike `swapIn`: what `slot` holds might be a live cache entry another
696
+ * slot reclaims this same `render()` pass (Phase 1). Reparenting is
697
+ * always safe; only the later fresh/clear pass releases stale content.
698
+ */
699
+ moveIn(slot, entry, index) {
700
+ this.applyAspect(slot, entry.item);
701
+ if (entry.extra) slot.media.replaceChildren(entry.node, entry.extra);
702
+ else slot.media.replaceChildren(entry.node);
703
+ slot.ready = true;
704
+ this.cache.set(index, entry);
705
+ }
706
+ /** 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. */
707
+ ensureImageDecoding(item, index, onLoad) {
708
+ if (this.pending.has(index)) return;
709
+ const img = document.createElement("img");
710
+ img.className = "shoji-slide-img";
711
+ img.alt = item.alt ?? "";
712
+ img.draggable = false;
713
+ if ("fetchPriority" in img) img.fetchPriority = "high";
714
+ if (item.srcset) img.srcset = item.srcset;
715
+ if (item.sizes) img.sizes = item.sizes;
716
+ img.src = item.src;
717
+ this.pending.set(index, img);
718
+ const reveal = () => {
719
+ this.pending.delete(index);
720
+ const entry = { node: img, item };
721
+ this.cache.set(index, entry);
722
+ const slot = this.slots.find((s) => s.assignedIndex === index);
723
+ if (!slot) return;
724
+ this.moveIn(slot, entry, index);
725
+ onLoad(index);
726
+ };
727
+ if (typeof img.decode === "function") {
728
+ img.decode().then(reveal, reveal);
729
+ } else {
730
+ img.addEventListener("load", reveal, { once: true });
731
+ img.addEventListener("error", reveal, { once: true });
732
+ }
733
+ }
734
+ /** 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. */
735
+ revealOpenPlaceholder(src, slot, index) {
736
+ const img = createOpenPlaceholder(src);
737
+ const reveal = () => {
738
+ var _a;
739
+ if (slot.assignedIndex !== index || slot.ready) return;
740
+ (_a = slot.media.querySelector(".shoji-slide-spinner")) == null ? void 0 : _a.remove();
741
+ slot.media.appendChild(img);
742
+ };
743
+ if (typeof img.decode === "function") {
744
+ img.decode().then(reveal, reveal);
745
+ } else {
746
+ img.addEventListener("load", reveal, { once: true });
747
+ img.addEventListener("error", reveal, { once: true });
748
+ }
749
+ }
750
+ /** Native `<video controls>` — real playback, not just a poster. Not autoplayed/muted: a deliberate click-to-play, not a background loop. */
751
+ renderVideo(item, slot, index, onLoad) {
752
+ const hasSource = Boolean(item.src) || Boolean(item.sources && item.sources.length > 0);
753
+ if (!hasSource) {
754
+ const placeholder = document.createElement("div");
755
+ placeholder.className = "shoji-slide-placeholder";
756
+ placeholder.textContent = "Video";
757
+ this.swapIn(slot, placeholder, item, index);
758
+ onLoad(index);
759
+ return;
760
+ }
761
+ const video = document.createElement("video");
762
+ video.className = "shoji-slide-video";
763
+ video.controls = true;
764
+ video.playsInline = true;
765
+ if (item.poster) video.poster = item.poster;
766
+ if (item.sources && item.sources.length > 0) {
767
+ for (const s of item.sources) {
768
+ const source = document.createElement("source");
769
+ source.src = s.src;
770
+ source.type = s.type;
771
+ video.appendChild(source);
772
+ }
773
+ } else {
774
+ video.src = item.src;
775
+ }
776
+ this.swapIn(slot, video, item, index, this.createVideoPlayOverlay(video));
777
+ const reveal = () => onLoad(index);
778
+ video.addEventListener("loadedmetadata", reveal, { once: true });
779
+ video.addEventListener("error", reveal, { once: true });
780
+ }
781
+ /** Tracks the video's own play/pause/ended state. Hide `.shoji-video-play-overlay` in CSS to opt out. */
782
+ createVideoPlayOverlay(video) {
783
+ const overlay = document.createElement("button");
784
+ overlay.type = "button";
785
+ overlay.className = "shoji-video-play-overlay";
786
+ overlay.innerHTML = PLAY_ICON;
787
+ overlay.ariaLabel = overlay.title = this.playVideoLabel;
788
+ overlay.hidden = !video.paused;
789
+ let pauseTimer = null;
790
+ const clearPauseTimer = () => {
791
+ if (pauseTimer === null) return;
792
+ clearTimeout(pauseTimer);
793
+ pauseTimer = null;
794
+ };
795
+ overlay.addEventListener("click", (event) => {
796
+ event.stopPropagation();
797
+ video.play().catch(() => {
798
+ });
799
+ });
800
+ video.addEventListener("play", () => {
801
+ clearPauseTimer();
802
+ overlay.hidden = true;
803
+ });
804
+ video.addEventListener("pause", () => {
805
+ clearPauseTimer();
806
+ pauseTimer = setTimeout(() => {
807
+ pauseTimer = null;
808
+ if (video.paused) overlay.hidden = false;
809
+ }, PAUSE_OVERLAY_DELAY_MS);
810
+ });
811
+ video.addEventListener("ended", () => {
812
+ clearPauseTimer();
813
+ overlay.hidden = false;
814
+ });
815
+ return overlay;
816
+ }
817
+ /** DESIGN.md §4-video. Unregistered provider → same placeholder as no-source video. */
818
+ renderProviderVideo(item, slot, index, onLoad) {
819
+ const video = item.video;
820
+ const renderFn = video.provider === "custom" ? video.render : this.videoProviders.get(video.provider);
821
+ if (!renderFn) {
822
+ const placeholder = document.createElement("div");
823
+ placeholder.className = "shoji-slide-placeholder";
824
+ placeholder.textContent = "Video";
825
+ this.swapIn(slot, placeholder, item, index);
826
+ onLoad(index);
827
+ return;
828
+ }
829
+ const container = document.createElement("div");
830
+ container.className = "shoji-slide-provider-video";
831
+ container.hidden = true;
832
+ const controller = new AbortController();
833
+ providerAbortControllers.set(container, controller);
834
+ slot.media.appendChild(container);
835
+ let revealed = false;
836
+ const onReady = () => {
837
+ var _a;
838
+ if (revealed || controller.signal.aborted) return;
839
+ revealed = true;
840
+ container.hidden = false;
841
+ (_a = slot.media.querySelector(".shoji-slide-spinner, .shoji-slide-open-placeholder")) == null ? void 0 : _a.remove();
842
+ this.applyAspect(slot, item);
843
+ slot.ready = true;
844
+ this.cache.set(index, { node: container, item });
845
+ onLoad(index);
846
+ };
847
+ renderFn(container, item, onReady, controller.signal);
848
+ }
849
+ destroy() {
850
+ for (const slot of this.slots) {
851
+ releaseVideo(slot.media);
852
+ slot.media.replaceChildren();
853
+ slot.assignedIndex = null;
854
+ slot.ready = false;
855
+ }
856
+ this.cache.clear();
857
+ this.pending.clear();
858
+ this.element.remove();
859
+ }
860
+ }
861
+ function createOpenPlaceholder(src) {
862
+ const img = document.createElement("img");
863
+ img.className = "shoji-slide-img shoji-slide-open-placeholder";
864
+ img.alt = "";
865
+ img.draggable = false;
866
+ img.src = src;
867
+ return img;
868
+ }
869
+ function createSpinner() {
870
+ const spinner = document.createElement("div");
871
+ spinner.className = "shoji-slide-spinner";
872
+ spinner.setAttribute("aria-hidden", "true");
873
+ return spinner;
874
+ }
875
+ const providerAbortControllers = /* @__PURE__ */ new WeakMap();
876
+ function releaseVideo(media) {
877
+ const video = media.querySelector("video");
878
+ if (video) releaseVideoNode(video);
879
+ const provider = media.querySelector(".shoji-slide-provider-video");
880
+ if (provider) releaseProviderNode(provider);
881
+ }
882
+ function releaseVideoNode(node) {
883
+ if (node.tagName !== "VIDEO") {
884
+ releaseProviderNode(node);
885
+ return;
886
+ }
887
+ const video = node;
888
+ video.pause();
889
+ video.removeAttribute("src");
890
+ video.load();
891
+ }
892
+ function releaseProviderNode(node) {
893
+ const controller = providerAbortControllers.get(node);
894
+ if (!controller) return;
895
+ providerAbortControllers.delete(node);
896
+ controller.abort();
897
+ }
898
+ function resolveAxis(config, direction) {
899
+ const sign = config.directional ? direction : 1;
900
+ const parts = [];
901
+ if (config.perspective) parts.push(`perspective(${config.perspective}px)`);
902
+ const tx = config.translateX !== void 0 ? config.translateX * sign : 0;
903
+ const ty = config.translateY ?? 0;
904
+ if (tx !== 0 || ty !== 0) parts.push(`translate(${tx}%, ${ty}%)`);
905
+ if (config.rotateY !== void 0) parts.push(`rotateY(${config.rotateY * sign}deg)`);
906
+ if (config.rotateX !== void 0) parts.push(`rotateX(${config.rotateX}deg)`);
907
+ if (config.rotateZ !== void 0) parts.push(`rotateZ(${config.rotateZ * sign}deg)`);
908
+ if (config.scale !== void 0) parts.push(`scale(${config.scale})`);
909
+ return { transform: parts.length > 0 ? parts.join(" ") : "none", opacity: config.opacity ?? 1 };
910
+ }
911
+ function preset(spec) {
912
+ return {
913
+ name: spec.name,
914
+ enter: (direction) => resolveAxis(spec.enter, direction),
915
+ leave: (direction) => resolveAxis(spec.leave, direction)
916
+ };
917
+ }
918
+ const PRESET_LIST = [
919
+ // The four DESIGN.md calls out by name: default mode, plus fade/zoom/deck.
920
+ preset({
921
+ name: "slide",
922
+ enter: { directional: true, translateX: 100 },
923
+ leave: { directional: true, translateX: -100 }
924
+ }),
925
+ preset({
926
+ name: "fade",
927
+ enter: { opacity: 0 },
928
+ leave: { opacity: 0 }
929
+ }),
930
+ preset({
931
+ name: "zoom",
932
+ enter: { scale: 0.6, opacity: 0 },
933
+ leave: { scale: 1.4, opacity: 0 }
934
+ }),
935
+ preset({
936
+ // "lg-style deck": the incoming slide drops into place from slightly
937
+ // above/small while the outgoing one recedes back and fades, like a
938
+ // card being placed on top of a deck — not direction-dependent, a deck
939
+ // doesn't care whether the card came from the left or right.
940
+ name: "deck",
941
+ enter: { translateY: 6, scale: 0.85, opacity: 0 },
942
+ leave: { translateY: -4, scale: 0.92, opacity: 0 }
943
+ }),
944
+ // Positional siblings of `slide`.
945
+ preset({
946
+ name: "slideVertical",
947
+ enter: { directional: true, translateY: 100 },
948
+ leave: { directional: true, translateY: -100 }
949
+ }),
950
+ preset({
951
+ name: "push",
952
+ // Same geometry as `slide` but travels further (120% vs 100%) and with
953
+ // no cross-fade at all — a harder, snappier "shove it off" feel.
954
+ enter: { directional: true, translateX: 120 },
955
+ leave: { directional: true, translateX: -120 }
956
+ }),
957
+ // Fixed-direction fades (not nav-direction-aware — a named entrance style).
958
+ preset({
959
+ name: "fadeUp",
960
+ enter: { translateY: 12, opacity: 0 },
961
+ leave: { translateY: -12, opacity: 0 }
962
+ }),
963
+ preset({
964
+ name: "fadeDown",
965
+ enter: { translateY: -12, opacity: 0 },
966
+ leave: { translateY: 12, opacity: 0 }
967
+ }),
968
+ preset({
969
+ name: "fadeLeft",
970
+ enter: { translateX: -12, opacity: 0 },
971
+ leave: { translateX: 12, opacity: 0 }
972
+ }),
973
+ preset({
974
+ name: "fadeRight",
975
+ enter: { translateX: 12, opacity: 0 },
976
+ leave: { translateX: -12, opacity: 0 }
977
+ }),
978
+ // Zoom family.
979
+ preset({ name: "zoomOut", enter: { scale: 1.4, opacity: 0 }, leave: { scale: 0.6, opacity: 0 } }),
980
+ preset({ name: "scaleUp", enter: { scale: 0.7 }, leave: { scale: 1.3 } }),
981
+ preset({ name: "scaleDown", enter: { scale: 1.3 }, leave: { scale: 0.7 } }),
982
+ // Rotation family — `rotate` is fixed-direction; `rotateLeft`/`rotateRight`
983
+ // tie their spin to nav direction instead.
984
+ preset({
985
+ name: "rotate",
986
+ enter: { rotateZ: 15, opacity: 0 },
987
+ leave: { rotateZ: -15, opacity: 0 }
988
+ }),
989
+ preset({
990
+ name: "rotateLeft",
991
+ enter: { directional: true, rotateZ: -25, opacity: 0 },
992
+ leave: { directional: true, rotateZ: 25, opacity: 0 }
993
+ }),
994
+ preset({
995
+ name: "rotateRight",
996
+ enter: { directional: true, rotateZ: 25, opacity: 0 },
997
+ leave: { directional: true, rotateZ: -25, opacity: 0 }
998
+ }),
999
+ // 3D family — needs `perspective()` in the same transform for real depth.
1000
+ preset({
1001
+ name: "flipX",
1002
+ enter: { rotateX: 90, perspective: 1200, opacity: 0 },
1003
+ leave: { rotateX: -90, perspective: 1200, opacity: 0 }
1004
+ }),
1005
+ preset({
1006
+ name: "flipY",
1007
+ enter: { directional: true, rotateY: 90, perspective: 1200, opacity: 0 },
1008
+ leave: { directional: true, rotateY: -90, perspective: 1200, opacity: 0 }
1009
+ }),
1010
+ preset({
1011
+ name: "cube",
1012
+ enter: { directional: true, rotateY: 70, translateX: 40, perspective: 1600, opacity: 1 },
1013
+ leave: { directional: true, rotateY: -70, translateX: -40, perspective: 1600, opacity: 1 }
1014
+ }),
1015
+ preset({
1016
+ name: "coverflow",
1017
+ enter: {
1018
+ directional: true,
1019
+ rotateY: 35,
1020
+ translateX: 60,
1021
+ scale: 0.8,
1022
+ perspective: 1400,
1023
+ opacity: 0
1024
+ },
1025
+ leave: {
1026
+ directional: true,
1027
+ rotateY: -35,
1028
+ translateX: -60,
1029
+ scale: 0.8,
1030
+ perspective: 1400,
1031
+ opacity: 0
1032
+ }
1033
+ }),
1034
+ // Fixed-direction drop.
1035
+ preset({
1036
+ name: "drop",
1037
+ enter: { translateY: -30, opacity: 0 },
1038
+ leave: { translateY: 20, opacity: 0 }
1039
+ })
1040
+ ];
1041
+ const TRANSITION_PRESETS = Object.fromEntries(
1042
+ PRESET_LIST.map((p) => [p.name, p])
1043
+ );
1044
+ function prefersReducedMotion() {
1045
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1046
+ }
1047
+ function parseCssTime(value) {
1048
+ var _a;
1049
+ const first = ((_a = value.split(",")[0]) == null ? void 0 : _a.trim()) ?? "";
1050
+ if (first.endsWith("ms")) return parseFloat(first);
1051
+ if (first.endsWith("s")) return parseFloat(first) * 1e3;
1052
+ return 0;
1053
+ }
1054
+ function waitForEnd(el, cb) {
1055
+ const style = getComputedStyle(el);
1056
+ const durationMs = Math.max(
1057
+ parseCssTime(style.transitionDuration),
1058
+ parseCssTime(style.animationDuration)
1059
+ );
1060
+ let done = false;
1061
+ const finish = () => {
1062
+ if (done) return;
1063
+ done = true;
1064
+ el.removeEventListener("transitionend", onEnd);
1065
+ el.removeEventListener("animationend", onEnd);
1066
+ cb();
1067
+ };
1068
+ const onEnd = (event) => {
1069
+ if (event.target === el) finish();
1070
+ };
1071
+ el.addEventListener("transitionend", onEnd);
1072
+ el.addEventListener("animationend", onEnd);
1073
+ setTimeout(finish, durationMs + 100);
1074
+ }
1075
+ function applyEnter(el, transform, opacity) {
1076
+ el.style.transition = "none";
1077
+ el.style.transformOrigin = "center";
1078
+ el.style.transform = transform;
1079
+ el.style.opacity = String(opacity);
1080
+ void el.offsetHeight;
1081
+ el.style.transition = "transform var(--shoji-duration) var(--shoji-easing), opacity var(--shoji-duration) var(--shoji-easing)";
1082
+ el.style.transform = "none";
1083
+ el.style.opacity = "1";
1084
+ }
1085
+ function applyLeave(el, transform, opacity) {
1086
+ el.style.transformOrigin = "center";
1087
+ el.style.transition = "transform var(--shoji-duration) var(--shoji-easing), opacity var(--shoji-duration) var(--shoji-easing)";
1088
+ void el.offsetHeight;
1089
+ el.style.transform = transform;
1090
+ el.style.opacity = String(opacity);
1091
+ }
1092
+ function clearInlineTransform(el) {
1093
+ el.style.transition = "";
1094
+ el.style.transform = "";
1095
+ el.style.opacity = "";
1096
+ el.style.transformOrigin = "";
1097
+ }
1098
+ class SlideTransition {
1099
+ constructor(slides) {
1100
+ this.slides = slides;
1101
+ }
1102
+ /** A recognized built-in preset (`TRANSITION_PRESETS`). */
1103
+ animate(preset2, direction, swapContent) {
1104
+ this.run(swapContent, (ghost, incoming) => {
1105
+ const leave = preset2.leave(direction);
1106
+ applyLeave(ghost, leave.transform, leave.opacity);
1107
+ const enter = preset2.enter(direction);
1108
+ applyEnter(incoming, enter.transform, enter.opacity);
1109
+ });
1110
+ }
1111
+ /** 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." */
1112
+ animateCustom(mode, direction, swapContent) {
1113
+ const dirAttr = direction === 1 ? "next" : "prev";
1114
+ this.run(
1115
+ swapContent,
1116
+ (ghost, incoming) => {
1117
+ ghost.dataset.shojiDirection = dirAttr;
1118
+ ghost.classList.add(`shoji-transition-${mode}-leave`);
1119
+ incoming.dataset.shojiDirection = dirAttr;
1120
+ incoming.classList.add(`shoji-transition-${mode}-enter`);
1121
+ },
1122
+ (incoming) => {
1123
+ incoming.classList.remove(`shoji-transition-${mode}-enter`);
1124
+ delete incoming.dataset.shojiDirection;
1125
+ }
1126
+ );
1127
+ }
1128
+ run(swapContent, kickOff, cleanupIncoming = clearInlineTransform) {
1129
+ if (prefersReducedMotion()) {
1130
+ swapContent();
1131
+ return;
1132
+ }
1133
+ const outgoing = this.slides.getActiveMedia();
1134
+ if (!outgoing) {
1135
+ swapContent();
1136
+ return;
1137
+ }
1138
+ const ghost = document.createElement("div");
1139
+ ghost.className = "shoji-slide-ghost";
1140
+ ghost.appendChild(outgoing.cloneNode(true));
1141
+ this.slides.element.appendChild(ghost);
1142
+ void ghost.offsetHeight;
1143
+ swapContent();
1144
+ const incoming = this.slides.getActiveMedia();
1145
+ if (!incoming) {
1146
+ ghost.remove();
1147
+ return;
1148
+ }
1149
+ kickOff(ghost, incoming);
1150
+ waitForEnd(ghost, () => ghost.remove());
1151
+ waitForEnd(incoming, () => cleanupIncoming(incoming));
1152
+ }
1153
+ }
1154
+ function itemKey(item) {
1155
+ return item.id ?? item.src;
1156
+ }
1157
+ function resolveElement(target) {
1158
+ if (typeof target !== "string") return target;
1159
+ const el = document.querySelector(target);
1160
+ if (!el) throw new Error(`Shoji: no element matches selector "${target}"`);
1161
+ return el;
1162
+ }
1163
+ const instanceRegistry = /* @__PURE__ */ new WeakMap();
1164
+ const allInstances = /* @__PURE__ */ new Set();
1165
+ const DEFAULT_LOCALE = {
1166
+ close: "Close",
1167
+ previous: "Previous image",
1168
+ next: "Next image",
1169
+ playVideo: "Play video",
1170
+ showCaption: "Show caption",
1171
+ hideCaption: "Hide caption"
1172
+ };
1173
+ function isBackdropClick(event) {
1174
+ return !event.composedPath().some(
1175
+ (node) => node instanceof Element && node.matches(".shoji-slide-img, button, video, .shoji-counter, .shoji-caption")
1176
+ );
1177
+ }
1178
+ function isDangerousHtmlCaption(value) {
1179
+ return typeof value === "object" && value !== null && typeof value.dangerouslySetInnerHTML === "string";
1180
+ }
1181
+ class Gallery {
1182
+ constructor(target, options = {}) {
1183
+ /** Not `readonly` — `reinit()` (§2.7) reassigns this + every option-derived field below; initializers here just satisfy strict-init. */
1184
+ __publicField(this, "options", {});
1185
+ __publicField(this, "element");
1186
+ __publicField(this, "bus", new EventBus());
1187
+ __publicField(this, "selector", DEFAULT_SELECTOR);
1188
+ __publicField(this, "isDynamicMode", false);
1189
+ __publicField(this, "preload", 1);
1190
+ __publicField(this, "locale", DEFAULT_LOCALE);
1191
+ __publicField(this, "showCounter", true);
1192
+ __publicField(this, "captionVisibleOnVideo", false);
1193
+ // DESIGN.md §2.3a
1194
+ __publicField(this, "loop", true);
1195
+ __publicField(this, "closable", true);
1196
+ __publicField(this, "autoHideDelay", 5e3);
1197
+ __publicField(this, "focusTrap", new FocusTrap());
1198
+ __publicField(this, "liveRegion", new LiveRegion());
1199
+ __publicField(this, "autoHideTimer", null);
1200
+ __publicField(this, "autoHidden", false);
1201
+ __publicField(this, "hoveredControlCount", 0);
1202
+ __publicField(this, "isClosing", false);
1203
+ __publicField(this, "itemList", []);
1204
+ __publicField(this, "scannedElements", []);
1205
+ __publicField(this, "activeIndex", 0);
1206
+ __publicField(this, "opened", false);
1207
+ __publicField(this, "destroyed", false);
1208
+ __publicField(this, "slides", null);
1209
+ __publicField(this, "dom", null);
1210
+ __publicField(this, "gesture", null);
1211
+ __publicField(this, "transition", null);
1212
+ __publicField(this, "shortcuts", /* @__PURE__ */ new Map());
1213
+ __publicField(this, "pluginStorage", /* @__PURE__ */ new Map());
1214
+ /** Backs `getActivePlugins()`. */
1215
+ __publicField(this, "activePluginNames", /* @__PURE__ */ new Set());
1216
+ __publicField(this, "videoProviders", /* @__PURE__ */ new Map());
1217
+ __publicField(this, "zoomGate", null);
1218
+ __publicField(this, "pluginCleanups", []);
1219
+ __publicField(this, "onContainerClick", (event) => {
1220
+ let node = event.target;
1221
+ while (node && node !== this.element) {
1222
+ if (node instanceof HTMLElement) {
1223
+ const index = this.scannedElements.indexOf(node);
1224
+ if (index !== -1) {
1225
+ event.preventDefault();
1226
+ this.open(index);
1227
+ return;
1228
+ }
1229
+ }
1230
+ node = node.parentNode;
1231
+ }
1232
+ });
1233
+ __publicField(this, "onOuterClick", (event) => {
1234
+ if (this.closable && isBackdropClick(event)) this.close();
1235
+ });
1236
+ /** DESIGN.md §2.8 — any interaction re-shows controls, restarts the idle clock. `autoHideDelay: 0` = "never show controls" — a no-op here. */
1237
+ __publicField(this, "onActivity", () => {
1238
+ if (this.autoHideDelay === 0) return;
1239
+ this.showControls();
1240
+ this.scheduleAutoHide();
1241
+ });
1242
+ __publicField(this, "onKeydown", (event) => {
1243
+ if (document.activeElement instanceof HTMLVideoElement && event.key !== "Escape") return;
1244
+ this.onActivity();
1245
+ switch (event.key) {
1246
+ case "Escape":
1247
+ if (this.closable) this.close();
1248
+ break;
1249
+ case "ArrowLeft":
1250
+ case "a":
1251
+ case "A":
1252
+ this.prev();
1253
+ break;
1254
+ case "ArrowRight":
1255
+ case "d":
1256
+ case "D":
1257
+ this.next();
1258
+ break;
1259
+ case "Home":
1260
+ this.goTo(0);
1261
+ break;
1262
+ case "End":
1263
+ this.goTo(this.itemList.length - 1);
1264
+ break;
1265
+ default: {
1266
+ const shortcut = this.shortcuts.get(event.key);
1267
+ if (!shortcut) return;
1268
+ shortcut(event);
1269
+ }
1270
+ }
1271
+ event.preventDefault();
1272
+ });
1273
+ this.element = resolveElement(target);
1274
+ instanceRegistry.set(this.element, this);
1275
+ allInstances.add(this);
1276
+ this.applyOptions(options);
1277
+ }
1278
+ /** DESIGN.md §2.7 — the live instance mounted on `el`, or `undefined` if none exists there (or it's since been destroyed). */
1279
+ static getInstance(el) {
1280
+ return instanceRegistry.get(el);
1281
+ }
1282
+ /** DESIGN.md §2.7 — every live instance, e.g. for page-wide teardown or devtools inspection. */
1283
+ static instances() {
1284
+ return allInstances.values();
1285
+ }
1286
+ /** Everything the constructor does after `this.element` is resolved — shared with `reinit()` (§2.7). */
1287
+ applyOptions(options) {
1288
+ this.options = options;
1289
+ this.selector = options.selector ?? DEFAULT_SELECTOR;
1290
+ this.isDynamicMode = options.items !== void 0;
1291
+ this.preload = options.preload ?? 1;
1292
+ this.locale = { ...DEFAULT_LOCALE, ...options.locale };
1293
+ this.showCounter = options.counter ?? true;
1294
+ this.loop = options.loop ?? true;
1295
+ this.closable = options.closable ?? true;
1296
+ this.autoHideDelay = options.autoHideDelay ?? 5e3;
1297
+ this.activeIndex = 0;
1298
+ this.scannedElements = [];
1299
+ if (this.isDynamicMode) {
1300
+ this.itemList = options.items ?? [];
1301
+ } else {
1302
+ const scanned = scanContainer(this.element, this.selector);
1303
+ this.itemList = scanned.map((s) => s.item);
1304
+ this.scannedElements = scanned.map((s) => s.element);
1305
+ this.element.addEventListener("click", this.onContainerClick);
1306
+ }
1307
+ this.ensureLightbox();
1308
+ this.dom.closeButton.hidden = !this.closable;
1309
+ if (options.openOnInit && this.itemList.length > 0) {
1310
+ this.open(options.index ?? 0);
1311
+ }
1312
+ }
1313
+ get items() {
1314
+ return this.itemList;
1315
+ }
1316
+ get currentIndex() {
1317
+ return this.activeIndex;
1318
+ }
1319
+ /** True from the first `open()` until `close()`/`destroy()`. */
1320
+ get isOpen() {
1321
+ return this.opened;
1322
+ }
1323
+ /** True once `destroy()` has run. */
1324
+ get isDestroyed() {
1325
+ return this.destroyed;
1326
+ }
1327
+ /** Whether auto-hide (§2.8) currently has controls faded. */
1328
+ get controlsHidden() {
1329
+ return this.autoHidden;
1330
+ }
1331
+ /** Names of plugins that actually initialized. */
1332
+ getActivePlugins() {
1333
+ return [...this.activePluginNames];
1334
+ }
1335
+ /** The active slide's `.shoji-slide-media` container. Empty before the first `open()`; `null` only after `destroy()`. */
1336
+ getActiveMedia() {
1337
+ var _a;
1338
+ return ((_a = this.slides) == null ? void 0 : _a.getActiveMedia()) ?? null;
1339
+ }
1340
+ /** DESIGN.md §4-zoom — suspends drag-to-navigate/close while zoomed. Single slot, not a multi-subscriber event. */
1341
+ registerZoomGate(isZoomed) {
1342
+ this.zoomGate = isZoomed;
1343
+ return () => {
1344
+ if (this.zoomGate === isZoomed) this.zoomGate = null;
1345
+ };
1346
+ }
1347
+ on(event, fn) {
1348
+ return this.bus.on(event, fn);
1349
+ }
1350
+ clampToRange(index) {
1351
+ return Math.min(Math.max(index, 0), Math.max(this.itemList.length - 1, 0));
1352
+ }
1353
+ ensureLightbox() {
1354
+ if (this.dom) return;
1355
+ this.slides = new SlideManager({
1356
+ preload: this.preload,
1357
+ playVideoLabel: this.locale.playVideo,
1358
+ videoProviders: this.videoProviders
1359
+ });
1360
+ this.transition = new SlideTransition(this.slides);
1361
+ const dom = buildLightboxDom(this.slides.element, this.locale);
1362
+ this.dom = dom;
1363
+ dom.outer.appendChild(this.liveRegion.element);
1364
+ dom.closeButton.addEventListener("click", () => this.close());
1365
+ dom.prevButton.addEventListener("click", () => this.prev());
1366
+ dom.nextButton.addEventListener("click", () => this.next());
1367
+ dom.captionToggleButton.addEventListener("click", () => {
1368
+ this.captionVisibleOnVideo = !this.captionVisibleOnVideo;
1369
+ this.updateCaptionVisibility();
1370
+ });
1371
+ dom.outer.addEventListener("click", this.onOuterClick);
1372
+ dom.outer.addEventListener("pointermove", this.onActivity, { passive: true });
1373
+ dom.outer.addEventListener("pointerdown", this.onActivity, { passive: true });
1374
+ dom.outer.addEventListener("touchstart", this.onActivity, { passive: true });
1375
+ dom.outer.addEventListener("wheel", this.onActivity, { passive: true });
1376
+ dom.outer.addEventListener("focusin", this.onActivity);
1377
+ dom.outer.addEventListener("focusout", () => this.scheduleAutoHide());
1378
+ for (const el of [
1379
+ dom.closeButton,
1380
+ dom.prevButton,
1381
+ dom.nextButton,
1382
+ dom.captionToggleButton,
1383
+ dom.caption,
1384
+ dom.counter,
1385
+ dom.toolbarLeft,
1386
+ dom.toolbarCenter,
1387
+ dom.toolbarRight
1388
+ ]) {
1389
+ this.wireControlHover(el);
1390
+ }
1391
+ document.body.appendChild(dom.outer);
1392
+ this.gesture = new GestureController(
1393
+ {
1394
+ dialog: dom.dialog,
1395
+ slides: this.slides,
1396
+ canGoNext: () => this.loop || this.activeIndex < this.itemList.length - 1,
1397
+ canGoPrev: () => this.loop || this.activeIndex > 0,
1398
+ // animate:false — a completed swipe already played its own
1399
+ // live-drag/settle animation (§2.4); running the §2.5 `mode`
1400
+ // transition on top would double-animate. See navigate()'s doc.
1401
+ next: () => this.navigate(this.nextIndex(), 1, false),
1402
+ prev: () => this.navigate(this.prevIndex(), -1, false),
1403
+ close: () => this.close(),
1404
+ canClose: () => this.closable,
1405
+ onActivity: () => this.onActivity(),
1406
+ isZoomed: () => {
1407
+ var _a;
1408
+ return ((_a = this.zoomGate) == null ? void 0 : _a.call(this)) ?? false;
1409
+ }
1410
+ },
1411
+ {
1412
+ onTap: (x, y) => this.bus.emit("tap", { x, y }),
1413
+ onDoubleTap: (x, y) => this.bus.emit("doubleTap", { x, y }),
1414
+ onPinchStart: (centerX, centerY) => this.bus.emit("pinchStart", { centerX, centerY }),
1415
+ onPinchMove: (scale, centerX, centerY) => this.bus.emit("pinchMove", { scale, centerX, centerY }),
1416
+ onPinchEnd: () => this.bus.emit("pinchEnd", {}),
1417
+ onWheelZoom: (deltaScale, x, y) => this.bus.emit("wheelZoom", { deltaScale, x, y })
1418
+ },
1419
+ this.options.gestures
1420
+ );
1421
+ this.initPlugins();
1422
+ }
1423
+ /** 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. */
1424
+ initPlugins() {
1425
+ this.activePluginNames.clear();
1426
+ this.videoProviders.clear();
1427
+ for (const plugin of this.options.plugins ?? []) {
1428
+ if (!plugin || typeof plugin.init !== "function") {
1429
+ console.error(
1430
+ "Shoji: skipping an invalid plugins[] entry (not a ShojiPlugin object):",
1431
+ plugin
1432
+ );
1433
+ continue;
1434
+ }
1435
+ const missing = (plugin.requires ?? []).filter((name) => !this.activePluginNames.has(name));
1436
+ if (missing.length > 0) {
1437
+ console.error(
1438
+ `Shoji: plugin "${plugin.name}" requires [${missing.join(", ")}] to be registered first — skipping.`
1439
+ );
1440
+ continue;
1441
+ }
1442
+ const pluginOptions = {
1443
+ ...plugin.defaults,
1444
+ ...this.options[plugin.name]
1445
+ };
1446
+ const ctx = {
1447
+ gallery: this,
1448
+ options: pluginOptions,
1449
+ on: this.on.bind(this),
1450
+ emit: (event, detail) => this.bus.emit(event, detail),
1451
+ ui: {
1452
+ toolbar: (slot, el) => this.pluginToolbar(slot, el),
1453
+ overlay: (el, layer) => this.pluginOverlay(el, layer),
1454
+ outer: () => this.dom.outer,
1455
+ registerShortcut: (key, fn) => {
1456
+ this.shortcuts.set(key, fn);
1457
+ return () => this.shortcuts.delete(key);
1458
+ },
1459
+ registerVideoProvider: (name, render) => {
1460
+ this.videoProviders.set(name, render);
1461
+ return () => {
1462
+ if (this.videoProviders.get(name) === render) this.videoProviders.delete(name);
1463
+ };
1464
+ }
1465
+ },
1466
+ storage: {
1467
+ get: (key) => this.pluginStorage.get(key),
1468
+ set: (key, value) => {
1469
+ this.pluginStorage.set(key, value);
1470
+ }
1471
+ }
1472
+ };
1473
+ const cleanup = plugin.init(ctx);
1474
+ if (typeof cleanup === "function") this.pluginCleanups.push(cleanup);
1475
+ this.activePluginNames.add(plugin.name);
1476
+ }
1477
+ }
1478
+ /**
1479
+ * DESIGN.md §3.1 — 'right' inserts immediately before the close button
1480
+ * (never after), so close stays fixed rightmost and plugins cluster to
1481
+ * its left in registration order: `plugins: [A, B, C]` reads A, B, C,
1482
+ * close. 'left'/'center' are independent zones for a plugin that doesn't
1483
+ * want to sit next to close.
1484
+ */
1485
+ pluginToolbar(slot, el) {
1486
+ const dom = this.dom;
1487
+ const isRawElement = el instanceof HTMLElement;
1488
+ const node = isRawElement ? el : this.buildToolbarButton(el);
1489
+ const unhover = isRawElement ? this.wireControlHover(node) : null;
1490
+ if (slot === "right") {
1491
+ dom.toolbarRight.insertBefore(node, dom.closeButton);
1492
+ } else {
1493
+ (slot === "left" ? dom.toolbarLeft : dom.toolbarCenter).appendChild(node);
1494
+ }
1495
+ return () => {
1496
+ unhover == null ? void 0 : unhover();
1497
+ node.remove();
1498
+ };
1499
+ }
1500
+ buildToolbarButton(spec) {
1501
+ const button = document.createElement("button");
1502
+ button.type = "button";
1503
+ button.className = "shoji-toolbar-button";
1504
+ if (spec.icon) {
1505
+ button.ariaLabel = button.title = spec.label;
1506
+ button.innerHTML = spec.icon;
1507
+ } else {
1508
+ button.textContent = spec.label;
1509
+ }
1510
+ button.addEventListener("click", spec.onClick);
1511
+ this.wireControlHover(button);
1512
+ return button;
1513
+ }
1514
+ pluginOverlay(el, layer) {
1515
+ if (layer !== void 0) el.style.zIndex = String(layer);
1516
+ this.dom.dialog.appendChild(el);
1517
+ const unhover = this.wireControlHover(el);
1518
+ return () => {
1519
+ unhover();
1520
+ el.remove();
1521
+ };
1522
+ }
1523
+ /** 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. */
1524
+ wireControlHover(el) {
1525
+ let hovering = false;
1526
+ const onEnter = () => {
1527
+ hovering = true;
1528
+ this.hoveredControlCount++;
1529
+ this.onActivity();
1530
+ };
1531
+ const onLeave = () => {
1532
+ hovering = false;
1533
+ this.hoveredControlCount--;
1534
+ this.scheduleAutoHide();
1535
+ };
1536
+ el.addEventListener("pointerenter", onEnter);
1537
+ el.addEventListener("pointerleave", onLeave);
1538
+ return () => {
1539
+ el.removeEventListener("pointerenter", onEnter);
1540
+ el.removeEventListener("pointerleave", onLeave);
1541
+ if (hovering) {
1542
+ hovering = false;
1543
+ this.hoveredControlCount--;
1544
+ this.scheduleAutoHide();
1545
+ }
1546
+ };
1547
+ }
1548
+ /**
1549
+ * DESIGN.md §2.8 — paused only by a real *hover*. Used to also treat a
1550
+ * *focused* control as active via `document.activeElement`, but a click
1551
+ * leaves a `<button>` focused indefinitely, permanently blocking
1552
+ * `hideControls()`. Focus still counts as activity (`focusin` →
1553
+ * `onActivity()`), it just no longer blocks the eventual hide.
1554
+ */
1555
+ isControlActive() {
1556
+ return this.hoveredControlCount > 0;
1557
+ }
1558
+ /**
1559
+ * The thumbnail for `index` — what the zoom transition animates to/from,
1560
+ * and what `activeThumbnail` marks/scrolls-to. `data-shoji-id` is an
1561
+ * explicit opt-in that works in any mode (needed for dynamic mode, which
1562
+ * has no scanned DOM); checked first so it can override selector mode's
1563
+ * default `scannedElements[index]` too, e.g. to target an inner element.
1564
+ */
1565
+ getOriginElement(index) {
1566
+ const item = this.itemList[index];
1567
+ if ((item == null ? void 0 : item.id) && typeof CSS !== "undefined" && typeof CSS.escape === "function") {
1568
+ const marked = document.querySelector(
1569
+ `[data-shoji-id="${CSS.escape(item.id)}"]`
1570
+ );
1571
+ if (marked) return marked;
1572
+ }
1573
+ return this.scannedElements[index] ?? null;
1574
+ }
1575
+ scheduleAutoHide() {
1576
+ if (this.autoHideTimer !== null) {
1577
+ clearTimeout(this.autoHideTimer);
1578
+ this.autoHideTimer = null;
1579
+ }
1580
+ if (!this.opened) return;
1581
+ if (this.autoHideDelay === 0) {
1582
+ this.hideControls();
1583
+ return;
1584
+ }
1585
+ this.autoHideTimer = setTimeout(() => this.hideControls(), this.autoHideDelay);
1586
+ }
1587
+ hideControls() {
1588
+ if (!this.dom || this.autoHidden || this.isControlActive()) return;
1589
+ this.autoHidden = true;
1590
+ this.dom.dialog.classList.add("shoji-controls-hidden");
1591
+ this.bus.emit("controls:hide", {});
1592
+ }
1593
+ showControls() {
1594
+ if (!this.dom || !this.autoHidden) return;
1595
+ this.autoHidden = false;
1596
+ this.dom.dialog.classList.remove("shoji-controls-hidden");
1597
+ this.bus.emit("controls:show", {});
1598
+ }
1599
+ /**
1600
+ * DESIGN.md §2.1 — `caption` is `string | HTMLElement | DangerousHtmlCaption`:
1601
+ * a plain string renders as text (`textContent`, auto-escaped, safe by
1602
+ * default); an `HTMLElement` is appended as real DOM (the host built it);
1603
+ * `{ dangerouslySetInnerHTML }` renders raw, unescaped HTML via `innerHTML`
1604
+ * — Shoji does not sanitize it, the host must, same contract as React's
1605
+ * identically-named escape hatch. Returns whether the caption element
1606
+ * should be hidden.
1607
+ */
1608
+ renderCaption(el, caption) {
1609
+ if (caption instanceof HTMLElement) {
1610
+ el.replaceChildren(caption);
1611
+ return false;
1612
+ }
1613
+ if (isDangerousHtmlCaption(caption)) {
1614
+ el.innerHTML = caption.dangerouslySetInnerHTML;
1615
+ return caption.dangerouslySetInnerHTML === "";
1616
+ }
1617
+ el.textContent = caption ?? "";
1618
+ return !caption;
1619
+ }
1620
+ /**
1621
+ * `.shoji-toolbar-button` only — never `.shoji-close`/`.shoji-nav`, which
1622
+ * are different classes entirely, so this can't accidentally block
1623
+ * closing or navigating away from a slow-loading slide. `tabIndex = -1`
1624
+ * (not just the CSS below) so a keyboard user tabbing through the
1625
+ * toolbar skips these entirely while disabled, not just visually dims
1626
+ * them — `pointer-events: none` alone wouldn't stop Enter/Space
1627
+ * activating an already-focused button.
1628
+ */
1629
+ setSlideLoading(loading) {
1630
+ if (!this.dom) return;
1631
+ this.dom.outer.classList.toggle("shoji-slide-loading", loading);
1632
+ for (const button of this.dom.outer.querySelectorAll(
1633
+ ".shoji-toolbar-button"
1634
+ )) {
1635
+ button.ariaDisabled = loading ? "true" : null;
1636
+ if (loading) button.tabIndex = -1;
1637
+ else button.removeAttribute("tabindex");
1638
+ }
1639
+ }
1640
+ /**
1641
+ * Content is always kept current (correct the instant loading finishes,
1642
+ * no text flash) — only `hidden` also gates on `isActiveReady()`, so a
1643
+ * caption for the *new* slide can't sit there readable while the
1644
+ * image/video it describes is still a spinner.
1645
+ */
1646
+ updateCaptionVisibility() {
1647
+ if (!this.dom || !this.slides) return;
1648
+ const item = this.itemList[this.activeIndex];
1649
+ const noCaption = this.renderCaption(this.dom.caption, item == null ? void 0 : item.caption);
1650
+ const isVideo = !!(item == null ? void 0 : item.video);
1651
+ this.dom.caption.classList.toggle("shoji-caption--video", isVideo);
1652
+ const hiddenByToggle = isVideo && !this.captionVisibleOnVideo;
1653
+ this.dom.caption.hidden = noCaption || !this.slides.isActiveReady() || hiddenByToggle;
1654
+ const btn = this.dom.captionToggleButton;
1655
+ btn.hidden = !isVideo || noCaption;
1656
+ if (!btn.hidden) {
1657
+ btn.setAttribute("aria-pressed", String(this.captionVisibleOnVideo));
1658
+ btn.ariaLabel = btn.title = this.captionVisibleOnVideo ? this.locale.hideCaption : this.locale.showCaption;
1659
+ }
1660
+ }
1661
+ renderCurrentSlide(openPlaceholderSrc) {
1662
+ if (!this.slides || !this.dom) return;
1663
+ const dom = this.dom;
1664
+ this.slides.render(
1665
+ this.itemList,
1666
+ this.activeIndex,
1667
+ (loadedIndex) => {
1668
+ if (loadedIndex === this.activeIndex) {
1669
+ this.bus.emit("slideItemLoad", { index: loadedIndex });
1670
+ this.setSlideLoading(false);
1671
+ this.updateCaptionVisibility();
1672
+ }
1673
+ },
1674
+ openPlaceholderSrc
1675
+ );
1676
+ this.setSlideLoading(!this.slides.isActiveReady());
1677
+ const item = this.itemList[this.activeIndex];
1678
+ const total = this.itemList.length;
1679
+ dom.counter.textContent = total > 0 ? `${this.activeIndex + 1} / ${total}` : "";
1680
+ dom.counter.hidden = !this.showCounter || total === 0;
1681
+ this.updateCaptionVisibility();
1682
+ const label = `Image ${this.activeIndex + 1} of ${total}${(item == null ? void 0 : item.alt) ? `: ${item.alt}` : ""}`;
1683
+ this.liveRegion.announce(label);
1684
+ dom.prevButton.hidden = total <= 1;
1685
+ dom.nextButton.hidden = total <= 1;
1686
+ dom.prevButton.disabled = !this.loop && this.activeIndex <= 0;
1687
+ dom.nextButton.disabled = !this.loop && this.activeIndex >= total - 1;
1688
+ }
1689
+ open(index = this.options.index ?? 0) {
1690
+ var _a;
1691
+ if (this.destroyed || this.opened) return;
1692
+ this.bus.emit("beforeOpen", { index });
1693
+ this.ensureLightbox();
1694
+ this.opened = true;
1695
+ this.activeIndex = index;
1696
+ this.captionVisibleOnVideo = !!this.options.showVideoCaption;
1697
+ lockBodyScroll();
1698
+ const origin = this.getOriginElement(index);
1699
+ this.renderCurrentSlide(this.resolveOpenPlaceholderSrc(this.itemList[index], origin));
1700
+ this.dom.outer.classList.add("shoji-open");
1701
+ document.addEventListener("keydown", this.onKeydown);
1702
+ this.focusTrap.activate(this.dom.dialog);
1703
+ this.scheduleAutoHide();
1704
+ this.applyMobileControlsSetting();
1705
+ const media = (_a = this.slides) == null ? void 0 : _a.getActiveMedia();
1706
+ if (media && origin) {
1707
+ zoomIn({ origin, target: media, aspectRatio: this.resolveAspectRatio(index, origin) });
1708
+ }
1709
+ this.bus.emit("open", { index });
1710
+ this.bus.emit("afterOpen", { index });
1711
+ }
1712
+ /** 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>. */
1713
+ resolveOpenPlaceholderSrc(item, origin) {
1714
+ if (item == null ? void 0 : item.thumb) return item.thumb;
1715
+ const dataThumb = origin == null ? void 0 : origin.getAttribute("data-shoji-thumb");
1716
+ if (dataThumb) return dataThumb;
1717
+ const img = origin == null ? void 0 : origin.querySelector("img");
1718
+ return (img == null ? void 0 : img.currentSrc) || (img == null ? void 0 : img.src) || void 0;
1719
+ }
1720
+ /**
1721
+ * DESIGN.md §2.2/§2.6 — navigates while open; always clamps an explicit
1722
+ * out-of-range index (this is a directed jump, not a step — `loop` only
1723
+ * affects `next()`/`prev()`, see below). No-op if not open. `animate`
1724
+ * (default `true`) runs the configured §2.5 transition; hosts that want
1725
+ * an instant jump — e.g. syncing to an external index without a visual
1726
+ * flourish — can pass `{ animate: false }`.
1727
+ */
1728
+ goTo(index, options) {
1729
+ if (this.destroyed || !this.opened || this.itemList.length === 0) return;
1730
+ const target = this.clampToRange(index);
1731
+ if (target === this.activeIndex) return;
1732
+ const direction = target > this.activeIndex ? 1 : -1;
1733
+ this.navigate(target, direction, (options == null ? void 0 : options.animate) ?? true);
1734
+ }
1735
+ next() {
1736
+ this.navigate(this.nextIndex(), 1, true);
1737
+ }
1738
+ prev() {
1739
+ this.navigate(this.prevIndex(), -1, true);
1740
+ }
1741
+ nextIndex() {
1742
+ const atEnd = this.activeIndex >= this.itemList.length - 1;
1743
+ return atEnd && this.loop ? 0 : this.activeIndex + 1;
1744
+ }
1745
+ prevIndex() {
1746
+ const atStart = this.activeIndex <= 0;
1747
+ return atStart && this.loop ? this.itemList.length - 1 : this.activeIndex - 1;
1748
+ }
1749
+ /**
1750
+ * DESIGN.md §2.5 — the shared path behind `goTo()`/`next()`/`prev()`.
1751
+ * `animate` is `false` only for `GestureController`'s own host callbacks
1752
+ * (see `ensureLightbox()`): a gesture-completed swipe already played its
1753
+ * own live-drag/settle animation, so running a *second*, `mode`-based
1754
+ * transition on top of it would visibly double-animate. Every other
1755
+ * caller — buttons, keyboard, autoplay, a plugin calling `goTo()` — gets
1756
+ * the real, configured transition.
1757
+ */
1758
+ navigate(target, direction, animate) {
1759
+ if (this.destroyed || !this.opened || this.itemList.length === 0) return;
1760
+ const clamped = this.clampToRange(target);
1761
+ if (clamped === this.activeIndex) return;
1762
+ const from = this.activeIndex;
1763
+ this.bus.emit("beforeSlide", { from, to: clamped });
1764
+ this.activeIndex = clamped;
1765
+ const swap = () => this.renderCurrentSlide();
1766
+ if (animate && this.transition) {
1767
+ const modeName = this.resolveTransitionMode();
1768
+ const builtin = TRANSITION_PRESETS[modeName];
1769
+ if (builtin) {
1770
+ this.transition.animate(builtin, direction, swap);
1771
+ } else {
1772
+ this.transition.animateCustom(modeName, direction, swap);
1773
+ }
1774
+ } else {
1775
+ swap();
1776
+ }
1777
+ this.bus.emit("afterSlide", { from, to: clamped });
1778
+ }
1779
+ /** DESIGN.md §2.5 — `mobileSettings.mode` overrides `mode` on a coarse-pointer device, evaluated fresh each navigation. */
1780
+ resolveTransitionMode() {
1781
+ var _a;
1782
+ if (this.isMobileQuery() && ((_a = this.options.mobileSettings) == null ? void 0 : _a.mode)) {
1783
+ return this.options.mobileSettings.mode;
1784
+ }
1785
+ return this.options.mode ?? "slide";
1786
+ }
1787
+ isMobileQuery() {
1788
+ return typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches;
1789
+ }
1790
+ /**
1791
+ * DESIGN.md §2.5 — `mobileSettings.controls: false` starts controls
1792
+ * hidden on a coarse-pointer device, reusing the existing §2.8 auto-hide
1793
+ * mechanism (`hideControls()`) rather than a separate permanent-hide
1794
+ * state: auto-hide is opacity-only, never removed from the tab order,
1795
+ * and already reveals on any activity — a genuinely *permanent* hide
1796
+ * would strand a touch user with no way to ever reach Close.
1797
+ */
1798
+ applyMobileControlsSetting() {
1799
+ var _a;
1800
+ if (this.isMobileQuery() && ((_a = this.options.mobileSettings) == null ? void 0 : _a.controls) === false) {
1801
+ this.hideControls();
1802
+ }
1803
+ }
1804
+ close() {
1805
+ var _a;
1806
+ if (this.destroyed || !this.opened || this.isClosing) return;
1807
+ this.bus.emit("beforeClose", {});
1808
+ this.isClosing = true;
1809
+ const media = (_a = this.slides) == null ? void 0 : _a.getActiveMedia();
1810
+ const origin = this.getOriginElement(this.activeIndex);
1811
+ if (media && origin) {
1812
+ const aspectRatio = this.resolveAspectRatio(this.activeIndex, origin);
1813
+ zoomOut({ origin, target: media, aspectRatio }, () => this.finishClose());
1814
+ } else {
1815
+ this.finishClose();
1816
+ }
1817
+ }
1818
+ /** `item.width`/`height`, else origin's `naturalWidth`/`naturalHeight` (accurate when `item.thumb` is unset). Feeds `computeTransform`'s letterbox-aware sizing. */
1819
+ resolveAspectRatio(index, origin) {
1820
+ const item = this.itemList[index];
1821
+ if ((item == null ? void 0 : item.width) && item.height) return item.width / item.height;
1822
+ const thumbImg = origin == null ? void 0 : origin.querySelector("img");
1823
+ if ((thumbImg == null ? void 0 : thumbImg.naturalWidth) && thumbImg.naturalHeight) {
1824
+ return thumbImg.naturalWidth / thumbImg.naturalHeight;
1825
+ }
1826
+ return void 0;
1827
+ }
1828
+ /**
1829
+ * Idempotent on purpose: a pending zoom-out's `transitionend`/fallback
1830
+ * timeout can still fire after `destroy()` has already force-finished the
1831
+ * close (§ destroy() below) — the `!this.opened` guard makes that a no-op
1832
+ * instead of a second `close`/`afterClose` emission on a torn-down gallery.
1833
+ */
1834
+ finishClose() {
1835
+ var _a, _b;
1836
+ if (!this.opened) return;
1837
+ this.isClosing = false;
1838
+ this.opened = false;
1839
+ unlockBodyScroll();
1840
+ document.removeEventListener("keydown", this.onKeydown);
1841
+ this.focusTrap.deactivate();
1842
+ (_a = this.dom) == null ? void 0 : _a.outer.classList.remove("shoji-open");
1843
+ if (this.autoHideTimer !== null) {
1844
+ clearTimeout(this.autoHideTimer);
1845
+ this.autoHideTimer = null;
1846
+ }
1847
+ this.autoHidden = false;
1848
+ this.hoveredControlCount = 0;
1849
+ (_b = this.dom) == null ? void 0 : _b.dialog.classList.remove("shoji-controls-hidden");
1850
+ this.bus.emit("close", {});
1851
+ this.bus.emit("afterClose", {});
1852
+ }
1853
+ /** DESIGN.md §2.1 — diffs by id (fallback src), preserving the active slide. */
1854
+ updateSlides(items, currentIndex) {
1855
+ if (this.destroyed) return;
1856
+ const activeItem = this.itemList[this.activeIndex];
1857
+ const activeKey = activeItem ? itemKey(activeItem) : void 0;
1858
+ this.itemList = items;
1859
+ let nextIndex = currentIndex;
1860
+ if (nextIndex === void 0 && activeKey !== void 0) {
1861
+ const preserved = items.findIndex((item) => itemKey(item) === activeKey);
1862
+ if (preserved !== -1) nextIndex = preserved;
1863
+ }
1864
+ this.activeIndex = Math.min(
1865
+ Math.max(nextIndex ?? this.activeIndex, 0),
1866
+ Math.max(items.length - 1, 0)
1867
+ );
1868
+ if (this.opened) this.renderCurrentSlide();
1869
+ this.bus.emit("itemsUpdated", { items: this.itemList });
1870
+ }
1871
+ /**
1872
+ * Sugar over `updateSlides()` for the common "insert some items" case —
1873
+ * splices `items` into a copy of the current list at `atIndex` (default:
1874
+ * append at the end) and hands the result to `updateSlides()`, which
1875
+ * still does all the real work (active-item preservation, live re-render
1876
+ * if open, `itemsUpdated`). `atIndex` follows `Array.prototype.splice`'s
1877
+ * own semantics (negative counts from the end, out-of-range clamps) —
1878
+ * no extra validation on top of that.
1879
+ */
1880
+ addSlides(items, atIndex) {
1881
+ if (this.destroyed) return;
1882
+ const next = [...this.itemList];
1883
+ next.splice(atIndex ?? next.length, 0, ...items);
1884
+ this.updateSlides(next);
1885
+ }
1886
+ /**
1887
+ * Sugar over `updateSlides()` for the common "remove some items" case.
1888
+ * Accepts a single id/index or an array mixing both: a `string` matches
1889
+ * the same `id ?? src` key `updateSlides()` already uses for active-item
1890
+ * preservation, a `number` matches that position in the *current*
1891
+ * `items` list (resolved against the list as it is now, before any
1892
+ * removal — passing `[0, 1]` removes the first two items, not "index 0,
1893
+ * then whatever became index 1 after that").
1894
+ */
1895
+ removeSlides(match) {
1896
+ if (this.destroyed) return;
1897
+ const matches = Array.isArray(match) ? match : [match];
1898
+ const indices = new Set(matches.filter((m) => typeof m === "number"));
1899
+ const keys = new Set(matches.filter((m) => typeof m === "string"));
1900
+ const next = this.itemList.filter((item, i) => !indices.has(i) && !keys.has(itemKey(item)));
1901
+ this.updateSlides(next);
1902
+ }
1903
+ /** DESIGN.md §2.7 — selector-mode only; sugar over updateSlides() sourced from a DOM rescan. */
1904
+ refresh() {
1905
+ if (this.destroyed) return;
1906
+ if (this.isDynamicMode) {
1907
+ console.warn("Shoji: refresh() is a no-op in dynamic mode — call updateSlides() instead.");
1908
+ return;
1909
+ }
1910
+ const scanned = scanContainer(this.element, this.selector);
1911
+ this.scannedElements = scanned.map((s) => s.element);
1912
+ this.updateSlides(scanned.map((s) => s.item));
1913
+ }
1914
+ /** Shared by `destroy()`/`reinit()` (§2.7): force-close, run plugin cleanups, drop gesture/transition/slide/dialog. */
1915
+ teardown() {
1916
+ var _a, _b, _c;
1917
+ if (this.opened) {
1918
+ if (!this.isClosing) this.bus.emit("beforeClose", {});
1919
+ this.finishClose();
1920
+ }
1921
+ for (const cleanup of this.pluginCleanups) cleanup();
1922
+ this.pluginCleanups = [];
1923
+ this.shortcuts.clear();
1924
+ this.pluginStorage.clear();
1925
+ this.activePluginNames.clear();
1926
+ this.videoProviders.clear();
1927
+ if (!this.isDynamicMode) {
1928
+ this.element.removeEventListener("click", this.onContainerClick);
1929
+ }
1930
+ (_a = this.gesture) == null ? void 0 : _a.destroy();
1931
+ this.gesture = null;
1932
+ this.transition = null;
1933
+ (_b = this.slides) == null ? void 0 : _b.destroy();
1934
+ (_c = this.dom) == null ? void 0 : _c.outer.remove();
1935
+ this.slides = null;
1936
+ this.dom = null;
1937
+ }
1938
+ destroy() {
1939
+ if (this.destroyed) return;
1940
+ if (instanceRegistry.get(this.element) === this) instanceRegistry.delete(this.element);
1941
+ allInstances.delete(this);
1942
+ this.teardown();
1943
+ this.bus.emit("destroy", {});
1944
+ this.bus.clear();
1945
+ this.destroyed = true;
1946
+ }
1947
+ /**
1948
+ * DESIGN.md §2.7 — full teardown + reconstruction on `this`, not a new
1949
+ * object: `Shoji.getInstance(el)`/any held reference keeps working across
1950
+ * the call, unlike `destroy()` + `new Shoji(...)`. Omit `options` to
1951
+ * rebuild unchanged (a hard reset); pass options to reconfigure
1952
+ * structurally. `gallery.on(...)` listeners do NOT survive — the event
1953
+ * bus is fully cleared, same as `destroy()` — re-`on()` anything needed.
1954
+ */
1955
+ reinit(options = this.options) {
1956
+ if (this.destroyed) return;
1957
+ this.teardown();
1958
+ this.bus.clear();
1959
+ this.applyOptions(options);
1960
+ }
1961
+ }
1962
+ export {
1963
+ EventBus,
1964
+ Gallery
1965
+ };
1966
+ //# sourceMappingURL=index.js.map