@0m0g1/griot 0.1.12 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0m0g1/griot",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "A self-contained block-based rich text editor and renderer built for historical document authoring.",
5
5
  "type": "module",
6
6
  "main": "./src/Griot.js",
@@ -4,11 +4,14 @@
4
4
  //
5
5
  // Usage:
6
6
  // import { renderGallery } from './GalleryRenderer.js';
7
- // const el = renderGallery(items, 'carousel');
7
+ // const el = renderGallery(items, 'carousel'); // loops by default
8
+ // const el = renderGallery(items, 'carousel', { loop: false }); // no loop
8
9
  // container.appendChild(el);
9
10
  //
10
11
  // items shape: { src?, url?, alt?, alt_text?, caption? }[]
11
12
  // layouts: 'grid' | 'masonry' | 'carousel' | 'strip'
13
+ // options:
14
+ // loop boolean (carousel only) whether prev/next wraps around. default: true
12
15
  // ─────────────────────────────────────────────────────────────────────────────
13
16
 
14
17
  import { lightbox } from './Lightbox.js';
@@ -21,12 +24,15 @@ const VALID_LAYOUTS = new Set(['grid', 'masonry', 'carousel', 'strip']);
21
24
  * Render a gallery element.
22
25
  * @param {object[]} items
23
26
  * @param {'grid'|'masonry'|'carousel'|'strip'} layout
27
+ * @param {{ loop?: boolean }} [options]
24
28
  * @returns {HTMLElement}
25
29
  */
