@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.
@@ -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