@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.
@@ -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();
@@ -10,21 +10,23 @@
10
10
  // onCiteClick(blockId) {},
11
11
  // highlightBlockId: null,
12
12
  // });
13
- // viewer.setHighlight(blockId); // highlight + scroll to a block
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 = container;
23
- this._options = options;
24
- this._doc = options.doc ?? null;
25
- this._books = options.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 = null;
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
+ }