@0m0g1/griot 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +322 -127
- package/package.json +1 -1
- package/src/Griot.js +33 -35
- package/src/blocks/BlockRenderer.js +240 -93
- package/src/blocks/BlockSchema.js +29 -86
- package/src/core/Block.js +42 -45
- package/src/core/Document.js +63 -98
- package/src/core/History.js +17 -42
- package/src/editor/Editor.js +405 -138
- package/src/editor/FormatToolbar.js +138 -0
- package/src/editor/Keyboard.js +92 -106
- package/src/editor/SlashMenu.js +197 -0
- package/src/inline/InlineLexer.js +69 -72
- package/src/inline/InlineRenderer.js +110 -95
package/src/editor/Editor.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// ─── Editor.js ────────────────────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// Block editor. Manages the full editing lifecycle: rendering, keyboard,
|
|
3
|
+
// focus, undo/redo, inline formatting toolbar, and markdown shortcuts.
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
6
|
// const editor = new Editor(containerEl, {
|
|
7
7
|
// doc,
|
|
8
8
|
// books,
|
|
9
|
-
// onChange(doc) {},
|
|
9
|
+
// onChange(doc) {},
|
|
10
10
|
// onEventClick(eventId) {},
|
|
11
11
|
// onCiteClick(blockId) {},
|
|
12
|
-
// onRequestBookPicker(blockId, cb) {},
|
|
12
|
+
// onRequestBookPicker(blockId, cb) {},
|
|
13
13
|
// });
|
|
14
14
|
// editor.destroy();
|
|
15
15
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -24,26 +24,58 @@ import { History } from '../core/History.
|
|
|
24
24
|
import { getBlockDef, getAllTypes, defaultMeta } from '../blocks/BlockSchema.js';
|
|
25
25
|
import {
|
|
26
26
|
attachKeyboardHandler,
|
|
27
|
-
getCursorOffset, setCursorOffset,
|
|
27
|
+
getCursorOffset, getSelectionOffsets, setCursorOffset,
|
|
28
28
|
focusAtEnd, focusAtStart,
|
|
29
|
-
}
|
|
30
|
-
import {
|
|
29
|
+
} from './Keyboard.js';
|
|
30
|
+
import { FormatToolbar } from './FormatToolbar.js';
|
|
31
|
+
import { renderInlineToDOM } from '../inline/InlineRenderer.js';
|
|
31
32
|
|
|
32
33
|
const TYPING_DEBOUNCE_MS = 400;
|
|
33
34
|
|
|
35
|
+
// Block types where Enter inserts a newline instead of splitting the block
|
|
36
|
+
const LIST_TYPES = new Set(['list_ul', 'list_ol']);
|
|
37
|
+
|
|
38
|
+
// Markdown block shortcuts: pattern (anchored) → { type, meta?, stripPrefix }
|
|
39
|
+
const BLOCK_SHORTCUTS = [
|
|
40
|
+
{ re: /^###### /, type: 'heading', meta: { level: 6 }, strip: 7 },
|
|
41
|
+
{ re: /^##### /, type: 'heading', meta: { level: 5 }, strip: 6 },
|
|
42
|
+
{ re: /^#### /, type: 'heading', meta: { level: 4 }, strip: 5 },
|
|
43
|
+
{ re: /^### /, type: 'heading', meta: { level: 3 }, strip: 4 },
|
|
44
|
+
{ re: /^## /, type: 'heading', meta: { level: 2 }, strip: 3 },
|
|
45
|
+
{ re: /^# /, type: 'heading', meta: { level: 1 }, strip: 2 },
|
|
46
|
+
{ re: /^> /, type: 'blockquote', strip: 2 },
|
|
47
|
+
{ re: /^- /, type: 'list_ul', strip: 2 },
|
|
48
|
+
{ re: /^\* /, type: 'list_ul', strip: 2 },
|
|
49
|
+
{ re: /^1\. /, type: 'list_ol', strip: 3 },
|
|
50
|
+
{ re: /^--- /, type: 'divider', strip: 4, clearText: true },
|
|
51
|
+
{ re: /^``` /, type: 'code', strip: 4 },
|
|
52
|
+
{ re: /^```$/, type: 'code', strip: 3 },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// Inline format-key → markdown syntax
|
|
56
|
+
const FORMAT_SYNTAX = { b: '**', i: '*', u: '__' };
|
|
57
|
+
|
|
34
58
|
export class Editor {
|
|
35
59
|
constructor(container, options = {}) {
|
|
36
|
-
this._container
|
|
37
|
-
this._options
|
|
38
|
-
this._history
|
|
39
|
-
this._doc
|
|
40
|
-
this._books
|
|
41
|
-
this._focusedId
|
|
42
|
-
this.
|
|
60
|
+
this._container = container;
|
|
61
|
+
this._options = options;
|
|
62
|
+
this._history = new History(options.doc);
|
|
63
|
+
this._doc = options.doc;
|
|
64
|
+
this._books = options.books ?? [];
|
|
65
|
+
this._focusedId = null;
|
|
66
|
+
this._focusedEl = null; // the active editable element
|
|
67
|
+
this._blockEls = new Map(); // blockId → { wrap, editable, preview }
|
|
43
68
|
this._typingTimer = null;
|
|
44
69
|
|
|
45
70
|
container.classList.add('griot-editor');
|
|
46
71
|
this._render();
|
|
72
|
+
|
|
73
|
+
// Floating format toolbar
|
|
74
|
+
this._toolbar = new FormatToolbar(container, {
|
|
75
|
+
onWrap: (syntax) => this._wrapSelection(syntax),
|
|
76
|
+
onLink: () => this._insertLink(),
|
|
77
|
+
onColor: () => this._insertColor(),
|
|
78
|
+
});
|
|
47
79
|
}
|
|
48
80
|
|
|
49
81
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
@@ -68,6 +100,7 @@ export class Editor {
|
|
|
68
100
|
|
|
69
101
|
destroy() {
|
|
70
102
|
clearTimeout(this._typingTimer);
|
|
103
|
+
this._toolbar.destroy();
|
|
71
104
|
this._container.innerHTML = '';
|
|
72
105
|
this._container.classList.remove('griot-editor');
|
|
73
106
|
this._blockEls.clear();
|
|
@@ -76,101 +109,81 @@ export class Editor {
|
|
|
76
109
|
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
77
110
|
|
|
78
111
|
_render() {
|
|
79
|
-
const container = this._container;
|
|
80
|
-
const doc = this._doc;
|
|
81
|
-
|
|
82
|
-
// Preserve focused block id across re-renders
|
|
83
112
|
const prevFocused = this._focusedId;
|
|
84
113
|
|
|
85
|
-
|
|
114
|
+
this._container.innerHTML = '';
|
|
86
115
|
this._blockEls.clear();
|
|
87
116
|
|
|
88
|
-
for (const block of
|
|
89
|
-
|
|
90
|
-
container.appendChild(wrap);
|
|
117
|
+
for (const block of this._doc.blocks) {
|
|
118
|
+
this._container.appendChild(this._renderBlock(block));
|
|
91
119
|
}
|
|
92
120
|
|
|
93
|
-
// Restore focus
|
|
94
121
|
if (prevFocused && this._blockEls.has(prevFocused)) {
|
|
95
122
|
const els = this._blockEls.get(prevFocused);
|
|
96
|
-
if (els.editable)
|
|
97
|
-
requestAnimationFrame(() => focusAtEnd(els.editable));
|
|
98
|
-
}
|
|
123
|
+
if (els.editable) requestAnimationFrame(() => focusAtEnd(els.editable));
|
|
99
124
|
}
|
|
100
125
|
}
|
|
101
126
|
|
|
102
127
|
_renderBlock(block) {
|
|
103
|
-
const def
|
|
104
|
-
|
|
105
|
-
// ── Outer wrapper ────────────────────────────────────────────
|
|
128
|
+
const def = getBlockDef(block.type);
|
|
106
129
|
const wrap = document.createElement('div');
|
|
107
130
|
wrap.className = 'griot-editor-block';
|
|
108
|
-
wrap.id
|
|
131
|
+
wrap.id = anchorId(block.id);
|
|
109
132
|
wrap.dataset.blockId = block.id;
|
|
110
133
|
wrap.dataset.blockType = block.type;
|
|
111
134
|
|
|
112
|
-
// ── Toolbar ──────────────────────────────────────────────────
|
|
113
135
|
wrap.appendChild(this._buildToolbar(block));
|
|
114
136
|
|
|
115
|
-
// ── Editable area ────────────────────────────────────────────
|
|
116
137
|
let editable = null;
|
|
117
138
|
|
|
118
139
|
if (def.hasText) {
|
|
140
|
+
// ── Callout / callout variants: icon input above editable ──────────────
|
|
141
|
+
if (block.type.startsWith('callout')) {
|
|
142
|
+
wrap.appendChild(this._buildCalloutMeta(block));
|
|
143
|
+
}
|
|
144
|
+
|
|
119
145
|
editable = document.createElement('div');
|
|
120
146
|
editable.contentEditable = 'plaintext-only';
|
|
121
147
|
editable.spellcheck = true;
|
|
122
148
|
editable.className = `griot-editor-block__input griot-input--${block.type}`;
|
|
123
149
|
editable.dataset.placeholder = def.placeholder ?? '';
|
|
124
150
|
|
|
125
|
-
if (block.type === 'heading')
|
|
126
|
-
editable.dataset.level = block.meta?.level ?? 2;
|
|
127
|
-
}
|
|
151
|
+
if (block.type === 'heading') editable.dataset.level = block.meta?.level ?? 2;
|
|
128
152
|
if (block.type === 'code') {
|
|
129
153
|
editable.style.fontFamily = 'monospace';
|
|
130
154
|
editable.style.whiteSpace = 'pre';
|
|
131
155
|
}
|
|
156
|
+
if (LIST_TYPES.has(block.type)) {
|
|
157
|
+
editable.style.whiteSpace = 'pre-wrap';
|
|
158
|
+
}
|
|
132
159
|
|
|
133
160
|
editable.textContent = block.text ?? '';
|
|
134
161
|
|
|
135
|
-
|
|
136
|
-
editable.addEventListener('input', () => {
|
|
137
|
-
const text = editable.textContent;
|
|
138
|
-
const updated = updateBlock(this._doc, block.id, { text });
|
|
139
|
-
this._doc = updated;
|
|
140
|
-
this._history.replace(updated);
|
|
141
|
-
|
|
142
|
-
clearTimeout(this._typingTimer);
|
|
143
|
-
this._typingTimer = setTimeout(() => {
|
|
144
|
-
this._history.push(this._doc);
|
|
145
|
-
this._emit();
|
|
146
|
-
}, TYPING_DEBOUNCE_MS);
|
|
147
|
-
|
|
148
|
-
// Refresh live preview if present
|
|
149
|
-
this._updatePreview(block.id, text);
|
|
150
|
-
});
|
|
151
|
-
|
|
162
|
+
editable.addEventListener('input', () => this._onInput(block.id, editable));
|
|
152
163
|
editable.addEventListener('focus', () => {
|
|
153
164
|
this._focusedId = block.id;
|
|
165
|
+
this._focusedEl = editable;
|
|
154
166
|
wrap.classList.add('is-focused');
|
|
155
167
|
});
|
|
156
168
|
editable.addEventListener('blur', () => {
|
|
157
169
|
wrap.classList.remove('is-focused');
|
|
170
|
+
if (this._focusedEl === editable) this._focusedEl = null;
|
|
158
171
|
});
|
|
159
172
|
|
|
160
173
|
attachKeyboardHandler(editable, block.id, {
|
|
161
|
-
onEnter:
|
|
162
|
-
onBackspaceAtStart:(id) => this._onBackspaceAtStart(id),
|
|
163
|
-
onDeleteAtEnd:
|
|
164
|
-
onTab:
|
|
165
|
-
onArrowUp:
|
|
166
|
-
onArrowDown:
|
|
167
|
-
onUndo:
|
|
168
|
-
onRedo:
|
|
174
|
+
onEnter: (id, offset) => this._onEnter(id, offset),
|
|
175
|
+
onBackspaceAtStart: (id) => this._onBackspaceAtStart(id),
|
|
176
|
+
onDeleteAtEnd: (id) => this._onDeleteAtEnd(id),
|
|
177
|
+
onTab: (id, shift) => this._onTab(id, shift),
|
|
178
|
+
onArrowUp: (id) => this._focusPrev(id),
|
|
179
|
+
onArrowDown: (id) => this._focusNext(id),
|
|
180
|
+
onUndo: () => this._undo(),
|
|
181
|
+
onRedo: () => this._redo(),
|
|
182
|
+
onFormatKey: (key) => this._wrapSelection(FORMAT_SYNTAX[key] ?? ''),
|
|
169
183
|
});
|
|
170
184
|
|
|
171
185
|
wrap.appendChild(editable);
|
|
172
186
|
|
|
173
|
-
// Live inline preview (for paragraph, blockquote, callout)
|
|
174
187
|
if (def.hasInline) {
|
|
175
188
|
const preview = document.createElement('div');
|
|
176
189
|
preview.className = 'griot-editor-block__preview';
|
|
@@ -182,7 +195,6 @@ export class Editor {
|
|
|
182
195
|
}
|
|
183
196
|
|
|
184
197
|
} else {
|
|
185
|
-
// Non-text blocks: render their special UI
|
|
186
198
|
wrap.appendChild(this._buildSpecialBlockUI(block));
|
|
187
199
|
this._blockEls.set(block.id, { wrap, editable: null, preview: null });
|
|
188
200
|
}
|
|
@@ -190,8 +202,9 @@ export class Editor {
|
|
|
190
202
|
return wrap;
|
|
191
203
|
}
|
|
192
204
|
|
|
205
|
+
// ── Toolbar ─────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
193
207
|
_buildToolbar(block) {
|
|
194
|
-
const def = getBlockDef(block.type);
|
|
195
208
|
const bar = document.createElement('div');
|
|
196
209
|
bar.className = 'griot-editor-block__toolbar';
|
|
197
210
|
|
|
@@ -201,14 +214,15 @@ export class Editor {
|
|
|
201
214
|
for (const type of getAllTypes()) {
|
|
202
215
|
const d = getBlockDef(type);
|
|
203
216
|
const opt = document.createElement('option');
|
|
204
|
-
opt.value
|
|
217
|
+
opt.value = type;
|
|
218
|
+
opt.textContent = `${d.icon} ${d.label}`;
|
|
205
219
|
if (type === block.type) opt.selected = true;
|
|
206
220
|
sel.appendChild(opt);
|
|
207
221
|
}
|
|
208
222
|
sel.addEventListener('change', () => this._changeType(block.id, sel.value));
|
|
209
223
|
bar.appendChild(sel);
|
|
210
224
|
|
|
211
|
-
// Heading level selector
|
|
225
|
+
// Heading level selector
|
|
212
226
|
if (block.type === 'heading') {
|
|
213
227
|
const lvlSel = document.createElement('select');
|
|
214
228
|
lvlSel.className = 'griot-editor-block__lvl-sel';
|
|
@@ -219,13 +233,26 @@ export class Editor {
|
|
|
219
233
|
lvlSel.appendChild(opt);
|
|
220
234
|
}
|
|
221
235
|
lvlSel.addEventListener('change', () => {
|
|
222
|
-
|
|
223
|
-
this._commit(doc);
|
|
236
|
+
this._commit(updateBlock(this._doc, block.id, { meta: { level: Number(lvlSel.value) } }));
|
|
224
237
|
});
|
|
225
238
|
bar.appendChild(lvlSel);
|
|
226
239
|
}
|
|
227
240
|
|
|
228
|
-
//
|
|
241
|
+
// Code language selector
|
|
242
|
+
if (block.type === 'code') {
|
|
243
|
+
const langInput = document.createElement('input');
|
|
244
|
+
langInput.type = 'text';
|
|
245
|
+
langInput.className = 'griot-editor-block__lang-input';
|
|
246
|
+
langInput.placeholder = 'language…';
|
|
247
|
+
langInput.value = block.meta?.language ?? '';
|
|
248
|
+
langInput.style.cssText = 'width:90px;';
|
|
249
|
+
langInput.addEventListener('change', () => {
|
|
250
|
+
this._commit(updateBlock(this._doc, block.id, { meta: { language: langInput.value } }));
|
|
251
|
+
});
|
|
252
|
+
bar.appendChild(langInput);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Book picker
|
|
229
256
|
if (block.type === 'book_citation') {
|
|
230
257
|
const btn = document.createElement('button');
|
|
231
258
|
btn.type = 'button';
|
|
@@ -233,53 +260,58 @@ export class Editor {
|
|
|
233
260
|
btn.textContent = block.meta?.bookId ? '✎ Change source' : '📖 Pick source…';
|
|
234
261
|
btn.addEventListener('click', () => {
|
|
235
262
|
this._options.onRequestBookPicker?.(block.id, ({ bookId, unitId, quote, note }) => {
|
|
236
|
-
|
|
237
|
-
meta: { ...block.meta, bookId, unitId, quote, note },
|
|
238
|
-
});
|
|
239
|
-
this._commit(doc);
|
|
263
|
+
this._commit(updateBlock(this._doc, block.id, { meta: { ...block.meta, bookId, unitId, quote, note } }));
|
|
240
264
|
});
|
|
241
265
|
});
|
|
242
266
|
bar.appendChild(btn);
|
|
243
267
|
}
|
|
244
268
|
|
|
245
|
-
//
|
|
269
|
+
// Move / add / delete actions
|
|
246
270
|
const actions = document.createElement('div');
|
|
247
271
|
actions.className = 'griot-editor-block__actions';
|
|
272
|
+
const idx = getBlockIndex(this._doc, block.id);
|
|
273
|
+
const total = this._doc.blocks.length;
|
|
248
274
|
|
|
249
|
-
const mkBtn = (label, title, onClick,
|
|
275
|
+
const mkBtn = (label, title, onClick, disabled = false) => {
|
|
250
276
|
const b = document.createElement('button');
|
|
251
277
|
b.type = 'button'; b.title = title;
|
|
252
|
-
b.className = `griot-editor-block__action-btn
|
|
278
|
+
b.className = `griot-editor-block__action-btn${disabled ? ' is-disabled' : ''}`;
|
|
253
279
|
b.textContent = label;
|
|
254
|
-
b.addEventListener('click', onClick);
|
|
280
|
+
if (!disabled) b.addEventListener('click', onClick);
|
|
255
281
|
return b;
|
|
256
282
|
};
|
|
257
283
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
actions.appendChild(mkBtn('
|
|
262
|
-
actions.appendChild(mkBtn('↓', 'Move down', () => this._move(block.id, 1), idx === total - 1 ? 'is-disabled' : ''));
|
|
263
|
-
actions.appendChild(mkBtn('+', 'Add block below', () => this._addAfter(block.id), 'is-add'));
|
|
264
|
-
actions.appendChild(mkBtn('×', 'Delete block', () => this._delete(block.id), 'is-delete'));
|
|
284
|
+
actions.appendChild(mkBtn('↑', 'Move up', () => this._move(block.id, -1), idx === 0));
|
|
285
|
+
actions.appendChild(mkBtn('↓', 'Move down', () => this._move(block.id, 1), idx === total - 1));
|
|
286
|
+
actions.appendChild(mkBtn('+', 'Add block below', () => this._addAfter(block.id)));
|
|
287
|
+
actions.appendChild(mkBtn('×', 'Delete block', () => this._delete(block.id)));
|
|
265
288
|
|
|
266
289
|
bar.appendChild(actions);
|
|
267
290
|
return bar;
|
|
268
291
|
}
|
|
269
292
|
|
|
293
|
+
// ── Callout meta (icon picker) ───────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
_buildCalloutMeta(block) {
|
|
296
|
+
const row = document.createElement('div');
|
|
297
|
+
row.className = 'griot-editor-block__callout-meta';
|
|
298
|
+
row.appendChild(this._metaInput(block, 'icon', '💡', { style: 'width:46px;text-align:center;font-size:18px' }));
|
|
299
|
+
return row;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Special (non-text) block UIs ─────────────────────────────────────────────
|
|
303
|
+
|
|
270
304
|
_buildSpecialBlockUI(block) {
|
|
271
305
|
const wrap = document.createElement('div');
|
|
272
306
|
wrap.className = 'griot-editor-block__special';
|
|
273
307
|
|
|
274
308
|
switch (block.type) {
|
|
309
|
+
|
|
275
310
|
case 'divider':
|
|
276
311
|
wrap.innerHTML = `<hr class="griot-divider">`;
|
|
277
312
|
break;
|
|
278
313
|
|
|
279
314
|
case 'image': {
|
|
280
|
-
const srcInput = this._metaInput(block, 'src', 'Image URL…', { style: 'flex:2' });
|
|
281
|
-
const altInput = this._metaInput(block, 'alt', 'Alt text…', {});
|
|
282
|
-
const capInput = this._metaInput(block, 'caption', 'Caption…', {});
|
|
283
315
|
if (block.meta?.src) {
|
|
284
316
|
const img = document.createElement('img');
|
|
285
317
|
img.src = block.meta.src;
|
|
@@ -289,23 +321,61 @@ export class Editor {
|
|
|
289
321
|
}
|
|
290
322
|
const row = document.createElement('div');
|
|
291
323
|
row.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
|
|
292
|
-
[
|
|
324
|
+
[
|
|
325
|
+
this._metaInput(block, 'src', 'Image URL…', { style: 'flex:2' }),
|
|
326
|
+
this._metaInput(block, 'alt', 'Alt text…', {}),
|
|
327
|
+
this._metaInput(block, 'caption', 'Caption…', {}),
|
|
328
|
+
].forEach(el => row.appendChild(el));
|
|
329
|
+
wrap.appendChild(row);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
case 'video': {
|
|
334
|
+
const { src = '' } = block.meta ?? {};
|
|
335
|
+
if (src) {
|
|
336
|
+
const yt = src.match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/))([a-zA-Z0-9_-]{11})/);
|
|
337
|
+
const vm = src.match(/vimeo\.com\/(\d+)/);
|
|
338
|
+
if (yt || vm) {
|
|
339
|
+
const embedSrc = yt
|
|
340
|
+
? `https://www.youtube.com/embed/${yt[1]}`
|
|
341
|
+
: `https://player.vimeo.com/video/${vm[1]}`;
|
|
342
|
+
const iframe = document.createElement('iframe');
|
|
343
|
+
iframe.src = embedSrc;
|
|
344
|
+
iframe.className = 'griot-editor-block__video-preview';
|
|
345
|
+
iframe.frameBorder = '0';
|
|
346
|
+
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
|
|
347
|
+
wrap.appendChild(iframe);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const row = document.createElement('div');
|
|
351
|
+
row.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
|
|
352
|
+
[
|
|
353
|
+
this._metaInput(block, 'src', 'YouTube / Vimeo / video URL…', { style: 'flex:2' }),
|
|
354
|
+
this._metaInput(block, 'caption', 'Caption…', {}),
|
|
355
|
+
].forEach(el => row.appendChild(el));
|
|
293
356
|
wrap.appendChild(row);
|
|
294
357
|
break;
|
|
295
358
|
}
|
|
296
359
|
|
|
360
|
+
case 'table': {
|
|
361
|
+
wrap.appendChild(this._buildTableUI(block));
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
|
|
297
365
|
case 'timeline_ref': {
|
|
298
366
|
const row = document.createElement('div');
|
|
299
367
|
row.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
368
|
+
[
|
|
369
|
+
this._metaInput(block, 'eventId', 'Event ID…', { style: 'flex:1;font-family:monospace' }),
|
|
370
|
+
this._metaInput(block, 'eventTitle', 'Display title…', { style: 'flex:2' }),
|
|
371
|
+
this._metaInput(block, 'note', 'Note (inline ok)…', { style: 'flex:3' }),
|
|
372
|
+
].forEach(el => row.appendChild(el));
|
|
303
373
|
wrap.appendChild(row);
|
|
304
374
|
break;
|
|
305
375
|
}
|
|
306
376
|
|
|
307
377
|
case 'book_citation': {
|
|
308
|
-
const { bookId, unitId
|
|
378
|
+
const { bookId, unitId } = block.meta ?? {};
|
|
309
379
|
if (bookId) {
|
|
310
380
|
const book = this._books.find(b => b.id === bookId);
|
|
311
381
|
const unit = book?.units?.find(u => u.id === unitId);
|
|
@@ -314,35 +384,140 @@ export class Editor {
|
|
|
314
384
|
label.textContent = book ? `📖 ${book.title} · ${unit?.label ?? '—'}` : '📖 Book not found';
|
|
315
385
|
wrap.appendChild(label);
|
|
316
386
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
wrap.appendChild(quoteArea);
|
|
320
|
-
wrap.appendChild(noteArea);
|
|
387
|
+
wrap.appendChild(this._metaTextarea(block, 'quote', 'Quoted passage…', { rows: 2 }));
|
|
388
|
+
wrap.appendChild(this._metaTextarea(block, 'note', 'Commentary (inline syntax ok)…', { rows: 2 }));
|
|
321
389
|
break;
|
|
322
390
|
}
|
|
391
|
+
}
|
|
323
392
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
393
|
+
return wrap;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Table editor UI ──────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
_buildTableUI(block) {
|
|
399
|
+
const headers = Array.isArray(block.meta?.headers) ? block.meta.headers : ['Column 1', 'Column 2'];
|
|
400
|
+
const rows = Array.isArray(block.meta?.rows) ? block.meta.rows : [['', '']];
|
|
401
|
+
const colCount = headers.length;
|
|
402
|
+
|
|
403
|
+
const container = document.createElement('div');
|
|
404
|
+
container.className = 'griot-editor-table';
|
|
405
|
+
|
|
406
|
+
const table = document.createElement('table');
|
|
407
|
+
table.className = 'griot-editor-table__grid';
|
|
408
|
+
|
|
409
|
+
// ── Header row ──
|
|
410
|
+
const thead = document.createElement('thead');
|
|
411
|
+
const headerTr = document.createElement('tr');
|
|
412
|
+
|
|
413
|
+
for (let ci = 0; ci < colCount; ci++) {
|
|
414
|
+
const th = document.createElement('th');
|
|
415
|
+
const input = document.createElement('input');
|
|
416
|
+
input.type = 'text';
|
|
417
|
+
input.value = headers[ci] ?? '';
|
|
418
|
+
input.placeholder = `Column ${ci + 1}`;
|
|
419
|
+
input.className = 'griot-editor-table__cell griot-editor-table__cell--header';
|
|
420
|
+
const colIdx = ci;
|
|
421
|
+
input.addEventListener('change', () => {
|
|
422
|
+
const h = [...headers]; h[colIdx] = input.value;
|
|
423
|
+
this._commit(updateBlock(this._doc, block.id, { meta: { headers: h } }));
|
|
424
|
+
});
|
|
425
|
+
th.appendChild(input);
|
|
426
|
+
headerTr.appendChild(th);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Column controls
|
|
430
|
+
const thCtrl = document.createElement('th');
|
|
431
|
+
thCtrl.className = 'griot-editor-table__ctrl-cell';
|
|
432
|
+
const addColBtn = this._mkSmallBtn('+col', 'Add column', () => {
|
|
433
|
+
this._commit(updateBlock(this._doc, block.id, {
|
|
434
|
+
meta: { headers: [...headers, `Column ${colCount + 1}`], rows: rows.map(r => [...r, '']) },
|
|
435
|
+
}));
|
|
436
|
+
});
|
|
437
|
+
thCtrl.appendChild(addColBtn);
|
|
438
|
+
if (colCount > 1) {
|
|
439
|
+
const delColBtn = this._mkSmallBtn('-col', 'Remove last column', () => {
|
|
440
|
+
this._commit(updateBlock(this._doc, block.id, {
|
|
441
|
+
meta: { headers: headers.slice(0, -1), rows: rows.map(r => r.slice(0, -1)) },
|
|
442
|
+
}));
|
|
443
|
+
}, 'is-del');
|
|
444
|
+
thCtrl.appendChild(delColBtn);
|
|
445
|
+
}
|
|
446
|
+
headerTr.appendChild(thCtrl);
|
|
447
|
+
thead.appendChild(headerTr);
|
|
448
|
+
table.appendChild(thead);
|
|
449
|
+
|
|
450
|
+
// ── Data rows ──
|
|
451
|
+
const tbody = document.createElement('tbody');
|
|
452
|
+
|
|
453
|
+
for (let ri = 0; ri < rows.length; ri++) {
|
|
454
|
+
const tr = document.createElement('tr');
|
|
455
|
+
for (let ci = 0; ci < colCount; ci++) {
|
|
456
|
+
const td = document.createElement('td');
|
|
457
|
+
const input = document.createElement('input');
|
|
458
|
+
input.type = 'text';
|
|
459
|
+
input.value = rows[ri][ci] ?? '';
|
|
460
|
+
input.placeholder = '…';
|
|
461
|
+
input.className = 'griot-editor-table__cell';
|
|
462
|
+
const rowIdx = ri, colIdx = ci;
|
|
463
|
+
input.addEventListener('change', () => {
|
|
464
|
+
const r = rows.map(row => [...row]);
|
|
465
|
+
r[rowIdx][colIdx] = input.value;
|
|
466
|
+
this._commit(updateBlock(this._doc, block.id, { meta: { rows: r } }));
|
|
467
|
+
});
|
|
468
|
+
td.appendChild(input);
|
|
469
|
+
tr.appendChild(td);
|
|
328
470
|
}
|
|
471
|
+
// Delete row button
|
|
472
|
+
const tdDel = document.createElement('td');
|
|
473
|
+
const rowIdx = ri;
|
|
474
|
+
tdDel.appendChild(this._mkSmallBtn('×', 'Delete row', () => {
|
|
475
|
+
if (rows.length <= 1) return;
|
|
476
|
+
this._commit(updateBlock(this._doc, block.id, {
|
|
477
|
+
meta: { rows: rows.filter((_, i) => i !== rowIdx) },
|
|
478
|
+
}));
|
|
479
|
+
}, 'is-del'));
|
|
480
|
+
tr.appendChild(tdDel);
|
|
481
|
+
tbody.appendChild(tr);
|
|
329
482
|
}
|
|
330
483
|
|
|
331
|
-
|
|
484
|
+
// Add row
|
|
485
|
+
const addRowTr = document.createElement('tr');
|
|
486
|
+
const addRowTd = document.createElement('td');
|
|
487
|
+
addRowTd.colSpan = colCount + 1;
|
|
488
|
+
addRowTd.appendChild(this._mkSmallBtn('+ Add row', 'Add row', () => {
|
|
489
|
+
this._commit(updateBlock(this._doc, block.id, {
|
|
490
|
+
meta: { rows: [...rows, new Array(colCount).fill('')] },
|
|
491
|
+
}));
|
|
492
|
+
}, 'is-add-row'));
|
|
493
|
+
addRowTr.appendChild(addRowTd);
|
|
494
|
+
tbody.appendChild(addRowTr);
|
|
495
|
+
|
|
496
|
+
table.appendChild(tbody);
|
|
497
|
+
container.appendChild(table);
|
|
498
|
+
return container;
|
|
332
499
|
}
|
|
333
500
|
|
|
334
|
-
|
|
501
|
+
_mkSmallBtn(label, title, onClick, extraClass = '') {
|
|
502
|
+
const b = document.createElement('button');
|
|
503
|
+
b.type = 'button'; b.title = title;
|
|
504
|
+
b.className = `griot-editor-table__btn ${extraClass}`.trim();
|
|
505
|
+
b.textContent = label;
|
|
506
|
+
b.addEventListener('click', onClick);
|
|
507
|
+
return b;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ── Meta input helpers ───────────────────────────────────────────────────────
|
|
335
511
|
|
|
336
|
-
_metaInput(block, key, placeholder,
|
|
512
|
+
_metaInput(block, key, placeholder, { style = '' } = {}) {
|
|
337
513
|
const el = document.createElement('input');
|
|
338
514
|
el.type = 'text';
|
|
339
515
|
el.className = 'griot-editor-block__meta-input';
|
|
340
516
|
el.placeholder = placeholder;
|
|
341
517
|
el.value = block.meta?.[key] ?? '';
|
|
342
|
-
if (
|
|
518
|
+
if (style) el.style.cssText = style;
|
|
343
519
|
el.addEventListener('input', () => {
|
|
344
|
-
|
|
345
|
-
this._commit(doc);
|
|
520
|
+
this._commit(updateBlock(this._doc, block.id, { meta: { [key]: el.value } }));
|
|
346
521
|
});
|
|
347
522
|
return el;
|
|
348
523
|
}
|
|
@@ -354,13 +529,12 @@ export class Editor {
|
|
|
354
529
|
el.placeholder = placeholder;
|
|
355
530
|
el.value = block.meta?.[key] ?? '';
|
|
356
531
|
el.addEventListener('input', () => {
|
|
357
|
-
|
|
358
|
-
this._commit(doc);
|
|
532
|
+
this._commit(updateBlock(this._doc, block.id, { meta: { [key]: el.value } }));
|
|
359
533
|
});
|
|
360
534
|
return el;
|
|
361
535
|
}
|
|
362
536
|
|
|
363
|
-
//
|
|
537
|
+
// ── Live preview ─────────────────────────────────────────────────────────────
|
|
364
538
|
|
|
365
539
|
_updatePreview(blockId, text, previewEl) {
|
|
366
540
|
const el = previewEl ?? this._blockEls.get(blockId)?.preview;
|
|
@@ -374,7 +548,94 @@ export class Editor {
|
|
|
374
548
|
}));
|
|
375
549
|
}
|
|
376
550
|
|
|
377
|
-
//
|
|
551
|
+
// ── Input handler (typing + markdown shortcuts) ───────────────────────────────
|
|
552
|
+
|
|
553
|
+
_onInput(blockId, editable) {
|
|
554
|
+
const text = editable.textContent;
|
|
555
|
+
|
|
556
|
+
// ── Markdown block shortcuts ────────────────────────────────────────────
|
|
557
|
+
for (const sc of BLOCK_SHORTCUTS) {
|
|
558
|
+
if (!sc.re.test(text)) continue;
|
|
559
|
+
|
|
560
|
+
const newText = sc.clearText ? '' : text.slice(sc.strip);
|
|
561
|
+
const patch = { type: sc.type, meta: { ...defaultMeta(sc.type), ...(sc.meta ?? {}) } };
|
|
562
|
+
if (sc.clearText || sc.type !== 'divider') patch.text = newText;
|
|
563
|
+
|
|
564
|
+
const doc = updateBlock(this._doc, blockId, patch);
|
|
565
|
+
this._commit(doc);
|
|
566
|
+
|
|
567
|
+
// Restore cursor after re-render
|
|
568
|
+
requestAnimationFrame(() => {
|
|
569
|
+
const els = this._blockEls.get(blockId);
|
|
570
|
+
if (els?.editable) focusAtStart(els.editable);
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ── Normal typing ───────────────────────────────────────────────────────
|
|
576
|
+
const updated = updateBlock(this._doc, blockId, { text });
|
|
577
|
+
this._doc = updated;
|
|
578
|
+
this._history.replace(updated);
|
|
579
|
+
|
|
580
|
+
clearTimeout(this._typingTimer);
|
|
581
|
+
this._typingTimer = setTimeout(() => {
|
|
582
|
+
this._history.push(this._doc);
|
|
583
|
+
this._emit();
|
|
584
|
+
}, TYPING_DEBOUNCE_MS);
|
|
585
|
+
|
|
586
|
+
this._updatePreview(blockId, text);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── Inline formatting ─────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
_wrapSelection(syntax) {
|
|
592
|
+
const el = this._focusedEl;
|
|
593
|
+
if (!el || !syntax) return;
|
|
594
|
+
|
|
595
|
+
const { start, end } = getSelectionOffsets(el);
|
|
596
|
+
const text = el.textContent;
|
|
597
|
+
const selected = text.slice(start, end);
|
|
598
|
+
if (!selected) return;
|
|
599
|
+
|
|
600
|
+
const newText = text.slice(0, start) + syntax + selected + syntax + text.slice(end);
|
|
601
|
+
el.textContent = newText;
|
|
602
|
+
setCursorOffset(el, end + syntax.length * 2);
|
|
603
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
_insertLink() {
|
|
607
|
+
const el = this._focusedEl;
|
|
608
|
+
if (!el) return;
|
|
609
|
+
const { start, end } = getSelectionOffsets(el);
|
|
610
|
+
const text = el.textContent;
|
|
611
|
+
const selected = text.slice(start, end).trim();
|
|
612
|
+
const url = window.prompt('Link URL:', 'https://');
|
|
613
|
+
if (!url) return;
|
|
614
|
+
const label = selected || 'Link';
|
|
615
|
+
const md = `[${label}](${url})`;
|
|
616
|
+
const newText = text.slice(0, start) + md + text.slice(end);
|
|
617
|
+
el.textContent = newText;
|
|
618
|
+
setCursorOffset(el, start + md.length);
|
|
619
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
_insertColor() {
|
|
623
|
+
const el = this._focusedEl;
|
|
624
|
+
if (!el) return;
|
|
625
|
+
const { start, end } = getSelectionOffsets(el);
|
|
626
|
+
const text = el.textContent;
|
|
627
|
+
const selected = text.slice(start, end).trim();
|
|
628
|
+
if (!selected) return;
|
|
629
|
+
const color = window.prompt('Color (hex or name, e.g. #e05 or tomato):', '#e05');
|
|
630
|
+
if (!color) return;
|
|
631
|
+
const md = `{${color}:${selected}}`;
|
|
632
|
+
const newText = text.slice(0, start) + md + text.slice(end);
|
|
633
|
+
el.textContent = newText;
|
|
634
|
+
setCursorOffset(el, start + md.length);
|
|
635
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ── Mutations ─────────────────────────────────────────────────────────────────
|
|
378
639
|
|
|
379
640
|
_commit(doc) {
|
|
380
641
|
this._doc = doc;
|
|
@@ -388,10 +649,10 @@ export class Editor {
|
|
|
388
649
|
}
|
|
389
650
|
|
|
390
651
|
_changeType(blockId, type) {
|
|
391
|
-
const block
|
|
392
|
-
const patch
|
|
393
|
-
if (getBlockDef(type).hasText
|
|
394
|
-
if (!getBlockDef(type).hasText) patch.text = null;
|
|
652
|
+
const block = this._doc.blocks.find(b => b.id === blockId);
|
|
653
|
+
const patch = { type, meta: defaultMeta(type) };
|
|
654
|
+
if (getBlockDef(type).hasText && block?.text === null) patch.text = '';
|
|
655
|
+
if (!getBlockDef(type).hasText && block?.text !== null) patch.text = null;
|
|
395
656
|
this._commit(updateBlock(this._doc, blockId, patch));
|
|
396
657
|
}
|
|
397
658
|
|
|
@@ -407,35 +668,45 @@ export class Editor {
|
|
|
407
668
|
|
|
408
669
|
_delete(blockId) {
|
|
409
670
|
if (this._doc.blocks.length <= 1) return;
|
|
410
|
-
const
|
|
671
|
+
const prev = getBlockBefore(this._doc, blockId);
|
|
411
672
|
this._commit(removeBlock(this._doc, blockId));
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
});
|
|
417
|
-
}
|
|
673
|
+
if (prev) requestAnimationFrame(() => {
|
|
674
|
+
const els = this._blockEls.get(prev.id);
|
|
675
|
+
if (els?.editable) focusAtEnd(els.editable);
|
|
676
|
+
});
|
|
418
677
|
}
|
|
419
678
|
|
|
420
679
|
_move(blockId, direction) {
|
|
421
|
-
const idx
|
|
422
|
-
if (idx < 0) return;
|
|
680
|
+
const idx = getBlockIndex(this._doc, blockId);
|
|
423
681
|
const toIdx = idx + direction;
|
|
424
682
|
if (toIdx < 0 || toIdx >= this._doc.blocks.length) return;
|
|
425
683
|
this._commit(moveBlock(this._doc, idx, toIdx));
|
|
426
684
|
}
|
|
427
685
|
|
|
428
|
-
//
|
|
686
|
+
// ── Keyboard actions ──────────────────────────────────────────────────────────
|
|
429
687
|
|
|
430
688
|
_onEnter(blockId, offset) {
|
|
689
|
+
const block = this._doc.blocks.find(b => b.id === blockId);
|
|
690
|
+
|
|
691
|
+
// In list blocks, Enter inserts a newline (new list item)
|
|
692
|
+
if (block && LIST_TYPES.has(block.type)) {
|
|
693
|
+
const el = this._blockEls.get(blockId)?.editable;
|
|
694
|
+
if (el) {
|
|
695
|
+
const text = el.textContent;
|
|
696
|
+
const newText = text.slice(0, offset) + '\n' + text.slice(offset);
|
|
697
|
+
el.textContent = newText;
|
|
698
|
+
setCursorOffset(el, offset + 1);
|
|
699
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
431
704
|
const [doc, newId] = splitBlock(this._doc, blockId, offset);
|
|
432
705
|
this._commit(doc);
|
|
433
|
-
if (newId) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
});
|
|
438
|
-
}
|
|
706
|
+
if (newId) requestAnimationFrame(() => {
|
|
707
|
+
const els = this._blockEls.get(newId);
|
|
708
|
+
if (els?.editable) focusAtStart(els.editable);
|
|
709
|
+
});
|
|
439
710
|
}
|
|
440
711
|
|
|
441
712
|
_onBackspaceAtStart(blockId) {
|
|
@@ -444,21 +715,17 @@ export class Editor {
|
|
|
444
715
|
this._commit(doc);
|
|
445
716
|
requestAnimationFrame(() => {
|
|
446
717
|
const els = this._blockEls.get(prevId);
|
|
447
|
-
if (els?.editable) {
|
|
448
|
-
els.editable.focus();
|
|
449
|
-
setCursorOffset(els.editable, mergeOffset);
|
|
450
|
-
}
|
|
718
|
+
if (els?.editable) { els.editable.focus(); setCursorOffset(els.editable, mergeOffset); }
|
|
451
719
|
});
|
|
452
720
|
}
|
|
453
721
|
|
|
454
722
|
_onDeleteAtEnd(blockId) {
|
|
455
|
-
const
|
|
456
|
-
if (
|
|
457
|
-
this._onBackspaceAtStart(nextBlock.id);
|
|
723
|
+
const next = getBlockAfter(this._doc, blockId);
|
|
724
|
+
if (next) this._onBackspaceAtStart(next.id);
|
|
458
725
|
}
|
|
459
726
|
|
|
460
727
|
_onTab(blockId, isShift) {
|
|
461
|
-
//
|
|
728
|
+
// Future: indent/outdent list items
|
|
462
729
|
}
|
|
463
730
|
|
|
464
731
|
_focusPrev(blockId) {
|
|
@@ -488,4 +755,4 @@ export class Editor {
|
|
|
488
755
|
this._render();
|
|
489
756
|
this._emit();
|
|
490
757
|
}
|
|
491
|
-
}
|
|
758
|
+
}
|