@0m0g1/griot 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +292 -222
- package/package.json +1 -1
- package/src/Griot.js +3 -0
- package/src/blocks/GalleryRenderer.js +417 -0
- package/src/viewer/Lightbox.js +320 -0
- package/src/viewer/Viewer.js +47 -8
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// ─── Lightbox.js ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Singleton full-screen image viewer.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// import { lightbox } from './Lightbox.js';
|
|
6
|
+
// lightbox.open(items, startIndex);
|
|
7
|
+
//
|
|
8
|
+
// items shape: { src?, url?, alt?, alt_text?, caption? }[]
|
|
9
|
+
// Keyboard: ← → Escape | Touch: swipe left/right | Click backdrop: close
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export class Lightbox {
|
|
13
|
+
constructor() {
|
|
14
|
+
this._el = null;
|
|
15
|
+
this._img = null;
|
|
16
|
+
this._cap = null;
|
|
17
|
+
this._ctr = null;
|
|
18
|
+
this._prev = null;
|
|
19
|
+
this._next = null;
|
|
20
|
+
this._items = [];
|
|
21
|
+
this._idx = 0;
|
|
22
|
+
this._isOpen = false;
|
|
23
|
+
this._touchStartX = 0;
|
|
24
|
+
this._onKey = this._onKey.bind(this);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Open the lightbox at a given index into items[]. */
|
|
30
|
+
open(items, startIndex = 0) {
|
|
31
|
+
if (!items?.length) return;
|
|
32
|
+
this._items = items;
|
|
33
|
+
this._idx = Math.max(0, Math.min(startIndex, items.length - 1));
|
|
34
|
+
if (!this._el) this._build();
|
|
35
|
+
this._show();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
close() {
|
|
39
|
+
if (!this._isOpen) return;
|
|
40
|
+
document.removeEventListener('keydown', this._onKey);
|
|
41
|
+
this._el.classList.remove('griot-lb--open');
|
|
42
|
+
document.body.style.overflow = '';
|
|
43
|
+
this._isOpen = false;
|
|
44
|
+
// Wait for CSS fade-out before hiding from layout
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
if (!this._isOpen && this._el) this._el.hidden = true;
|
|
47
|
+
}, 270);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Build DOM ───────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
_build() {
|
|
53
|
+
const el = document.createElement('div');
|
|
54
|
+
el.className = 'griot-lb';
|
|
55
|
+
el.hidden = true;
|
|
56
|
+
el.setAttribute('role', 'dialog');
|
|
57
|
+
el.setAttribute('aria-modal', 'true');
|
|
58
|
+
el.setAttribute('aria-label', 'Image viewer');
|
|
59
|
+
|
|
60
|
+
// Backdrop click
|
|
61
|
+
el.addEventListener('click', e => { if (e.target === el) this.close(); });
|
|
62
|
+
|
|
63
|
+
// Close button
|
|
64
|
+
const close = _mkBtn('✕', 'griot-lb__close', 'Close');
|
|
65
|
+
close.addEventListener('click', () => this.close());
|
|
66
|
+
|
|
67
|
+
// Prev / Next
|
|
68
|
+
const prev = _mkBtn('‹', 'griot-lb__nav griot-lb__nav--prev', 'Previous image');
|
|
69
|
+
const next = _mkBtn('›', 'griot-lb__nav griot-lb__nav--next', 'Next image');
|
|
70
|
+
prev.addEventListener('click', e => { e.stopPropagation(); this._move(-1); });
|
|
71
|
+
next.addEventListener('click', e => { e.stopPropagation(); this._move(1); });
|
|
72
|
+
|
|
73
|
+
// Stage: image + caption
|
|
74
|
+
const stage = document.createElement('div');
|
|
75
|
+
stage.className = 'griot-lb__stage';
|
|
76
|
+
stage.addEventListener('click', e => e.stopPropagation());
|
|
77
|
+
|
|
78
|
+
const img = document.createElement('img');
|
|
79
|
+
img.className = 'griot-lb__img';
|
|
80
|
+
img.alt = '';
|
|
81
|
+
img.draggable = false;
|
|
82
|
+
|
|
83
|
+
const cap = document.createElement('p');
|
|
84
|
+
cap.className = 'griot-lb__caption';
|
|
85
|
+
|
|
86
|
+
stage.append(img, cap);
|
|
87
|
+
|
|
88
|
+
// Counter
|
|
89
|
+
const ctr = document.createElement('div');
|
|
90
|
+
ctr.className = 'griot-lb__counter';
|
|
91
|
+
|
|
92
|
+
// Thumbnail strip (hidden until > 1 item)
|
|
93
|
+
const strip = document.createElement('div');
|
|
94
|
+
strip.className = 'griot-lb__strip';
|
|
95
|
+
|
|
96
|
+
el.append(close, prev, next, stage, ctr, strip);
|
|
97
|
+
|
|
98
|
+
// Touch swipe
|
|
99
|
+
el.addEventListener('touchstart', e => {
|
|
100
|
+
this._touchStartX = e.touches[0].clientX;
|
|
101
|
+
}, { passive: true });
|
|
102
|
+
el.addEventListener('touchend', e => {
|
|
103
|
+
const dx = e.changedTouches[0].clientX - this._touchStartX;
|
|
104
|
+
if (Math.abs(dx) > 50) this._move(dx < 0 ? 1 : -1);
|
|
105
|
+
}, { passive: true });
|
|
106
|
+
|
|
107
|
+
document.body.appendChild(el);
|
|
108
|
+
|
|
109
|
+
this._el = el;
|
|
110
|
+
this._img = img;
|
|
111
|
+
this._cap = cap;
|
|
112
|
+
this._ctr = ctr;
|
|
113
|
+
this._prev = prev;
|
|
114
|
+
this._next = next;
|
|
115
|
+
this._strip = strip;
|
|
116
|
+
|
|
117
|
+
// Inject styles once
|
|
118
|
+
_injectStyles();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Visibility ──────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
_show() {
|
|
124
|
+
this._isOpen = true;
|
|
125
|
+
this._el.hidden = false;
|
|
126
|
+
document.body.style.overflow = 'hidden';
|
|
127
|
+
document.addEventListener('keydown', this._onKey);
|
|
128
|
+
|
|
129
|
+
// Double rAF ensures the hidden→visible transition actually runs
|
|
130
|
+
requestAnimationFrame(() =>
|
|
131
|
+
requestAnimationFrame(() => this._el.classList.add('griot-lb--open'))
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
this._buildStrip();
|
|
135
|
+
this._update();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Navigation ──────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
_move(dir) {
|
|
141
|
+
if (this._items.length <= 1) return;
|
|
142
|
+
this._idx = (this._idx + dir + this._items.length) % this._items.length;
|
|
143
|
+
this._update();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Render current item ─────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
_update() {
|
|
149
|
+
const item = this._items[this._idx];
|
|
150
|
+
if (!item) return;
|
|
151
|
+
|
|
152
|
+
const src = item.src ?? item.url ?? '';
|
|
153
|
+
const alt = item.alt ?? item.alt_text ?? '';
|
|
154
|
+
const cap = item.caption ?? '';
|
|
155
|
+
|
|
156
|
+
// Fade-swap: fade out → preload → set src → fade in
|
|
157
|
+
this._img.style.opacity = '0';
|
|
158
|
+
this._img.alt = alt;
|
|
159
|
+
|
|
160
|
+
const done = () => { this._img.src = src; this._img.style.opacity = '1'; };
|
|
161
|
+
const tmp = new Image();
|
|
162
|
+
tmp.onload = done;
|
|
163
|
+
tmp.onerror = done;
|
|
164
|
+
tmp.src = src;
|
|
165
|
+
|
|
166
|
+
this._cap.textContent = cap;
|
|
167
|
+
this._cap.hidden = !cap;
|
|
168
|
+
|
|
169
|
+
const single = this._items.length <= 1;
|
|
170
|
+
this._prev.hidden = single;
|
|
171
|
+
this._next.hidden = single;
|
|
172
|
+
this._ctr.textContent = single ? '' : `${this._idx + 1} / ${this._items.length}`;
|
|
173
|
+
|
|
174
|
+
// Sync strip active thumb
|
|
175
|
+
this._strip.querySelectorAll('.griot-lb__thumb').forEach((th, i) => {
|
|
176
|
+
th.classList.toggle('is-active', i === this._idx);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Thumbnail strip (built once per open() call) ────────────────────────────
|
|
181
|
+
|
|
182
|
+
_buildStrip() {
|
|
183
|
+
this._strip.innerHTML = '';
|
|
184
|
+
if (this._items.length < 2 || this._items.length > 24) {
|
|
185
|
+
this._strip.hidden = true;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this._strip.hidden = false;
|
|
189
|
+
|
|
190
|
+
this._items.forEach((item, i) => {
|
|
191
|
+
const th = document.createElement('button');
|
|
192
|
+
th.type = 'button';
|
|
193
|
+
th.className = `griot-lb__thumb${i === this._idx ? ' is-active' : ''}`;
|
|
194
|
+
th.setAttribute('aria-label', `Image ${i + 1}`);
|
|
195
|
+
|
|
196
|
+
const img = document.createElement('img');
|
|
197
|
+
img.src = item.src ?? item.url ?? '';
|
|
198
|
+
img.alt = '';
|
|
199
|
+
img.loading = 'lazy';
|
|
200
|
+
img.draggable = false;
|
|
201
|
+
|
|
202
|
+
th.appendChild(img);
|
|
203
|
+
th.addEventListener('click', e => { e.stopPropagation(); this._idx = i; this._update(); });
|
|
204
|
+
this._strip.appendChild(th);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Keyboard ────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
_onKey(e) {
|
|
211
|
+
if (e.key === 'Escape') { e.preventDefault(); this.close(); }
|
|
212
|
+
else if (e.key === 'ArrowLeft') { e.preventDefault(); this._move(-1); }
|
|
213
|
+
else if (e.key === 'ArrowRight') { e.preventDefault(); this._move(1); }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
function _mkBtn(label, className, ariaLabel) {
|
|
220
|
+
const b = document.createElement('button');
|
|
221
|
+
b.type = 'button';
|
|
222
|
+
b.className = className;
|
|
223
|
+
b.setAttribute('aria-label', ariaLabel);
|
|
224
|
+
b.textContent = label;
|
|
225
|
+
return b;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let _stylesInjected = false;
|
|
229
|
+
function _injectStyles() {
|
|
230
|
+
if (_stylesInjected || typeof document === 'undefined') return;
|
|
231
|
+
_stylesInjected = true;
|
|
232
|
+
const s = document.createElement('style');
|
|
233
|
+
s.id = 'griot-lightbox-styles';
|
|
234
|
+
s.textContent = `
|
|
235
|
+
/* ── Lightbox overlay ───────────────────────────────────────────────────── */
|
|
236
|
+
.griot-lb {
|
|
237
|
+
position: fixed; inset: 0; z-index: 9000;
|
|
238
|
+
background: rgba(0,0,0,0);
|
|
239
|
+
display: flex; flex-direction: column;
|
|
240
|
+
align-items: center; justify-content: center;
|
|
241
|
+
transition: background 0.25s;
|
|
242
|
+
overscroll-behavior: none;
|
|
243
|
+
}
|
|
244
|
+
.griot-lb--open { background: rgba(0,0,0,0.92); }
|
|
245
|
+
|
|
246
|
+
/* Stage */
|
|
247
|
+
.griot-lb__stage {
|
|
248
|
+
position: relative; display: flex; flex-direction: column;
|
|
249
|
+
align-items: center; max-width: 92vw; max-height: 80vh;
|
|
250
|
+
}
|
|
251
|
+
.griot-lb__img {
|
|
252
|
+
max-width: 92vw; max-height: 80vh;
|
|
253
|
+
object-fit: contain; border-radius: 6px;
|
|
254
|
+
transition: opacity 0.18s;
|
|
255
|
+
user-select: none; -webkit-user-drag: none;
|
|
256
|
+
}
|
|
257
|
+
.griot-lb__caption {
|
|
258
|
+
font-size: 13px; color: #94a3b8;
|
|
259
|
+
margin: 10px 0 0; text-align: center;
|
|
260
|
+
max-width: 70ch; line-height: 1.5;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* Nav buttons */
|
|
264
|
+
.griot-lb__nav {
|
|
265
|
+
position: fixed; top: 50%; transform: translateY(-50%);
|
|
266
|
+
background: rgba(255,255,255,0.10); border: none;
|
|
267
|
+
color: #e2e8f0; font-size: 32px; line-height: 1;
|
|
268
|
+
cursor: pointer; width: 52px; height: 88px;
|
|
269
|
+
display: flex; align-items: center; justify-content: center;
|
|
270
|
+
transition: background 0.15s; z-index: 1;
|
|
271
|
+
}
|
|
272
|
+
.griot-lb__nav:hover { background: rgba(255,255,255,0.22); }
|
|
273
|
+
.griot-lb__nav--prev { left: 0; border-radius: 0 8px 8px 0; }
|
|
274
|
+
.griot-lb__nav--next { right: 0; border-radius: 8px 0 0 8px; }
|
|
275
|
+
|
|
276
|
+
/* Close */
|
|
277
|
+
.griot-lb__close {
|
|
278
|
+
position: fixed; top: 14px; right: 18px;
|
|
279
|
+
background: rgba(255,255,255,0.10); border: none;
|
|
280
|
+
color: #e2e8f0; font-size: 18px; line-height: 1;
|
|
281
|
+
cursor: pointer; width: 38px; height: 38px;
|
|
282
|
+
border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
|
283
|
+
transition: background 0.15s; z-index: 2;
|
|
284
|
+
}
|
|
285
|
+
.griot-lb__close:hover { background: rgba(255,255,255,0.22); }
|
|
286
|
+
|
|
287
|
+
/* Counter */
|
|
288
|
+
.griot-lb__counter {
|
|
289
|
+
position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
|
|
290
|
+
font-size: 13px; color: #64748b; letter-spacing: 0.04em;
|
|
291
|
+
pointer-events: none;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* Thumbnail strip */
|
|
295
|
+
.griot-lb__strip {
|
|
296
|
+
position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%);
|
|
297
|
+
display: flex; gap: 6px; max-width: 90vw;
|
|
298
|
+
overflow-x: auto; padding: 4px;
|
|
299
|
+
}
|
|
300
|
+
.griot-lb__thumb {
|
|
301
|
+
flex-shrink: 0; width: 52px; height: 38px;
|
|
302
|
+
border: 2px solid transparent; border-radius: 4px;
|
|
303
|
+
overflow: hidden; cursor: pointer; padding: 0;
|
|
304
|
+
background: transparent; transition: border-color 0.15s, opacity 0.15s;
|
|
305
|
+
opacity: 0.55;
|
|
306
|
+
}
|
|
307
|
+
.griot-lb__thumb:hover { opacity: 0.85; }
|
|
308
|
+
.griot-lb__thumb.is-active { border-color: #6366f1; opacity: 1; }
|
|
309
|
+
.griot-lb__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
310
|
+
|
|
311
|
+
@media (max-width: 600px) {
|
|
312
|
+
.griot-lb__nav { width: 40px; height: 64px; font-size: 24px; }
|
|
313
|
+
.griot-lb__strip { display: none; }
|
|
314
|
+
}
|
|
315
|
+
`;
|
|
316
|
+
document.head.appendChild(s);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Shared singleton — import and use directly everywhere. */
|
|
320
|
+
export const lightbox = new Lightbox();
|
package/src/viewer/Viewer.js
CHANGED
|
@@ -10,21 +10,23 @@
|
|
|
10
10
|
// onCiteClick(blockId) {},
|
|
11
11
|
// highlightBlockId: null,
|
|
12
12
|
// });
|
|
13
|
-
// viewer.setHighlight(blockId);
|
|
13
|
+
// viewer.setHighlight(blockId); // highlight + scroll to a block
|
|
14
|
+
// viewer.setGalleryLayout(blockId, layout); // switch a gallery's layout
|
|
14
15
|
// viewer.destroy();
|
|
15
16
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
17
|
|
|
17
18
|
import { anchorId, scrollToBlock } from '../core/Block.js';
|
|
18
19
|
import { renderBlock } from '../blocks/BlockRenderer.js';
|
|
20
|
+
import { renderGallery } from '../blocks/GalleryRenderer.js';
|
|
19
21
|
|
|
20
22
|
export class Viewer {
|
|
21
23
|
constructor(container, options = {}) {
|
|
22
|
-
this._container
|
|
23
|
-
this._options
|
|
24
|
-
this._doc
|
|
25
|
-
this._books
|
|
24
|
+
this._container = container;
|
|
25
|
+
this._options = options;
|
|
26
|
+
this._doc = options.doc ?? null;
|
|
27
|
+
this._books = options.books ?? [];
|
|
26
28
|
this._highlighted = options.highlightBlockId ?? null;
|
|
27
|
-
this._hlTimer
|
|
29
|
+
this._hlTimer = null;
|
|
28
30
|
|
|
29
31
|
container.classList.add('griot-viewer');
|
|
30
32
|
if (this._doc) this._render();
|
|
@@ -44,7 +46,6 @@ export class Viewer {
|
|
|
44
46
|
|
|
45
47
|
/** Scroll to block and briefly highlight it */
|
|
46
48
|
setHighlight(blockId, { scroll = true, behavior = 'smooth' } = {}) {
|
|
47
|
-
// Remove old highlight
|
|
48
49
|
clearTimeout(this._hlTimer);
|
|
49
50
|
if (this._highlighted) {
|
|
50
51
|
document.getElementById(anchorId(this._highlighted))
|
|
@@ -64,6 +65,44 @@ export class Viewer {
|
|
|
64
65
|
}, 2200);
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Switch the layout of a gallery block without re-rendering the whole doc.
|
|
70
|
+
* Finds the rendered gallery element by blockId, swaps it with a freshly
|
|
71
|
+
* rendered gallery using the new layout, and updates block.meta in-place
|
|
72
|
+
* so any subsequent full re-render preserves the user's choice.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} blockId
|
|
75
|
+
* @param {'grid'|'masonry'|'carousel'|'strip'} layout
|
|
76
|
+
*/
|
|
77
|
+
setGalleryLayout(blockId, layout) {
|
|
78
|
+
if (!this._doc) return;
|
|
79
|
+
|
|
80
|
+
// Find the block in the document
|
|
81
|
+
const block = this._doc.blocks.find(b => b.id === blockId);
|
|
82
|
+
if (!block || block.type !== 'gallery') return;
|
|
83
|
+
|
|
84
|
+
// Update meta so a future setDoc() call preserves the layout
|
|
85
|
+
block.meta = { ...block.meta, layout };
|
|
86
|
+
|
|
87
|
+
// Find the rendered DOM element
|
|
88
|
+
const existing = document.getElementById(anchorId(blockId));
|
|
89
|
+
if (!existing) return;
|
|
90
|
+
|
|
91
|
+
// Render a fresh gallery with the new layout
|
|
92
|
+
const fresh = renderGallery(block.meta.items ?? [], layout);
|
|
93
|
+
fresh.classList.add('griot-block');
|
|
94
|
+
fresh.id = anchorId(blockId);
|
|
95
|
+
fresh.dataset.blockId = blockId;
|
|
96
|
+
fresh.dataset.blockType = 'gallery';
|
|
97
|
+
|
|
98
|
+
// Preserve highlight state
|
|
99
|
+
if (existing.classList.contains('griot-block--highlight')) {
|
|
100
|
+
fresh.classList.add('griot-block--highlight');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
existing.replaceWith(fresh);
|
|
104
|
+
}
|
|
105
|
+
|
|
67
106
|
destroy() {
|
|
68
107
|
clearTimeout(this._hlTimer);
|
|
69
108
|
this._container.innerHTML = '';
|
|
@@ -89,4 +128,4 @@ export class Viewer {
|
|
|
89
128
|
this._container.appendChild(el);
|
|
90
129
|
}
|
|
91
130
|
}
|
|
92
|
-
}
|
|
131
|
+
}
|