26
- export function renderGallery(items = [], layout = 'grid') {
30
+ export function renderGallery(items = [], layout = 'grid', options = {}) {
27
31
  _injectStyles();
28
32
 
29
33
  const l = VALID_LAYOUTS.has(layout) ? layout : 'grid';
34
+ const opts = { loop: true, ...options };
35
+
30
36
  const wrap = document.createElement('div');
31
37
  wrap.className = `griot-gallery griot-gallery--${l}`;
32
38
  wrap.dataset.layout = l;
@@ -40,7 +46,7 @@ export function renderGallery(items = [], layout = 'grid') {
40
46
  }
41
47
 
42
48
  switch (l) {
43
- case 'carousel': return _carousel(items, wrap);
49
+ case 'carousel': return _carousel(items, wrap, opts);
44
50
  case 'masonry': return _masonry(items, wrap);
45
51
  case 'strip': return _strip(items, wrap);
46
52
  default: return _grid(items, wrap);
@@ -113,16 +119,10 @@ function _itemEl(item, index, allItems) {
113
119
 
114
120
  // ── Carousel ──────────────────────────────────────────────────────────────────
115
121
 
116
- function _carousel(items, wrap) {
122
+ function _carousel(items, wrap, opts) {
123
+ const loop = opts.loop !== false; // default true
117
124
  let idx = 0;
118
125
 
119
- // ── DOM structure ────────────────────────────────────────────────────────
120
- // .griot-carousel__viewport (clips)
121
- // .griot-carousel__track (slides)
122
- // .griot-carousel__slide × N
123
- // .griot-carousel__controls (prev · counter · next)
124
- // .griot-carousel__dots (dot buttons, hidden if > 12 items)
125
-
126
126
  const viewport = document.createElement('div');
127
127
  viewport.className = 'griot-carousel__viewport';
128
128
 
@@ -140,8 +140,6 @@ function _carousel(items, wrap) {
140
140
  img.decoding = 'async';
141
141
  img.draggable = false;
142
142
 
143
- // Click on carousel image → open lightbox at CURRENT idx (not i, since user
144
- // may have navigated away from the first image)
145
143
  img.addEventListener('click', () => lightbox.open(items, idx));
146
144
 
147
145
  slide.appendChild(img);
@@ -185,8 +183,17 @@ function _carousel(items, wrap) {
185
183
 
186
184
  // ── Navigation logic ──────────────────────────────────────────────────────
187
185
 
186
+ function canGoPrev() { return loop || idx > 0; }
187
+ function canGoNext() { return loop || idx < items.length - 1; }
188
+
188
189
  function goTo(n, animate = true) {
189
- idx = Math.max(0, Math.min(n, items.length - 1));
190
+ if (loop) {
191
+ // wrap around in both directions
192
+ idx = ((n % items.length) + items.length) % items.length;
193
+ } else {
194
+ // clamp to valid range
195
+ idx = Math.max(0, Math.min(n, items.length - 1));
196
+ }
190
197
 
191
198
  if (!animate) {
192
199
  track.style.transition = 'none';
@@ -196,10 +203,13 @@ function _carousel(items, wrap) {
196
203
  track.style.transform = `translateX(-${idx * 100}%)`;
197
204
  counter.textContent = `${idx + 1} / ${items.length}`;
198
205
 
199
- prevBtn.disabled = items.length <= 1;
200
- nextBtn.disabled = items.length <= 1;
201
- prevBtn.classList.toggle('is-edge', idx === 0);
202
- nextBtn.classList.toggle('is-edge', idx === items.length - 1);
206
+ // In loop mode buttons are always enabled (single-item galleries still hide them)
207
+ prevBtn.disabled = items.length <= 1 || (!loop && idx === 0);
208
+ nextBtn.disabled = items.length <= 1 || (!loop && idx === items.length - 1);
209
+
210
+ // edge class only meaningful when not looping
211
+ prevBtn.classList.toggle('is-edge', !loop && idx === 0);
212
+ nextBtn.classList.toggle('is-edge', !loop && idx === items.length - 1);
203
213
 
204
214
  dotEls.forEach((d, i) => {
205
215
  d.classList.toggle('is-active', i === idx);
@@ -207,10 +217,10 @@ function _carousel(items, wrap) {
207
217
  });
208
218
  }
209
219
 
210
- prevBtn.addEventListener('click', () => goTo(idx - 1));
211
- nextBtn.addEventListener('click', () => goTo(idx + 1));
220
+ prevBtn.addEventListener('click', () => { if (canGoPrev()) goTo(idx - 1); });
221
+ nextBtn.addEventListener('click', () => { if (canGoNext()) goTo(idx + 1); });
212
222
 
213
- // Touch / swipe on the viewport
223
+ // Touch / swipe
214
224
  let touchX = 0, touchY = 0, isScrolling = null;
215
225
 
216
226
  viewport.addEventListener('touchstart', e => {
@@ -230,14 +240,18 @@ function _carousel(items, wrap) {
230
240
  viewport.addEventListener('touchend', e => {
231
241
  if (isScrolling) return;
232
242
  const dx = e.changedTouches[0].clientX - touchX;
233
- if (Math.abs(dx) > 40) goTo(dx < 0 ? idx + 1 : idx - 1);
243
+ if (Math.abs(dx) > 40) {
244
+ const dir = dx < 0 ? 1 : -1;
245
+ if (dir === -1 && canGoPrev()) goTo(idx - 1);
246
+ if (dir === 1 && canGoNext()) goTo(idx + 1);
247
+ }
234
248
  }, { passive: true });
235
249
 
236
250
  // Keyboard when carousel has focus
237
251
  wrap.tabIndex = 0;
238
252
  wrap.addEventListener('keydown', e => {
239
- if (e.key === 'ArrowLeft') { e.preventDefault(); goTo(idx - 1); }
240
- if (e.key === 'ArrowRight') { e.preventDefault(); goTo(idx + 1); }
253
+ if (e.key === 'ArrowLeft' && canGoPrev()) { e.preventDefault(); goTo(idx - 1); }
254
+ if (e.key === 'ArrowRight' && canGoNext()) { e.preventDefault(); goTo(idx + 1); }
241
255
  });
242
256
 
243
257
  // Accessibility
@@ -250,7 +264,7 @@ function _carousel(items, wrap) {
250
264
 
251
265
  wrap.append(viewport, controls, dots);
252
266
 
253
- goTo(0, false); // initial position, no animation
267
+ goTo(0, false);
254
268
  return wrap;
255
269
  }
256
270
 
@@ -7,6 +7,13 @@
7
7
  //
8
8
  // items shape: { src?, url?, alt?, alt_text?, caption? }[]
9
9
  // Keyboard: ← → Escape | Touch: swipe left/right | Click backdrop: close
10
+ //
11
+ // Fix: the singleton is stored on globalThis.__griot_lightbox so that even if
12
+ // this module is evaluated more than once (e.g. two different import paths
13
+ // resolving to separate webpack module cache entries — one via Griot.js facade,
14
+ // one via direct ./Lightbox.js import), all callers share the exact same
15
+ // instance. Without this, clicking outside one overlay closed it but left the
16
+ // second overlay (from the other instance) stuck on screen.
10
17
  // ─────────────────────────────────────────────────────────────────────────────
11
18
 
12
19
  export class Lightbox {
@@ -41,7 +48,6 @@ export class Lightbox {
41
48
  this._el.classList.remove('griot-lb--open');
42
49
  document.body.style.overflow = '';
43
50
  this._isOpen = false;
44
- // Wait for CSS fade-out before hiding from layout
45
51
  setTimeout(() => {
46
52
  if (!this._isOpen && this._el) this._el.hidden = true;
47
53
  }, 270);
@@ -57,20 +63,16 @@ export class Lightbox {
57
63
  el.setAttribute('aria-modal', 'true');
58
64
  el.setAttribute('aria-label', 'Image viewer');
59
65
 
60
- // Backdrop click
61
66
  el.addEventListener('click', e => { if (e.target === el) this.close(); });
62
67
 
63
- // Close button
64
68
  const close = _mkBtn('✕', 'griot-lb__close', 'Close');
65
69
  close.addEventListener('click', () => this.close());
66
70
 
67
- // Prev / Next
68
71
  const prev = _mkBtn('‹', 'griot-lb__nav griot-lb__nav--prev', 'Previous image');
69
72
  const next = _mkBtn('›', 'griot-lb__nav griot-lb__nav--next', 'Next image');
70
73
  prev.addEventListener('click', e => { e.stopPropagation(); this._move(-1); });
71
74
  next.addEventListener('click', e => { e.stopPropagation(); this._move(1); });
72
75
 
73
- // Stage: image + caption
74
76
  const stage = document.createElement('div');
75
77
  stage.className = 'griot-lb__stage';
76
78
  stage.addEventListener('click', e => e.stopPropagation());
@@ -85,17 +87,14 @@ export class Lightbox {
85
87
 
86
88
  stage.append(img, cap);
87
89
 
88
- // Counter
89
90
  const ctr = document.createElement('div');
90
91
  ctr.className = 'griot-lb__counter';
91
92
 
92
- // Thumbnail strip (hidden until > 1 item)
93
93
  const strip = document.createElement('div');
94
94
  strip.className = 'griot-lb__strip';
95
95
 
96
96
  el.append(close, prev, next, stage, ctr, strip);
97
97
 
98
- // Touch swipe
99
98
  el.addEventListener('touchstart', e => {
100
99
  this._touchStartX = e.touches[0].clientX;
101
100
  }, { passive: true });
@@ -114,7 +113,6 @@ export class Lightbox {
114
113
  this._next = next;
115
114
  this._strip = strip;
116
115
 
117
- // Inject styles once
118
116
  _injectStyles();
119
117
  }
120
118
 
@@ -126,7 +124,6 @@ export class Lightbox {
126
124
  document.body.style.overflow = 'hidden';
127
125
  document.addEventListener('keydown', this._onKey);
128
126
 
129
- // Double rAF ensures the hidden→visible transition actually runs
130
127
  requestAnimationFrame(() =>
131
128
  requestAnimationFrame(() => this._el.classList.add('griot-lb--open'))
132
129
  );
@@ -153,7 +150,6 @@ export class Lightbox {
153
150
  const alt = item.alt ?? item.alt_text ?? '';
154
151
  const cap = item.caption ?? '';
155
152
 
156
- // Fade-swap: fade out → preload → set src → fade in
157
153
  this._img.style.opacity = '0';
158
154
  this._img.alt = alt;
159
155
 
@@ -171,13 +167,12 @@ export class Lightbox {
171
167
  this._next.hidden = single;
172
168
  this._ctr.textContent = single ? '' : `${this._idx + 1} / ${this._items.length}`;
173
169
 
174
- // Sync strip active thumb
175
170
  this._strip.querySelectorAll('.griot-lb__thumb').forEach((th, i) => {
176
171
  th.classList.toggle('is-active', i === this._idx);
177
172
  });
178
173
  }
179
174
 
180
- // ── Thumbnail strip (built once per open() call) ────────────────────────────
175
+ // ── Thumbnail strip ─────────────────────────────────────────────────────────
181
176
 
182
177
  _buildStrip() {
183
178
  this._strip.innerHTML = '';
@@ -232,7 +227,6 @@ function _injectStyles() {
232
227
  const s = document.createElement('style');
233
228
  s.id = 'griot-lightbox-styles';
234
229
  s.textContent = `
235
- /* ── Lightbox overlay ───────────────────────────────────────────────────── */
236
230
  .griot-lb {
237
231
  position: fixed; inset: 0; z-index: 9000;
238
232
  background: rgba(0,0,0,0);
@@ -242,8 +236,6 @@ function _injectStyles() {
242
236
  overscroll-behavior: none;
243
237
  }
244
238
  .griot-lb--open { background: rgba(0,0,0,0.92); }
245
-
246
- /* Stage */
247
239
  .griot-lb__stage {
248
240
  position: relative; display: flex; flex-direction: column;
249
241
  align-items: center; max-width: 92vw; max-height: 80vh;
@@ -259,8 +251,6 @@ function _injectStyles() {
259
251
  margin: 10px 0 0; text-align: center;
260
252
  max-width: 70ch; line-height: 1.5;
261
253
  }
262
-
263
- /* Nav buttons */
264
254
  .griot-lb__nav {
265
255
  position: fixed; top: 50%; transform: translateY(-50%);
266
256
  background: rgba(255,255,255,0.10); border: none;
@@ -272,8 +262,6 @@ function _injectStyles() {
272
262
  .griot-lb__nav:hover { background: rgba(255,255,255,0.22); }
273
263
  .griot-lb__nav--prev { left: 0; border-radius: 0 8px 8px 0; }
274
264
  .griot-lb__nav--next { right: 0; border-radius: 8px 0 0 8px; }
275
-
276
- /* Close */
277
265
  .griot-lb__close {
278
266
  position: fixed; top: 14px; right: 18px;
279
267
  background: rgba(255,255,255,0.10); border: none;
@@ -283,15 +271,11 @@ function _injectStyles() {
283
271
  transition: background 0.15s; z-index: 2;
284
272
  }
285
273
  .griot-lb__close:hover { background: rgba(255,255,255,0.22); }
286
-
287
- /* Counter */
288
274
  .griot-lb__counter {
289
275
  position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
290
276
  font-size: 13px; color: #64748b; letter-spacing: 0.04em;
291
277
  pointer-events: none;
292
278
  }
293
-
294
- /* Thumbnail strip */
295
279
  .griot-lb__strip {
296
280
  position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%);
297
281
  display: flex; gap: 6px; max-width: 90vw;
@@ -307,7 +291,6 @@ function _injectStyles() {
307
291
  .griot-lb__thumb:hover { opacity: 0.85; }
308
292
  .griot-lb__thumb.is-active { border-color: #6366f1; opacity: 1; }
309
293
  .griot-lb__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
310
-
311
294
  @media (max-width: 600px) {
312
295
  .griot-lb__nav { width: 40px; height: 64px; font-size: 24px; }
313
296
  .griot-lb__strip { display: none; }
@@ -316,5 +299,18 @@ function _injectStyles() {
316
299
  document.head.appendChild(s);
317
300
  }
318
301
 
302
+ // ── Singleton ─────────────────────────────────────────────────────────────────
303
+ // Store on globalThis so all module instances (regardless of import path or
304
+ // webpack chunk) share the exact same object. This prevents two overlays being
305
+ // appended to document.body when the module is evaluated more than once.
306
+
307
+ const GLOBAL_KEY = '__griot_lightbox__';
308
+
309
+ if (typeof globalThis !== 'undefined' && !globalThis[GLOBAL_KEY]) {
310
+ globalThis[GLOBAL_KEY] = new Lightbox();
311
+ }
312
+
319
313
  /** Shared singleton — import and use directly everywhere. */
320
- export const lightbox = new Lightbox();
314
+ export const lightbox = (typeof globalThis !== 'undefined' && globalThis[GLOBAL_KEY])
315
+ ? globalThis[GLOBAL_KEY]
316
+ : new Lightbox();