@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,43 @@
|
|
|
1
|
+
export class EventEmitter {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._events = Object.create(null);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
on(event, listener) {
|
|
7
|
+
if (!this._events[event]) this._events[event] = [];
|
|
8
|
+
this._events[event].push(listener);
|
|
9
|
+
return this;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
once(event, listener) {
|
|
13
|
+
const wrapper = (...args) => {
|
|
14
|
+
listener(...args);
|
|
15
|
+
this.off(event, wrapper);
|
|
16
|
+
};
|
|
17
|
+
wrapper._original = listener;
|
|
18
|
+
return this.on(event, wrapper);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
off(event, listener) {
|
|
22
|
+
if (!this._events[event]) return this;
|
|
23
|
+
this._events[event] = this._events[event].filter(
|
|
24
|
+
(l) => l !== listener && l._original !== listener
|
|
25
|
+
);
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
emit(event, ...args) {
|
|
30
|
+
if (!this._events[event]) return false;
|
|
31
|
+
[...this._events[event]].forEach((l) => l(...args));
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
removeAllListeners(event) {
|
|
36
|
+
if (event) {
|
|
37
|
+
delete this._events[event];
|
|
38
|
+
} else {
|
|
39
|
+
this._events = Object.create(null);
|
|
40
|
+
}
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Central registry for media adapters and plugins so the host app
|
|
2
|
+
// can swap or extend them without forking the library.
|
|
3
|
+
const adapters = new Map();
|
|
4
|
+
const plugins = new Map();
|
|
5
|
+
|
|
6
|
+
export const Registry = {
|
|
7
|
+
registerAdapter(type, adapter) {
|
|
8
|
+
adapters.set(type, adapter);
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
getAdapter(type) {
|
|
12
|
+
return adapters.get(type) ?? null;
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
registerPlugin(name, plugin) {
|
|
16
|
+
plugins.set(name, plugin);
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
getPlugin(name) {
|
|
20
|
+
return plugins.get(name) ?? null;
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
getPlugins() {
|
|
24
|
+
return [...plugins.values()];
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export function createElement(tag, attrs = {}, ...children) {
|
|
2
|
+
const el = document.createElement(tag);
|
|
3
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
4
|
+
if (k === 'class') el.className = v;
|
|
5
|
+
else if (k.startsWith('data-')) el.dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
|
|
6
|
+
else if (k === 'aria' || k.startsWith('aria-')) el.setAttribute(k, v);
|
|
7
|
+
else el[k] = v;
|
|
8
|
+
}
|
|
9
|
+
children.flat().forEach((child) => {
|
|
10
|
+
if (child == null) return;
|
|
11
|
+
el.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
|
|
12
|
+
});
|
|
13
|
+
return el;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function mergeDeep(target, ...sources) {
|
|
17
|
+
for (const src of sources) {
|
|
18
|
+
for (const key of Object.keys(src ?? {})) {
|
|
19
|
+
if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
|
|
20
|
+
if (!target[key] || typeof target[key] !== 'object') target[key] = {};
|
|
21
|
+
mergeDeep(target[key], src[key]);
|
|
22
|
+
} else {
|
|
23
|
+
target[key] = src[key];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return target;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getYouTubeId(url) {
|
|
31
|
+
const m = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
|
32
|
+
return m ? m[1] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getVimeoId(url) {
|
|
36
|
+
const m = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
|
|
37
|
+
return m ? m[1] : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isVideoUrl(url) {
|
|
41
|
+
return /\.(mp4|webm|ogg|mov)(\?.*)?$/i.test(url);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isYouTubeUrl(url) {
|
|
45
|
+
return /youtube\.com|youtu\.be/.test(url);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isVimeoUrl(url) {
|
|
49
|
+
return /vimeo\.com/.test(url);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function detectMediaType(src) {
|
|
53
|
+
if (!src) return 'unknown';
|
|
54
|
+
if (isYouTubeUrl(src)) return 'youtube';
|
|
55
|
+
if (isVimeoUrl(src)) return 'vimeo';
|
|
56
|
+
if (isVideoUrl(src)) return 'video';
|
|
57
|
+
if (/\.(jpg|jpeg|png|gif|webp|avif|svg)(\?.*)?$/i.test(src)) return 'image';
|
|
58
|
+
return 'iframe';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function trapFocus(container) {
|
|
62
|
+
const focusable = container.querySelectorAll(
|
|
63
|
+
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'
|
|
64
|
+
);
|
|
65
|
+
const first = focusable[0];
|
|
66
|
+
const last = focusable[focusable.length - 1];
|
|
67
|
+
|
|
68
|
+
function handler(e) {
|
|
69
|
+
if (e.key !== 'Tab') return;
|
|
70
|
+
if (e.shiftKey) {
|
|
71
|
+
if (document.activeElement === first) { e.preventDefault(); last?.focus(); }
|
|
72
|
+
} else {
|
|
73
|
+
if (document.activeElement === last) { e.preventDefault(); first?.focus(); }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
container.addEventListener('keydown', handler);
|
|
77
|
+
return () => container.removeEventListener('keydown', handler);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function requestFullscreen(el) {
|
|
81
|
+
return (el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen)?.call(el);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function exitFullscreen() {
|
|
85
|
+
return (document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen)?.call(document);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isFullscreen() {
|
|
89
|
+
return !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function clamp(value, min, max) {
|
|
93
|
+
return Math.min(Math.max(value, min), max);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function noop() {}
|
|
97
|
+
|
|
98
|
+
export function debounce(fn, ms) {
|
|
99
|
+
let t;
|
|
100
|
+
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isTouchDevice() {
|
|
104
|
+
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function supportsPassive() {
|
|
108
|
+
let ok = false;
|
|
109
|
+
try {
|
|
110
|
+
window.addEventListener('test', null, Object.defineProperty({}, 'passive', { get() { ok = true; } }));
|
|
111
|
+
} catch (_) {}
|
|
112
|
+
return ok;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const passiveOpt = supportsPassive() ? { passive: true } : false;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Core
|
|
2
|
+
export { Crowdbox, LightboxGallery } from './core/Crowdbox.js';
|
|
3
|
+
export { EventEmitter } from './core/EventEmitter.js';
|
|
4
|
+
export { Registry } from './core/Registry.js';
|
|
5
|
+
|
|
6
|
+
// Adapters
|
|
7
|
+
import { ImageAdapter } from './adapters/image.js';
|
|
8
|
+
import { VideoAdapter } from './adapters/video.js';
|
|
9
|
+
import { YouTubeAdapter } from './adapters/youtube.js';
|
|
10
|
+
import { VimeoAdapter } from './adapters/vimeo.js';
|
|
11
|
+
import { IframeAdapter } from './adapters/iframe.js';
|
|
12
|
+
|
|
13
|
+
// Plugins
|
|
14
|
+
import { ZoomPlugin } from './plugins/zoom/zoom.js';
|
|
15
|
+
import { ThumbsPlugin } from './plugins/thumbs/thumbs.js';
|
|
16
|
+
import { FullscreenPlugin } from './plugins/fullscreen/fullscreen.js';
|
|
17
|
+
import { DownloadPlugin } from './plugins/download/download.js';
|
|
18
|
+
import { SharePlugin } from './plugins/share/share.js';
|
|
19
|
+
|
|
20
|
+
export { ImageAdapter, VideoAdapter, YouTubeAdapter, VimeoAdapter, IframeAdapter };
|
|
21
|
+
export { ZoomPlugin, ThumbsPlugin, FullscreenPlugin, DownloadPlugin, SharePlugin };
|
|
22
|
+
|
|
23
|
+
// Auto-register everything so the library works out of the box
|
|
24
|
+
import { Registry as Reg } from './core/Registry.js';
|
|
25
|
+
|
|
26
|
+
Reg.registerAdapter('image', ImageAdapter);
|
|
27
|
+
Reg.registerAdapter('video', VideoAdapter);
|
|
28
|
+
Reg.registerAdapter('youtube', YouTubeAdapter);
|
|
29
|
+
Reg.registerAdapter('vimeo', VimeoAdapter);
|
|
30
|
+
Reg.registerAdapter('iframe', IframeAdapter);
|
|
31
|
+
|
|
32
|
+
Reg.registerPlugin('zoom', ZoomPlugin);
|
|
33
|
+
Reg.registerPlugin('thumbnails', ThumbsPlugin);
|
|
34
|
+
Reg.registerPlugin('fullscreen', FullscreenPlugin);
|
|
35
|
+
Reg.registerPlugin('download', DownloadPlugin);
|
|
36
|
+
Reg.registerPlugin('share', SharePlugin);
|
|
37
|
+
|
|
38
|
+
// ─── Convenience factory ─────────────────────────────────────────────────────
|
|
39
|
+
import { Crowdbox } from './core/Crowdbox.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a Crowdbox instance and return it.
|
|
43
|
+
* This is the primary public API for vanilla / script-tag usage.
|
|
44
|
+
*/
|
|
45
|
+
export function createGallery(options = {}) {
|
|
46
|
+
return new Crowdbox(options);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Auto-init from [data-lgx-init] attribute
|
|
50
|
+
if (typeof document !== 'undefined') {
|
|
51
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
52
|
+
document.querySelectorAll('[data-lgx-init]').forEach((root) => {
|
|
53
|
+
try {
|
|
54
|
+
const opts = JSON.parse(root.dataset.lgxInit || '{}');
|
|
55
|
+
new Crowdbox({ selector: `#${root.id} [data-lgx]`, ...opts });
|
|
56
|
+
} catch (_) {}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default Crowdbox;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class DownloadPlugin {
|
|
2
|
+
static pluginName = 'download';
|
|
3
|
+
|
|
4
|
+
constructor(gallery) {
|
|
5
|
+
this.gallery = gallery;
|
|
6
|
+
this._btn = null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
init(gallery) {
|
|
10
|
+
this.gallery = gallery;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
buildToolbar(toolbar) {
|
|
14
|
+
if (!this.gallery.opts.download) return;
|
|
15
|
+
|
|
16
|
+
this._btn = document.createElement('button');
|
|
17
|
+
this._btn.type = 'button';
|
|
18
|
+
this._btn.className = 'lgx-btn lgx-btn--download';
|
|
19
|
+
this._btn.setAttribute('aria-label', this.gallery.opts.i18n.download);
|
|
20
|
+
this._btn.title = this.gallery.opts.i18n.download;
|
|
21
|
+
this._btn.innerHTML = this.gallery._svgIcon('download').outerHTML;
|
|
22
|
+
this._btn.addEventListener('click', () => this._download());
|
|
23
|
+
|
|
24
|
+
const close = toolbar.querySelector('.lgx-btn--close');
|
|
25
|
+
toolbar.insertBefore(this._btn, close);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
afterSlide(index, item) {
|
|
29
|
+
if (!this._btn) return;
|
|
30
|
+
// Hide download for video embeds where we can't trigger a download
|
|
31
|
+
const canDownload = ['image', 'video'].includes(item.type);
|
|
32
|
+
this._btn.style.display = canDownload ? '' : 'none';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_download() {
|
|
36
|
+
const item = this.gallery._items[this.gallery._index];
|
|
37
|
+
if (!item) return;
|
|
38
|
+
const url = item.download || item.src;
|
|
39
|
+
const a = document.createElement('a');
|
|
40
|
+
a.href = url;
|
|
41
|
+
a.download = item.downloadName || url.split('/').pop() || 'download';
|
|
42
|
+
a.style.display = 'none';
|
|
43
|
+
document.body.appendChild(a);
|
|
44
|
+
a.click();
|
|
45
|
+
document.body.removeChild(a);
|
|
46
|
+
this.gallery.emit('download', { item });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
destroy() {}
|
|
50
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { requestFullscreen, exitFullscreen, isFullscreen } from '../../core/utils.js';
|
|
2
|
+
|
|
3
|
+
export class FullscreenPlugin {
|
|
4
|
+
static pluginName = 'fullscreen';
|
|
5
|
+
|
|
6
|
+
constructor(gallery) {
|
|
7
|
+
this.gallery = gallery;
|
|
8
|
+
this._btn = null;
|
|
9
|
+
this._onChange = null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
init(gallery) {
|
|
13
|
+
this.gallery = gallery;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
buildToolbar(toolbar) {
|
|
17
|
+
if (!this.gallery.opts.fullscreen) return;
|
|
18
|
+
|
|
19
|
+
this._btn = document.createElement('button');
|
|
20
|
+
this._btn.type = 'button';
|
|
21
|
+
this._btn.className = 'lgx-btn lgx-btn--fullscreen';
|
|
22
|
+
this._btn.setAttribute('aria-label', this.gallery.opts.i18n.fullscreen);
|
|
23
|
+
this._btn.title = this.gallery.opts.i18n.fullscreen;
|
|
24
|
+
this._btn.innerHTML = this.gallery._svgIcon('fullscreen').outerHTML;
|
|
25
|
+
this._btn.addEventListener('click', () => this.toggle());
|
|
26
|
+
|
|
27
|
+
const close = toolbar.querySelector('.lgx-btn--close');
|
|
28
|
+
toolbar.insertBefore(this._btn, close);
|
|
29
|
+
|
|
30
|
+
this._onChange = () => this._updateBtn();
|
|
31
|
+
document.addEventListener('fullscreenchange', this._onChange);
|
|
32
|
+
document.addEventListener('webkitfullscreenchange', this._onChange);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
toggle() {
|
|
36
|
+
if (isFullscreen()) {
|
|
37
|
+
exitFullscreen();
|
|
38
|
+
} else {
|
|
39
|
+
requestFullscreen(this.gallery._dom.modal);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
toggleFullscreen() {
|
|
44
|
+
this.toggle();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_updateBtn() {
|
|
48
|
+
if (!this._btn) return;
|
|
49
|
+
const full = isFullscreen();
|
|
50
|
+
this._btn.setAttribute('aria-pressed', full ? 'true' : 'false');
|
|
51
|
+
this.gallery._dom.modal?.classList.toggle('lgx-modal--fullscreen', full);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterClose() {
|
|
55
|
+
if (isFullscreen()) exitFullscreen();
|
|
56
|
+
if (this._onChange) {
|
|
57
|
+
document.removeEventListener('fullscreenchange', this._onChange);
|
|
58
|
+
document.removeEventListener('webkitfullscreenchange', this._onChange);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
destroy() {
|
|
63
|
+
this.afterClose();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export class SharePlugin {
|
|
2
|
+
static pluginName = 'share';
|
|
3
|
+
|
|
4
|
+
constructor(gallery) {
|
|
5
|
+
this.gallery = gallery;
|
|
6
|
+
this._btn = null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
init(gallery) {
|
|
10
|
+
this.gallery = gallery;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
buildToolbar(toolbar) {
|
|
14
|
+
if (!this.gallery.opts.share) return;
|
|
15
|
+
|
|
16
|
+
this._btn = document.createElement('button');
|
|
17
|
+
this._btn.type = 'button';
|
|
18
|
+
this._btn.className = 'lgx-btn lgx-btn--share';
|
|
19
|
+
this._btn.setAttribute('aria-label', this.gallery.opts.i18n.share);
|
|
20
|
+
this._btn.title = this.gallery.opts.i18n.share;
|
|
21
|
+
this._btn.innerHTML = this.gallery._svgIcon('share').outerHTML;
|
|
22
|
+
this._btn.addEventListener('click', () => this._share());
|
|
23
|
+
|
|
24
|
+
const close = toolbar.querySelector('.lgx-btn--close');
|
|
25
|
+
toolbar.insertBefore(this._btn, close);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async _share() {
|
|
29
|
+
const item = this.gallery._items[this.gallery._index];
|
|
30
|
+
if (!item) return;
|
|
31
|
+
|
|
32
|
+
const shareData = {
|
|
33
|
+
title: item.caption || document.title,
|
|
34
|
+
url: item.shareUrl || item.src,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Native Web Share API (mobile)
|
|
38
|
+
if (navigator.share) {
|
|
39
|
+
try {
|
|
40
|
+
await navigator.share(shareData);
|
|
41
|
+
this.gallery.emit('share', { item, method: 'native' });
|
|
42
|
+
return;
|
|
43
|
+
} catch (_) {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fallback: copy URL to clipboard
|
|
47
|
+
if (navigator.clipboard) {
|
|
48
|
+
await navigator.clipboard.writeText(shareData.url);
|
|
49
|
+
this._showToast('Link copied!');
|
|
50
|
+
this.gallery.emit('share', { item, method: 'clipboard' });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_showToast(msg) {
|
|
55
|
+
const toast = document.createElement('div');
|
|
56
|
+
toast.className = 'lgx-toast';
|
|
57
|
+
toast.textContent = msg;
|
|
58
|
+
this.gallery._dom.modal?.appendChild(toast);
|
|
59
|
+
setTimeout(() => toast.remove(), 2500);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
destroy() {}
|
|
63
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export class ThumbsPlugin {
|
|
2
|
+
static pluginName = 'thumbnails';
|
|
3
|
+
|
|
4
|
+
constructor(gallery) {
|
|
5
|
+
this.gallery = gallery;
|
|
6
|
+
this._strip = null;
|
|
7
|
+
this._thumbEls = [];
|
|
8
|
+
this._observer = null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
init(gallery) {
|
|
12
|
+
this.gallery = gallery;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
afterBuildDOM() {
|
|
16
|
+
if (!this.gallery.opts.thumbnails) return;
|
|
17
|
+
if (this.gallery._items.length < 2) return;
|
|
18
|
+
|
|
19
|
+
this._buildStrip();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterSlide(index) {
|
|
23
|
+
this._activate(index);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_buildStrip() {
|
|
27
|
+
const gallery = this.gallery;
|
|
28
|
+
const strip = document.createElement('div');
|
|
29
|
+
strip.className = 'lgx-thumbs';
|
|
30
|
+
strip.setAttribute('role', 'tablist');
|
|
31
|
+
strip.setAttribute('aria-label', 'Gallery thumbnails');
|
|
32
|
+
|
|
33
|
+
this._thumbEls = gallery._items.map((item, i) => {
|
|
34
|
+
const thumb = document.createElement('button');
|
|
35
|
+
thumb.type = 'button';
|
|
36
|
+
thumb.className = 'lgx-thumb';
|
|
37
|
+
thumb.setAttribute('role', 'tab');
|
|
38
|
+
thumb.setAttribute('aria-label', `Slide ${i + 1}${item.caption ? ': ' + item.caption : ''}`);
|
|
39
|
+
thumb.setAttribute('aria-selected', i === gallery._index ? 'true' : 'false');
|
|
40
|
+
thumb.dataset.lgxThumbIndex = i;
|
|
41
|
+
|
|
42
|
+
const adapter = gallery._resolveAdapter(item);
|
|
43
|
+
const thumbSrc = adapter?.getThumbnail(item);
|
|
44
|
+
|
|
45
|
+
if (thumbSrc) {
|
|
46
|
+
const img = document.createElement('img');
|
|
47
|
+
img.alt = '';
|
|
48
|
+
img.loading = 'lazy';
|
|
49
|
+
|
|
50
|
+
// IntersectionObserver lazy load
|
|
51
|
+
if ('IntersectionObserver' in window) {
|
|
52
|
+
img.dataset.src = thumbSrc;
|
|
53
|
+
this._observe(img);
|
|
54
|
+
} else {
|
|
55
|
+
img.src = thumbSrc;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
thumb.appendChild(img);
|
|
59
|
+
} else {
|
|
60
|
+
// Video/iframe fallback: show index number
|
|
61
|
+
thumb.appendChild(document.createTextNode(i + 1));
|
|
62
|
+
thumb.classList.add('lgx-thumb--no-image');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
thumb.addEventListener('click', () => gallery.goTo(i));
|
|
66
|
+
return thumb;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this._thumbEls.forEach((t) => strip.appendChild(t));
|
|
70
|
+
gallery._dom.container.appendChild(strip);
|
|
71
|
+
gallery._dom.thumbStrip = strip;
|
|
72
|
+
this._strip = strip;
|
|
73
|
+
|
|
74
|
+
this._activate(gallery._index);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_activate(index) {
|
|
78
|
+
this._thumbEls.forEach((t, i) => {
|
|
79
|
+
const active = i === index;
|
|
80
|
+
t.classList.toggle('lgx-thumb--active', active);
|
|
81
|
+
t.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Scroll active thumb into view
|
|
85
|
+
const activeThumb = this._thumbEls[index];
|
|
86
|
+
if (activeThumb && this._strip) {
|
|
87
|
+
activeThumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_observe(img) {
|
|
92
|
+
if (!this._observer) {
|
|
93
|
+
this._observer = new IntersectionObserver((entries) => {
|
|
94
|
+
entries.forEach((e) => {
|
|
95
|
+
if (e.isIntersecting) {
|
|
96
|
+
e.target.src = e.target.dataset.src;
|
|
97
|
+
this._observer.unobserve(e.target);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}, { rootMargin: '100px' });
|
|
101
|
+
}
|
|
102
|
+
this._observer.observe(img);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
destroy() {
|
|
106
|
+
this._observer?.disconnect();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export class ZoomPlugin {
|
|
2
|
+
static pluginName = 'zoom';
|
|
3
|
+
|
|
4
|
+
constructor(gallery) {
|
|
5
|
+
this.gallery = gallery;
|
|
6
|
+
this._btnIn = null;
|
|
7
|
+
this._btnOut = null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
init(gallery) {
|
|
11
|
+
this.gallery = gallery;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
buildToolbar(toolbar) {
|
|
15
|
+
if (!this.gallery.opts.zoom) return;
|
|
16
|
+
|
|
17
|
+
this._btnIn = this._makeBtn('zoomin', this.gallery.opts.i18n.zoomIn, () => this.gallery.zoomIn());
|
|
18
|
+
this._btnOut = this._makeBtn('zoomout', this.gallery.opts.i18n.zoomOut, () => this.gallery.zoomOut());
|
|
19
|
+
|
|
20
|
+
// Insert before close button
|
|
21
|
+
const close = toolbar.querySelector('.lgx-btn--close');
|
|
22
|
+
toolbar.insertBefore(this._btnOut, close);
|
|
23
|
+
toolbar.insertBefore(this._btnIn, this._btnOut);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterSlide(index, item) {
|
|
27
|
+
const isImage = item.type === 'image';
|
|
28
|
+
if (this._btnIn) this._btnIn.style.display = isImage ? '' : 'none';
|
|
29
|
+
if (this._btnOut) this._btnOut.style.display = isImage ? '' : 'none';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterZoom(zoom) {
|
|
33
|
+
const min = this.gallery.opts.zoomMin;
|
|
34
|
+
const max = this.gallery.opts.zoomMax;
|
|
35
|
+
if (this._btnIn) this._btnIn.disabled = zoom >= max;
|
|
36
|
+
if (this._btnOut) this._btnOut.disabled = zoom <= min;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_makeBtn(iconName, label, onClick) {
|
|
40
|
+
const btn = document.createElement('button');
|
|
41
|
+
btn.type = 'button';
|
|
42
|
+
btn.className = `lgx-btn lgx-btn--${iconName}`;
|
|
43
|
+
btn.setAttribute('aria-label', label);
|
|
44
|
+
btn.title = label;
|
|
45
|
+
btn.innerHTML = this.gallery._svgIcon(iconName).outerHTML;
|
|
46
|
+
btn.addEventListener('click', onClick);
|
|
47
|
+
return btn;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
destroy() {}
|
|
51
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import { Lightbox } from './Lightbox.jsx';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A ready-to-use responsive grid that opens a Lightbox on click.
|
|
6
|
+
*
|
|
7
|
+
* Props:
|
|
8
|
+
* items GalleryItem[]
|
|
9
|
+
* columns number (default 3)
|
|
10
|
+
* gap number in px (default 8)
|
|
11
|
+
* lightboxOptions CrowdboxOptions
|
|
12
|
+
* className string
|
|
13
|
+
*/
|
|
14
|
+
export function GalleryGrid({ items = [], columns = 3, gap = 8, lightboxOptions = {}, className = '' }) {
|
|
15
|
+
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
16
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
17
|
+
|
|
18
|
+
const handleClick = useCallback((index) => {
|
|
19
|
+
setActiveIndex(index);
|
|
20
|
+
setLightboxOpen(true);
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
const gridStyle = {
|
|
24
|
+
display: 'grid',
|
|
25
|
+
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
26
|
+
gap: `${gap}px`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<div className={`lgx-grid ${className}`} style={gridStyle}>
|
|
32
|
+
{items.map((item, i) => {
|
|
33
|
+
const thumb = item.thumb || (item.type === 'image' ? item.src : null);
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
key={i}
|
|
37
|
+
type="button"
|
|
38
|
+
className="lgx-grid__item"
|
|
39
|
+
onClick={() => handleClick(i)}
|
|
40
|
+
aria-label={item.caption || `Open item ${i + 1}`}
|
|
41
|
+
style={{
|
|
42
|
+
padding: 0,
|
|
43
|
+
border: 'none',
|
|
44
|
+
background: '#111',
|
|
45
|
+
cursor: 'pointer',
|
|
46
|
+
overflow: 'hidden',
|
|
47
|
+
borderRadius: 4,
|
|
48
|
+
aspectRatio: '1',
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{thumb ? (
|
|
52
|
+
<img
|
|
53
|
+
src={thumb}
|
|
54
|
+
alt={item.alt || item.caption || ''}
|
|
55
|
+
loading="lazy"
|
|
56
|
+
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
57
|
+
/>
|
|
58
|
+
) : (
|
|
59
|
+
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#fff', fontSize: 24 }}>▶</span>
|
|
60
|
+
)}
|
|
61
|
+
</button>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<Lightbox
|
|
67
|
+
items={items}
|
|
68
|
+
open={lightboxOpen}
|
|
69
|
+
startIndex={activeIndex}
|
|
70
|
+
onClose={() => setLightboxOpen(false)}
|
|
71
|
+
{...lightboxOptions}
|
|
72
|
+
/>
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
}
|