@0m0g1/griot 0.1.9 → 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 +1 -1
- package/src/blocks/BlockRenderer.js +82 -38
- package/src/blocks/BlockSchema.js +21 -19
- package/src/editor/Editor.js +439 -27
- package/src/inline/InlineRenderer.js +4 -1
package/package.json
CHANGED
|
@@ -8,15 +8,16 @@
|
|
|
8
8
|
// onCiteClick — (blockId) => void
|
|
9
9
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
10
|
|
|
11
|
-
import { anchorId }
|
|
11
|
+
import { anchorId } from '../core/Block.js';
|
|
12
12
|
import { renderInlineToDOM, renderInlineToHTML, escHtml, escAttr } from '../inline/InlineRenderer.js';
|
|
13
|
-
import { getBlockDef }
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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',
|
|
8
|
-
heading: { category:'text',
|
|
9
|
-
blockquote: { category:'text',
|
|
10
|
-
callout: { category:'text',
|
|
11
|
-
callout_warning: { category:'text',
|
|
12
|
-
callout_tip: { category:'text',
|
|
13
|
-
callout_danger: { category:'text',
|
|
14
|
-
code: { category:'text',
|
|
15
|
-
list_ul: { category:'text',
|
|
16
|
-
list_ol: { category:'text',
|
|
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',
|
|
19
|
-
video: { category:'media',
|
|
20
|
-
audio: { category:'media',
|
|
21
|
-
gallery: { category:'media',
|
|
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',
|
|
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',
|
|
26
|
-
|
|
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:'⏱',
|
|
29
|
-
book_citation: { category:'structure', label:'Book Citation',
|
|
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; }
|
package/src/editor/Editor.js
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
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
|
-
|
|
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…',
|
|
398
|
-
wrap.appendChild(this._metaTextarea(block, 'note',
|
|
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
|
-
|
|
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>`;
|