@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,739 @@
|
|
|
1
|
+
import { EventEmitter } from './EventEmitter.js';
|
|
2
|
+
import { Registry } from './Registry.js';
|
|
3
|
+
import {
|
|
4
|
+
createElement,
|
|
5
|
+
mergeDeep,
|
|
6
|
+
detectMediaType,
|
|
7
|
+
trapFocus,
|
|
8
|
+
clamp,
|
|
9
|
+
passiveOpt,
|
|
10
|
+
} from './utils.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
selector: '[data-lgx]',
|
|
14
|
+
gallerySelector: null, // group items by this attr value
|
|
15
|
+
loop: true,
|
|
16
|
+
keyboard: true,
|
|
17
|
+
touch: true,
|
|
18
|
+
drag: true,
|
|
19
|
+
zoom: true,
|
|
20
|
+
thumbnails: true,
|
|
21
|
+
captions: true,
|
|
22
|
+
counter: true,
|
|
23
|
+
fullscreen: true,
|
|
24
|
+
download: false,
|
|
25
|
+
share: false,
|
|
26
|
+
closeOnBackdrop: true,
|
|
27
|
+
closeOnEscape: true,
|
|
28
|
+
animationDuration: 300,
|
|
29
|
+
slideAnimationDuration: 360,
|
|
30
|
+
zoomStep: 0.5,
|
|
31
|
+
zoomMax: 4,
|
|
32
|
+
zoomMin: 1,
|
|
33
|
+
lazyLoad: true,
|
|
34
|
+
preload: 1, // preload N slides ahead/behind
|
|
35
|
+
autoplay: false,
|
|
36
|
+
autoplayInterval: 4000,
|
|
37
|
+
showAutoplay: true, // show/hide autoplay button in toolbar
|
|
38
|
+
i18n: {
|
|
39
|
+
close: 'Close',
|
|
40
|
+
prev: 'Previous',
|
|
41
|
+
next: 'Next',
|
|
42
|
+
zoomIn: 'Zoom in',
|
|
43
|
+
zoomOut: 'Zoom out',
|
|
44
|
+
fullscreen: 'Fullscreen',
|
|
45
|
+
download: 'Download',
|
|
46
|
+
share: 'Share',
|
|
47
|
+
autoplayStart: 'Start slideshow',
|
|
48
|
+
autoplayStop: 'Stop slideshow',
|
|
49
|
+
counter: '{current} of {total}',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export class Crowdbox extends EventEmitter {
|
|
54
|
+
constructor(options = {}) {
|
|
55
|
+
super();
|
|
56
|
+
this.opts = mergeDeep({}, DEFAULTS, options);
|
|
57
|
+
this._items = [];
|
|
58
|
+
this._index = 0;
|
|
59
|
+
this._open = false;
|
|
60
|
+
this._zoom = 1;
|
|
61
|
+
this._panX = 0;
|
|
62
|
+
this._panY = 0;
|
|
63
|
+
this._autoplayTimer = null;
|
|
64
|
+
this._releaseFocusTrap = null;
|
|
65
|
+
this._previousFocus = null;
|
|
66
|
+
this._plugins = [];
|
|
67
|
+
this._dom = {};
|
|
68
|
+
|
|
69
|
+
this._initPlugins();
|
|
70
|
+
this._bindTriggers();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
open(items, startIndex = 0) {
|
|
76
|
+
if (this._open) this.close();
|
|
77
|
+
this._items = this._normalizeItems(items);
|
|
78
|
+
this._index = clamp(startIndex, 0, this._items.length - 1);
|
|
79
|
+
this._open = true;
|
|
80
|
+
this._zoom = 1;
|
|
81
|
+
this._panX = 0;
|
|
82
|
+
this._panY = 0;
|
|
83
|
+
|
|
84
|
+
this._buildDOM();
|
|
85
|
+
this._attachKeyboard();
|
|
86
|
+
this._attachTouch();
|
|
87
|
+
this._renderSlide(this._index);
|
|
88
|
+
this._preload(this._index);
|
|
89
|
+
this._pluginHook('afterOpen');
|
|
90
|
+
this.emit('open', { index: this._index, item: this._items[this._index] });
|
|
91
|
+
|
|
92
|
+
if (this.opts.autoplay && !this._isAutoplayBlockingMedia(this._items[this._index])) this._startAutoplay();
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
close() {
|
|
97
|
+
if (!this._open) return this;
|
|
98
|
+
this._open = false;
|
|
99
|
+
this._stopAutoplay();
|
|
100
|
+
this._pauseCurrentMedia();
|
|
101
|
+
this._pluginHook('beforeClose');
|
|
102
|
+
|
|
103
|
+
const modal = this._dom.modal;
|
|
104
|
+
if (modal) {
|
|
105
|
+
modal.style.animation = `lgx-fade-out ${this.opts.animationDuration}ms ease forwards`;
|
|
106
|
+
setTimeout(() => { modal.remove(); this._dom = {}; }, this.opts.animationDuration);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this._detachKeyboard();
|
|
110
|
+
if (this._releaseFocusTrap) { this._releaseFocusTrap(); this._releaseFocusTrap = null; }
|
|
111
|
+
if (this._previousFocus) { this._previousFocus.focus(); this._previousFocus = null; }
|
|
112
|
+
document.body.style.overflow = '';
|
|
113
|
+
|
|
114
|
+
this._pluginHook('afterClose');
|
|
115
|
+
this.emit('close');
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
next() {
|
|
120
|
+
const total = this._items.length;
|
|
121
|
+
if (!total) return this;
|
|
122
|
+
if (!this.opts.loop && this._index >= total - 1) return this;
|
|
123
|
+
this._goTo((this._index + 1) % total, 'next');
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
prev() {
|
|
128
|
+
const total = this._items.length;
|
|
129
|
+
if (!total) return this;
|
|
130
|
+
if (!this.opts.loop && this._index === 0) return this;
|
|
131
|
+
this._goTo((this._index - 1 + total) % total, 'prev');
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
goTo(index) {
|
|
136
|
+
const i = clamp(index, 0, this._items.length - 1);
|
|
137
|
+
this._goTo(i, i > this._index ? 'next' : 'prev');
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
zoomIn() {
|
|
142
|
+
this._applyZoom(this._zoom + this.opts.zoomStep);
|
|
143
|
+
return this;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
zoomOut() {
|
|
147
|
+
this._applyZoom(this._zoom - this.opts.zoomStep);
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
resetZoom() {
|
|
152
|
+
this._applyZoom(1, true);
|
|
153
|
+
return this;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
startAutoplay() { this._startAutoplay(); return this; }
|
|
157
|
+
stopAutoplay() { this._stopAutoplay(); return this; }
|
|
158
|
+
toggleAutoplay() {
|
|
159
|
+
this._autoplayTimer ? this._stopAutoplay() : this._startAutoplay();
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
destroy() {
|
|
164
|
+
this.close();
|
|
165
|
+
this._detachTriggers();
|
|
166
|
+
this._plugins.forEach((p) => p.destroy?.());
|
|
167
|
+
this.removeAllListeners();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Internal ─────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
_normalizeItems(items) {
|
|
173
|
+
if (!Array.isArray(items)) items = [items];
|
|
174
|
+
return items.map((item) => {
|
|
175
|
+
if (typeof item === 'string') item = { src: item };
|
|
176
|
+
if (!item.type) item.type = detectMediaType(item.src);
|
|
177
|
+
return item;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_initPlugins() {
|
|
182
|
+
const pluginDefs = Registry.getPlugins();
|
|
183
|
+
pluginDefs.forEach((Def) => {
|
|
184
|
+
if (this.opts[Def.pluginName] === false) return;
|
|
185
|
+
const inst = typeof Def === 'function' ? new Def(this) : Def;
|
|
186
|
+
if (typeof inst.init === 'function') inst.init(this);
|
|
187
|
+
this._plugins.push(inst);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_pluginHook(hook, ...args) {
|
|
192
|
+
this._plugins.forEach((p) => p[hook]?.(...args));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── DOM ──────────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
_buildDOM() {
|
|
198
|
+
this._previousFocus = document.activeElement;
|
|
199
|
+
document.body.style.overflow = 'hidden';
|
|
200
|
+
|
|
201
|
+
const modal = createElement('div', {
|
|
202
|
+
class: 'lgx-modal',
|
|
203
|
+
role: 'dialog',
|
|
204
|
+
'aria-modal': 'true',
|
|
205
|
+
'aria-label': 'Media lightbox',
|
|
206
|
+
});
|
|
207
|
+
modal.style.setProperty('--lgx-duration', `${this.opts.animationDuration}ms`);
|
|
208
|
+
modal.style.setProperty('--lgx-slide-duration', `${this.opts.slideAnimationDuration}ms`);
|
|
209
|
+
|
|
210
|
+
const backdrop = createElement('div', { class: 'lgx-backdrop' });
|
|
211
|
+
if (this.opts.closeOnBackdrop) {
|
|
212
|
+
backdrop.addEventListener('click', () => this.close());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const container = createElement('div', { class: 'lgx-container' });
|
|
216
|
+
|
|
217
|
+
// Toolbar
|
|
218
|
+
const toolbar = this._buildToolbar();
|
|
219
|
+
|
|
220
|
+
// Stage — nav buttons live here so top:50% is relative to the image area,
|
|
221
|
+
// not the full container (which includes caption + thumbnail strip).
|
|
222
|
+
const stage = createElement('div', { class: 'lgx-stage', 'aria-live': 'polite' });
|
|
223
|
+
const slideWrapper = createElement('div', { class: 'lgx-slide-wrapper' });
|
|
224
|
+
const btnPrev = this._buildNavBtn('prev');
|
|
225
|
+
const btnNext = this._buildNavBtn('next');
|
|
226
|
+
stage.append(slideWrapper, btnPrev, btnNext);
|
|
227
|
+
|
|
228
|
+
// Caption — normal flex child so it sits between stage and thumbnail strip
|
|
229
|
+
// without z-index fights. Hidden via display:none when empty.
|
|
230
|
+
const caption = createElement('div', { class: 'lgx-caption', 'aria-live': 'polite' });
|
|
231
|
+
caption.style.display = 'none';
|
|
232
|
+
|
|
233
|
+
// Counter (absolute, overlays top of stage)
|
|
234
|
+
const counter = createElement('div', { class: 'lgx-counter', 'aria-live': 'polite' });
|
|
235
|
+
|
|
236
|
+
// Loading spinner
|
|
237
|
+
const spinner = createElement('div', { class: 'lgx-spinner', 'aria-hidden': 'true' },
|
|
238
|
+
createElement('div', { class: 'lgx-spinner__ring' })
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// Order matters for flex layout: stage grows, caption sits below it, thumbs at bottom
|
|
242
|
+
container.append(toolbar, stage, caption, counter, spinner);
|
|
243
|
+
|
|
244
|
+
modal.append(backdrop, container);
|
|
245
|
+
document.body.appendChild(modal);
|
|
246
|
+
|
|
247
|
+
// Animate in
|
|
248
|
+
modal.style.animation = `lgx-fade-in ${this.opts.animationDuration}ms ease forwards`;
|
|
249
|
+
|
|
250
|
+
const btnAutoplay = toolbar.querySelector('.lgx-btn--autoplay');
|
|
251
|
+
this._dom = { modal, backdrop, container, stage, slideWrapper, toolbar, caption, counter, spinner, btnPrev, btnNext, btnAutoplay };
|
|
252
|
+
this._releaseFocusTrap = trapFocus(modal);
|
|
253
|
+
|
|
254
|
+
// Focus the container for keyboard events
|
|
255
|
+
container.setAttribute('tabindex', '-1');
|
|
256
|
+
container.focus();
|
|
257
|
+
|
|
258
|
+
this._pluginHook('afterBuildDOM');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_buildToolbar() {
|
|
262
|
+
const toolbar = createElement('div', { class: 'lgx-toolbar', role: 'toolbar', 'aria-label': 'Gallery controls' });
|
|
263
|
+
|
|
264
|
+
const close = createElement('button', {
|
|
265
|
+
class: 'lgx-btn lgx-btn--close',
|
|
266
|
+
type: 'button',
|
|
267
|
+
'aria-label': this.opts.i18n.close,
|
|
268
|
+
title: this.opts.i18n.close,
|
|
269
|
+
}, this._svgIcon('close'));
|
|
270
|
+
close.addEventListener('click', () => this.close());
|
|
271
|
+
|
|
272
|
+
const autoplay = createElement('button', {
|
|
273
|
+
class: 'lgx-btn lgx-btn--autoplay',
|
|
274
|
+
type: 'button',
|
|
275
|
+
'aria-label': this.opts.i18n.autoplayStart,
|
|
276
|
+
title: this.opts.i18n.autoplayStart,
|
|
277
|
+
'aria-pressed': 'false',
|
|
278
|
+
}, this._svgIcon('play'));
|
|
279
|
+
autoplay.addEventListener('click', () => this.toggleAutoplay());
|
|
280
|
+
|
|
281
|
+
if (this.opts.showAutoplay) toolbar.appendChild(autoplay);
|
|
282
|
+
toolbar.appendChild(close);
|
|
283
|
+
this._dom.btnAutoplay = this.opts.showAutoplay ? autoplay : null;
|
|
284
|
+
this._dom.btnClose = close;
|
|
285
|
+
this._pluginHook('buildToolbar', toolbar);
|
|
286
|
+
|
|
287
|
+
return toolbar;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_buildNavBtn(dir) {
|
|
291
|
+
const label = dir === 'prev' ? this.opts.i18n.prev : this.opts.i18n.next;
|
|
292
|
+
const btn = createElement('button', {
|
|
293
|
+
class: `lgx-btn lgx-btn--nav lgx-btn--${dir}`,
|
|
294
|
+
type: 'button',
|
|
295
|
+
'aria-label': label,
|
|
296
|
+
title: label,
|
|
297
|
+
}, this._svgIcon(dir));
|
|
298
|
+
|
|
299
|
+
btn.addEventListener('click', () => (dir === 'prev' ? this.prev() : this.next()));
|
|
300
|
+
|
|
301
|
+
if (this._items.length <= 1) btn.style.display = 'none';
|
|
302
|
+
return btn;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Slide Rendering ──────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
_renderSlide(index, direction = null) {
|
|
308
|
+
const item = this._items[index];
|
|
309
|
+
if (!item) return;
|
|
310
|
+
|
|
311
|
+
this._pauseCurrentMedia();
|
|
312
|
+
|
|
313
|
+
const adapter = this._resolveAdapter(item);
|
|
314
|
+
if (!adapter) return;
|
|
315
|
+
|
|
316
|
+
const slide = adapter.render(item);
|
|
317
|
+
slide.dataset.lgxIndex = index;
|
|
318
|
+
|
|
319
|
+
// Animate out old slide
|
|
320
|
+
const old = this._dom.slideWrapper.querySelector('.lgx-slide--active');
|
|
321
|
+
|
|
322
|
+
if (old && direction) {
|
|
323
|
+
const outClass = direction === 'next' ? 'lgx-slide--out-left' : 'lgx-slide--out-right';
|
|
324
|
+
const inClass = direction === 'next' ? 'lgx-slide--in-right' : 'lgx-slide--in-left';
|
|
325
|
+
|
|
326
|
+
// Add inClass BEFORE appending so fill-mode "both" starts the slide
|
|
327
|
+
// at opacity:0 — prevents the 1-frame flash at its final visible position.
|
|
328
|
+
slide.classList.add(inClass);
|
|
329
|
+
this._dom.slideWrapper.appendChild(slide);
|
|
330
|
+
|
|
331
|
+
// Defer the outgoing animation by one rAF so the browser has committed
|
|
332
|
+
// the new slide's start state before anything moves.
|
|
333
|
+
requestAnimationFrame(() => {
|
|
334
|
+
old.classList.remove('lgx-slide--active');
|
|
335
|
+
old.classList.add(outClass);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Use animationend for frame-accurate cleanup; setTimeout is the fallback
|
|
339
|
+
// in case animationend never fires (e.g. display:none mid-animation).
|
|
340
|
+
let done = false;
|
|
341
|
+
const cleanup = () => {
|
|
342
|
+
if (done) return;
|
|
343
|
+
done = true;
|
|
344
|
+
old.remove();
|
|
345
|
+
slide.classList.remove(inClass);
|
|
346
|
+
slide.classList.add('lgx-slide--active');
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
old.addEventListener('animationend', cleanup, { once: true });
|
|
350
|
+
setTimeout(cleanup, this.opts.slideAnimationDuration + 100);
|
|
351
|
+
} else {
|
|
352
|
+
this._dom.slideWrapper.innerHTML = '';
|
|
353
|
+
slide.classList.add('lgx-slide--active');
|
|
354
|
+
this._dom.slideWrapper.appendChild(slide);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
this._dom.currentSlide = slide;
|
|
358
|
+
this._index = index;
|
|
359
|
+
this._zoom = 1;
|
|
360
|
+
this._panX = 0;
|
|
361
|
+
|
|
362
|
+
// Show spinner, hide once slide reports loaded (JS fallback for browsers
|
|
363
|
+
// without :has() support — CSS handles it in modern browsers).
|
|
364
|
+
this._watchSlideLoaded(slide);
|
|
365
|
+
this._bindMediaAutoplayStop(slide, item);
|
|
366
|
+
if (this._autoplayTimer && this._isAutoplayBlockingMedia(item)) this._stopAutoplay();
|
|
367
|
+
this._panY = 0;
|
|
368
|
+
this._updateUI();
|
|
369
|
+
this._pluginHook('afterSlide', index, item);
|
|
370
|
+
this.emit('slide', { index, item });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
_goTo(index, direction) {
|
|
374
|
+
this._renderSlide(index, direction);
|
|
375
|
+
this._preload(index);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_watchSlideLoaded(slide) {
|
|
379
|
+
const spinner = this._dom.spinner;
|
|
380
|
+
if (!spinner) return;
|
|
381
|
+
|
|
382
|
+
// Already loaded (e.g. cached image or video adapter)
|
|
383
|
+
if (slide.classList.contains('lgx-content--loaded')) {
|
|
384
|
+
spinner.style.display = 'none';
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
spinner.style.display = '';
|
|
389
|
+
|
|
390
|
+
// Disconnect any previous observer
|
|
391
|
+
this._spinnerObserver?.disconnect();
|
|
392
|
+
|
|
393
|
+
this._spinnerObserver = new MutationObserver(() => {
|
|
394
|
+
if (slide.classList.contains('lgx-content--loaded')) {
|
|
395
|
+
spinner.style.display = 'none';
|
|
396
|
+
this._spinnerObserver.disconnect();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
this._spinnerObserver.observe(slide, { attributes: true, attributeFilter: ['class'] });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_preload(index) {
|
|
403
|
+
if (!this.opts.lazyLoad) return;
|
|
404
|
+
const n = this.opts.preload;
|
|
405
|
+
const total = this._items.length;
|
|
406
|
+
for (let i = 1; i <= n; i++) {
|
|
407
|
+
[
|
|
408
|
+
(index + i) % total,
|
|
409
|
+
(index - i + total) % total,
|
|
410
|
+
].forEach((pi) => {
|
|
411
|
+
const pitem = this._items[pi];
|
|
412
|
+
if (pitem?.type === 'image' && !pitem._preloaded) {
|
|
413
|
+
const img = new Image();
|
|
414
|
+
img.src = pitem.src;
|
|
415
|
+
pitem._preloaded = true;
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
_resolveAdapter(item) {
|
|
422
|
+
const adapter = Registry.getAdapter(item.type);
|
|
423
|
+
if (adapter) return adapter;
|
|
424
|
+
// Fallback: scan all adapters
|
|
425
|
+
const all = ['image', 'video', 'youtube', 'vimeo', 'iframe'];
|
|
426
|
+
for (const t of all) {
|
|
427
|
+
const a = Registry.getAdapter(t);
|
|
428
|
+
if (a?.canHandle(item)) return a;
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
_pauseCurrentMedia() {
|
|
434
|
+
const slide = this._dom.slideWrapper?.querySelector('.lgx-slide--active, .lgx-content');
|
|
435
|
+
if (slide?._pause) slide._pause();
|
|
436
|
+
// Pause html5 video elements
|
|
437
|
+
slide?.querySelectorAll('video').forEach((v) => v.pause());
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ─── UI Updates ───────────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
_updateUI() {
|
|
443
|
+
const { _index: i, _items: items, opts, _dom: dom } = this;
|
|
444
|
+
const total = items.length;
|
|
445
|
+
|
|
446
|
+
// Counter
|
|
447
|
+
if (dom.counter && opts.counter) {
|
|
448
|
+
dom.counter.textContent = opts.i18n.counter
|
|
449
|
+
.replace('{current}', i + 1)
|
|
450
|
+
.replace('{total}', total);
|
|
451
|
+
dom.counter.style.display = total > 1 ? '' : 'none';
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Caption
|
|
455
|
+
if (dom.caption && opts.captions) {
|
|
456
|
+
const cap = String(items[i]?.caption ?? '').trim();
|
|
457
|
+
dom.caption.innerHTML = cap;
|
|
458
|
+
dom.caption.style.display = cap ? '' : 'none';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Nav buttons
|
|
462
|
+
if (!opts.loop) {
|
|
463
|
+
if (dom.btnPrev) dom.btnPrev.disabled = i === 0;
|
|
464
|
+
if (dom.btnNext) dom.btnNext.disabled = i === total - 1;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Show/hide nav for single-item galleries
|
|
468
|
+
const showNav = total > 1;
|
|
469
|
+
if (dom.btnPrev) dom.btnPrev.style.display = showNav ? '' : 'none';
|
|
470
|
+
if (dom.btnNext) dom.btnNext.style.display = showNav ? '' : 'none';
|
|
471
|
+
if (dom.btnAutoplay) dom.btnAutoplay.style.display = showNav ? '' : 'none';
|
|
472
|
+
|
|
473
|
+
this._updateAutoplayButton();
|
|
474
|
+
|
|
475
|
+
this._pluginHook('updateUI');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
_updateAutoplayButton() {
|
|
479
|
+
const btn = this._dom.btnAutoplay;
|
|
480
|
+
if (!btn) return;
|
|
481
|
+
|
|
482
|
+
const active = !!this._autoplayTimer;
|
|
483
|
+
const label = active ? this.opts.i18n.autoplayStop : this.opts.i18n.autoplayStart;
|
|
484
|
+
btn.classList.toggle('lgx-btn--active', active);
|
|
485
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
486
|
+
btn.setAttribute('aria-label', label);
|
|
487
|
+
btn.title = label;
|
|
488
|
+
btn.innerHTML = this._svgIcon(active ? 'stop' : 'play').outerHTML;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ─── Zoom / Pan ───────────────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
_applyZoom(newZoom, reset = false) {
|
|
494
|
+
newZoom = clamp(newZoom, this.opts.zoomMin, this.opts.zoomMax);
|
|
495
|
+
if (reset) { newZoom = 1; this._panX = 0; this._panY = 0; }
|
|
496
|
+
this._zoom = newZoom;
|
|
497
|
+
|
|
498
|
+
const img = this._dom.slideWrapper?.querySelector('.lgx-image');
|
|
499
|
+
if (!img) return;
|
|
500
|
+
|
|
501
|
+
img.style.transform = `translate(${this._panX}px, ${this._panY}px) scale(${newZoom})`;
|
|
502
|
+
img.style.cursor = newZoom > 1 ? 'move' : 'default';
|
|
503
|
+
|
|
504
|
+
this._pluginHook('afterZoom', newZoom);
|
|
505
|
+
this.emit('zoom', { zoom: newZoom });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ─── Keyboard ─────────────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
_attachKeyboard() {
|
|
511
|
+
if (!this.opts.keyboard) return;
|
|
512
|
+
this._onKeydown = (e) => {
|
|
513
|
+
switch (e.key) {
|
|
514
|
+
case 'ArrowRight': this.next(); break;
|
|
515
|
+
case 'ArrowLeft': this.prev(); break;
|
|
516
|
+
case 'Escape': if (this.opts.closeOnEscape) this.close(); break;
|
|
517
|
+
case 'f': case 'F': this._pluginHook('toggleFullscreen'); break;
|
|
518
|
+
case '+': case '=': this.zoomIn(); break;
|
|
519
|
+
case '-': this.zoomOut(); break;
|
|
520
|
+
case '0': this.resetZoom(); break;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
document.addEventListener('keydown', this._onKeydown);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
_detachKeyboard() {
|
|
527
|
+
if (this._onKeydown) document.removeEventListener('keydown', this._onKeydown);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ─── Touch / Drag ─────────────────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
_attachTouch() {
|
|
533
|
+
if (!this.opts.touch && !this.opts.drag) return;
|
|
534
|
+
const stage = this._dom.stage;
|
|
535
|
+
if (!stage) return;
|
|
536
|
+
|
|
537
|
+
let startX, startY, startPanX, startPanY, isDragging = false;
|
|
538
|
+
const SWIPE_THRESHOLD = 50;
|
|
539
|
+
|
|
540
|
+
const onStart = (e) => {
|
|
541
|
+
const pt = e.touches ? e.touches[0] : e;
|
|
542
|
+
startX = pt.clientX;
|
|
543
|
+
startY = pt.clientY;
|
|
544
|
+
startPanX = this._panX;
|
|
545
|
+
startPanY = this._panY;
|
|
546
|
+
isDragging = true;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const onMove = (e) => {
|
|
550
|
+
if (!isDragging) return;
|
|
551
|
+
const pt = e.touches ? e.touches[0] : e;
|
|
552
|
+
const dx = pt.clientX - startX;
|
|
553
|
+
const dy = pt.clientY - startY;
|
|
554
|
+
|
|
555
|
+
if (this._zoom > 1) {
|
|
556
|
+
// Pan mode
|
|
557
|
+
this._panX = startPanX + dx;
|
|
558
|
+
this._panY = startPanY + dy;
|
|
559
|
+
const img = this._dom.slideWrapper?.querySelector('.lgx-image');
|
|
560
|
+
if (img) img.style.transform = `translate(${this._panX}px, ${this._panY}px) scale(${this._zoom})`;
|
|
561
|
+
e.preventDefault();
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const onEnd = (e) => {
|
|
566
|
+
if (!isDragging) return;
|
|
567
|
+
isDragging = false;
|
|
568
|
+
const pt = e.changedTouches ? e.changedTouches[0] : e;
|
|
569
|
+
const dx = pt.clientX - startX;
|
|
570
|
+
|
|
571
|
+
if (this._zoom === 1 && Math.abs(dx) > SWIPE_THRESHOLD) {
|
|
572
|
+
dx < 0 ? this.next() : this.prev();
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// Double-tap / double-click zoom
|
|
577
|
+
let lastTap = 0;
|
|
578
|
+
const onTap = (e) => {
|
|
579
|
+
const now = Date.now();
|
|
580
|
+
if (now - lastTap < 300) {
|
|
581
|
+
this._zoom === 1 ? this._applyZoom(2) : this.resetZoom();
|
|
582
|
+
e.preventDefault();
|
|
583
|
+
}
|
|
584
|
+
lastTap = now;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
stage.addEventListener('mousedown', onStart);
|
|
588
|
+
stage.addEventListener('mousemove', onMove);
|
|
589
|
+
stage.addEventListener('mouseup', onEnd);
|
|
590
|
+
stage.addEventListener('touchstart', onStart, passiveOpt);
|
|
591
|
+
stage.addEventListener('touchmove', onMove, { passive: false });
|
|
592
|
+
stage.addEventListener('touchend', onEnd, passiveOpt);
|
|
593
|
+
stage.addEventListener('click', onTap);
|
|
594
|
+
|
|
595
|
+
// Pinch zoom
|
|
596
|
+
let initDist = 0, initZoom = 1;
|
|
597
|
+
stage.addEventListener('touchstart', (e) => {
|
|
598
|
+
if (e.touches.length === 2) {
|
|
599
|
+
initDist = Math.hypot(
|
|
600
|
+
e.touches[0].clientX - e.touches[1].clientX,
|
|
601
|
+
e.touches[0].clientY - e.touches[1].clientY
|
|
602
|
+
);
|
|
603
|
+
initZoom = this._zoom;
|
|
604
|
+
}
|
|
605
|
+
}, passiveOpt);
|
|
606
|
+
|
|
607
|
+
stage.addEventListener('touchmove', (e) => {
|
|
608
|
+
if (e.touches.length === 2) {
|
|
609
|
+
const dist = Math.hypot(
|
|
610
|
+
e.touches[0].clientX - e.touches[1].clientX,
|
|
611
|
+
e.touches[0].clientY - e.touches[1].clientY
|
|
612
|
+
);
|
|
613
|
+
this._applyZoom(initZoom * (dist / initDist));
|
|
614
|
+
e.preventDefault();
|
|
615
|
+
}
|
|
616
|
+
}, { passive: false });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ─── Autoplay ──────────────────────────────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
_startAutoplay() {
|
|
622
|
+
if (this._isAutoplayBlockingMedia(this._items[this._index])) {
|
|
623
|
+
this._stopAutoplay();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
this._stopAutoplay();
|
|
627
|
+
this._autoplayTimer = setInterval(() => this.next(), this.opts.autoplayInterval);
|
|
628
|
+
this._updateAutoplayButton();
|
|
629
|
+
this.emit('autoplay:start');
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
_stopAutoplay() {
|
|
633
|
+
if (this._autoplayTimer) {
|
|
634
|
+
clearInterval(this._autoplayTimer);
|
|
635
|
+
this._autoplayTimer = null;
|
|
636
|
+
this._updateAutoplayButton();
|
|
637
|
+
this.emit('autoplay:stop');
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
_bindMediaAutoplayStop(slide, item) {
|
|
642
|
+
if (!this._isAutoplayBlockingMedia(item)) return;
|
|
643
|
+
|
|
644
|
+
const stop = () => this._stopAutoplay();
|
|
645
|
+
slide.addEventListener('lgx:media-play', stop, { once: true });
|
|
646
|
+
slide.querySelectorAll('video').forEach((video) => {
|
|
647
|
+
video.addEventListener('play', stop, { once: true });
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
_isAutoplayBlockingMedia(item) {
|
|
652
|
+
return ['video', 'youtube', 'vimeo', 'iframe'].includes(item?.type);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ─── Trigger binding ──────────────────────────────────────────────────────────
|
|
656
|
+
|
|
657
|
+
_bindTriggers() {
|
|
658
|
+
if (typeof document === 'undefined') return;
|
|
659
|
+
if (!this.opts.selector) return;
|
|
660
|
+
|
|
661
|
+
const handler = (e) => {
|
|
662
|
+
const trigger = e.target.closest(this.opts.selector);
|
|
663
|
+
if (!trigger) return;
|
|
664
|
+
e.preventDefault();
|
|
665
|
+
|
|
666
|
+
// Gather all items from the same gallery group
|
|
667
|
+
const groupAttr = trigger.dataset.lgxGallery || trigger.closest('[data-lgx-gallery]')?.dataset.lgxGallery;
|
|
668
|
+
let siblings;
|
|
669
|
+
|
|
670
|
+
if (groupAttr) {
|
|
671
|
+
siblings = [...document.querySelectorAll(`[data-lgx-gallery="${groupAttr}"] ${this.opts.selector}, ${this.opts.selector}[data-lgx-gallery="${groupAttr}"]`)];
|
|
672
|
+
} else {
|
|
673
|
+
siblings = [trigger];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const items = siblings.map((el) => this._itemFromElement(el));
|
|
677
|
+
const startIndex = siblings.indexOf(trigger);
|
|
678
|
+
this.open(items, Math.max(0, startIndex));
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
document.addEventListener('click', handler);
|
|
682
|
+
this._triggerHandler = handler;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
_detachTriggers() {
|
|
686
|
+
if (this._triggerHandler) {
|
|
687
|
+
document.removeEventListener('click', this._triggerHandler);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
_itemFromElement(el) {
|
|
692
|
+
const d = el.dataset;
|
|
693
|
+
const img = el.matches('img') ? el : el.querySelector('img');
|
|
694
|
+
const captionEl = el.querySelector('[data-lgx-caption], figcaption, .caption, .lgx-caption-text');
|
|
695
|
+
const caption = d.lgxCaption
|
|
696
|
+
|| img?.dataset?.lgxCaption
|
|
697
|
+
|| captionEl?.innerHTML
|
|
698
|
+
|| el.getAttribute('aria-label')
|
|
699
|
+
|| el.title
|
|
700
|
+
|| img?.title
|
|
701
|
+
|| img?.alt
|
|
702
|
+
|| '';
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
src: d.lgxSrc || el.href || el.src || '',
|
|
706
|
+
type: d.lgxType || undefined,
|
|
707
|
+
thumb: d.lgxThumb || img?.src || null,
|
|
708
|
+
caption,
|
|
709
|
+
alt: d.lgxAlt || img?.alt || '',
|
|
710
|
+
download: d.lgxDownload || null,
|
|
711
|
+
autoplay: d.lgxAutoplay !== undefined,
|
|
712
|
+
poster: d.lgxPoster || null,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ─── SVG Icons ────────────────────────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
_svgIcon(name) {
|
|
719
|
+
const icons = {
|
|
720
|
+
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>',
|
|
721
|
+
prev: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15,18 9,12 15,6"/></svg>',
|
|
722
|
+
next: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9,18 15,12 9,6"/></svg>',
|
|
723
|
+
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>',
|
|
724
|
+
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg>',
|
|
725
|
+
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>',
|
|
726
|
+
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>',
|
|
727
|
+
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>',
|
|
728
|
+
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>',
|
|
729
|
+
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>',
|
|
730
|
+
};
|
|
731
|
+
const div = document.createElement('span');
|
|
732
|
+
div.className = 'lgx-icon';
|
|
733
|
+
div.setAttribute('aria-hidden', 'true');
|
|
734
|
+
div.innerHTML = icons[name] ?? '';
|
|
735
|
+
return div;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export { Crowdbox as LightboxGallery };
|