@thisiscrowd/crowdbox 1.0.0
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/README.md +348 -0
- package/dist/crowdbox.css +1 -0
- package/dist/crowdbox.css.map +1 -0
- package/dist/crowdbox.esm.js +1557 -0
- package/dist/crowdbox.esm.js.map +1 -0
- package/dist/crowdbox.umd.js +8 -0
- package/dist/crowdbox.umd.js.map +1 -0
- package/package.json +59 -0
- package/src/adapters/iframe.js +30 -0
- package/src/adapters/image.js +91 -0
- package/src/adapters/video.js +44 -0
- package/src/adapters/vimeo.js +52 -0
- package/src/adapters/youtube.js +54 -0
- package/src/browser.js +7 -0
- package/src/core/Crowdbox.js +739 -0
- package/src/core/EventEmitter.js +43 -0
- package/src/core/LightboxGallery.js +2 -0
- package/src/core/Registry.js +26 -0
- package/src/core/utils.js +115 -0
- package/src/index.js +61 -0
- package/src/plugins/download/download.js +50 -0
- package/src/plugins/fullscreen/fullscreen.js +65 -0
- package/src/plugins/share/share.js +63 -0
- package/src/plugins/thumbs/thumbs.js +108 -0
- package/src/plugins/zoom/zoom.js +51 -0
- package/src/react/GalleryGrid.jsx +75 -0
- package/src/react/Lightbox.jsx +36 -0
- package/src/react/useLightbox.js +34 -0
- package/src/styles/_animations.scss +62 -0
- package/src/styles/_mosaic.scss +46 -0
- package/src/styles/_responsive.scss +34 -0
- package/src/styles/_thumbnails.scss +66 -0
- package/src/styles/_toolbar.scss +150 -0
- package/src/styles/_variables.scss +23 -0
- package/src/styles/main.scss +164 -0
- package/types/index.d.ts +239 -0
|
@@ -0,0 +1,1557 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Crowdbox v1.0.0
|
|
3
|
+
* A modern, lightweight, extensible Crowdbox gallery plugin supporting images and video
|
|
4
|
+
* (c) 2026 Crowdbox
|
|
5
|
+
* Released under the MIT License.
|
|
6
|
+
*/
|
|
7
|
+
class EventEmitter {
|
|
8
|
+
constructor() {
|
|
9
|
+
this._events = Object.create(null);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
on(event, listener) {
|
|
13
|
+
if (!this._events[event]) this._events[event] = [];
|
|
14
|
+
this._events[event].push(listener);
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
once(event, listener) {
|
|
19
|
+
const wrapper = (...args) => {
|
|
20
|
+
listener(...args);
|
|
21
|
+
this.off(event, wrapper);
|
|
22
|
+
};
|
|
23
|
+
wrapper._original = listener;
|
|
24
|
+
return this.on(event, wrapper);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
off(event, listener) {
|
|
28
|
+
if (!this._events[event]) return this;
|
|
29
|
+
this._events[event] = this._events[event].filter(
|
|
30
|
+
(l) => l !== listener && l._original !== listener
|
|
31
|
+
);
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
emit(event, ...args) {
|
|
36
|
+
if (!this._events[event]) return false;
|
|
37
|
+
[...this._events[event]].forEach((l) => l(...args));
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
removeAllListeners(event) {
|
|
42
|
+
if (event) {
|
|
43
|
+
delete this._events[event];
|
|
44
|
+
} else {
|
|
45
|
+
this._events = Object.create(null);
|
|
46
|
+
}
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Central registry for media adapters and plugins so the host app
|
|
52
|
+
// can swap or extend them without forking the library.
|
|
53
|
+
const adapters = new Map();
|
|
54
|
+
const plugins = new Map();
|
|
55
|
+
|
|
56
|
+
const Registry = {
|
|
57
|
+
registerAdapter(type, adapter) {
|
|
58
|
+
adapters.set(type, adapter);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
getAdapter(type) {
|
|
62
|
+
return adapters.get(type) ?? null;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
registerPlugin(name, plugin) {
|
|
66
|
+
plugins.set(name, plugin);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
getPlugin(name) {
|
|
70
|
+
return plugins.get(name) ?? null;
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
getPlugins() {
|
|
74
|
+
return [...plugins.values()];
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function createElement(tag, attrs = {}, ...children) {
|
|
79
|
+
const el = document.createElement(tag);
|
|
80
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
81
|
+
if (k === 'class') el.className = v;
|
|
82
|
+
else if (k.startsWith('data-')) el.dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
|
|
83
|
+
else if (k === 'aria' || k.startsWith('aria-')) el.setAttribute(k, v);
|
|
84
|
+
else el[k] = v;
|
|
85
|
+
}
|
|
86
|
+
children.flat().forEach((child) => {
|
|
87
|
+
if (child == null) return;
|
|
88
|
+
el.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
|
|
89
|
+
});
|
|
90
|
+
return el;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mergeDeep(target, ...sources) {
|
|
94
|
+
for (const src of sources) {
|
|
95
|
+
for (const key of Object.keys(src ?? {})) {
|
|
96
|
+
if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
|
|
97
|
+
if (!target[key] || typeof target[key] !== 'object') target[key] = {};
|
|
98
|
+
mergeDeep(target[key], src[key]);
|
|
99
|
+
} else {
|
|
100
|
+
target[key] = src[key];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return target;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getYouTubeId(url) {
|
|
108
|
+
const m = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
|
109
|
+
return m ? m[1] : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getVimeoId(url) {
|
|
113
|
+
const m = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
|
|
114
|
+
return m ? m[1] : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isVideoUrl(url) {
|
|
118
|
+
return /\.(mp4|webm|ogg|mov)(\?.*)?$/i.test(url);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isYouTubeUrl(url) {
|
|
122
|
+
return /youtube\.com|youtu\.be/.test(url);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isVimeoUrl(url) {
|
|
126
|
+
return /vimeo\.com/.test(url);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function detectMediaType(src) {
|
|
130
|
+
if (!src) return 'unknown';
|
|
131
|
+
if (isYouTubeUrl(src)) return 'youtube';
|
|
132
|
+
if (isVimeoUrl(src)) return 'vimeo';
|
|
133
|
+
if (isVideoUrl(src)) return 'video';
|
|
134
|
+
if (/\.(jpg|jpeg|png|gif|webp|avif|svg)(\?.*)?$/i.test(src)) return 'image';
|
|
135
|
+
return 'iframe';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function trapFocus(container) {
|
|
139
|
+
const focusable = container.querySelectorAll(
|
|
140
|
+
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'
|
|
141
|
+
);
|
|
142
|
+
const first = focusable[0];
|
|
143
|
+
const last = focusable[focusable.length - 1];
|
|
144
|
+
|
|
145
|
+
function handler(e) {
|
|
146
|
+
if (e.key !== 'Tab') return;
|
|
147
|
+
if (e.shiftKey) {
|
|
148
|
+
if (document.activeElement === first) { e.preventDefault(); last?.focus(); }
|
|
149
|
+
} else {
|
|
150
|
+
if (document.activeElement === last) { e.preventDefault(); first?.focus(); }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
container.addEventListener('keydown', handler);
|
|
154
|
+
return () => container.removeEventListener('keydown', handler);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function requestFullscreen(el) {
|
|
158
|
+
return (el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen)?.call(el);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function exitFullscreen() {
|
|
162
|
+
return (document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen)?.call(document);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isFullscreen() {
|
|
166
|
+
return !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function clamp(value, min, max) {
|
|
170
|
+
return Math.min(Math.max(value, min), max);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function supportsPassive() {
|
|
174
|
+
let ok = false;
|
|
175
|
+
try {
|
|
176
|
+
window.addEventListener('test', null, Object.defineProperty({}, 'passive', { get() { ok = true; } }));
|
|
177
|
+
} catch (_) {}
|
|
178
|
+
return ok;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const passiveOpt = supportsPassive() ? { passive: true } : false;
|
|
182
|
+
|
|
183
|
+
const DEFAULTS = {
|
|
184
|
+
selector: '[data-lgx]',
|
|
185
|
+
gallerySelector: null, // group items by this attr value
|
|
186
|
+
loop: true,
|
|
187
|
+
keyboard: true,
|
|
188
|
+
touch: true,
|
|
189
|
+
drag: true,
|
|
190
|
+
zoom: true,
|
|
191
|
+
thumbnails: true,
|
|
192
|
+
captions: true,
|
|
193
|
+
counter: true,
|
|
194
|
+
fullscreen: true,
|
|
195
|
+
download: false,
|
|
196
|
+
share: false,
|
|
197
|
+
closeOnBackdrop: true,
|
|
198
|
+
closeOnEscape: true,
|
|
199
|
+
animationDuration: 300,
|
|
200
|
+
slideAnimationDuration: 360,
|
|
201
|
+
zoomStep: 0.5,
|
|
202
|
+
zoomMax: 4,
|
|
203
|
+
zoomMin: 1,
|
|
204
|
+
lazyLoad: true,
|
|
205
|
+
preload: 1, // preload N slides ahead/behind
|
|
206
|
+
autoplay: false,
|
|
207
|
+
autoplayInterval: 4000,
|
|
208
|
+
showAutoplay: true, // show/hide autoplay button in toolbar
|
|
209
|
+
i18n: {
|
|
210
|
+
close: 'Close',
|
|
211
|
+
prev: 'Previous',
|
|
212
|
+
next: 'Next',
|
|
213
|
+
zoomIn: 'Zoom in',
|
|
214
|
+
zoomOut: 'Zoom out',
|
|
215
|
+
fullscreen: 'Fullscreen',
|
|
216
|
+
download: 'Download',
|
|
217
|
+
share: 'Share',
|
|
218
|
+
autoplayStart: 'Start slideshow',
|
|
219
|
+
autoplayStop: 'Stop slideshow',
|
|
220
|
+
counter: '{current} of {total}',
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
class Crowdbox extends EventEmitter {
|
|
225
|
+
constructor(options = {}) {
|
|
226
|
+
super();
|
|
227
|
+
this.opts = mergeDeep({}, DEFAULTS, options);
|
|
228
|
+
this._items = [];
|
|
229
|
+
this._index = 0;
|
|
230
|
+
this._open = false;
|
|
231
|
+
this._zoom = 1;
|
|
232
|
+
this._panX = 0;
|
|
233
|
+
this._panY = 0;
|
|
234
|
+
this._autoplayTimer = null;
|
|
235
|
+
this._releaseFocusTrap = null;
|
|
236
|
+
this._previousFocus = null;
|
|
237
|
+
this._plugins = [];
|
|
238
|
+
this._dom = {};
|
|
239
|
+
|
|
240
|
+
this._initPlugins();
|
|
241
|
+
this._bindTriggers();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
open(items, startIndex = 0) {
|
|
247
|
+
if (this._open) this.close();
|
|
248
|
+
this._items = this._normalizeItems(items);
|
|
249
|
+
this._index = clamp(startIndex, 0, this._items.length - 1);
|
|
250
|
+
this._open = true;
|
|
251
|
+
this._zoom = 1;
|
|
252
|
+
this._panX = 0;
|
|
253
|
+
this._panY = 0;
|
|
254
|
+
|
|
255
|
+
this._buildDOM();
|
|
256
|
+
this._attachKeyboard();
|
|
257
|
+
this._attachTouch();
|
|
258
|
+
this._renderSlide(this._index);
|
|
259
|
+
this._preload(this._index);
|
|
260
|
+
this._pluginHook('afterOpen');
|
|
261
|
+
this.emit('open', { index: this._index, item: this._items[this._index] });
|
|
262
|
+
|
|
263
|
+
if (this.opts.autoplay && !this._isAutoplayBlockingMedia(this._items[this._index])) this._startAutoplay();
|
|
264
|
+
return this;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
close() {
|
|
268
|
+
if (!this._open) return this;
|
|
269
|
+
this._open = false;
|
|
270
|
+
this._stopAutoplay();
|
|
271
|
+
this._pauseCurrentMedia();
|
|
272
|
+
this._pluginHook('beforeClose');
|
|
273
|
+
|
|
274
|
+
const modal = this._dom.modal;
|
|
275
|
+
if (modal) {
|
|
276
|
+
modal.style.animation = `lgx-fade-out ${this.opts.animationDuration}ms ease forwards`;
|
|
277
|
+
setTimeout(() => { modal.remove(); this._dom = {}; }, this.opts.animationDuration);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this._detachKeyboard();
|
|
281
|
+
if (this._releaseFocusTrap) { this._releaseFocusTrap(); this._releaseFocusTrap = null; }
|
|
282
|
+
if (this._previousFocus) { this._previousFocus.focus(); this._previousFocus = null; }
|
|
283
|
+
document.body.style.overflow = '';
|
|
284
|
+
|
|
285
|
+
this._pluginHook('afterClose');
|
|
286
|
+
this.emit('close');
|
|
287
|
+
return this;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
next() {
|
|
291
|
+
const total = this._items.length;
|
|
292
|
+
if (!total) return this;
|
|
293
|
+
if (!this.opts.loop && this._index >= total - 1) return this;
|
|
294
|
+
this._goTo((this._index + 1) % total, 'next');
|
|
295
|
+
return this;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
prev() {
|
|
299
|
+
const total = this._items.length;
|
|
300
|
+
if (!total) return this;
|
|
301
|
+
if (!this.opts.loop && this._index === 0) return this;
|
|
302
|
+
this._goTo((this._index - 1 + total) % total, 'prev');
|
|
303
|
+
return this;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
goTo(index) {
|
|
307
|
+
const i = clamp(index, 0, this._items.length - 1);
|
|
308
|
+
this._goTo(i, i > this._index ? 'next' : 'prev');
|
|
309
|
+
return this;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
zoomIn() {
|
|
313
|
+
this._applyZoom(this._zoom + this.opts.zoomStep);
|
|
314
|
+
return this;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
zoomOut() {
|
|
318
|
+
this._applyZoom(this._zoom - this.opts.zoomStep);
|
|
319
|
+
return this;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
resetZoom() {
|
|
323
|
+
this._applyZoom(1, true);
|
|
324
|
+
return this;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
startAutoplay() { this._startAutoplay(); return this; }
|
|
328
|
+
stopAutoplay() { this._stopAutoplay(); return this; }
|
|
329
|
+
toggleAutoplay() {
|
|
330
|
+
this._autoplayTimer ? this._stopAutoplay() : this._startAutoplay();
|
|
331
|
+
return this;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
destroy() {
|
|
335
|
+
this.close();
|
|
336
|
+
this._detachTriggers();
|
|
337
|
+
this._plugins.forEach((p) => p.destroy?.());
|
|
338
|
+
this.removeAllListeners();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Internal ─────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
_normalizeItems(items) {
|
|
344
|
+
if (!Array.isArray(items)) items = [items];
|
|
345
|
+
return items.map((item) => {
|
|
346
|
+
if (typeof item === 'string') item = { src: item };
|
|
347
|
+
if (!item.type) item.type = detectMediaType(item.src);
|
|
348
|
+
return item;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_initPlugins() {
|
|
353
|
+
const pluginDefs = Registry.getPlugins();
|
|
354
|
+
pluginDefs.forEach((Def) => {
|
|
355
|
+
if (this.opts[Def.pluginName] === false) return;
|
|
356
|
+
const inst = typeof Def === 'function' ? new Def(this) : Def;
|
|
357
|
+
if (typeof inst.init === 'function') inst.init(this);
|
|
358
|
+
this._plugins.push(inst);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
_pluginHook(hook, ...args) {
|
|
363
|
+
this._plugins.forEach((p) => p[hook]?.(...args));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ─── DOM ──────────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
_buildDOM() {
|
|
369
|
+
this._previousFocus = document.activeElement;
|
|
370
|
+
document.body.style.overflow = 'hidden';
|
|
371
|
+
|
|
372
|
+
const modal = createElement('div', {
|
|
373
|
+
class: 'lgx-modal',
|
|
374
|
+
role: 'dialog',
|
|
375
|
+
'aria-modal': 'true',
|
|
376
|
+
'aria-label': 'Media lightbox',
|
|
377
|
+
});
|
|
378
|
+
modal.style.setProperty('--lgx-duration', `${this.opts.animationDuration}ms`);
|
|
379
|
+
modal.style.setProperty('--lgx-slide-duration', `${this.opts.slideAnimationDuration}ms`);
|
|
380
|
+
|
|
381
|
+
const backdrop = createElement('div', { class: 'lgx-backdrop' });
|
|
382
|
+
if (this.opts.closeOnBackdrop) {
|
|
383
|
+
backdrop.addEventListener('click', () => this.close());
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const container = createElement('div', { class: 'lgx-container' });
|
|
387
|
+
|
|
388
|
+
// Toolbar
|
|
389
|
+
const toolbar = this._buildToolbar();
|
|
390
|
+
|
|
391
|
+
// Stage — nav buttons live here so top:50% is relative to the image area,
|
|
392
|
+
// not the full container (which includes caption + thumbnail strip).
|
|
393
|
+
const stage = createElement('div', { class: 'lgx-stage', 'aria-live': 'polite' });
|
|
394
|
+
const slideWrapper = createElement('div', { class: 'lgx-slide-wrapper' });
|
|
395
|
+
const btnPrev = this._buildNavBtn('prev');
|
|
396
|
+
const btnNext = this._buildNavBtn('next');
|
|
397
|
+
stage.append(slideWrapper, btnPrev, btnNext);
|
|
398
|
+
|
|
399
|
+
// Caption — normal flex child so it sits between stage and thumbnail strip
|
|
400
|
+
// without z-index fights. Hidden via display:none when empty.
|
|
401
|
+
const caption = createElement('div', { class: 'lgx-caption', 'aria-live': 'polite' });
|
|
402
|
+
caption.style.display = 'none';
|
|
403
|
+
|
|
404
|
+
// Counter (absolute, overlays top of stage)
|
|
405
|
+
const counter = createElement('div', { class: 'lgx-counter', 'aria-live': 'polite' });
|
|
406
|
+
|
|
407
|
+
// Loading spinner
|
|
408
|
+
const spinner = createElement('div', { class: 'lgx-spinner', 'aria-hidden': 'true' },
|
|
409
|
+
createElement('div', { class: 'lgx-spinner__ring' })
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// Order matters for flex layout: stage grows, caption sits below it, thumbs at bottom
|
|
413
|
+
container.append(toolbar, stage, caption, counter, spinner);
|
|
414
|
+
|
|
415
|
+
modal.append(backdrop, container);
|
|
416
|
+
document.body.appendChild(modal);
|
|
417
|
+
|
|
418
|
+
// Animate in
|
|
419
|
+
modal.style.animation = `lgx-fade-in ${this.opts.animationDuration}ms ease forwards`;
|
|
420
|
+
|
|
421
|
+
const btnAutoplay = toolbar.querySelector('.lgx-btn--autoplay');
|
|
422
|
+
this._dom = { modal, backdrop, container, stage, slideWrapper, toolbar, caption, counter, spinner, btnPrev, btnNext, btnAutoplay };
|
|
423
|
+
this._releaseFocusTrap = trapFocus(modal);
|
|
424
|
+
|
|
425
|
+
// Focus the container for keyboard events
|
|
426
|
+
container.setAttribute('tabindex', '-1');
|
|
427
|
+
container.focus();
|
|
428
|
+
|
|
429
|
+
this._pluginHook('afterBuildDOM');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
_buildToolbar() {
|
|
433
|
+
const toolbar = createElement('div', { class: 'lgx-toolbar', role: 'toolbar', 'aria-label': 'Gallery controls' });
|
|
434
|
+
|
|
435
|
+
const close = createElement('button', {
|
|
436
|
+
class: 'lgx-btn lgx-btn--close',
|
|
437
|
+
type: 'button',
|
|
438
|
+
'aria-label': this.opts.i18n.close,
|
|
439
|
+
title: this.opts.i18n.close,
|
|
440
|
+
}, this._svgIcon('close'));
|
|
441
|
+
close.addEventListener('click', () => this.close());
|
|
442
|
+
|
|
443
|
+
const autoplay = createElement('button', {
|
|
444
|
+
class: 'lgx-btn lgx-btn--autoplay',
|
|
445
|
+
type: 'button',
|
|
446
|
+
'aria-label': this.opts.i18n.autoplayStart,
|
|
447
|
+
title: this.opts.i18n.autoplayStart,
|
|
448
|
+
'aria-pressed': 'false',
|
|
449
|
+
}, this._svgIcon('play'));
|
|
450
|
+
autoplay.addEventListener('click', () => this.toggleAutoplay());
|
|
451
|
+
|
|
452
|
+
if (this.opts.showAutoplay) toolbar.appendChild(autoplay);
|
|
453
|
+
toolbar.appendChild(close);
|
|
454
|
+
this._dom.btnAutoplay = this.opts.showAutoplay ? autoplay : null;
|
|
455
|
+
this._dom.btnClose = close;
|
|
456
|
+
this._pluginHook('buildToolbar', toolbar);
|
|
457
|
+
|
|
458
|
+
return toolbar;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
_buildNavBtn(dir) {
|
|
462
|
+
const label = dir === 'prev' ? this.opts.i18n.prev : this.opts.i18n.next;
|
|
463
|
+
const btn = createElement('button', {
|
|
464
|
+
class: `lgx-btn lgx-btn--nav lgx-btn--${dir}`,
|
|
465
|
+
type: 'button',
|
|
466
|
+
'aria-label': label,
|
|
467
|
+
title: label,
|
|
468
|
+
}, this._svgIcon(dir));
|
|
469
|
+
|
|
470
|
+
btn.addEventListener('click', () => (dir === 'prev' ? this.prev() : this.next()));
|
|
471
|
+
|
|
472
|
+
if (this._items.length <= 1) btn.style.display = 'none';
|
|
473
|
+
return btn;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ─── Slide Rendering ──────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
_renderSlide(index, direction = null) {
|
|
479
|
+
const item = this._items[index];
|
|
480
|
+
if (!item) return;
|
|
481
|
+
|
|
482
|
+
this._pauseCurrentMedia();
|
|
483
|
+
|
|
484
|
+
const adapter = this._resolveAdapter(item);
|
|
485
|
+
if (!adapter) return;
|
|
486
|
+
|
|
487
|
+
const slide = adapter.render(item);
|
|
488
|
+
slide.dataset.lgxIndex = index;
|
|
489
|
+
|
|
490
|
+
// Animate out old slide
|
|
491
|
+
const old = this._dom.slideWrapper.querySelector('.lgx-slide--active');
|
|
492
|
+
|
|
493
|
+
if (old && direction) {
|
|
494
|
+
const outClass = direction === 'next' ? 'lgx-slide--out-left' : 'lgx-slide--out-right';
|
|
495
|
+
const inClass = direction === 'next' ? 'lgx-slide--in-right' : 'lgx-slide--in-left';
|
|
496
|
+
|
|
497
|
+
// Add inClass BEFORE appending so fill-mode "both" starts the slide
|
|
498
|
+
// at opacity:0 — prevents the 1-frame flash at its final visible position.
|
|
499
|
+
slide.classList.add(inClass);
|
|
500
|
+
this._dom.slideWrapper.appendChild(slide);
|
|
501
|
+
|
|
502
|
+
// Defer the outgoing animation by one rAF so the browser has committed
|
|
503
|
+
// the new slide's start state before anything moves.
|
|
504
|
+
requestAnimationFrame(() => {
|
|
505
|
+
old.classList.remove('lgx-slide--active');
|
|
506
|
+
old.classList.add(outClass);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Use animationend for frame-accurate cleanup; setTimeout is the fallback
|
|
510
|
+
// in case animationend never fires (e.g. display:none mid-animation).
|
|
511
|
+
let done = false;
|
|
512
|
+
const cleanup = () => {
|
|
513
|
+
if (done) return;
|
|
514
|
+
done = true;
|
|
515
|
+
old.remove();
|
|
516
|
+
slide.classList.remove(inClass);
|
|
517
|
+
slide.classList.add('lgx-slide--active');
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
old.addEventListener('animationend', cleanup, { once: true });
|
|
521
|
+
setTimeout(cleanup, this.opts.slideAnimationDuration + 100);
|
|
522
|
+
} else {
|
|
523
|
+
this._dom.slideWrapper.innerHTML = '';
|
|
524
|
+
slide.classList.add('lgx-slide--active');
|
|
525
|
+
this._dom.slideWrapper.appendChild(slide);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this._dom.currentSlide = slide;
|
|
529
|
+
this._index = index;
|
|
530
|
+
this._zoom = 1;
|
|
531
|
+
this._panX = 0;
|
|
532
|
+
|
|
533
|
+
// Show spinner, hide once slide reports loaded (JS fallback for browsers
|
|
534
|
+
// without :has() support — CSS handles it in modern browsers).
|
|
535
|
+
this._watchSlideLoaded(slide);
|
|
536
|
+
this._bindMediaAutoplayStop(slide, item);
|
|
537
|
+
if (this._autoplayTimer && this._isAutoplayBlockingMedia(item)) this._stopAutoplay();
|
|
538
|
+
this._panY = 0;
|
|
539
|
+
this._updateUI();
|
|
540
|
+
this._pluginHook('afterSlide', index, item);
|
|
541
|
+
this.emit('slide', { index, item });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
_goTo(index, direction) {
|
|
545
|
+
this._renderSlide(index, direction);
|
|
546
|
+
this._preload(index);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
_watchSlideLoaded(slide) {
|
|
550
|
+
const spinner = this._dom.spinner;
|
|
551
|
+
if (!spinner) return;
|
|
552
|
+
|
|
553
|
+
// Already loaded (e.g. cached image or video adapter)
|
|
554
|
+
if (slide.classList.contains('lgx-content--loaded')) {
|
|
555
|
+
spinner.style.display = 'none';
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
spinner.style.display = '';
|
|
560
|
+
|
|
561
|
+
// Disconnect any previous observer
|
|
562
|
+
this._spinnerObserver?.disconnect();
|
|
563
|
+
|
|
564
|
+
this._spinnerObserver = new MutationObserver(() => {
|
|
565
|
+
if (slide.classList.contains('lgx-content--loaded')) {
|
|
566
|
+
spinner.style.display = 'none';
|
|
567
|
+
this._spinnerObserver.disconnect();
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
this._spinnerObserver.observe(slide, { attributes: true, attributeFilter: ['class'] });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
_preload(index) {
|
|
574
|
+
if (!this.opts.lazyLoad) return;
|
|
575
|
+
const n = this.opts.preload;
|
|
576
|
+
const total = this._items.length;
|
|
577
|
+
for (let i = 1; i <= n; i++) {
|
|
578
|
+
[
|
|
579
|
+
(index + i) % total,
|
|
580
|
+
(index - i + total) % total,
|
|
581
|
+
].forEach((pi) => {
|
|
582
|
+
const pitem = this._items[pi];
|
|
583
|
+
if (pitem?.type === 'image' && !pitem._preloaded) {
|
|
584
|
+
const img = new Image();
|
|
585
|
+
img.src = pitem.src;
|
|
586
|
+
pitem._preloaded = true;
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
_resolveAdapter(item) {
|
|
593
|
+
const adapter = Registry.getAdapter(item.type);
|
|
594
|
+
if (adapter) return adapter;
|
|
595
|
+
// Fallback: scan all adapters
|
|
596
|
+
const all = ['image', 'video', 'youtube', 'vimeo', 'iframe'];
|
|
597
|
+
for (const t of all) {
|
|
598
|
+
const a = Registry.getAdapter(t);
|
|
599
|
+
if (a?.canHandle(item)) return a;
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
_pauseCurrentMedia() {
|
|
605
|
+
const slide = this._dom.slideWrapper?.querySelector('.lgx-slide--active, .lgx-content');
|
|
606
|
+
if (slide?._pause) slide._pause();
|
|
607
|
+
// Pause html5 video elements
|
|
608
|
+
slide?.querySelectorAll('video').forEach((v) => v.pause());
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ─── UI Updates ───────────────────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
_updateUI() {
|
|
614
|
+
const { _index: i, _items: items, opts, _dom: dom } = this;
|
|
615
|
+
const total = items.length;
|
|
616
|
+
|
|
617
|
+
// Counter
|
|
618
|
+
if (dom.counter && opts.counter) {
|
|
619
|
+
dom.counter.textContent = opts.i18n.counter
|
|
620
|
+
.replace('{current}', i + 1)
|
|
621
|
+
.replace('{total}', total);
|
|
622
|
+
dom.counter.style.display = total > 1 ? '' : 'none';
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Caption
|
|
626
|
+
if (dom.caption && opts.captions) {
|
|
627
|
+
const cap = String(items[i]?.caption ?? '').trim();
|
|
628
|
+
dom.caption.innerHTML = cap;
|
|
629
|
+
dom.caption.style.display = cap ? '' : 'none';
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Nav buttons
|
|
633
|
+
if (!opts.loop) {
|
|
634
|
+
if (dom.btnPrev) dom.btnPrev.disabled = i === 0;
|
|
635
|
+
if (dom.btnNext) dom.btnNext.disabled = i === total - 1;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Show/hide nav for single-item galleries
|
|
639
|
+
const showNav = total > 1;
|
|
640
|
+
if (dom.btnPrev) dom.btnPrev.style.display = showNav ? '' : 'none';
|
|
641
|
+
if (dom.btnNext) dom.btnNext.style.display = showNav ? '' : 'none';
|
|
642
|
+
if (dom.btnAutoplay) dom.btnAutoplay.style.display = showNav ? '' : 'none';
|
|
643
|
+
|
|
644
|
+
this._updateAutoplayButton();
|
|
645
|
+
|
|
646
|
+
this._pluginHook('updateUI');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
_updateAutoplayButton() {
|
|
650
|
+
const btn = this._dom.btnAutoplay;
|
|
651
|
+
if (!btn) return;
|
|
652
|
+
|
|
653
|
+
const active = !!this._autoplayTimer;
|
|
654
|
+
const label = active ? this.opts.i18n.autoplayStop : this.opts.i18n.autoplayStart;
|
|
655
|
+
btn.classList.toggle('lgx-btn--active', active);
|
|
656
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
657
|
+
btn.setAttribute('aria-label', label);
|
|
658
|
+
btn.title = label;
|
|
659
|
+
btn.innerHTML = this._svgIcon(active ? 'stop' : 'play').outerHTML;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ─── Zoom / Pan ───────────────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
_applyZoom(newZoom, reset = false) {
|
|
665
|
+
newZoom = clamp(newZoom, this.opts.zoomMin, this.opts.zoomMax);
|
|
666
|
+
if (reset) { newZoom = 1; this._panX = 0; this._panY = 0; }
|
|
667
|
+
this._zoom = newZoom;
|
|
668
|
+
|
|
669
|
+
const img = this._dom.slideWrapper?.querySelector('.lgx-image');
|
|
670
|
+
if (!img) return;
|
|
671
|
+
|
|
672
|
+
img.style.transform = `translate(${this._panX}px, ${this._panY}px) scale(${newZoom})`;
|
|
673
|
+
img.style.cursor = newZoom > 1 ? 'move' : 'default';
|
|
674
|
+
|
|
675
|
+
this._pluginHook('afterZoom', newZoom);
|
|
676
|
+
this.emit('zoom', { zoom: newZoom });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ─── Keyboard ─────────────────────────────────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
_attachKeyboard() {
|
|
682
|
+
if (!this.opts.keyboard) return;
|
|
683
|
+
this._onKeydown = (e) => {
|
|
684
|
+
switch (e.key) {
|
|
685
|
+
case 'ArrowRight': this.next(); break;
|
|
686
|
+
case 'ArrowLeft': this.prev(); break;
|
|
687
|
+
case 'Escape': if (this.opts.closeOnEscape) this.close(); break;
|
|
688
|
+
case 'f': case 'F': this._pluginHook('toggleFullscreen'); break;
|
|
689
|
+
case '+': case '=': this.zoomIn(); break;
|
|
690
|
+
case '-': this.zoomOut(); break;
|
|
691
|
+
case '0': this.resetZoom(); break;
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
document.addEventListener('keydown', this._onKeydown);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
_detachKeyboard() {
|
|
698
|
+
if (this._onKeydown) document.removeEventListener('keydown', this._onKeydown);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ─── Touch / Drag ─────────────────────────────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
_attachTouch() {
|
|
704
|
+
if (!this.opts.touch && !this.opts.drag) return;
|
|
705
|
+
const stage = this._dom.stage;
|
|
706
|
+
if (!stage) return;
|
|
707
|
+
|
|
708
|
+
let startX, startY, startPanX, startPanY, isDragging = false;
|
|
709
|
+
const SWIPE_THRESHOLD = 50;
|
|
710
|
+
|
|
711
|
+
const onStart = (e) => {
|
|
712
|
+
const pt = e.touches ? e.touches[0] : e;
|
|
713
|
+
startX = pt.clientX;
|
|
714
|
+
startY = pt.clientY;
|
|
715
|
+
startPanX = this._panX;
|
|
716
|
+
startPanY = this._panY;
|
|
717
|
+
isDragging = true;
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const onMove = (e) => {
|
|
721
|
+
if (!isDragging) return;
|
|
722
|
+
const pt = e.touches ? e.touches[0] : e;
|
|
723
|
+
const dx = pt.clientX - startX;
|
|
724
|
+
const dy = pt.clientY - startY;
|
|
725
|
+
|
|
726
|
+
if (this._zoom > 1) {
|
|
727
|
+
// Pan mode
|
|
728
|
+
this._panX = startPanX + dx;
|
|
729
|
+
this._panY = startPanY + dy;
|
|
730
|
+
const img = this._dom.slideWrapper?.querySelector('.lgx-image');
|
|
731
|
+
if (img) img.style.transform = `translate(${this._panX}px, ${this._panY}px) scale(${this._zoom})`;
|
|
732
|
+
e.preventDefault();
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
const onEnd = (e) => {
|
|
737
|
+
if (!isDragging) return;
|
|
738
|
+
isDragging = false;
|
|
739
|
+
const pt = e.changedTouches ? e.changedTouches[0] : e;
|
|
740
|
+
const dx = pt.clientX - startX;
|
|
741
|
+
|
|
742
|
+
if (this._zoom === 1 && Math.abs(dx) > SWIPE_THRESHOLD) {
|
|
743
|
+
dx < 0 ? this.next() : this.prev();
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
// Double-tap / double-click zoom
|
|
748
|
+
let lastTap = 0;
|
|
749
|
+
const onTap = (e) => {
|
|
750
|
+
const now = Date.now();
|
|
751
|
+
if (now - lastTap < 300) {
|
|
752
|
+
this._zoom === 1 ? this._applyZoom(2) : this.resetZoom();
|
|
753
|
+
e.preventDefault();
|
|
754
|
+
}
|
|
755
|
+
lastTap = now;
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
stage.addEventListener('mousedown', onStart);
|
|
759
|
+
stage.addEventListener('mousemove', onMove);
|
|
760
|
+
stage.addEventListener('mouseup', onEnd);
|
|
761
|
+
stage.addEventListener('touchstart', onStart, passiveOpt);
|
|
762
|
+
stage.addEventListener('touchmove', onMove, { passive: false });
|
|
763
|
+
stage.addEventListener('touchend', onEnd, passiveOpt);
|
|
764
|
+
stage.addEventListener('click', onTap);
|
|
765
|
+
|
|
766
|
+
// Pinch zoom
|
|
767
|
+
let initDist = 0, initZoom = 1;
|
|
768
|
+
stage.addEventListener('touchstart', (e) => {
|
|
769
|
+
if (e.touches.length === 2) {
|
|
770
|
+
initDist = Math.hypot(
|
|
771
|
+
e.touches[0].clientX - e.touches[1].clientX,
|
|
772
|
+
e.touches[0].clientY - e.touches[1].clientY
|
|
773
|
+
);
|
|
774
|
+
initZoom = this._zoom;
|
|
775
|
+
}
|
|
776
|
+
}, passiveOpt);
|
|
777
|
+
|
|
778
|
+
stage.addEventListener('touchmove', (e) => {
|
|
779
|
+
if (e.touches.length === 2) {
|
|
780
|
+
const dist = Math.hypot(
|
|
781
|
+
e.touches[0].clientX - e.touches[1].clientX,
|
|
782
|
+
e.touches[0].clientY - e.touches[1].clientY
|
|
783
|
+
);
|
|
784
|
+
this._applyZoom(initZoom * (dist / initDist));
|
|
785
|
+
e.preventDefault();
|
|
786
|
+
}
|
|
787
|
+
}, { passive: false });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ─── Autoplay ──────────────────────────────────────────────────────────────────
|
|
791
|
+
|
|
792
|
+
_startAutoplay() {
|
|
793
|
+
if (this._isAutoplayBlockingMedia(this._items[this._index])) {
|
|
794
|
+
this._stopAutoplay();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
this._stopAutoplay();
|
|
798
|
+
this._autoplayTimer = setInterval(() => this.next(), this.opts.autoplayInterval);
|
|
799
|
+
this._updateAutoplayButton();
|
|
800
|
+
this.emit('autoplay:start');
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
_stopAutoplay() {
|
|
804
|
+
if (this._autoplayTimer) {
|
|
805
|
+
clearInterval(this._autoplayTimer);
|
|
806
|
+
this._autoplayTimer = null;
|
|
807
|
+
this._updateAutoplayButton();
|
|
808
|
+
this.emit('autoplay:stop');
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
_bindMediaAutoplayStop(slide, item) {
|
|
813
|
+
if (!this._isAutoplayBlockingMedia(item)) return;
|
|
814
|
+
|
|
815
|
+
const stop = () => this._stopAutoplay();
|
|
816
|
+
slide.addEventListener('lgx:media-play', stop, { once: true });
|
|
817
|
+
slide.querySelectorAll('video').forEach((video) => {
|
|
818
|
+
video.addEventListener('play', stop, { once: true });
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
_isAutoplayBlockingMedia(item) {
|
|
823
|
+
return ['video', 'youtube', 'vimeo', 'iframe'].includes(item?.type);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ─── Trigger binding ──────────────────────────────────────────────────────────
|
|
827
|
+
|
|
828
|
+
_bindTriggers() {
|
|
829
|
+
if (typeof document === 'undefined') return;
|
|
830
|
+
if (!this.opts.selector) return;
|
|
831
|
+
|
|
832
|
+
const handler = (e) => {
|
|
833
|
+
const trigger = e.target.closest(this.opts.selector);
|
|
834
|
+
if (!trigger) return;
|
|
835
|
+
e.preventDefault();
|
|
836
|
+
|
|
837
|
+
// Gather all items from the same gallery group
|
|
838
|
+
const groupAttr = trigger.dataset.lgxGallery || trigger.closest('[data-lgx-gallery]')?.dataset.lgxGallery;
|
|
839
|
+
let siblings;
|
|
840
|
+
|
|
841
|
+
if (groupAttr) {
|
|
842
|
+
siblings = [...document.querySelectorAll(`[data-lgx-gallery="${groupAttr}"] ${this.opts.selector}, ${this.opts.selector}[data-lgx-gallery="${groupAttr}"]`)];
|
|
843
|
+
} else {
|
|
844
|
+
siblings = [trigger];
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const items = siblings.map((el) => this._itemFromElement(el));
|
|
848
|
+
const startIndex = siblings.indexOf(trigger);
|
|
849
|
+
this.open(items, Math.max(0, startIndex));
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
document.addEventListener('click', handler);
|
|
853
|
+
this._triggerHandler = handler;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
_detachTriggers() {
|
|
857
|
+
if (this._triggerHandler) {
|
|
858
|
+
document.removeEventListener('click', this._triggerHandler);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
_itemFromElement(el) {
|
|
863
|
+
const d = el.dataset;
|
|
864
|
+
const img = el.matches('img') ? el : el.querySelector('img');
|
|
865
|
+
const captionEl = el.querySelector('[data-lgx-caption], figcaption, .caption, .lgx-caption-text');
|
|
866
|
+
const caption = d.lgxCaption
|
|
867
|
+
|| img?.dataset?.lgxCaption
|
|
868
|
+
|| captionEl?.innerHTML
|
|
869
|
+
|| el.getAttribute('aria-label')
|
|
870
|
+
|| el.title
|
|
871
|
+
|| img?.title
|
|
872
|
+
|| img?.alt
|
|
873
|
+
|| '';
|
|
874
|
+
|
|
875
|
+
return {
|
|
876
|
+
src: d.lgxSrc || el.href || el.src || '',
|
|
877
|
+
type: d.lgxType || undefined,
|
|
878
|
+
thumb: d.lgxThumb || img?.src || null,
|
|
879
|
+
caption,
|
|
880
|
+
alt: d.lgxAlt || img?.alt || '',
|
|
881
|
+
download: d.lgxDownload || null,
|
|
882
|
+
autoplay: d.lgxAutoplay !== undefined,
|
|
883
|
+
poster: d.lgxPoster || null,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// ─── SVG Icons ────────────────────────────────────────────────────────────────
|
|
888
|
+
|
|
889
|
+
_svgIcon(name) {
|
|
890
|
+
const icons = {
|
|
891
|
+
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
|
892
|
+
prev: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15,18 9,12 15,6"/></svg>',
|
|
893
|
+
next: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9,18 15,12 9,6"/></svg>',
|
|
894
|
+
play: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.14v13.72a1 1 0 0 0 1.52.86l11.43-6.86a1 1 0 0 0 0-1.72L9.52 4.28A1 1 0 0 0 8 5.14z"/></svg>',
|
|
895
|
+
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg>',
|
|
896
|
+
zoomin: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
|
|
897
|
+
zoomout: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
|
|
898
|
+
fullscreen: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15,3 21,3 21,9"/><polyline points="9,21 3,21 3,15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>',
|
|
899
|
+
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
|
|
900
|
+
share: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
|
|
901
|
+
};
|
|
902
|
+
const div = document.createElement('span');
|
|
903
|
+
div.className = 'lgx-icon';
|
|
904
|
+
div.setAttribute('aria-hidden', 'true');
|
|
905
|
+
div.innerHTML = icons[name] ?? '';
|
|
906
|
+
return div;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const COLS = 8;
|
|
911
|
+
const ROWS = 7;
|
|
912
|
+
const TILE_DURATION = 420; // ms — must match CSS animation duration
|
|
913
|
+
const MAX_DELAY = 380; // ms — last tile starts at this offset
|
|
914
|
+
|
|
915
|
+
// Tile colours — dark palette with subtle variation
|
|
916
|
+
const TILE_COLORS = [
|
|
917
|
+
'#111116', '#13131a', '#0f0f14', '#161620',
|
|
918
|
+
'#121218', '#101015', '#141419', '#0e0e13',
|
|
919
|
+
];
|
|
920
|
+
|
|
921
|
+
function buildMosaic() {
|
|
922
|
+
const mosaic = document.createElement('div');
|
|
923
|
+
mosaic.className = 'lgx-mosaic';
|
|
924
|
+
mosaic.style.gridTemplateColumns = `repeat(${COLS}, 1fr)`;
|
|
925
|
+
mosaic.style.gridTemplateRows = `repeat(${ROWS}, 1fr)`;
|
|
926
|
+
|
|
927
|
+
for (let r = 0; r < ROWS; r++) {
|
|
928
|
+
for (let c = 0; c < COLS; c++) {
|
|
929
|
+
const tile = document.createElement('div');
|
|
930
|
+
tile.className = 'lgx-mosaic__tile';
|
|
931
|
+
|
|
932
|
+
// Diagonal wave: top-left tiles dissolve first
|
|
933
|
+
const diagRatio = (r + c) / (ROWS + COLS - 2); // 0 → 1
|
|
934
|
+
const delay = Math.round(diagRatio * MAX_DELAY);
|
|
935
|
+
tile.style.setProperty('--lgx-tile-delay', `${delay}ms`);
|
|
936
|
+
|
|
937
|
+
// Pick a colour from the palette so adjacent tiles vary slightly
|
|
938
|
+
tile.style.background = TILE_COLORS[(r * COLS + c) % TILE_COLORS.length];
|
|
939
|
+
|
|
940
|
+
mosaic.appendChild(tile);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return mosaic;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const ImageAdapter = {
|
|
948
|
+
type: 'image',
|
|
949
|
+
|
|
950
|
+
canHandle(item) {
|
|
951
|
+
return item.type === 'image' || /\.(jpg|jpeg|png|gif|webp|avif|svg)(\?.*)?$/i.test(item.src ?? '');
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
render(item) {
|
|
955
|
+
const wrapper = document.createElement('div');
|
|
956
|
+
wrapper.className = 'lgx-content lgx-content--image';
|
|
957
|
+
// position:relative and overflow:hidden are set in CSS on .lgx-content--image
|
|
958
|
+
// so the mosaic tiles (position:absolute inside) are clipped correctly.
|
|
959
|
+
|
|
960
|
+
const img = document.createElement('img');
|
|
961
|
+
img.className = 'lgx-image';
|
|
962
|
+
img.alt = item.alt ?? item.caption ?? '';
|
|
963
|
+
img.draggable = false;
|
|
964
|
+
|
|
965
|
+
// Low-res thumb while full image loads
|
|
966
|
+
if (item.thumb) {
|
|
967
|
+
img.src = item.thumb;
|
|
968
|
+
img.style.filter = 'blur(8px)';
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const loadStart = Date.now();
|
|
972
|
+
const hi = new Image();
|
|
973
|
+
hi.onload = () => {
|
|
974
|
+
img.src = hi.src;
|
|
975
|
+
img.style.filter = '';
|
|
976
|
+
img.style.transition = 'filter 0.3s';
|
|
977
|
+
wrapper.classList.add('lgx-content--loaded');
|
|
978
|
+
|
|
979
|
+
// Skip mosaic when the image was served from cache (load time < 40 ms) —
|
|
980
|
+
// it would collide with the slide-in transition and cause flickering.
|
|
981
|
+
const fromNetwork = Date.now() - loadStart > 40;
|
|
982
|
+
if (fromNetwork) {
|
|
983
|
+
const mosaic = buildMosaic();
|
|
984
|
+
wrapper.appendChild(mosaic);
|
|
985
|
+
const totalMs = MAX_DELAY + TILE_DURATION + 60;
|
|
986
|
+
setTimeout(() => mosaic.remove(), totalMs);
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
hi.onerror = () => wrapper.classList.add('lgx-content--error');
|
|
991
|
+
hi.src = item.src;
|
|
992
|
+
|
|
993
|
+
wrapper.appendChild(img);
|
|
994
|
+
return wrapper;
|
|
995
|
+
},
|
|
996
|
+
|
|
997
|
+
getThumbnail(item) {
|
|
998
|
+
return item.thumb ?? item.src;
|
|
999
|
+
},
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
const VideoAdapter = {
|
|
1003
|
+
type: 'video',
|
|
1004
|
+
|
|
1005
|
+
canHandle(item) {
|
|
1006
|
+
return item.type === 'video' || /\.(mp4|webm|ogg|mov)(\?.*)?$/i.test(item.src ?? '');
|
|
1007
|
+
},
|
|
1008
|
+
|
|
1009
|
+
render(item) {
|
|
1010
|
+
const wrapper = document.createElement('div');
|
|
1011
|
+
wrapper.className = 'lgx-content lgx-content--video';
|
|
1012
|
+
|
|
1013
|
+
const video = document.createElement('video');
|
|
1014
|
+
video.className = 'lgx-video';
|
|
1015
|
+
video.controls = true;
|
|
1016
|
+
video.playsInline = true;
|
|
1017
|
+
video.preload = 'metadata';
|
|
1018
|
+
if (item.autoplay) video.autoplay = true;
|
|
1019
|
+
if (item.loop) video.loop = true;
|
|
1020
|
+
if (item.muted) video.muted = true;
|
|
1021
|
+
if (item.poster) video.poster = item.poster;
|
|
1022
|
+
video.addEventListener('play', () => {
|
|
1023
|
+
wrapper.dispatchEvent(new CustomEvent('lgx:media-play', { bubbles: true }));
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
const sources = Array.isArray(item.src) ? item.src : [item.src];
|
|
1027
|
+
sources.forEach((s) => {
|
|
1028
|
+
const src = typeof s === 'string' ? { src: s } : s;
|
|
1029
|
+
const source = document.createElement('source');
|
|
1030
|
+
source.src = src.src;
|
|
1031
|
+
if (src.type) source.type = src.type;
|
|
1032
|
+
video.appendChild(source);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
wrapper.appendChild(video);
|
|
1036
|
+
wrapper.classList.add('lgx-content--loaded');
|
|
1037
|
+
|
|
1038
|
+
wrapper._pause = () => video.pause();
|
|
1039
|
+
return wrapper;
|
|
1040
|
+
},
|
|
1041
|
+
|
|
1042
|
+
getThumbnail(item) {
|
|
1043
|
+
return item.thumb ?? item.poster ?? null;
|
|
1044
|
+
},
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
const YouTubeAdapter = {
|
|
1048
|
+
type: 'youtube',
|
|
1049
|
+
|
|
1050
|
+
canHandle(item) {
|
|
1051
|
+
return item.type === 'youtube' || /youtube\.com|youtu\.be/.test(item.src ?? '');
|
|
1052
|
+
},
|
|
1053
|
+
|
|
1054
|
+
render(item) {
|
|
1055
|
+
const wrapper = document.createElement('div');
|
|
1056
|
+
wrapper.className = 'lgx-content lgx-content--youtube lgx-content--iframe';
|
|
1057
|
+
|
|
1058
|
+
const id = getYouTubeId(item.src);
|
|
1059
|
+
if (!id) {
|
|
1060
|
+
wrapper.textContent = 'Invalid YouTube URL';
|
|
1061
|
+
return wrapper;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const params = new URLSearchParams({
|
|
1065
|
+
autoplay: item.autoplay ? '1' : '0',
|
|
1066
|
+
rel: '0',
|
|
1067
|
+
modestbranding: '1',
|
|
1068
|
+
enablejsapi: '1',
|
|
1069
|
+
...(item.start ? { start: item.start } : {}),
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
const iframe = document.createElement('iframe');
|
|
1073
|
+
iframe.className = 'lgx-iframe';
|
|
1074
|
+
iframe.src = `https://www.youtube.com/embed/${id}?${params}`;
|
|
1075
|
+
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
|
|
1076
|
+
iframe.allowFullscreen = true;
|
|
1077
|
+
iframe.setAttribute('loading', 'lazy');
|
|
1078
|
+
iframe.title = item.caption ?? 'YouTube video';
|
|
1079
|
+
|
|
1080
|
+
iframe.addEventListener('load', () => wrapper.classList.add('lgx-content--loaded'));
|
|
1081
|
+
|
|
1082
|
+
wrapper.appendChild(iframe);
|
|
1083
|
+
|
|
1084
|
+
wrapper._pause = () => {
|
|
1085
|
+
try {
|
|
1086
|
+
iframe.contentWindow?.postMessage('{"event":"command","func":"pauseVideo","args":""}', '*');
|
|
1087
|
+
} catch (_) {}
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
return wrapper;
|
|
1091
|
+
},
|
|
1092
|
+
|
|
1093
|
+
getThumbnail(item) {
|
|
1094
|
+
if (item.thumb) return item.thumb;
|
|
1095
|
+
const id = getYouTubeId(item.src);
|
|
1096
|
+
return id ? `https://img.youtube.com/vi/${id}/mqdefault.jpg` : null;
|
|
1097
|
+
},
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
const VimeoAdapter = {
|
|
1101
|
+
type: 'vimeo',
|
|
1102
|
+
|
|
1103
|
+
canHandle(item) {
|
|
1104
|
+
return item.type === 'vimeo' || /vimeo\.com/.test(item.src ?? '');
|
|
1105
|
+
},
|
|
1106
|
+
|
|
1107
|
+
render(item) {
|
|
1108
|
+
const wrapper = document.createElement('div');
|
|
1109
|
+
wrapper.className = 'lgx-content lgx-content--vimeo lgx-content--iframe';
|
|
1110
|
+
|
|
1111
|
+
const id = getVimeoId(item.src);
|
|
1112
|
+
if (!id) {
|
|
1113
|
+
wrapper.textContent = 'Invalid Vimeo URL';
|
|
1114
|
+
return wrapper;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const params = new URLSearchParams({
|
|
1118
|
+
autoplay: item.autoplay ? '1' : '0',
|
|
1119
|
+
title: '0',
|
|
1120
|
+
byline: '0',
|
|
1121
|
+
portrait: '0',
|
|
1122
|
+
api: '1',
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
const iframe = document.createElement('iframe');
|
|
1126
|
+
iframe.className = 'lgx-iframe';
|
|
1127
|
+
iframe.src = `https://player.vimeo.com/video/${id}?${params}`;
|
|
1128
|
+
iframe.allow = 'autoplay; fullscreen; picture-in-picture';
|
|
1129
|
+
iframe.allowFullscreen = true;
|
|
1130
|
+
iframe.setAttribute('loading', 'lazy');
|
|
1131
|
+
iframe.title = item.caption ?? 'Vimeo video';
|
|
1132
|
+
|
|
1133
|
+
iframe.addEventListener('load', () => wrapper.classList.add('lgx-content--loaded'));
|
|
1134
|
+
|
|
1135
|
+
wrapper.appendChild(iframe);
|
|
1136
|
+
|
|
1137
|
+
wrapper._pause = () => {
|
|
1138
|
+
try {
|
|
1139
|
+
iframe.contentWindow?.postMessage('{"method":"pause"}', '*');
|
|
1140
|
+
} catch (_) {}
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
return wrapper;
|
|
1144
|
+
},
|
|
1145
|
+
|
|
1146
|
+
getThumbnail(item) {
|
|
1147
|
+
return item.thumb ?? null;
|
|
1148
|
+
},
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
const IframeAdapter = {
|
|
1152
|
+
type: 'iframe',
|
|
1153
|
+
|
|
1154
|
+
canHandle(item) {
|
|
1155
|
+
return item.type === 'iframe';
|
|
1156
|
+
},
|
|
1157
|
+
|
|
1158
|
+
render(item) {
|
|
1159
|
+
const wrapper = document.createElement('div');
|
|
1160
|
+
wrapper.className = 'lgx-content lgx-content--iframe';
|
|
1161
|
+
|
|
1162
|
+
const iframe = document.createElement('iframe');
|
|
1163
|
+
iframe.className = 'lgx-iframe';
|
|
1164
|
+
iframe.src = item.src;
|
|
1165
|
+
iframe.allowFullscreen = true;
|
|
1166
|
+
iframe.setAttribute('loading', 'lazy');
|
|
1167
|
+
iframe.title = item.caption ?? 'Embedded content';
|
|
1168
|
+
if (item.iframeWidth) iframe.style.width = item.iframeWidth;
|
|
1169
|
+
if (item.iframeHeight) iframe.style.height = item.iframeHeight;
|
|
1170
|
+
|
|
1171
|
+
iframe.addEventListener('load', () => wrapper.classList.add('lgx-content--loaded'));
|
|
1172
|
+
|
|
1173
|
+
wrapper.appendChild(iframe);
|
|
1174
|
+
return wrapper;
|
|
1175
|
+
},
|
|
1176
|
+
|
|
1177
|
+
getThumbnail(item) {
|
|
1178
|
+
return item.thumb ?? null;
|
|
1179
|
+
},
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
class ZoomPlugin {
|
|
1183
|
+
static pluginName = 'zoom';
|
|
1184
|
+
|
|
1185
|
+
constructor(gallery) {
|
|
1186
|
+
this.gallery = gallery;
|
|
1187
|
+
this._btnIn = null;
|
|
1188
|
+
this._btnOut = null;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
init(gallery) {
|
|
1192
|
+
this.gallery = gallery;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
buildToolbar(toolbar) {
|
|
1196
|
+
if (!this.gallery.opts.zoom) return;
|
|
1197
|
+
|
|
1198
|
+
this._btnIn = this._makeBtn('zoomin', this.gallery.opts.i18n.zoomIn, () => this.gallery.zoomIn());
|
|
1199
|
+
this._btnOut = this._makeBtn('zoomout', this.gallery.opts.i18n.zoomOut, () => this.gallery.zoomOut());
|
|
1200
|
+
|
|
1201
|
+
// Insert before close button
|
|
1202
|
+
const close = toolbar.querySelector('.lgx-btn--close');
|
|
1203
|
+
toolbar.insertBefore(this._btnOut, close);
|
|
1204
|
+
toolbar.insertBefore(this._btnIn, this._btnOut);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
afterSlide(index, item) {
|
|
1208
|
+
const isImage = item.type === 'image';
|
|
1209
|
+
if (this._btnIn) this._btnIn.style.display = isImage ? '' : 'none';
|
|
1210
|
+
if (this._btnOut) this._btnOut.style.display = isImage ? '' : 'none';
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
afterZoom(zoom) {
|
|
1214
|
+
const min = this.gallery.opts.zoomMin;
|
|
1215
|
+
const max = this.gallery.opts.zoomMax;
|
|
1216
|
+
if (this._btnIn) this._btnIn.disabled = zoom >= max;
|
|
1217
|
+
if (this._btnOut) this._btnOut.disabled = zoom <= min;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
_makeBtn(iconName, label, onClick) {
|
|
1221
|
+
const btn = document.createElement('button');
|
|
1222
|
+
btn.type = 'button';
|
|
1223
|
+
btn.className = `lgx-btn lgx-btn--${iconName}`;
|
|
1224
|
+
btn.setAttribute('aria-label', label);
|
|
1225
|
+
btn.title = label;
|
|
1226
|
+
btn.innerHTML = this.gallery._svgIcon(iconName).outerHTML;
|
|
1227
|
+
btn.addEventListener('click', onClick);
|
|
1228
|
+
return btn;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
destroy() {}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
class ThumbsPlugin {
|
|
1235
|
+
static pluginName = 'thumbnails';
|
|
1236
|
+
|
|
1237
|
+
constructor(gallery) {
|
|
1238
|
+
this.gallery = gallery;
|
|
1239
|
+
this._strip = null;
|
|
1240
|
+
this._thumbEls = [];
|
|
1241
|
+
this._observer = null;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
init(gallery) {
|
|
1245
|
+
this.gallery = gallery;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
afterBuildDOM() {
|
|
1249
|
+
if (!this.gallery.opts.thumbnails) return;
|
|
1250
|
+
if (this.gallery._items.length < 2) return;
|
|
1251
|
+
|
|
1252
|
+
this._buildStrip();
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
afterSlide(index) {
|
|
1256
|
+
this._activate(index);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
_buildStrip() {
|
|
1260
|
+
const gallery = this.gallery;
|
|
1261
|
+
const strip = document.createElement('div');
|
|
1262
|
+
strip.className = 'lgx-thumbs';
|
|
1263
|
+
strip.setAttribute('role', 'tablist');
|
|
1264
|
+
strip.setAttribute('aria-label', 'Gallery thumbnails');
|
|
1265
|
+
|
|
1266
|
+
this._thumbEls = gallery._items.map((item, i) => {
|
|
1267
|
+
const thumb = document.createElement('button');
|
|
1268
|
+
thumb.type = 'button';
|
|
1269
|
+
thumb.className = 'lgx-thumb';
|
|
1270
|
+
thumb.setAttribute('role', 'tab');
|
|
1271
|
+
thumb.setAttribute('aria-label', `Slide ${i + 1}${item.caption ? ': ' + item.caption : ''}`);
|
|
1272
|
+
thumb.setAttribute('aria-selected', i === gallery._index ? 'true' : 'false');
|
|
1273
|
+
thumb.dataset.lgxThumbIndex = i;
|
|
1274
|
+
|
|
1275
|
+
const adapter = gallery._resolveAdapter(item);
|
|
1276
|
+
const thumbSrc = adapter?.getThumbnail(item);
|
|
1277
|
+
|
|
1278
|
+
if (thumbSrc) {
|
|
1279
|
+
const img = document.createElement('img');
|
|
1280
|
+
img.alt = '';
|
|
1281
|
+
img.loading = 'lazy';
|
|
1282
|
+
|
|
1283
|
+
// IntersectionObserver lazy load
|
|
1284
|
+
if ('IntersectionObserver' in window) {
|
|
1285
|
+
img.dataset.src = thumbSrc;
|
|
1286
|
+
this._observe(img);
|
|
1287
|
+
} else {
|
|
1288
|
+
img.src = thumbSrc;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
thumb.appendChild(img);
|
|
1292
|
+
} else {
|
|
1293
|
+
// Video/iframe fallback: show index number
|
|
1294
|
+
thumb.appendChild(document.createTextNode(i + 1));
|
|
1295
|
+
thumb.classList.add('lgx-thumb--no-image');
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
thumb.addEventListener('click', () => gallery.goTo(i));
|
|
1299
|
+
return thumb;
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
this._thumbEls.forEach((t) => strip.appendChild(t));
|
|
1303
|
+
gallery._dom.container.appendChild(strip);
|
|
1304
|
+
gallery._dom.thumbStrip = strip;
|
|
1305
|
+
this._strip = strip;
|
|
1306
|
+
|
|
1307
|
+
this._activate(gallery._index);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
_activate(index) {
|
|
1311
|
+
this._thumbEls.forEach((t, i) => {
|
|
1312
|
+
const active = i === index;
|
|
1313
|
+
t.classList.toggle('lgx-thumb--active', active);
|
|
1314
|
+
t.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
// Scroll active thumb into view
|
|
1318
|
+
const activeThumb = this._thumbEls[index];
|
|
1319
|
+
if (activeThumb && this._strip) {
|
|
1320
|
+
activeThumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
_observe(img) {
|
|
1325
|
+
if (!this._observer) {
|
|
1326
|
+
this._observer = new IntersectionObserver((entries) => {
|
|
1327
|
+
entries.forEach((e) => {
|
|
1328
|
+
if (e.isIntersecting) {
|
|
1329
|
+
e.target.src = e.target.dataset.src;
|
|
1330
|
+
this._observer.unobserve(e.target);
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
}, { rootMargin: '100px' });
|
|
1334
|
+
}
|
|
1335
|
+
this._observer.observe(img);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
destroy() {
|
|
1339
|
+
this._observer?.disconnect();
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
class FullscreenPlugin {
|
|
1344
|
+
static pluginName = 'fullscreen';
|
|
1345
|
+
|
|
1346
|
+
constructor(gallery) {
|
|
1347
|
+
this.gallery = gallery;
|
|
1348
|
+
this._btn = null;
|
|
1349
|
+
this._onChange = null;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
init(gallery) {
|
|
1353
|
+
this.gallery = gallery;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
buildToolbar(toolbar) {
|
|
1357
|
+
if (!this.gallery.opts.fullscreen) return;
|
|
1358
|
+
|
|
1359
|
+
this._btn = document.createElement('button');
|
|
1360
|
+
this._btn.type = 'button';
|
|
1361
|
+
this._btn.className = 'lgx-btn lgx-btn--fullscreen';
|
|
1362
|
+
this._btn.setAttribute('aria-label', this.gallery.opts.i18n.fullscreen);
|
|
1363
|
+
this._btn.title = this.gallery.opts.i18n.fullscreen;
|
|
1364
|
+
this._btn.innerHTML = this.gallery._svgIcon('fullscreen').outerHTML;
|
|
1365
|
+
this._btn.addEventListener('click', () => this.toggle());
|
|
1366
|
+
|
|
1367
|
+
const close = toolbar.querySelector('.lgx-btn--close');
|
|
1368
|
+
toolbar.insertBefore(this._btn, close);
|
|
1369
|
+
|
|
1370
|
+
this._onChange = () => this._updateBtn();
|
|
1371
|
+
document.addEventListener('fullscreenchange', this._onChange);
|
|
1372
|
+
document.addEventListener('webkitfullscreenchange', this._onChange);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
toggle() {
|
|
1376
|
+
if (isFullscreen()) {
|
|
1377
|
+
exitFullscreen();
|
|
1378
|
+
} else {
|
|
1379
|
+
requestFullscreen(this.gallery._dom.modal);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
toggleFullscreen() {
|
|
1384
|
+
this.toggle();
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
_updateBtn() {
|
|
1388
|
+
if (!this._btn) return;
|
|
1389
|
+
const full = isFullscreen();
|
|
1390
|
+
this._btn.setAttribute('aria-pressed', full ? 'true' : 'false');
|
|
1391
|
+
this.gallery._dom.modal?.classList.toggle('lgx-modal--fullscreen', full);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
afterClose() {
|
|
1395
|
+
if (isFullscreen()) exitFullscreen();
|
|
1396
|
+
if (this._onChange) {
|
|
1397
|
+
document.removeEventListener('fullscreenchange', this._onChange);
|
|
1398
|
+
document.removeEventListener('webkitfullscreenchange', this._onChange);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
destroy() {
|
|
1403
|
+
this.afterClose();
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
class DownloadPlugin {
|
|
1408
|
+
static pluginName = 'download';
|
|
1409
|
+
|
|
1410
|
+
constructor(gallery) {
|
|
1411
|
+
this.gallery = gallery;
|
|
1412
|
+
this._btn = null;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
init(gallery) {
|
|
1416
|
+
this.gallery = gallery;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
buildToolbar(toolbar) {
|
|
1420
|
+
if (!this.gallery.opts.download) return;
|
|
1421
|
+
|
|
1422
|
+
this._btn = document.createElement('button');
|
|
1423
|
+
this._btn.type = 'button';
|
|
1424
|
+
this._btn.className = 'lgx-btn lgx-btn--download';
|
|
1425
|
+
this._btn.setAttribute('aria-label', this.gallery.opts.i18n.download);
|
|
1426
|
+
this._btn.title = this.gallery.opts.i18n.download;
|
|
1427
|
+
this._btn.innerHTML = this.gallery._svgIcon('download').outerHTML;
|
|
1428
|
+
this._btn.addEventListener('click', () => this._download());
|
|
1429
|
+
|
|
1430
|
+
const close = toolbar.querySelector('.lgx-btn--close');
|
|
1431
|
+
toolbar.insertBefore(this._btn, close);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
afterSlide(index, item) {
|
|
1435
|
+
if (!this._btn) return;
|
|
1436
|
+
// Hide download for video embeds where we can't trigger a download
|
|
1437
|
+
const canDownload = ['image', 'video'].includes(item.type);
|
|
1438
|
+
this._btn.style.display = canDownload ? '' : 'none';
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
_download() {
|
|
1442
|
+
const item = this.gallery._items[this.gallery._index];
|
|
1443
|
+
if (!item) return;
|
|
1444
|
+
const url = item.download || item.src;
|
|
1445
|
+
const a = document.createElement('a');
|
|
1446
|
+
a.href = url;
|
|
1447
|
+
a.download = item.downloadName || url.split('/').pop() || 'download';
|
|
1448
|
+
a.style.display = 'none';
|
|
1449
|
+
document.body.appendChild(a);
|
|
1450
|
+
a.click();
|
|
1451
|
+
document.body.removeChild(a);
|
|
1452
|
+
this.gallery.emit('download', { item });
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
destroy() {}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
class SharePlugin {
|
|
1459
|
+
static pluginName = 'share';
|
|
1460
|
+
|
|
1461
|
+
constructor(gallery) {
|
|
1462
|
+
this.gallery = gallery;
|
|
1463
|
+
this._btn = null;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
init(gallery) {
|
|
1467
|
+
this.gallery = gallery;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
buildToolbar(toolbar) {
|
|
1471
|
+
if (!this.gallery.opts.share) return;
|
|
1472
|
+
|
|
1473
|
+
this._btn = document.createElement('button');
|
|
1474
|
+
this._btn.type = 'button';
|
|
1475
|
+
this._btn.className = 'lgx-btn lgx-btn--share';
|
|
1476
|
+
this._btn.setAttribute('aria-label', this.gallery.opts.i18n.share);
|
|
1477
|
+
this._btn.title = this.gallery.opts.i18n.share;
|
|
1478
|
+
this._btn.innerHTML = this.gallery._svgIcon('share').outerHTML;
|
|
1479
|
+
this._btn.addEventListener('click', () => this._share());
|
|
1480
|
+
|
|
1481
|
+
const close = toolbar.querySelector('.lgx-btn--close');
|
|
1482
|
+
toolbar.insertBefore(this._btn, close);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
async _share() {
|
|
1486
|
+
const item = this.gallery._items[this.gallery._index];
|
|
1487
|
+
if (!item) return;
|
|
1488
|
+
|
|
1489
|
+
const shareData = {
|
|
1490
|
+
title: item.caption || document.title,
|
|
1491
|
+
url: item.shareUrl || item.src,
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
// Native Web Share API (mobile)
|
|
1495
|
+
if (navigator.share) {
|
|
1496
|
+
try {
|
|
1497
|
+
await navigator.share(shareData);
|
|
1498
|
+
this.gallery.emit('share', { item, method: 'native' });
|
|
1499
|
+
return;
|
|
1500
|
+
} catch (_) {}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// Fallback: copy URL to clipboard
|
|
1504
|
+
if (navigator.clipboard) {
|
|
1505
|
+
await navigator.clipboard.writeText(shareData.url);
|
|
1506
|
+
this._showToast('Link copied!');
|
|
1507
|
+
this.gallery.emit('share', { item, method: 'clipboard' });
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
_showToast(msg) {
|
|
1512
|
+
const toast = document.createElement('div');
|
|
1513
|
+
toast.className = 'lgx-toast';
|
|
1514
|
+
toast.textContent = msg;
|
|
1515
|
+
this.gallery._dom.modal?.appendChild(toast);
|
|
1516
|
+
setTimeout(() => toast.remove(), 2500);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
destroy() {}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// Core
|
|
1523
|
+
|
|
1524
|
+
Registry.registerAdapter('image', ImageAdapter);
|
|
1525
|
+
Registry.registerAdapter('video', VideoAdapter);
|
|
1526
|
+
Registry.registerAdapter('youtube', YouTubeAdapter);
|
|
1527
|
+
Registry.registerAdapter('vimeo', VimeoAdapter);
|
|
1528
|
+
Registry.registerAdapter('iframe', IframeAdapter);
|
|
1529
|
+
|
|
1530
|
+
Registry.registerPlugin('zoom', ZoomPlugin);
|
|
1531
|
+
Registry.registerPlugin('thumbnails', ThumbsPlugin);
|
|
1532
|
+
Registry.registerPlugin('fullscreen', FullscreenPlugin);
|
|
1533
|
+
Registry.registerPlugin('download', DownloadPlugin);
|
|
1534
|
+
Registry.registerPlugin('share', SharePlugin);
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Create a Crowdbox instance and return it.
|
|
1538
|
+
* This is the primary public API for vanilla / script-tag usage.
|
|
1539
|
+
*/
|
|
1540
|
+
function createGallery(options = {}) {
|
|
1541
|
+
return new Crowdbox(options);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// Auto-init from [data-lgx-init] attribute
|
|
1545
|
+
if (typeof document !== 'undefined') {
|
|
1546
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1547
|
+
document.querySelectorAll('[data-lgx-init]').forEach((root) => {
|
|
1548
|
+
try {
|
|
1549
|
+
const opts = JSON.parse(root.dataset.lgxInit || '{}');
|
|
1550
|
+
new Crowdbox({ selector: `#${root.id} [data-lgx]`, ...opts });
|
|
1551
|
+
} catch (_) {}
|
|
1552
|
+
});
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
export { Crowdbox, DownloadPlugin, EventEmitter, FullscreenPlugin, IframeAdapter, ImageAdapter, Crowdbox as LightboxGallery, Registry, SharePlugin, ThumbsPlugin, VideoAdapter, VimeoAdapter, YouTubeAdapter, ZoomPlugin, createGallery, Crowdbox as default };
|
|
1557
|
+
//# sourceMappingURL=crowdbox.esm.js.map
|