@0m0g1/griot 0.1.0
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 +179 -0
- package/package.json +17 -0
- package/src/Griot.js +54 -0
- package/src/blocks/BlockRenderer.js +201 -0
- package/src/blocks/BlockSchema.js +94 -0
- package/src/core/Block.js +63 -0
- package/src/core/Document.js +128 -0
- package/src/core/History.js +58 -0
- package/src/editor/Editor.js +491 -0
- package/src/editor/Keyboard.js +169 -0
- package/src/inline/InlineLexer.js +90 -0
- package/src/inline/InlineRenderer.js +128 -0
- package/src/viewer/Viewer.js +92 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ─── Document.js ──────────────────────────────────────────────────────────────
|
|
2
|
+
// An ordered list of blocks with CRUD operations.
|
|
3
|
+
// All mutating methods return a NEW document (immutable updates) so History
|
|
4
|
+
// can cheaply snapshot the state.
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
import { createBlock, cloneBlock, isValidBlock } from './Block.js';
|
|
8
|
+
|
|
9
|
+
let _docSeq = 0;
|
|
10
|
+
const docUid = () => `doc_${Date.now()}_${(++_docSeq).toString(36)}`;
|
|
11
|
+
|
|
12
|
+
// ─── Factory ──────────────────────────────────────────────────────────────────
|
|
13
|
+
export function createDocument(title = 'Untitled', blocks = null) {
|
|
14
|
+
return {
|
|
15
|
+
id: docUid(),
|
|
16
|
+
version: 1,
|
|
17
|
+
title,
|
|
18
|
+
createdAt: new Date().toISOString(),
|
|
19
|
+
updatedAt: new Date().toISOString(),
|
|
20
|
+
blocks: blocks ?? [createBlock('heading', { text: title, meta: { level: 1 } })],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
25
|
+
function touch(doc) {
|
|
26
|
+
return { ...doc, updatedAt: new Date().toISOString() };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function withBlocks(doc, blocks) {
|
|
30
|
+
return touch({ ...doc, blocks });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Reads ────────────────────────────────────────────────────────────────────
|
|
34
|
+
export function getBlock(doc, blockId) {
|
|
35
|
+
return doc.blocks.find(b => b.id === blockId) ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getBlockIndex(doc, blockId) {
|
|
39
|
+
return doc.blocks.findIndex(b => b.id === blockId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getBlockAfter(doc, blockId) {
|
|
43
|
+
const i = getBlockIndex(doc, blockId);
|
|
44
|
+
return i >= 0 && i < doc.blocks.length - 1 ? doc.blocks[i + 1] : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getBlockBefore(doc, blockId) {
|
|
48
|
+
const i = getBlockIndex(doc, blockId);
|
|
49
|
+
return i > 0 ? doc.blocks[i - 1] : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Writes ───────────────────────────────────────────────────────────────────
|
|
53
|
+
export function updateBlock(doc, blockId, patch) {
|
|
54
|
+
return withBlocks(doc, doc.blocks.map(b =>
|
|
55
|
+
b.id === blockId ? { ...b, ...patch, meta: { ...b.meta, ...(patch.meta ?? {}) } } : b
|
|
56
|
+
));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function insertBlockAfter(doc, blockId, newBlock) {
|
|
60
|
+
const i = getBlockIndex(doc, blockId);
|
|
61
|
+
if (i < 0) return withBlocks(doc, [...doc.blocks, newBlock]);
|
|
62
|
+
const next = [...doc.blocks];
|
|
63
|
+
next.splice(i + 1, 0, newBlock);
|
|
64
|
+
return withBlocks(doc, next);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function insertBlockBefore(doc, blockId, newBlock) {
|
|
68
|
+
const i = getBlockIndex(doc, blockId);
|
|
69
|
+
if (i < 0) return withBlocks(doc, [newBlock, ...doc.blocks]);
|
|
70
|
+
const next = [...doc.blocks];
|
|
71
|
+
next.splice(i, 0, newBlock);
|
|
72
|
+
return withBlocks(doc, next);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function removeBlock(doc, blockId) {
|
|
76
|
+
if (doc.blocks.length <= 1) return doc; // never empty
|
|
77
|
+
return withBlocks(doc, doc.blocks.filter(b => b.id !== blockId));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function moveBlock(doc, fromIndex, toIndex) {
|
|
81
|
+
if (fromIndex === toIndex) return doc;
|
|
82
|
+
const next = [...doc.blocks];
|
|
83
|
+
const [moved] = next.splice(fromIndex, 1);
|
|
84
|
+
next.splice(toIndex, 0, moved);
|
|
85
|
+
return withBlocks(doc, next);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Split a text block at a cursor offset — returns [docWithSplit, newBlockId]
|
|
89
|
+
export function splitBlock(doc, blockId, offset) {
|
|
90
|
+
const block = getBlock(doc, blockId);
|
|
91
|
+
if (!block || block.text === null) return [doc, null];
|
|
92
|
+
|
|
93
|
+
const before = block.text.slice(0, offset);
|
|
94
|
+
const after = block.text.slice(offset);
|
|
95
|
+
|
|
96
|
+
const newBlock = createBlock('paragraph', { text: after });
|
|
97
|
+
const updated = updateBlock(doc, blockId, { text: before });
|
|
98
|
+
const final = insertBlockAfter(updated, blockId, newBlock);
|
|
99
|
+
|
|
100
|
+
return [final, newBlock.id];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Merge block into the one before it — returns [docWithMerge, prevBlockId, mergeOffset]
|
|
104
|
+
export function mergeBlockWithPrev(doc, blockId) {
|
|
105
|
+
const prev = getBlockBefore(doc, blockId);
|
|
106
|
+
const curr = getBlock(doc, blockId);
|
|
107
|
+
if (!prev || !curr) return [doc, null, 0];
|
|
108
|
+
if (prev.text === null || curr.text === null) return [doc, null, 0];
|
|
109
|
+
|
|
110
|
+
const mergeOffset = prev.text.length;
|
|
111
|
+
const merged = updateBlock(doc, prev.id, { text: prev.text + curr.text });
|
|
112
|
+
const removed = removeBlock(merged, blockId);
|
|
113
|
+
|
|
114
|
+
return [removed, prev.id, mergeOffset];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Serialise / deserialise ──────────────────────────────────────────────────
|
|
118
|
+
export function toJSON(doc) {
|
|
119
|
+
return JSON.stringify(doc, null, 2);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function fromJSON(json) {
|
|
123
|
+
const data = typeof json === 'string' ? JSON.parse(json) : json;
|
|
124
|
+
if (!data?.blocks || !Array.isArray(data.blocks)) {
|
|
125
|
+
throw new Error('[Griot] fromJSON: invalid document — missing blocks array');
|
|
126
|
+
}
|
|
127
|
+
return data;
|
|
128
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ─── History.js ───────────────────────────────────────────────────────────────
|
|
2
|
+
// Linear undo/redo stack.
|
|
3
|
+
// Works with immutable document snapshots — just push the whole doc each time.
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export class History {
|
|
7
|
+
constructor(initialDoc, { maxDepth = 100 } = {}) {
|
|
8
|
+
this._stack = [initialDoc];
|
|
9
|
+
this._cursor = 0;
|
|
10
|
+
this._maxDepth = maxDepth;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Current document
|
|
14
|
+
get current() { return this._stack[this._cursor]; }
|
|
15
|
+
|
|
16
|
+
get canUndo() { return this._cursor > 0; }
|
|
17
|
+
get canRedo() { return this._cursor < this._stack.length - 1; }
|
|
18
|
+
|
|
19
|
+
// Push a new state (truncates any redo branch)
|
|
20
|
+
push(doc) {
|
|
21
|
+
// Truncate future if we're not at the top
|
|
22
|
+
this._stack = this._stack.slice(0, this._cursor + 1);
|
|
23
|
+
this._stack.push(doc);
|
|
24
|
+
|
|
25
|
+
// Enforce max depth
|
|
26
|
+
if (this._stack.length > this._maxDepth) {
|
|
27
|
+
this._stack = this._stack.slice(this._stack.length - this._maxDepth);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this._cursor = this._stack.length - 1;
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
undo() {
|
|
35
|
+
if (!this.canUndo) return this.current;
|
|
36
|
+
this._cursor--;
|
|
37
|
+
return this.current;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
redo() {
|
|
41
|
+
if (!this.canRedo) return this.current;
|
|
42
|
+
this._cursor++;
|
|
43
|
+
return this.current;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Replace current snapshot without branching (for transient changes
|
|
47
|
+
// like typing individual characters — debounce the push externally)
|
|
48
|
+
replace(doc) {
|
|
49
|
+
this._stack[this._cursor] = doc;
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
clear(doc) {
|
|
54
|
+
this._stack = [doc];
|
|
55
|
+
this._cursor = 0;
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// ─── Editor.js ────────────────────────────────────────────────────────────────
|
|
2
|
+
// The block editor. Mounts into a container element and manages the full
|
|
3
|
+
// editing lifecycle: rendering, keyboard, focus, undo/redo.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// const editor = new Editor(containerEl, {
|
|
7
|
+
// doc,
|
|
8
|
+
// books,
|
|
9
|
+
// onChange(doc) {}, // called after every change (debounced for typing)
|
|
10
|
+
// onEventClick(eventId) {},
|
|
11
|
+
// onCiteClick(blockId) {},
|
|
12
|
+
// onRequestBookPicker(blockId, cb) {}, // open your SourcePicker UI
|
|
13
|
+
// });
|
|
14
|
+
// editor.destroy();
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
import { createBlock, isTextBlock, anchorId } from '../core/Block.js';
|
|
18
|
+
import {
|
|
19
|
+
updateBlock, splitBlock, mergeBlockWithPrev,
|
|
20
|
+
insertBlockAfter, removeBlock, getBlockIndex,
|
|
21
|
+
getBlockBefore, getBlockAfter, moveBlock,
|
|
22
|
+
} from '../core/Document.js';
|
|
23
|
+
import { History } from '../core/History.js';
|
|
24
|
+
import { getBlockDef, getAllTypes, defaultMeta } from '../blocks/BlockSchema.js';
|
|
25
|
+
import {
|
|
26
|
+
attachKeyboardHandler,
|
|
27
|
+
getCursorOffset, setCursorOffset,
|
|
28
|
+
focusAtEnd, focusAtStart,
|
|
29
|
+
} from './Keyboard.js';
|
|
30
|
+
import { renderInlineToDOM } from '../inline/InlineRenderer.js';
|
|
31
|
+
|
|
32
|
+
const TYPING_DEBOUNCE_MS = 400;
|
|
33
|
+
|
|
34
|
+
export class Editor {
|
|
35
|
+
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 }
|
|
43
|
+
this._typingTimer = null;
|
|
44
|
+
|
|
45
|
+
container.classList.add('griot-editor');
|
|
46
|
+
this._render();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
get doc() { return this._doc; }
|
|
52
|
+
|
|
53
|
+
setDoc(doc) {
|
|
54
|
+
this._history.push(doc);
|
|
55
|
+
this._doc = doc;
|
|
56
|
+
this._render();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setBooks(books) {
|
|
60
|
+
this._books = books;
|
|
61
|
+
this._render();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
focus(blockId) {
|
|
65
|
+
const els = this._blockEls.get(blockId);
|
|
66
|
+
if (els?.editable) focusAtEnd(els.editable);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
destroy() {
|
|
70
|
+
clearTimeout(this._typingTimer);
|
|
71
|
+
this._container.innerHTML = '';
|
|
72
|
+
this._container.classList.remove('griot-editor');
|
|
73
|
+
this._blockEls.clear();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
_render() {
|
|
79
|
+
const container = this._container;
|
|
80
|
+
const doc = this._doc;
|
|
81
|
+
|
|
82
|
+
// Preserve focused block id across re-renders
|
|
83
|
+
const prevFocused = this._focusedId;
|
|
84
|
+
|
|
85
|
+
container.innerHTML = '';
|
|
86
|
+
this._blockEls.clear();
|
|
87
|
+
|
|
88
|
+
for (const block of doc.blocks) {
|
|
89
|
+
const wrap = this._renderBlock(block);
|
|
90
|
+
container.appendChild(wrap);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Restore focus
|
|
94
|
+
if (prevFocused && this._blockEls.has(prevFocused)) {
|
|
95
|
+
const els = this._blockEls.get(prevFocused);
|
|
96
|
+
if (els.editable) {
|
|
97
|
+
requestAnimationFrame(() => focusAtEnd(els.editable));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_renderBlock(block) {
|
|
103
|
+
const def = getBlockDef(block.type);
|
|
104
|
+
|
|
105
|
+
// ── Outer wrapper ────────────────────────────────────────────
|
|
106
|
+
const wrap = document.createElement('div');
|
|
107
|
+
wrap.className = 'griot-editor-block';
|
|
108
|
+
wrap.id = anchorId(block.id);
|
|
109
|
+
wrap.dataset.blockId = block.id;
|
|
110
|
+
wrap.dataset.blockType = block.type;
|
|
111
|
+
|
|
112
|
+
// ── Toolbar ──────────────────────────────────────────────────
|
|
113
|
+
wrap.appendChild(this._buildToolbar(block));
|
|
114
|
+
|
|
115
|
+
// ── Editable area ────────────────────────────────────────────
|
|
116
|
+
let editable = null;
|
|
117
|
+
|
|
118
|
+
if (def.hasText) {
|
|
119
|
+
editable = document.createElement('div');
|
|
120
|
+
editable.contentEditable = 'plaintext-only';
|
|
121
|
+
editable.spellcheck = true;
|
|
122
|
+
editable.className = `griot-editor-block__input griot-input--${block.type}`;
|
|
123
|
+
editable.dataset.placeholder = def.placeholder ?? '';
|
|
124
|
+
|
|
125
|
+
if (block.type === 'heading') {
|
|
126
|
+
editable.dataset.level = block.meta?.level ?? 2;
|
|
127
|
+
}
|
|
128
|
+
if (block.type === 'code') {
|
|
129
|
+
editable.style.fontFamily = 'monospace';
|
|
130
|
+
editable.style.whiteSpace = 'pre';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
editable.textContent = block.text ?? '';
|
|
134
|
+
|
|
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
|
+
|
|
152
|
+
editable.addEventListener('focus', () => {
|
|
153
|
+
this._focusedId = block.id;
|
|
154
|
+
wrap.classList.add('is-focused');
|
|
155
|
+
});
|
|
156
|
+
editable.addEventListener('blur', () => {
|
|
157
|
+
wrap.classList.remove('is-focused');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
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(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
wrap.appendChild(editable);
|
|
172
|
+
|
|
173
|
+
// Live inline preview (for paragraph, blockquote, callout)
|
|
174
|
+
if (def.hasInline) {
|
|
175
|
+
const preview = document.createElement('div');
|
|
176
|
+
preview.className = 'griot-editor-block__preview';
|
|
177
|
+
wrap.appendChild(preview);
|
|
178
|
+
this._updatePreview(block.id, block.text ?? '', preview);
|
|
179
|
+
this._blockEls.set(block.id, { wrap, editable, preview });
|
|
180
|
+
} else {
|
|
181
|
+
this._blockEls.set(block.id, { wrap, editable, preview: null });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
} else {
|
|
185
|
+
// Non-text blocks: render their special UI
|
|
186
|
+
wrap.appendChild(this._buildSpecialBlockUI(block));
|
|
187
|
+
this._blockEls.set(block.id, { wrap, editable: null, preview: null });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return wrap;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_buildToolbar(block) {
|
|
194
|
+
const def = getBlockDef(block.type);
|
|
195
|
+
const bar = document.createElement('div');
|
|
196
|
+
bar.className = 'griot-editor-block__toolbar';
|
|
197
|
+
|
|
198
|
+
// Type selector
|
|
199
|
+
const sel = document.createElement('select');
|
|
200
|
+
sel.className = 'griot-editor-block__type-sel';
|
|
201
|
+
for (const type of getAllTypes()) {
|
|
202
|
+
const d = getBlockDef(type);
|
|
203
|
+
const opt = document.createElement('option');
|
|
204
|
+
opt.value = type; opt.textContent = `${d.icon} ${d.label}`;
|
|
205
|
+
if (type === block.type) opt.selected = true;
|
|
206
|
+
sel.appendChild(opt);
|
|
207
|
+
}
|
|
208
|
+
sel.addEventListener('change', () => this._changeType(block.id, sel.value));
|
|
209
|
+
bar.appendChild(sel);
|
|
210
|
+
|
|
211
|
+
// Heading level selector (only for heading type)
|
|
212
|
+
if (block.type === 'heading') {
|
|
213
|
+
const lvlSel = document.createElement('select');
|
|
214
|
+
lvlSel.className = 'griot-editor-block__lvl-sel';
|
|
215
|
+
for (let i = 1; i <= 6; i++) {
|
|
216
|
+
const opt = document.createElement('option');
|
|
217
|
+
opt.value = i; opt.textContent = `H${i}`;
|
|
218
|
+
if (i === (block.meta?.level ?? 2)) opt.selected = true;
|
|
219
|
+
lvlSel.appendChild(opt);
|
|
220
|
+
}
|
|
221
|
+
lvlSel.addEventListener('change', () => {
|
|
222
|
+
const doc = updateBlock(this._doc, block.id, { meta: { level: Number(lvlSel.value) } });
|
|
223
|
+
this._commit(doc);
|
|
224
|
+
});
|
|
225
|
+
bar.appendChild(lvlSel);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Book picker button (only for book_citation)
|
|
229
|
+
if (block.type === 'book_citation') {
|
|
230
|
+
const btn = document.createElement('button');
|
|
231
|
+
btn.type = 'button';
|
|
232
|
+
btn.className = 'griot-editor-block__pick-btn';
|
|
233
|
+
btn.textContent = block.meta?.bookId ? '✎ Change source' : '📖 Pick source…';
|
|
234
|
+
btn.addEventListener('click', () => {
|
|
235
|
+
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);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
bar.appendChild(btn);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Action buttons
|
|
246
|
+
const actions = document.createElement('div');
|
|
247
|
+
actions.className = 'griot-editor-block__actions';
|
|
248
|
+
|
|
249
|
+
const mkBtn = (label, title, onClick, extraClass = '') => {
|
|
250
|
+
const b = document.createElement('button');
|
|
251
|
+
b.type = 'button'; b.title = title;
|
|
252
|
+
b.className = `griot-editor-block__action-btn ${extraClass}`.trim();
|
|
253
|
+
b.textContent = label;
|
|
254
|
+
b.addEventListener('click', onClick);
|
|
255
|
+
return b;
|
|
256
|
+
};
|
|
257
|
+
|
|
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'));
|
|
265
|
+
|
|
266
|
+
bar.appendChild(actions);
|
|
267
|
+
return bar;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_buildSpecialBlockUI(block) {
|
|
271
|
+
const wrap = document.createElement('div');
|
|
272
|
+
wrap.className = 'griot-editor-block__special';
|
|
273
|
+
|
|
274
|
+
switch (block.type) {
|
|
275
|
+
case 'divider':
|
|
276
|
+
wrap.innerHTML = `<hr class="griot-divider">`;
|
|
277
|
+
break;
|
|
278
|
+
|
|
279
|
+
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
|
+
if (block.meta?.src) {
|
|
284
|
+
const img = document.createElement('img');
|
|
285
|
+
img.src = block.meta.src;
|
|
286
|
+
img.alt = block.meta.alt ?? '';
|
|
287
|
+
img.className = 'griot-editor-block__img-preview';
|
|
288
|
+
wrap.appendChild(img);
|
|
289
|
+
}
|
|
290
|
+
const row = document.createElement('div');
|
|
291
|
+
row.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
|
|
292
|
+
[srcInput, altInput, capInput].forEach(el => row.appendChild(el));
|
|
293
|
+
wrap.appendChild(row);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
case 'timeline_ref': {
|
|
298
|
+
const row = document.createElement('div');
|
|
299
|
+
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' }));
|
|
303
|
+
wrap.appendChild(row);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
case 'book_citation': {
|
|
308
|
+
const { bookId, unitId, quote = '', note = '' } = block.meta ?? {};
|
|
309
|
+
if (bookId) {
|
|
310
|
+
const book = this._books.find(b => b.id === bookId);
|
|
311
|
+
const unit = book?.units?.find(u => u.id === unitId);
|
|
312
|
+
const label = document.createElement('div');
|
|
313
|
+
label.className = 'griot-editor-block__citation-label';
|
|
314
|
+
label.textContent = book ? `📖 ${book.title} · ${unit?.label ?? '—'}` : '📖 Book not found';
|
|
315
|
+
wrap.appendChild(label);
|
|
316
|
+
}
|
|
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);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
|
|
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;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return wrap;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Meta input helpers ─────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
_metaInput(block, key, placeholder, attrs = {}) {
|
|
337
|
+
const el = document.createElement('input');
|
|
338
|
+
el.type = 'text';
|
|
339
|
+
el.className = 'griot-editor-block__meta-input';
|
|
340
|
+
el.placeholder = placeholder;
|
|
341
|
+
el.value = block.meta?.[key] ?? '';
|
|
342
|
+
if (attrs.style) el.style.cssText = attrs.style;
|
|
343
|
+
el.addEventListener('input', () => {
|
|
344
|
+
const doc = updateBlock(this._doc, block.id, { meta: { [key]: el.value } });
|
|
345
|
+
this._commit(doc);
|
|
346
|
+
});
|
|
347
|
+
return el;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
_metaTextarea(block, key, placeholder, { rows = 2 } = {}) {
|
|
351
|
+
const el = document.createElement('textarea');
|
|
352
|
+
el.className = 'griot-editor-block__meta-input griot-editor-block__meta-textarea';
|
|
353
|
+
el.rows = rows;
|
|
354
|
+
el.placeholder = placeholder;
|
|
355
|
+
el.value = block.meta?.[key] ?? '';
|
|
356
|
+
el.addEventListener('input', () => {
|
|
357
|
+
const doc = updateBlock(this._doc, block.id, { meta: { [key]: el.value } });
|
|
358
|
+
this._commit(doc);
|
|
359
|
+
});
|
|
360
|
+
return el;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ─── Live preview ────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
_updatePreview(blockId, text, previewEl) {
|
|
366
|
+
const el = previewEl ?? this._blockEls.get(blockId)?.preview;
|
|
367
|
+
if (!el) return;
|
|
368
|
+
el.innerHTML = '';
|
|
369
|
+
if (!text?.trim()) { el.style.display = 'none'; return; }
|
|
370
|
+
el.style.display = '';
|
|
371
|
+
el.appendChild(renderInlineToDOM(text, {
|
|
372
|
+
onEventClick: this._options.onEventClick,
|
|
373
|
+
onCiteClick: this._options.onCiteClick,
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ─── Mutations ───────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
_commit(doc) {
|
|
380
|
+
this._doc = doc;
|
|
381
|
+
this._history.push(doc);
|
|
382
|
+
this._render();
|
|
383
|
+
this._emit();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
_emit() {
|
|
387
|
+
this._options.onChange?.(this._doc);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
_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;
|
|
395
|
+
this._commit(updateBlock(this._doc, blockId, patch));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
_addAfter(blockId) {
|
|
399
|
+
const nb = createBlock('paragraph');
|
|
400
|
+
const doc = insertBlockAfter(this._doc, blockId, nb);
|
|
401
|
+
this._commit(doc);
|
|
402
|
+
requestAnimationFrame(() => {
|
|
403
|
+
const els = this._blockEls.get(nb.id);
|
|
404
|
+
if (els?.editable) focusAtStart(els.editable);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
_delete(blockId) {
|
|
409
|
+
if (this._doc.blocks.length <= 1) return;
|
|
410
|
+
const prevBlock = getBlockBefore(this._doc, blockId);
|
|
411
|
+
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
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
_move(blockId, direction) {
|
|
421
|
+
const idx = getBlockIndex(this._doc, blockId);
|
|
422
|
+
if (idx < 0) return;
|
|
423
|
+
const toIdx = idx + direction;
|
|
424
|
+
if (toIdx < 0 || toIdx >= this._doc.blocks.length) return;
|
|
425
|
+
this._commit(moveBlock(this._doc, idx, toIdx));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─── Keyboard actions ────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
_onEnter(blockId, offset) {
|
|
431
|
+
const [doc, newId] = splitBlock(this._doc, blockId, offset);
|
|
432
|
+
this._commit(doc);
|
|
433
|
+
if (newId) {
|
|
434
|
+
requestAnimationFrame(() => {
|
|
435
|
+
const els = this._blockEls.get(newId);
|
|
436
|
+
if (els?.editable) focusAtStart(els.editable);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
_onBackspaceAtStart(blockId) {
|
|
442
|
+
const [doc, prevId, mergeOffset] = mergeBlockWithPrev(this._doc, blockId);
|
|
443
|
+
if (!prevId) return;
|
|
444
|
+
this._commit(doc);
|
|
445
|
+
requestAnimationFrame(() => {
|
|
446
|
+
const els = this._blockEls.get(prevId);
|
|
447
|
+
if (els?.editable) {
|
|
448
|
+
els.editable.focus();
|
|
449
|
+
setCursorOffset(els.editable, mergeOffset);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
_onDeleteAtEnd(blockId) {
|
|
455
|
+
const nextBlock = getBlockAfter(this._doc, blockId);
|
|
456
|
+
if (!nextBlock) return;
|
|
457
|
+
this._onBackspaceAtStart(nextBlock.id);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
_onTab(blockId, isShift) {
|
|
461
|
+
// Placeholder: indent/outdent for list blocks in future
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
_focusPrev(blockId) {
|
|
465
|
+
const prev = getBlockBefore(this._doc, blockId);
|
|
466
|
+
if (prev) {
|
|
467
|
+
const els = this._blockEls.get(prev.id);
|
|
468
|
+
if (els?.editable) focusAtEnd(els.editable);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
_focusNext(blockId) {
|
|
473
|
+
const next = getBlockAfter(this._doc, blockId);
|
|
474
|
+
if (next) {
|
|
475
|
+
const els = this._blockEls.get(next.id);
|
|
476
|
+
if (els?.editable) focusAtStart(els.editable);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
_undo() {
|
|
481
|
+
this._doc = this._history.undo();
|
|
482
|
+
this._render();
|
|
483
|
+
this._emit();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
_redo() {
|
|
487
|
+
this._doc = this._history.redo();
|
|
488
|
+
this._render();
|
|
489
|
+
this._emit();
|
|
490
|
+
}
|
|
491
|
+
}
|