@0m0g1/griot 0.1.8 → 0.1.10

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.8",
3
+ "version": "0.1.10",
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",
@@ -8,15 +8,16 @@
8
8
  // onCiteClick — (blockId) => void
9
9
  // ─────────────────────────────────────────────────────────────────────────────
10
10
 
11
- import { anchorId } from '../core/Block.js';
11
+ import { anchorId } from '../core/Block.js';
12
12
  import { renderInlineToDOM, renderInlineToHTML, escHtml, escAttr } from '../inline/InlineRenderer.js';
13
- import { getBlockDef } from './BlockSchema.js';
14
-
15
- const LAYOUT_OPTIONS = ['grid','masonry','carousel','strip'];
13
+ import { getBlockDef } from './BlockSchema.js';
14
+ import { renderGallery } from './GalleryRenderer.js';
15
+ import { lightbox } from './Lightbox.js';
16
16
 
17
17
  // ─── Public ───────────────────────────────────────────────────────────────────
18
18
 
19
19
  export function renderBlock(block, opts = {}) {
20
+ _injectStyles();
20
21
  const el = _render(block, opts);
21
22
  if (el) {
22
23
  el.id = anchorId(block.id);
@@ -101,6 +102,34 @@ function _render(block, opts) {
101
102
  return el;
102
103
  }
103
104
 
105
+ // ── Checklist ─────────────────────────────────────────────────────────────
106
+
107
+ case 'checklist': {
108
+ const items = Array.isArray(meta.items) ? meta.items : [];
109
+ const el = document.createElement('ul');
110
+ el.className = 'griot-block griot-checklist';
111
+
112
+ for (const item of items) {
113
+ const li = document.createElement('li');
114
+ li.className = `griot-checklist__item${item.checked ? ' is-checked' : ''}`;
115
+
116
+ const cb = document.createElement('input');
117
+ cb.type = 'checkbox';
118
+ cb.checked = !!item.checked;
119
+ cb.disabled = true;
120
+ cb.className = 'griot-checklist__checkbox';
121
+ cb.setAttribute('aria-hidden', 'true');
122
+
123
+ const span = document.createElement('span');
124
+ span.className = 'griot-checklist__text';
125
+ if (item.text) span.appendChild(il(item.text, opts));
126
+
127
+ li.append(cb, span);
128
+ el.appendChild(li);
129
+ }
130
+ return el;
131
+ }
132
+
104
133
  // ── Media ─────────────────────────────────────────────────────────────────
105
134
 
106
135
  case 'image': {
@@ -113,7 +142,12 @@ function _render(block, opts) {
113
142
  fig.appendChild(sp);
114
143
  } else if (meta.src) {
115
144
  const img = document.createElement('img');
116
- img.src = meta.src; img.alt = meta.alt ?? '';
145
+ img.src = meta.src;
146
+ img.alt = meta.alt ?? '';
147
+ img.style.cursor = 'zoom-in';
148
+ img.addEventListener('click', () =>
149
+ lightbox.open([{ src: meta.src, alt: meta.alt, caption: meta.caption }], 0)
150
+ );
117
151
  fig.appendChild(img);
118
152
  if (meta.caption) {
119
153
  const cap = document.createElement('figcaption');
@@ -164,7 +198,9 @@ function _render(block, opts) {
164
198
  }
165
199
 
166
200
  case 'gallery': {
167
- return _renderGallery(meta.items ?? [], meta.layout ?? 'grid', opts);
201
+ const galleryEl = renderGallery(meta.items ?? [], meta.layout ?? 'grid');
202
+ galleryEl.classList.add('griot-block');
203
+ return galleryEl;
168
204
  }
169
205
 
170
206
  case 'embed': {
@@ -186,6 +222,21 @@ function _render(block, opts) {
186
222
 
187
223
  // ── Structure ─────────────────────────────────────────────────────────────
188
224
 
225
+ case 'columns': {
226
+ const columns = Array.isArray(meta.columns) ? meta.columns : [{ text: '' }, { text: '' }];
227
+ const el = document.createElement('div');
228
+ el.className = 'griot-block griot-columns';
229
+ el.style.setProperty('--griot-col-count', String(columns.length));
230
+
231
+ for (const col of columns) {
232
+ const colEl = document.createElement('div');
233
+ colEl.className = 'griot-columns__col';
234
+ if (col.text?.trim()) colEl.appendChild(il(col.text, opts));
235
+ el.appendChild(colEl);
236
+ }
237
+ return el;
238
+ }
239
+
189
240
  case 'table': {
190
241
  const headers = Array.isArray(meta.headers) ? meta.headers : [];
191
242
  const rows = Array.isArray(meta.rows) ? meta.rows : [];
@@ -256,36 +307,6 @@ function _render(block, opts) {
256
307
  }
257
308
  }
258
309
 
259
- // ─── Gallery renderer ─────────────────────────────────────────────────────────
260
-
261
- function _renderGallery(items, layout, opts) {
262
- const wrap = document.createElement('div');
263
- wrap.className = `griot-block griot-gallery griot-gallery--${LAYOUT_OPTIONS.includes(layout)?layout:'grid'}`;
264
-
265
- if (!items.length) {
266
- wrap.innerHTML = `<div class="griot-gallery__empty">No images yet</div>`;
267
- return wrap;
268
- }
269
-
270
- for (const [i, item] of items.entries()) {
271
- const el = document.createElement('div');
272
- el.className = 'griot-gallery__item';
273
- const img = document.createElement('img');
274
- img.src = item.src ?? item.url ?? '';
275
- img.alt = item.alt ?? item.alt_text ?? '';
276
- img.loading = 'lazy';
277
- el.appendChild(img);
278
- if (item.caption) {
279
- const cap = document.createElement('p');
280
- cap.className = 'griot-gallery__caption';
281
- cap.textContent = item.caption;
282
- el.appendChild(cap);
283
- }
284
- wrap.appendChild(el);
285
- }
286
- return wrap;
287
- }
288
-
289
310
  // ─── Citation renderer ────────────────────────────────────────────────────────
290
311
 
291
312
  function _renderCitation(block, opts) {
@@ -344,5 +365,28 @@ function _scEmbed(src) {
344
365
  return null;
345
366
  }
346
367
 
347
- // Export helpers for editor use
348
- export { _ytEmbed as resolveYouTube, _vimeoEmbed as resolveVimeo, _spotifyEmbed as resolveSpotify, _scEmbed as resolveSoundCloud };
368
+ export { _ytEmbed as resolveYouTube, _vimeoEmbed as resolveVimeo, _spotifyEmbed as resolveSpotify, _scEmbed as resolveSoundCloud };
369
+
370
+ // ─── Style injection ──────────────────────────────────────────────────────────
371
+
372
+ let _stylesInjected = false;
373
+ function _injectStyles() {
374
+ if (_stylesInjected || typeof document === 'undefined') return;
375
+ _stylesInjected = true;
376
+ const s = document.createElement('style');
377
+ s.id = 'griot-block-styles';
378
+ s.textContent = `
379
+ /* ── Checklist ──────────────────────────────────────────────────────────── */
380
+ .griot-checklist { list-style:none; padding:0; margin:0; }
381
+ .griot-checklist__item { display:flex; align-items:baseline; gap:10px; padding:3px 0; line-height:1.6; }
382
+ .griot-checklist__checkbox { flex-shrink:0; width:15px; height:15px; margin:0; accent-color:#6366f1; cursor:default; position:relative; top:2px; }
383
+ .griot-checklist__text { flex:1; }
384
+ .griot-checklist__item.is-checked .griot-checklist__text { text-decoration:line-through; opacity:0.45; }
385
+
386
+ /* ── Columns ─────────────────────────────────────────────────────────────── */
387
+ .griot-columns { display:grid; grid-template-columns:repeat(var(--griot-col-count,2),1fr); gap:24px; align-items:start; }
388
+ .griot-columns__col { min-width:0; line-height:1.7; }
389
+ @media (max-width:640px) { .griot-columns { grid-template-columns:1fr; } }
390
+ `;
391
+ document.head.appendChild(s);
392
+ }
@@ -4,29 +4,31 @@
4
4
  // ─────────────────────────────────────────────────────────────────────────────
5
5
 
6
6
  const SCHEMA = {
7
- paragraph: { category:'text', label:'Paragraph', icon:'¶', slashLabel:'Text', hasText:true, hasInline:true, defaultMeta:{}, placeholder:'Write something… **bold** *italic* `code` ==highlight==' },
8
- heading: { category:'text', label:'Heading', icon:'H', slashLabel:'Heading', hasText:true, hasInline:false, defaultMeta:{ level:2 }, placeholder:'Heading…' },
9
- blockquote: { category:'text', label:'Quote', icon:'❝', slashLabel:'Quote', hasText:true, hasInline:true, defaultMeta:{}, placeholder:'Quote…' },
10
- callout: { category:'text', label:'Callout', icon:'💡', slashLabel:'Callout', hasText:true, hasInline:true, defaultMeta:{ icon:'💡' }, placeholder:'Callout text…' },
11
- callout_warning: { category:'text', label:'Warning', icon:'⚠️', slashLabel:'Warning', hasText:true, hasInline:true, defaultMeta:{ icon:'⚠️' }, placeholder:'Warning message…' },
12
- callout_tip: { category:'text', label:'Tip', icon:'✅', slashLabel:'Tip', hasText:true, hasInline:true, defaultMeta:{ icon:'✅' }, placeholder:'Tip or note…' },
13
- callout_danger: { category:'text', label:'Danger', icon:'🚨', slashLabel:'Danger', hasText:true, hasInline:true, defaultMeta:{ icon:'🚨' }, placeholder:'Critical warning…' },
14
- code: { category:'text', label:'Code', icon:'</>', slashLabel:'Code block', hasText:true, hasInline:false, defaultMeta:{ language:'' }, placeholder:'// code…' },
15
- list_ul: { category:'text', label:'Bullet List', icon:'•', slashLabel:'Bullet list', hasText:true, hasInline:false, defaultMeta:{}, placeholder:'Item 1\nItem 2\nItem 3' },
16
- list_ol: { category:'text', label:'Numbered List', icon:'1.', slashLabel:'Numbered list', hasText:true, hasInline:false, defaultMeta:{}, placeholder:'First item\nSecond item' },
7
+ paragraph: { category:'text', label:'Paragraph', icon:'¶', slashLabel:'Text', hasText:true, hasInline:true, defaultMeta:{}, placeholder:'Write something… **bold** *italic* `code` ==highlight==' },
8
+ heading: { category:'text', label:'Heading', icon:'H', slashLabel:'Heading', hasText:true, hasInline:false, defaultMeta:{ level:2 }, placeholder:'Heading…' },
9
+ blockquote: { category:'text', label:'Quote', icon:'❝', slashLabel:'Quote', hasText:true, hasInline:true, defaultMeta:{}, placeholder:'Quote…' },
10
+ callout: { category:'text', label:'Callout', icon:'💡', slashLabel:'Callout', hasText:true, hasInline:true, defaultMeta:{ icon:'💡' }, placeholder:'Callout text…' },
11
+ callout_warning: { category:'text', label:'Warning', icon:'⚠️', slashLabel:'Warning', hasText:true, hasInline:true, defaultMeta:{ icon:'⚠️' }, placeholder:'Warning message…' },
12
+ callout_tip: { category:'text', label:'Tip', icon:'✅', slashLabel:'Tip', hasText:true, hasInline:true, defaultMeta:{ icon:'✅' }, placeholder:'Tip or note…' },
13
+ callout_danger: { category:'text', label:'Danger', icon:'🚨', slashLabel:'Danger', hasText:true, hasInline:true, defaultMeta:{ icon:'🚨' }, placeholder:'Critical warning…' },
14
+ code: { category:'text', label:'Code', icon:'</>', slashLabel:'Code block', hasText:true, hasInline:false, defaultMeta:{ language:'' }, placeholder:'// code…' },
15
+ list_ul: { category:'text', label:'Bullet List', icon:'•', slashLabel:'Bullet list', hasText:true, hasInline:false, defaultMeta:{}, placeholder:'Item 1\nItem 2\nItem 3' },
16
+ list_ol: { category:'text', label:'Numbered List', icon:'1.', slashLabel:'Numbered list', hasText:true, hasInline:false, defaultMeta:{}, placeholder:'First item\nSecond item' },
17
+ checklist: { category:'text', label:'Checklist', icon:'☑', slashLabel:'Checklist', hasText:false, hasInline:false, defaultMeta:{ items:[{ text:'', checked:false }] }, placeholder:null },
17
18
 
18
- image: { category:'media', label:'Image', icon:'🖼', slashLabel:'Image', hasText:false, hasInline:false, defaultMeta:{ src:'', alt:'', caption:'', width:'full' }, placeholder:null },
19
- video: { category:'media', label:'Video', icon:'▶', slashLabel:'Video', hasText:false, hasInline:false, defaultMeta:{ src:'', caption:'', embedUrl:null, platform:null }, placeholder:null },
20
- audio: { category:'media', label:'Audio', icon:'🎵', slashLabel:'Audio', hasText:false, hasInline:false, defaultMeta:{ src:'', caption:'', embedUrl:null, platform:null }, placeholder:null },
21
- gallery: { category:'media', label:'Gallery', icon:'▦', slashLabel:'Gallery', hasText:false, hasInline:false, defaultMeta:{ items:[], layout:'grid' }, placeholder:null },
19
+ image: { category:'media', label:'Image', icon:'🖼', slashLabel:'Image', hasText:false, hasInline:false, defaultMeta:{ src:'', alt:'', caption:'', width:'full' }, placeholder:null },
20
+ video: { category:'media', label:'Video', icon:'▶', slashLabel:'Video', hasText:false, hasInline:false, defaultMeta:{ src:'', caption:'', embedUrl:null, platform:null }, placeholder:null },
21
+ audio: { category:'media', label:'Audio', icon:'🎵', slashLabel:'Audio', hasText:false, hasInline:false, defaultMeta:{ src:'', caption:'', embedUrl:null, platform:null }, placeholder:null },
22
+ gallery: { category:'media', label:'Gallery', icon:'▦', slashLabel:'Gallery', hasText:false, hasInline:false, defaultMeta:{ items:[], layout:'grid' }, placeholder:null },
22
23
 
23
- embed: { category:'embed', label:'Embed', icon:'⬡', slashLabel:'Embed / iframe', hasText:false, hasInline:false, defaultMeta:{ src:'', height:400, caption:'' }, placeholder:null },
24
+ embed: { category:'embed', label:'Embed', icon:'⬡', slashLabel:'Embed / iframe', hasText:false, hasInline:false, defaultMeta:{ src:'', height:400, caption:'' }, placeholder:null },
24
25
 
25
- table: { category:'structure', label:'Table', icon:'⊞', slashLabel:'Table', hasText:false, hasInline:false, defaultMeta:{ headers:['Column 1','Column 2'], rows:[['','']] }, placeholder:null },
26
- divider: { category:'structure', label:'Divider', icon:'', slashLabel:'Divider', hasText:false, hasInline:false, defaultMeta:{}, placeholder:null },
26
+ table: { category:'structure', label:'Table', icon:'⊞', slashLabel:'Table', hasText:false, hasInline:false, defaultMeta:{ headers:['Column 1','Column 2'], rows:[['','']] }, placeholder:null },
27
+ columns: { category:'structure', label:'Columns', icon:'', slashLabel:'Columns', hasText:false, hasInline:false, defaultMeta:{ columns:[{ text:'' },{ text:'' }] }, placeholder:null },
28
+ divider: { category:'structure', label:'Divider', icon:'—', slashLabel:'Divider', hasText:false, hasInline:false, defaultMeta:{}, placeholder:null },
27
29
 
28
- timeline_ref: { category:'structure', label:'Timeline Event', icon:'⏱', slashLabel:'Timeline event', hasText:false, hasInline:false, defaultMeta:{ eventId:'', eventTitle:'', note:'' }, placeholder:null },
29
- book_citation: { category:'structure', label:'Book Citation', icon:'📖', slashLabel:'Book citation', hasText:false, hasInline:false, defaultMeta:{ bookId:'', unitId:'', quote:'', note:'' }, placeholder:null },
30
+ timeline_ref: { category:'structure', label:'Timeline Event', icon:'⏱', slashLabel:'Timeline event', hasText:false, hasInline:false, defaultMeta:{ eventId:'', eventTitle:'', note:'' }, placeholder:null },
31
+ book_citation: { category:'structure', label:'Book Citation', icon:'📖', slashLabel:'Book citation', hasText:false, hasInline:false, defaultMeta:{ bookId:'', unitId:'', quote:'', note:'' }, placeholder:null },
30
32
  };
31
33
 
32
34
  export function getBlockDef(type) { return SCHEMA[type] ?? SCHEMA.paragraph; }
@@ -18,7 +18,7 @@ import { createBlock, isTextBlock, anchorId } from '../core/Block.js'
18
18
  import {
19
19
  updateBlock, splitBlock, mergeBlockWithPrev,
20
20
  insertBlockAfter, removeBlock, getBlockIndex,
21
- getBlockBefore, getBlockAfter, moveBlock,
21
+ getBlock, getBlockBefore, getBlockAfter, moveBlock,
22
22
  } from '../core/Document.js';
23
23
  import { History } from '../core/History.js';
24
24
  import { getBlockDef, getAllTypes, defaultMeta } from '../blocks/BlockSchema.js';
@@ -32,6 +32,7 @@ import { DropHandler } from './DropHandler.js'
32
32
  import { renderInlineToDOM } from '../inline/InlineRenderer.js';
33
33
 
34
34
  const TYPING_DEBOUNCE_MS = 400;
35
+ const UPLOAD_URL_DEFAULT = '/api/upload/insight-media';
35
36
 
36
37
  // Block types where Enter inserts a newline instead of splitting the block
37
38
  const LIST_TYPES = new Set(['list_ul', 'list_ol']);
@@ -51,6 +52,7 @@ const BLOCK_SHORTCUTS = [
51
52
  { re: /^--- /, type: 'divider', strip: 4, clearText: true },
52
53
  { re: /^``` /, type: 'code', strip: 4 },
53
54
  { re: /^```$/, type: 'code', strip: 3 },
55
+ { re: /^\[\] /, type: 'checklist', strip: 3, clearText: true },
54
56
  ];
55
57
 
56
58
  // Inline format-key → markdown syntax
@@ -169,7 +171,8 @@ export class Editor {
169
171
 
170
172
  editable.textContent = block.text ?? '';
171
173
 
172
- editable.addEventListener('input', () => this._onInput(block.id, editable));
174
+ editable.addEventListener('input', () => this._onInput(block.id, editable));
175
+ editable.addEventListener('paste', (e) => this._onPaste(block.id, editable, e));
173
176
  editable.addEventListener('focus', () => {
174
177
  this._focusedId = block.id;
175
178
  this._focusedEl = editable;
@@ -324,8 +327,7 @@ export class Editor {
324
327
  case 'image': {
325
328
  if (block.meta?.src) {
326
329
  const img = document.createElement('img');
327
- img.src = block.meta.src;
328
- img.alt = block.meta.alt ?? '';
330
+ img.src = block.meta.src; img.alt = block.meta.alt ?? '';
329
331
  img.className = 'griot-editor-block__img-preview';
330
332
  wrap.appendChild(img);
331
333
  }
@@ -340,46 +342,296 @@ export class Editor {
340
342
  break;
341
343
  }
342
344
 
345
+ case 'gallery': {
346
+ const items = Array.isArray(block.meta?.items) ? block.meta.items : [];
347
+ const layout = block.meta?.layout ?? 'grid';
348
+
349
+ if (items.length) {
350
+ const thumbs = document.createElement('div');
351
+ thumbs.className = 'griot-editor-gallery__thumbs';
352
+ items.forEach((item, i) => {
353
+ const thumb = document.createElement('div');
354
+ thumb.className = `griot-editor-gallery__thumb${item._uploading ? ' is-uploading' : ''}`;
355
+ if (item._uploading) {
356
+ thumb.innerHTML = `<div class="griot-editor-gallery__thumb-spinner"></div>`;
357
+ } else {
358
+ const img = document.createElement('img');
359
+ img.src = item.src ?? item.url ?? ''; img.alt = item.alt ?? item.alt_text ?? '';
360
+ thumb.appendChild(img);
361
+ const cap = document.createElement('input');
362
+ cap.type = 'text'; cap.className = 'griot-editor-gallery__thumb-caption';
363
+ cap.placeholder = 'Caption…'; cap.value = item.caption ?? '';
364
+ cap.addEventListener('input', () => {
365
+ const b = getBlock(this._doc, block.id);
366
+ const its = Array.isArray(b?.meta?.items) ? b.meta.items : [];
367
+ const next = its.map((it, j) => j === i ? { ...it, caption: cap.value } : it);
368
+ this._doc = updateBlock(this._doc, block.id, { meta: { items: next } });
369
+ this._history.replace(this._doc);
370
+ clearTimeout(this._typingTimer);
371
+ this._typingTimer = setTimeout(() => { this._history.push(this._doc); this._emit(); }, TYPING_DEBOUNCE_MS);
372
+ });
373
+ thumb.appendChild(cap);
374
+ const remove = document.createElement('button');
375
+ remove.type = 'button'; remove.className = 'griot-editor-gallery__thumb-remove';
376
+ remove.textContent = '×'; remove.title = 'Remove';
377
+ remove.addEventListener('click', () => {
378
+ const b = getBlock(this._doc, block.id);
379
+ const next = (b?.meta?.items ?? []).filter((_, j) => j !== i);
380
+ this._commit(updateBlock(this._doc, block.id, { meta: { items: next } }));
381
+ });
382
+ thumb.appendChild(remove);
383
+ }
384
+ thumbs.appendChild(thumb);
385
+ });
386
+ wrap.appendChild(thumbs);
387
+ }
388
+
389
+ const addRow = document.createElement('div');
390
+ addRow.className = 'griot-editor-gallery__add-row';
391
+ const fileInput = document.createElement('input');
392
+ fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.multiple = true; fileInput.style.display = 'none';
393
+ fileInput.addEventListener('change', async (e) => {
394
+ const files = [...(e.target.files ?? [])]; e.target.value = '';
395
+ if (files.length) await this._galleryUpload(block.id, files);
396
+ });
397
+ const addBtn = document.createElement('button');
398
+ addBtn.type = 'button'; addBtn.className = 'griot-editor-block__pick-btn';
399
+ addBtn.textContent = '+ Upload images'; addBtn.addEventListener('click', () => fileInput.click());
400
+ const urlInput = document.createElement('input');
401
+ urlInput.type = 'url'; urlInput.className = 'griot-editor-block__meta-input';
402
+ urlInput.placeholder = 'Image URL…'; urlInput.style.flex = '1';
403
+ const addUrlBtn = document.createElement('button');
404
+ addUrlBtn.type = 'button'; addUrlBtn.className = 'griot-editor-block__pick-btn';
405
+ addUrlBtn.textContent = '+ Add URL';
406
+ addUrlBtn.addEventListener('click', () => {
407
+ const url = urlInput.value.trim(); if (!url) return;
408
+ const b = getBlock(this._doc, block.id);
409
+ const next = [...(b?.meta?.items ?? []), { src: url, url, alt: '', caption: '' }];
410
+ this._commit(updateBlock(this._doc, block.id, { meta: { items: next } }));
411
+ urlInput.value = '';
412
+ });
413
+ urlInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addUrlBtn.click(); });
414
+ addRow.append(fileInput, addBtn, urlInput, addUrlBtn);
415
+ wrap.appendChild(addRow);
416
+
417
+ const layoutRow = document.createElement('div');
418
+ layoutRow.className = 'griot-editor-gallery__layout-row';
419
+ const lbl = document.createElement('span');
420
+ lbl.className = 'griot-editor-gallery__layout-label'; lbl.textContent = 'Layout:';
421
+ layoutRow.appendChild(lbl);
422
+ for (const l of ['grid', 'masonry', 'carousel', 'strip']) {
423
+ const btn = document.createElement('button');
424
+ btn.type = 'button'; btn.textContent = l;
425
+ btn.className = `griot-editor-gallery__layout-btn${layout === l ? ' is-active' : ''}`;
426
+ btn.addEventListener('click', () => this._commit(updateBlock(this._doc, block.id, { meta: { layout: l } })));
427
+ layoutRow.appendChild(btn);
428
+ }
429
+ wrap.appendChild(layoutRow);
430
+ break;
431
+ }
432
+
343
433
  case 'video': {
344
434
  const { src = '' } = block.meta ?? {};
345
435
  if (src) {
346
436
  const yt = src.match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/))([a-zA-Z0-9_-]{11})/);
347
437
  const vm = src.match(/vimeo\.com\/(\d+)/);
348
438
  if (yt || vm) {
349
- const embedSrc = yt
350
- ? `https://www.youtube.com/embed/${yt[1]}`
351
- : `https://player.vimeo.com/video/${vm[1]}`;
439
+ const embedSrc = yt ? `https://www.youtube.com/embed/${yt[1]}` : `https://player.vimeo.com/video/${vm[1]}`;
352
440
  const iframe = document.createElement('iframe');
353
- iframe.src = embedSrc;
354
- iframe.className = 'griot-editor-block__video-preview';
441
+ iframe.src = embedSrc; iframe.className = 'griot-editor-block__video-preview';
355
442
  iframe.frameBorder = '0';
356
443
  iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
357
444
  wrap.appendChild(iframe);
358
445
  }
359
446
  }
360
- const row = document.createElement('div');
361
- row.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
362
- [
363
- this._metaInput(block, 'src', 'YouTube / Vimeo / video URL…', { style: 'flex:2' }),
364
- this._metaInput(block, 'caption', 'Caption…', {}),
365
- ].forEach(el => row.appendChild(el));
447
+ const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
448
+ [this._metaInput(block, 'src', 'YouTube / Vimeo / video URL…', { style: 'flex:2' }), this._metaInput(block, 'caption', 'Caption…', {})].forEach(el => row.appendChild(el));
366
449
  wrap.appendChild(row);
367
450
  break;
368
451
  }
369
452
 
453
+ case 'audio': {
454
+ const { src = '' } = block.meta ?? {};
455
+ if (src) {
456
+ const sp = src.match(/open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/);
457
+ const sc = src.includes('soundcloud.com/');
458
+ if (sp) {
459
+ const iframe = document.createElement('iframe');
460
+ iframe.src = `https://open.spotify.com/embed/${sp[1]}/${sp[2]}`;
461
+ iframe.className = 'griot-editor-block__audio-preview'; iframe.frameBorder = '0';
462
+ iframe.allow = 'autoplay; clipboard-write; encrypted-media'; wrap.appendChild(iframe);
463
+ } else if (sc) {
464
+ const iframe = document.createElement('iframe');
465
+ iframe.src = `https://w.soundcloud.com/player/?url=${encodeURIComponent(src)}&color=%236366f1&auto_play=false`;
466
+ iframe.className = 'griot-editor-block__audio-preview'; iframe.frameBorder = '0'; wrap.appendChild(iframe);
467
+ } else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?.*)?$/i.test(src)) {
468
+ const audio = document.createElement('audio');
469
+ audio.src = src; audio.controls = true; audio.className = 'griot-editor-block__audio-native'; wrap.appendChild(audio);
470
+ }
471
+ }
472
+ const fileInput = document.createElement('input');
473
+ fileInput.type = 'file'; fileInput.accept = 'audio/*'; fileInput.style.display = 'none';
474
+ fileInput.addEventListener('change', async (e) => {
475
+ const files = [...(e.target.files ?? [])]; e.target.value = '';
476
+ if (!files.length) return;
477
+ const results = await this._uploadFiles(files.slice(0, 1));
478
+ if (results[0]) this._commit(updateBlock(this._doc, block.id, { meta: { src: results[0].url ?? results[0].src ?? '', caption: block.meta?.caption ?? '' } }));
479
+ });
480
+ const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
481
+ const uploadBtn = document.createElement('button');
482
+ uploadBtn.type = 'button'; uploadBtn.className = 'griot-editor-block__pick-btn';
483
+ uploadBtn.textContent = '⬆ Upload audio'; uploadBtn.addEventListener('click', () => fileInput.click());
484
+ row.append(fileInput, uploadBtn, this._metaInput(block, 'src', 'SoundCloud / Spotify / audio URL…', { style: 'flex:2' }), this._metaInput(block, 'caption', 'Caption…', {}));
485
+ wrap.appendChild(row);
486
+ break;
487
+ }
488
+
489
+ case 'embed': {
490
+ const { src = '', height = 400 } = block.meta ?? {};
491
+ if (src) {
492
+ const preview = document.createElement('iframe');
493
+ preview.src = src; preview.style.height = `${height}px`;
494
+ preview.className = 'griot-editor-block__embed-preview'; preview.frameBorder = '0';
495
+ preview.allow = 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media';
496
+ preview.allowFullscreen = true; wrap.appendChild(preview);
497
+ }
498
+ const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;align-items:center';
499
+ const heightInput = document.createElement('input');
500
+ heightInput.type = 'number'; heightInput.className = 'griot-editor-block__meta-input';
501
+ heightInput.placeholder = 'Height px'; heightInput.value = String(height);
502
+ heightInput.min = '100'; heightInput.max = '1200'; heightInput.style.width = '90px';
503
+ heightInput.addEventListener('change', () => {
504
+ const h = Math.max(100, Math.min(1200, Number(heightInput.value) || 400));
505
+ this._commit(updateBlock(this._doc, block.id, { meta: { height: h } }));
506
+ });
507
+ row.append(this._metaInput(block, 'src', 'Embed URL or iframe src…', { style: 'flex:3' }), heightInput, this._metaInput(block, 'caption', 'Caption…', {}));
508
+ wrap.appendChild(row);
509
+ break;
510
+ }
511
+
512
+ case 'columns': {
513
+ const columns = Array.isArray(block.meta?.columns) ? block.meta.columns : [{ text: '' }, { text: '' }];
514
+ const grid = document.createElement('div');
515
+ grid.className = 'griot-editor-columns';
516
+ grid.style.setProperty('--griot-col-count', String(columns.length));
517
+ columns.forEach((col, i) => {
518
+ const colWrap = document.createElement('div'); colWrap.className = 'griot-editor-columns__col';
519
+ const ed = document.createElement('div');
520
+ ed.contentEditable = 'plaintext-only'; ed.className = 'griot-editor-columns__editable';
521
+ ed.dataset.placeholder = 'Column text…'; ed.spellcheck = true; ed.textContent = col.text ?? '';
522
+ const preview = document.createElement('div'); preview.className = 'griot-editor-columns__preview';
523
+ const refreshPreview = (text) => {
524
+ preview.innerHTML = '';
525
+ if (text?.trim()) preview.appendChild(renderInlineToDOM(text, { onEventClick: this._options.onEventClick, onCiteClick: this._options.onCiteClick }));
526
+ };
527
+ refreshPreview(col.text ?? '');
528
+ ed.addEventListener('input', () => {
529
+ const text = ed.textContent;
530
+ const b = getBlock(this._doc, block.id);
531
+ const cols = Array.isArray(b?.meta?.columns) ? b.meta.columns : columns;
532
+ const next = cols.map((c, j) => j === i ? { ...c, text } : c);
533
+ this._doc = updateBlock(this._doc, block.id, { meta: { columns: next } });
534
+ this._history.replace(this._doc); refreshPreview(text);
535
+ clearTimeout(this._typingTimer);
536
+ this._typingTimer = setTimeout(() => { this._history.push(this._doc); this._emit(); }, TYPING_DEBOUNCE_MS);
537
+ });
538
+ colWrap.append(ed, preview); grid.appendChild(colWrap);
539
+ });
540
+ const ctrlRow = document.createElement('div'); ctrlRow.className = 'griot-editor-columns__controls';
541
+ if (columns.length < 4) {
542
+ ctrlRow.appendChild(this._mkSmallBtn('+ col', 'Add column', () => {
543
+ const b = getBlock(this._doc, block.id);
544
+ const cols = Array.isArray(b?.meta?.columns) ? b.meta.columns : columns;
545
+ this._commit(updateBlock(this._doc, block.id, { meta: { columns: [...cols, { text: '' }] } }));
546
+ }));
547
+ }
548
+ if (columns.length > 2) {
549
+ ctrlRow.appendChild(this._mkSmallBtn('- col', 'Remove last column', () => {
550
+ const b = getBlock(this._doc, block.id);
551
+ const cols = Array.isArray(b?.meta?.columns) ? b.meta.columns : columns;
552
+ this._commit(updateBlock(this._doc, block.id, { meta: { columns: cols.slice(0, -1) } }));
553
+ }, 'is-del'));
554
+ }
555
+ wrap.append(grid, ctrlRow);
556
+ break;
557
+ }
558
+
559
+ case 'checklist': {
560
+ const items = Array.isArray(block.meta?.items) ? block.meta.items : [];
561
+ const list = document.createElement('div'); list.className = 'griot-editor-checklist';
562
+ const renderRows = () => {
563
+ list.innerHTML = '';
564
+ const b = getBlock(this._doc, block.id);
565
+ const current = Array.isArray(b?.meta?.items) ? b.meta.items : items;
566
+ current.forEach((item, i) => {
567
+ const row = document.createElement('div'); row.className = 'griot-editor-checklist__row';
568
+ const cb = document.createElement('input');
569
+ cb.type = 'checkbox'; cb.checked = !!item.checked; cb.className = 'griot-editor-checklist__cb';
570
+ cb.addEventListener('change', () => {
571
+ const b2 = getBlock(this._doc, block.id);
572
+ const its = Array.isArray(b2?.meta?.items) ? b2.meta.items : [];
573
+ this._commit(updateBlock(this._doc, block.id, { meta: { items: its.map((it, j) => j === i ? { ...it, checked: cb.checked } : it) } }));
574
+ });
575
+ const textInput = document.createElement('input');
576
+ textInput.type = 'text'; textInput.className = 'griot-editor-block__meta-input griot-editor-checklist__text';
577
+ textInput.value = item.text ?? ''; textInput.placeholder = 'List item…';
578
+ textInput.addEventListener('input', () => {
579
+ const b2 = getBlock(this._doc, block.id);
580
+ const its = Array.isArray(b2?.meta?.items) ? b2.meta.items : [];
581
+ this._doc = updateBlock(this._doc, block.id, { meta: { items: its.map((it, j) => j === i ? { ...it, text: textInput.value } : it) } });
582
+ this._history.replace(this._doc);
583
+ clearTimeout(this._typingTimer);
584
+ this._typingTimer = setTimeout(() => { this._history.push(this._doc); this._emit(); }, TYPING_DEBOUNCE_MS);
585
+ });
586
+ textInput.addEventListener('keydown', (e) => {
587
+ if (e.key === 'Enter') {
588
+ e.preventDefault();
589
+ const b2 = getBlock(this._doc, block.id);
590
+ const its = [...(Array.isArray(b2?.meta?.items) ? b2.meta.items : [])];
591
+ its.splice(i + 1, 0, { text: '', checked: false });
592
+ this._commit(updateBlock(this._doc, block.id, { meta: { items: its } }));
593
+ requestAnimationFrame(() => { const el = this._container.querySelector(`[data-block-id="${block.id}"]`); el?.querySelectorAll('.griot-editor-checklist__text')?.[i + 1]?.focus(); });
594
+ }
595
+ if (e.key === 'Backspace' && textInput.value === '') {
596
+ e.preventDefault();
597
+ const b2 = getBlock(this._doc, block.id);
598
+ const its = Array.isArray(b2?.meta?.items) ? b2.meta.items : [];
599
+ if (its.length <= 1) return;
600
+ this._commit(updateBlock(this._doc, block.id, { meta: { items: its.filter((_, j) => j !== i) } }));
601
+ requestAnimationFrame(() => { const el = this._container.querySelector(`[data-block-id="${block.id}"]`); const ins = el?.querySelectorAll('.griot-editor-checklist__text'); ins?.[Math.min(i, ins.length - 1)]?.focus(); });
602
+ }
603
+ });
604
+ const delBtn = this._mkSmallBtn('×', 'Remove item', () => {
605
+ const b2 = getBlock(this._doc, block.id);
606
+ const its = Array.isArray(b2?.meta?.items) ? b2.meta.items : [];
607
+ if (its.length <= 1) return;
608
+ this._commit(updateBlock(this._doc, block.id, { meta: { items: its.filter((_, j) => j !== i) } }));
609
+ }, 'is-del');
610
+ row.append(cb, textInput, delBtn); list.appendChild(row);
611
+ });
612
+ const addBtn = document.createElement('button');
613
+ addBtn.type = 'button'; addBtn.className = 'griot-editor-block__pick-btn'; addBtn.style.marginTop = '6px';
614
+ addBtn.textContent = '+ Add item';
615
+ addBtn.addEventListener('click', () => {
616
+ const b2 = getBlock(this._doc, block.id);
617
+ const its = Array.isArray(b2?.meta?.items) ? b2.meta.items : [];
618
+ this._commit(updateBlock(this._doc, block.id, { meta: { items: [...its, { text: '', checked: false }] } }));
619
+ requestAnimationFrame(() => { const el = this._container.querySelector(`[data-block-id="${block.id}"]`); const ins = el?.querySelectorAll('.griot-editor-checklist__text'); ins?.[ins.length - 1]?.focus(); });
620
+ });
621
+ list.appendChild(addBtn);
622
+ };
623
+ renderRows(); wrap.appendChild(list);
624
+ break;
625
+ }
626
+
370
627
  case 'table': {
371
628
  wrap.appendChild(this._buildTableUI(block));
372
629
  break;
373
630
  }
374
631
 
375
632
  case 'timeline_ref': {
376
- const row = document.createElement('div');
377
- row.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
378
- [
379
- this._metaInput(block, 'eventId', 'Event ID…', { style: 'flex:1;font-family:monospace' }),
380
- this._metaInput(block, 'eventTitle', 'Display title…', { style: 'flex:2' }),
381
- this._metaInput(block, 'note', 'Note (inline ok)…', { style: 'flex:3' }),
382
- ].forEach(el => row.appendChild(el));
633
+ const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
634
+ [this._metaInput(block, 'eventId', 'Event ID…', { style: 'flex:1;font-family:monospace' }), this._metaInput(block, 'eventTitle', 'Display title…', { style: 'flex:2' }), this._metaInput(block, 'note', 'Note (inline ok)…', { style: 'flex:3' })].forEach(el => row.appendChild(el));
383
635
  wrap.appendChild(row);
384
636
  break;
385
637
  }
@@ -389,13 +641,12 @@ export class Editor {
389
641
  if (bookId) {
390
642
  const book = this._books.find(b => b.id === bookId);
391
643
  const unit = book?.units?.find(u => u.id === unitId);
392
- const label = document.createElement('div');
393
- label.className = 'griot-editor-block__citation-label';
644
+ const label = document.createElement('div'); label.className = 'griot-editor-block__citation-label';
394
645
  label.textContent = book ? `📖 ${book.title} · ${unit?.label ?? '—'}` : '📖 Book not found';
395
646
  wrap.appendChild(label);
396
647
  }
397
- wrap.appendChild(this._metaTextarea(block, 'quote', 'Quoted passage…', { rows: 2 }));
398
- wrap.appendChild(this._metaTextarea(block, 'note', 'Commentary (inline syntax ok)…', { rows: 2 }));
648
+ wrap.appendChild(this._metaTextarea(block, 'quote', 'Quoted passage…', { rows: 2 }));
649
+ wrap.appendChild(this._metaTextarea(block, 'note', 'Commentary (inline syntax ok)…', { rows: 2 }));
399
650
  break;
400
651
  }
401
652
  }
@@ -571,6 +822,16 @@ export class Editor {
571
822
  const patch = { type: sc.type, meta: { ...defaultMeta(sc.type), ...(sc.meta ?? {}) } };
572
823
  if (sc.clearText || sc.type !== 'divider') patch.text = newText;
573
824
 
825
+ // Checklist shortcut creates a checklist block (text=null)
826
+ if (sc.type === 'checklist') {
827
+ this._commit(updateBlock(this._doc, blockId, { type: 'checklist', meta: { items: [{ text: '', checked: false }] } }));
828
+ requestAnimationFrame(() => {
829
+ const el = this._container.querySelector(`[data-block-id="${blockId}"]`);
830
+ el?.querySelector('.griot-editor-checklist__text')?.focus();
831
+ });
832
+ return;
833
+ }
834
+
574
835
  const doc = updateBlock(this._doc, blockId, patch);
575
836
  this._commit(doc);
576
837
 
@@ -754,7 +1015,102 @@ export class Editor {
754
1015
  }
755
1016
  }
756
1017
 
757
- _undo() {
1018
+ // ── URL paste detection ──────────────────────────────────────────────────────
1019
+
1020
+ _onPaste(blockId, editable, e) {
1021
+ const pasted = e.clipboardData?.getData('text/plain')?.trim();
1022
+ if (!pasted || !/^https?:\/\/[^\s]{4,}$/.test(pasted)) return;
1023
+ const current = editable.textContent.trim();
1024
+ if (current !== '' && current !== pasted) return;
1025
+ const block = getBlock(this._doc, blockId);
1026
+ if (!block) return;
1027
+
1028
+ if (/\.(jpe?g|png|webp|gif|avif|svg|bmp|tiff?)(\?[^#]*)?$/i.test(pasted)) {
1029
+ e.preventDefault();
1030
+ this._commit(updateBlock(this._doc, blockId, { type: 'image', meta: { src: pasted, alt: '', caption: '', width: 'full' } }));
1031
+ return;
1032
+ }
1033
+ const yt = pasted.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/);
1034
+ if (yt) {
1035
+ e.preventDefault();
1036
+ this._commit(updateBlock(this._doc, blockId, { type: 'video', meta: { src: pasted, embedUrl: `https://www.youtube.com/embed/${yt[1]}?rel=0`, caption: '' } }));
1037
+ return;
1038
+ }
1039
+ const vm = pasted.match(/vimeo\.com\/(\d+)/);
1040
+ if (vm) {
1041
+ e.preventDefault();
1042
+ this._commit(updateBlock(this._doc, blockId, { type: 'video', meta: { src: pasted, embedUrl: `https://player.vimeo.com/video/${vm[1]}`, caption: '' } }));
1043
+ return;
1044
+ }
1045
+ const sp = pasted.match(/open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/);
1046
+ if (sp) {
1047
+ e.preventDefault();
1048
+ this._commit(updateBlock(this._doc, blockId, { type: 'audio', meta: { src: pasted, embedUrl: `https://open.spotify.com/embed/${sp[1]}/${sp[2]}`, caption: '' } }));
1049
+ return;
1050
+ }
1051
+ if (pasted.includes('soundcloud.com/')) {
1052
+ e.preventDefault();
1053
+ this._commit(updateBlock(this._doc, blockId, { type: 'audio', meta: { src: pasted, embedUrl: `https://w.soundcloud.com/player/?url=${encodeURIComponent(pasted)}&color=%236366f1&auto_play=false`, caption: '' } }));
1054
+ return;
1055
+ }
1056
+ if (/\.(mp4|webm|mov|ogv)(\?[^#]*)?$/i.test(pasted)) {
1057
+ e.preventDefault();
1058
+ this._commit(updateBlock(this._doc, blockId, { type: 'video', meta: { src: pasted, caption: '' } }));
1059
+ return;
1060
+ }
1061
+ if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?[^#]*)?$/i.test(pasted)) {
1062
+ e.preventDefault();
1063
+ this._commit(updateBlock(this._doc, blockId, { type: 'audio', meta: { src: pasted, caption: '' } }));
1064
+ return;
1065
+ }
1066
+ if (block.type === 'paragraph' && current === '') {
1067
+ e.preventDefault();
1068
+ this._commit(updateBlock(this._doc, blockId, { type: 'embed', meta: { src: pasted, height: 400, caption: '' } }));
1069
+ }
1070
+ }
1071
+
1072
+ // ── Upload helpers ────────────────────────────────────────────────────────────
1073
+
1074
+ async _uploadFiles(files) {
1075
+ if (!files.length) return [];
1076
+ if (typeof this._options.onUpload === 'function') {
1077
+ const results = await Promise.allSettled(files.map(f => this._options.onUpload(f)));
1078
+ return results.filter(r => r.status === 'fulfilled' && r.value).map(r => r.value);
1079
+ }
1080
+ const url = this._options.uploadUrl ?? UPLOAD_URL_DEFAULT;
1081
+ const fd = new FormData();
1082
+ files.forEach(f => fd.append('file', f));
1083
+ try {
1084
+ const res = await fetch(url, { method: 'POST', body: fd });
1085
+ const data = await res.json();
1086
+ if (!res.ok) throw new Error(data?.error ?? 'Upload failed');
1087
+ return (data.files ?? []).filter(f => !f.error);
1088
+ } catch (err) {
1089
+ console.error('[Editor] upload failed:', err);
1090
+ return [];
1091
+ }
1092
+ }
1093
+
1094
+ async _galleryUpload(blockId, files) {
1095
+ const b = getBlock(this._doc, blockId);
1096
+ if (!b) return;
1097
+ const placeholders = files.map(() => ({ _uploading: true }));
1098
+ this._commit(updateBlock(this._doc, blockId, { meta: { items: [...(b.meta?.items ?? []), ...placeholders] } }));
1099
+ try {
1100
+ const results = await this._uploadFiles(files);
1101
+ const b2 = getBlock(this._doc, blockId);
1102
+ if (!b2) return;
1103
+ const cleaned = (b2.meta?.items ?? []).filter(it => !it._uploading);
1104
+ const newItems = results.map(r => ({ src: r.url ?? r.src ?? '', url: r.url ?? r.src ?? '', alt: r.alt_text ?? '', caption: r.caption ?? '' }));
1105
+ this._commit(updateBlock(this._doc, blockId, { meta: { items: [...cleaned, ...newItems] } }));
1106
+ } catch {
1107
+ const b2 = getBlock(this._doc, blockId);
1108
+ if (!b2) return;
1109
+ this._commit(updateBlock(this._doc, blockId, { meta: { items: (b2.meta?.items ?? []).filter(it => !it._uploading) } }));
1110
+ }
1111
+ }
1112
+
1113
+ _undo() {
758
1114
  this._doc = this._history.undo();
759
1115
  this._render();
760
1116
  this._emit();
@@ -765,4 +1121,60 @@ export class Editor {
765
1121
  this._render();
766
1122
  this._emit();
767
1123
  }
1124
+ }
1125
+
1126
+ // ─── Editor style injection ───────────────────────────────────────────────────
1127
+ // Styles for gallery editor UI, columns editor, checklist editor, audio preview.
1128
+ // Injected once; the base editor styles (griot-editor-block, etc.) live in
1129
+ // the project's griot.css.
1130
+
1131
+ let _editorStylesInjected = false;
1132
+ function _injectEditorStyles() {
1133
+ if (_editorStylesInjected || typeof document === 'undefined') return;
1134
+ _editorStylesInjected = true;
1135
+ const s = document.createElement('style');
1136
+ s.id = 'griot-editor-extra-styles';
1137
+ s.textContent = `
1138
+ /* ── Gallery editor ─────────────────────────────────────────────────────── */
1139
+ .griot-editor-gallery__thumbs { display:grid; grid-template-columns:repeat(auto-fill,minmax(110px,1fr)); gap:8px; margin-bottom:10px; }
1140
+ .griot-editor-gallery__thumb { position:relative; border-radius:8px; overflow:hidden; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); }
1141
+ .griot-editor-gallery__thumb img { width:100%; aspect-ratio:4/3; object-fit:cover; display:block; }
1142
+ .griot-editor-gallery__thumb-caption { width:100%; box-sizing:border-box; background:none; border:none; border-top:1px solid rgba(255,255,255,0.08); color:#94a3b8; font-size:11px; padding:4px 6px; font-family:inherit; }
1143
+ .griot-editor-gallery__thumb-caption:focus { outline:none; color:#e2e8f0; }
1144
+ .griot-editor-gallery__thumb-remove { position:absolute; top:4px; right:4px; background:rgba(0,0,0,0.6); border:none; color:#f87171; font-size:13px; width:22px; height:22px; border-radius:50%; cursor:pointer; display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity 0.15s; }
1145
+ .griot-editor-gallery__thumb:hover .griot-editor-gallery__thumb-remove { opacity:1; }
1146
+ .griot-editor-gallery__thumb.is-uploading { display:flex; align-items:center; justify-content:center; min-height:80px; }
1147
+ .griot-editor-gallery__thumb-spinner { width:20px; height:20px; border:2px solid rgba(99,102,241,0.25); border-top-color:#6366f1; border-radius:50%; animation:griotSpin 0.7s linear infinite; }
1148
+ @keyframes griotSpin { to { transform:rotate(360deg); } }
1149
+ .griot-editor-gallery__add-row { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px; align-items:center; }
1150
+ .griot-editor-gallery__layout-row { display:flex; align-items:center; gap:6px; }
1151
+ .griot-editor-gallery__layout-label { font-size:11px; color:#475569; }
1152
+ .griot-editor-gallery__layout-btn { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.10); border-radius:5px; color:#64748b; padding:3px 10px; font-size:12px; cursor:pointer; font-family:inherit; transition:background 0.15s,color 0.15s; }
1153
+ .griot-editor-gallery__layout-btn:hover { background:rgba(99,102,241,0.10); color:#a5b4fc; }
1154
+ .griot-editor-gallery__layout-btn.is-active { background:rgba(99,102,241,0.18); border-color:rgba(99,102,241,0.5); color:#a5b4fc; }
1155
+
1156
+ /* ── Audio editor ───────────────────────────────────────────────────────── */
1157
+ .griot-editor-block__audio-preview { width:100%; height:80px; border:none; display:block; margin-bottom:8px; border-radius:8px; }
1158
+ .griot-editor-block__audio-native { width:100%; display:block; margin-bottom:8px; }
1159
+
1160
+ /* ── Embed editor ───────────────────────────────────────────────────────── */
1161
+ .griot-editor-block__embed-preview { width:100%; display:block; margin-bottom:8px; border-radius:8px; border:1px solid rgba(255,255,255,0.08); }
1162
+
1163
+ /* ── Columns editor ─────────────────────────────────────────────────────── */
1164
+ .griot-editor-columns { display:grid; grid-template-columns:repeat(var(--griot-col-count,2),1fr); gap:12px; margin-bottom:8px; }
1165
+ .griot-editor-columns__col { display:flex; flex-direction:column; gap:4px; }
1166
+ .griot-editor-columns__editable { min-height:60px; padding:8px 10px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.10); border-radius:6px; color:#e2e8f0; font-size:13px; line-height:1.6; outline:none; }
1167
+ .griot-editor-columns__editable:empty::before { content:attr(data-placeholder); color:#334155; pointer-events:none; }
1168
+ .griot-editor-columns__editable:focus { border-color:rgba(99,102,241,0.45); }
1169
+ .griot-editor-columns__preview { font-size:12px; color:#64748b; padding:4px 2px; min-height:0; line-height:1.5; }
1170
+ .griot-editor-columns__preview:empty { display:none; }
1171
+ .griot-editor-columns__controls { display:flex; gap:6px; margin-top:4px; }
1172
+
1173
+ /* ── Checklist editor ───────────────────────────────────────────────────── */
1174
+ .griot-editor-checklist { display:flex; flex-direction:column; gap:4px; }
1175
+ .griot-editor-checklist__row { display:flex; align-items:center; gap:6px; }
1176
+ .griot-editor-checklist__cb { flex-shrink:0; width:15px; height:15px; accent-color:#6366f1; cursor:pointer; }
1177
+ .griot-editor-checklist__text { flex:1; }
1178
+ `;
1179
+ document.head.appendChild(s);
768
1180
  }
@@ -5,6 +5,7 @@
5
5
  // ─────────────────────────────────────────────────────────────────────────────
6
6
 
7
7
  import { tokenizeInline, TOKEN } from './InlineLexer.js';
8
+ import { lightbox } from '../blocks/Lightbox.js';
8
9
 
9
10
  // ── DOM rendering ─────────────────────────────────────────────────────────────
10
11
 
@@ -68,6 +69,8 @@ function _toNode(t, opts) {
68
69
  el.src = t.src;
69
70
  el.alt = t.alt ?? '';
70
71
  el.className = 'griot-inline-img';
72
+ el.style.cursor = 'zoom-in';
73
+ el.addEventListener('click', () => lightbox.open([{ src: t.src, alt: t.alt, caption: t.alt }], 0));
71
74
  return el;
72
75
  }
73
76
  case TOKEN.LINK: {
@@ -119,7 +122,7 @@ function _toHTML(t) {
119
122
  case TOKEN.HIGHLIGHT: return `<mark class="griot-highlight">${escHtml(t.text)}</mark>`;
120
123
  case TOKEN.COLOR_MARK: return `<span class="griot-color-mark" style="color:${escAttr(t.color)}">${escHtml(t.text)}</span>`;
121
124
  case TOKEN.CODE: return `<code class="griot-inline-code">${escHtml(t.code)}</code>`;
122
- case TOKEN.IMAGE: return `<img class="griot-inline-img" src="${escAttr(t.src)}" alt="${escAttr(t.alt ?? '')}">`;
125
+ case TOKEN.IMAGE: return `<img class="griot-inline-img" src="${escAttr(t.src)}" alt="${escAttr(t.alt ?? '')}" style="cursor:zoom-in">`;
123
126
  case TOKEN.LINK: return `<a class="griot-link" href="${escAttr(t.href)}" target="_blank" rel="noopener noreferrer">${escHtml(t.label)}</a>`;
124
127
  case TOKEN.EVENT_REF: return `<button type="button" class="griot-chip griot-chip--event" data-event-id="${escAttr(t.eventId)}"><span class="griot-chip__icon">⏱</span><span class="griot-chip__label">${escHtml(t.label)}</span></button>`;
125
128
  case TOKEN.CITE_REF: return `<button type="button" class="griot-chip griot-chip--cite" data-block-id="${escAttr(t.blockId)}"><span class="griot-chip__icon">📖</span><span class="griot-chip__label">${escHtml(t.label)}</span></button>`;
File without changes