@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.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/esm/core/EventBus.d.ts +16 -0
- package/dist/esm/core/FocusTrap.d.ts +9 -0
- package/dist/esm/core/Gallery.d.ts +221 -0
- package/dist/esm/core/GestureController.d.ts +50 -0
- package/dist/esm/core/LiveRegion.d.ts +6 -0
- package/dist/esm/core/SlideManager.d.ts +83 -0
- package/dist/esm/core/bodyScrollLock.d.ts +2 -0
- package/dist/esm/core/dom.d.ts +27 -0
- package/dist/esm/core/icons.d.ts +6 -0
- package/dist/esm/core/index.d.ts +5 -0
- package/dist/esm/core/index.js +1966 -0
- package/dist/esm/core/index.js.map +1 -0
- package/dist/esm/core/plugin.d.ts +54 -0
- package/dist/esm/core/rotateFlipNormalize.d.ts +19 -0
- package/dist/esm/core/scan.d.ts +8 -0
- package/dist/esm/core/types.d.ts +268 -0
- package/dist/esm/core/zoomTransition.d.ts +33 -0
- package/dist/esm/gestures/GestureEngine.d.ts +78 -0
- package/dist/esm/index.css +475 -0
- package/dist/esm/index.d.ts +32 -0
- package/dist/esm/index.js +24 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/index2.css +22 -0
- package/dist/esm/index3.css +166 -0
- package/dist/esm/index4.css +27 -0
- package/dist/esm/plugins/activeThumbnail/index.d.ts +26 -0
- package/dist/esm/plugins/activeThumbnail/index.js +48 -0
- package/dist/esm/plugins/activeThumbnail/index.js.map +1 -0
- package/dist/esm/plugins/autoplay/icons.d.ts +3 -0
- package/dist/esm/plugins/autoplay/index.d.ts +17 -0
- package/dist/esm/plugins/autoplay/index.js +177 -0
- package/dist/esm/plugins/autoplay/index.js.map +1 -0
- package/dist/esm/plugins/fullscreen/icons.d.ts +3 -0
- package/dist/esm/plugins/fullscreen/index.d.ts +17 -0
- package/dist/esm/plugins/fullscreen/index.js +74 -0
- package/dist/esm/plugins/fullscreen/index.js.map +1 -0
- package/dist/esm/plugins/layout/index.d.ts +191 -0
- package/dist/esm/plugins/layout/index.js +746 -0
- package/dist/esm/plugins/layout/index.js.map +1 -0
- package/dist/esm/plugins/layout/justified.d.ts +68 -0
- package/dist/esm/plugins/layout/masonry.d.ts +92 -0
- package/dist/esm/plugins/rotateFlip/icons.d.ts +5 -0
- package/dist/esm/plugins/rotateFlip/index.d.ts +17 -0
- package/dist/esm/plugins/rotateFlip/index.js +138 -0
- package/dist/esm/plugins/rotateFlip/index.js.map +1 -0
- package/dist/esm/plugins/video/index.d.ts +13 -0
- package/dist/esm/plugins/video/index.js +95 -0
- package/dist/esm/plugins/video/index.js.map +1 -0
- package/dist/esm/plugins/video/youtube.d.ts +61 -0
- package/dist/esm/plugins/zoom/icons.d.ts +4 -0
- package/dist/esm/plugins/zoom/index.d.ts +30 -0
- package/dist/esm/plugins/zoom/index.js +274 -0
- package/dist/esm/plugins/zoom/index.js.map +1 -0
- package/dist/esm/plugins/zoom/zoomMath.d.ts +24 -0
- package/dist/esm/transitions/SlideTransition.d.ts +25 -0
- package/dist/esm/transitions/presets.d.ts +21 -0
- package/dist/esm/zoomTransition-bbKHpVpA.js +110 -0
- package/dist/esm/zoomTransition-bbKHpVpA.js.map +1 -0
- package/dist/shoji.css +690 -0
- package/dist/shoji.js +3605 -0
- package/dist/shoji.js.map +1 -0
- package/dist/shoji.min.css +1 -0
- package/dist/shoji.min.js +2 -0
- package/dist/shoji.min.js.map +1 -0
- 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
|