@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.
@@ -1,15 +1,15 @@
1
1
  // ─── Editor.js ────────────────────────────────────────────────────────────────
2
- // The block editor. Mounts into a container element and manages the full
3
- // editing lifecycle: rendering, keyboard, focus, undo/redo.
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) {}, // called after every change (debounced for typing)
9
+ // onChange(doc) {},
10
10
  // onEventClick(eventId) {},
11
11
  // onCiteClick(blockId) {},
12
- // onRequestBookPicker(blockId, cb) {}, // open your SourcePicker UI
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
- } from './Keyboard.js';
30
- import { renderInlineToDOM } from '../inline/InlineRenderer.js';
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 = container;
37
- this._options = options;
38
- this._history = new History(options.doc);
39
- this._doc = options.doc;
40
- this._books = options.books ?? [];
41
- this._focusedId = null;
42
- this._blockEls = new Map(); // blockId { wrap, editable, preview }
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
- container.innerHTML = '';
114
+ this._container.innerHTML = '';
86
115
  this._blockEls.clear();
87
116
 
88
- for (const block of doc.blocks) {
89
- const wrap = this._renderBlock(block);
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 = getBlockDef(block.type);
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 = anchorId(block.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
- // Typing update doc (debounced history push)
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: (id, offset) => this._onEnter(id, offset),
162
- onBackspaceAtStart:(id) => this._onBackspaceAtStart(id),
163
- onDeleteAtEnd: (id) => this._onDeleteAtEnd(id),
164
- onTab: (id, shift) => this._onTab(id, shift),
165
- onArrowUp: (id) => this._focusPrev(id),
166
- onArrowDown: (id) => this._focusNext(id),
167
- onUndo: () => this._undo(),
168
- onRedo: () => this._redo(),
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 = type; opt.textContent = `${d.icon} ${d.label}`;
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 (only for heading type)
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
- const doc = updateBlock(this._doc, block.id, { meta: { level: Number(lvlSel.value) } });
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
- // Book picker button (only for book_citation)
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
- const doc = updateBlock(this._doc, block.id, {
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
- // Action buttons
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, extraClass = '') => {
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 ${extraClass}`.trim();
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
- const idx = getBlockIndex(this._doc, block.id);
259
- const total = this._doc.blocks.length;
260
-
261
- actions.appendChild(mkBtn('', 'Move up', () => this._move(block.id, -1), idx === 0 ? 'is-disabled' : ''));
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
- [srcInput, altInput, capInput].forEach(el => row.appendChild(el));
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
- row.appendChild(this._metaInput(block, 'eventId', 'Event ID…', { style: 'flex:1;font-family:monospace' }));
301
- row.appendChild(this._metaInput(block, 'eventTitle', 'Display title…', { style: 'flex:2' }));
302
- row.appendChild(this._metaInput(block, 'note', 'Note (inline ok)…', { style: 'flex:3' }));
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, quote = '', note = '' } = block.meta ?? {};
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
- const quoteArea = this._metaTextarea(block, 'quote', 'Quoted passage…', { rows: 2 });
318
- const noteArea = this._metaTextarea(block, 'note', 'Commentary (inline syntax ok)…', { rows: 2 });
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
- case 'callout': {
325
- const iconInput = this._metaInput(block, 'icon', '💡', { style: 'width:46px;text-align:center;font-size:18px' });
326
- wrap.appendChild(iconInput);
327
- break;
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
- return wrap;
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
- // ─── Meta input helpers ─────────────────────────────────────────────────────
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, attrs = {}) {
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 (attrs.style) el.style.cssText = attrs.style;
518
+ if (style) el.style.cssText = style;
343
519
  el.addEventListener('input', () => {
344
- const doc = updateBlock(this._doc, block.id, { meta: { [key]: el.value } });
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
- const doc = updateBlock(this._doc, block.id, { meta: { [key]: el.value } });
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
- // ─── Live preview ────────────────────────────────────────────────────────────
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
- // ─── Mutations ───────────────────────────────────────────────────────────────
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 = this._doc.blocks.find(b => b.id === blockId);
392
- const patch = { type, meta: defaultMeta(type) };
393
- if (getBlockDef(type).hasText && block?.text === null) patch.text = '';
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 prevBlock = getBlockBefore(this._doc, blockId);
671
+ const prev = getBlockBefore(this._doc, blockId);
411
672
  this._commit(removeBlock(this._doc, blockId));
412
- if (prevBlock) {
413
- requestAnimationFrame(() => {
414
- const els = this._blockEls.get(prevBlock.id);
415
- if (els?.editable) focusAtEnd(els.editable);
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 = getBlockIndex(this._doc, blockId);
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
- // ─── Keyboard actions ────────────────────────────────────────────────────────
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
- requestAnimationFrame(() => {
435
- const els = this._blockEls.get(newId);
436
- if (els?.editable) focusAtStart(els.editable);
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 nextBlock = getBlockAfter(this._doc, blockId);
456
- if (!nextBlock) return;
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
- // Placeholder: indent/outdent for list blocks in future
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
+ }