@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,169 @@
|
|
|
1
|
+
// ─── Keyboard.js ──────────────────────────────────────────────────────────────
|
|
2
|
+
// All keyboard behaviour for the block editor.
|
|
3
|
+
// Pure logic — receives the current doc + focused block ID, returns events
|
|
4
|
+
// the Editor class acts on.
|
|
5
|
+
//
|
|
6
|
+
// Exported: attachKeyboardHandler(el, callbacks)
|
|
7
|
+
// Attaches a keydown listener to a contenteditable element for one block.
|
|
8
|
+
// Calls back into Editor which owns the document state.
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {HTMLElement} el The contenteditable element
|
|
13
|
+
* @param {object} callbacks
|
|
14
|
+
* onEnter(blockId, offset) — Enter pressed: split at cursor offset
|
|
15
|
+
* onBackspaceAtStart(blockId) — Backspace at offset 0: merge with prev
|
|
16
|
+
* onDeleteAtEnd(blockId) — Delete at end: merge next into this
|
|
17
|
+
* onTab(blockId, shift) — Tab / Shift+Tab
|
|
18
|
+
* onArrowUp(blockId) — Arrow up at first line → move focus to prev block
|
|
19
|
+
* onArrowDown(blockId) — Arrow down at last line → move focus to next block
|
|
20
|
+
* onUndo() — Ctrl/Cmd+Z
|
|
21
|
+
* onRedo() — Ctrl/Cmd+Shift+Z or Ctrl+Y
|
|
22
|
+
*/
|
|
23
|
+
export function attachKeyboardHandler(el, blockId, callbacks) {
|
|
24
|
+
el.addEventListener('keydown', (e) => {
|
|
25
|
+
const {
|
|
26
|
+
onEnter, onBackspaceAtStart, onDeleteAtEnd,
|
|
27
|
+
onTab, onArrowUp, onArrowDown, onUndo, onRedo,
|
|
28
|
+
} = callbacks;
|
|
29
|
+
|
|
30
|
+
const ctrl = e.ctrlKey || e.metaKey;
|
|
31
|
+
|
|
32
|
+
// ── Undo / Redo ──────────────────────────────────────────────
|
|
33
|
+
if (ctrl && e.key === 'z' && !e.shiftKey) {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
onUndo?.();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if ((ctrl && e.key === 'z' && e.shiftKey) || (ctrl && e.key === 'y')) {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
onRedo?.();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Enter → split block ──────────────────────────────────────
|
|
45
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
onEnter?.(blockId, getCursorOffset(el));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Backspace at start → merge with previous ─────────────────
|
|
52
|
+
if (e.key === 'Backspace' && getCursorOffset(el) === 0 && !hasSelection()) {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
onBackspaceAtStart?.(blockId);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Delete at end → merge next into this ─────────────────────
|
|
59
|
+
if (e.key === 'Delete' && getCursorOffset(el) === el.textContent.length && !hasSelection()) {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
onDeleteAtEnd?.(blockId);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Tab ───────────────────────────────────────────────────────
|
|
66
|
+
if (e.key === 'Tab') {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
onTab?.(blockId, e.shiftKey);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Arrow navigation across blocks ───────────────────────────
|
|
73
|
+
if (e.key === 'ArrowUp' && isAtFirstLine(el)) {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
onArrowUp?.(blockId);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (e.key === 'ArrowDown' && isAtLastLine(el)) {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
onArrowDown?.(blockId);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Cursor helpers ───────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** Returns the character offset of the caret within el.textContent */
|
|
89
|
+
export function getCursorOffset(el) {
|
|
90
|
+
const sel = window.getSelection();
|
|
91
|
+
if (!sel || sel.rangeCount === 0) return 0;
|
|
92
|
+
const range = sel.getRangeAt(0).cloneRange();
|
|
93
|
+
range.selectNodeContents(el);
|
|
94
|
+
range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset);
|
|
95
|
+
return range.toString().length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Place the caret at a specific character offset within el */
|
|
99
|
+
export function setCursorOffset(el, offset) {
|
|
100
|
+
const range = document.createRange();
|
|
101
|
+
const sel = window.getSelection();
|
|
102
|
+
if (!sel) return;
|
|
103
|
+
|
|
104
|
+
let remaining = offset;
|
|
105
|
+
let found = false;
|
|
106
|
+
|
|
107
|
+
function walk(node) {
|
|
108
|
+
if (found) return;
|
|
109
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
110
|
+
if (remaining <= node.textContent.length) {
|
|
111
|
+
range.setStart(node, remaining);
|
|
112
|
+
range.setEnd(node, remaining);
|
|
113
|
+
found = true;
|
|
114
|
+
} else {
|
|
115
|
+
remaining -= node.textContent.length;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
for (const child of node.childNodes) walk(child);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
walk(el);
|
|
123
|
+
|
|
124
|
+
if (!found) {
|
|
125
|
+
// Clamp to end
|
|
126
|
+
range.selectNodeContents(el);
|
|
127
|
+
range.collapse(false);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
sel.removeAllRanges();
|
|
131
|
+
sel.addRange(range);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Focus el and place caret at end */
|
|
135
|
+
export function focusAtEnd(el) {
|
|
136
|
+
el.focus();
|
|
137
|
+
setCursorOffset(el, el.textContent.length);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Focus el and place caret at start */
|
|
141
|
+
export function focusAtStart(el) {
|
|
142
|
+
el.focus();
|
|
143
|
+
setCursorOffset(el, 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Line detection ───────────────────────────────────────────────────────────
|
|
147
|
+
function hasSelection() {
|
|
148
|
+
const sel = window.getSelection();
|
|
149
|
+
return sel && sel.type === 'Range';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isAtFirstLine(el) {
|
|
153
|
+
const sel = window.getSelection();
|
|
154
|
+
if (!sel || sel.rangeCount === 0) return false;
|
|
155
|
+
const range = sel.getRangeAt(0);
|
|
156
|
+
const rect = range.getBoundingClientRect();
|
|
157
|
+
const elRect = el.getBoundingClientRect();
|
|
158
|
+
// Within 1.5x line-height from top
|
|
159
|
+
return Math.abs(rect.top - elRect.top) < 30;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isAtLastLine(el) {
|
|
163
|
+
const sel = window.getSelection();
|
|
164
|
+
if (!sel || sel.rangeCount === 0) return false;
|
|
165
|
+
const range = sel.getRangeAt(0);
|
|
166
|
+
const rect = range.getBoundingClientRect();
|
|
167
|
+
const elRect = el.getBoundingClientRect();
|
|
168
|
+
return Math.abs(rect.bottom - elRect.bottom) < 30;
|
|
169
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// ─── InlineLexer.js ───────────────────────────────────────────────────────────
|
|
2
|
+
// Tokenises inline markdown-like syntax into a flat token array.
|
|
3
|
+
// Runs on a single text string (the content of one block's text field).
|
|
4
|
+
//
|
|
5
|
+
// Supported syntax:
|
|
6
|
+
// **bold** → { type:'bold', text }
|
|
7
|
+
// *italic* → { type:'italic', text }
|
|
8
|
+
// `code` → { type:'code', text }
|
|
9
|
+
// [label](url) → { type:'link', text, href }
|
|
10
|
+
// [[event:id|label]] → { type:'event_ref', eventId, label }
|
|
11
|
+
// [[cite:blockId|label]] → { type:'cite_ref', blockId, label }
|
|
12
|
+
// plain text → { type:'text', text }
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export const TOKEN = Object.freeze({
|
|
16
|
+
TEXT: 'text',
|
|
17
|
+
BOLD: 'bold',
|
|
18
|
+
ITALIC: 'italic',
|
|
19
|
+
CODE: 'code',
|
|
20
|
+
LINK: 'link',
|
|
21
|
+
EVENT_REF: 'event_ref',
|
|
22
|
+
CITE_REF: 'cite_ref',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Combined regex — order matters (longest/most-specific first)
|
|
26
|
+
const INLINE_RE = new RegExp(
|
|
27
|
+
[
|
|
28
|
+
/(\*\*(.+?)\*\*)/, // bold
|
|
29
|
+
/(\*(.+?)\*)/, // italic
|
|
30
|
+
/(`([^`]+)`)/, // inline code
|
|
31
|
+
/(\[(.+?)\]\((.+?)\))/, // link
|
|
32
|
+
/(\[\[event:([^\]|]+)(?:\|([^\]]*))?\]\])/, // event_ref
|
|
33
|
+
/(\[\[cite:([^\]|]+)(?:\|([^\]]*))?\]\])/, // cite_ref
|
|
34
|
+
].map(r => r.source).join('|'),
|
|
35
|
+
'g'
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export function tokenizeInline(text = '') {
|
|
39
|
+
if (!text) return [];
|
|
40
|
+
|
|
41
|
+
const tokens = [];
|
|
42
|
+
let last = 0;
|
|
43
|
+
let m;
|
|
44
|
+
|
|
45
|
+
INLINE_RE.lastIndex = 0;
|
|
46
|
+
|
|
47
|
+
while ((m = INLINE_RE.exec(text)) !== null) {
|
|
48
|
+
// Flush plain text before this match
|
|
49
|
+
if (m.index > last) {
|
|
50
|
+
tokens.push({ type: TOKEN.TEXT, text: text.slice(last, m.index) });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (m[1]) {
|
|
54
|
+
// bold
|
|
55
|
+
tokens.push({ type: TOKEN.BOLD, text: m[2] });
|
|
56
|
+
} else if (m[3]) {
|
|
57
|
+
// italic
|
|
58
|
+
tokens.push({ type: TOKEN.ITALIC, text: m[4] });
|
|
59
|
+
} else if (m[5]) {
|
|
60
|
+
// inline code
|
|
61
|
+
tokens.push({ type: TOKEN.CODE, text: m[6] });
|
|
62
|
+
} else if (m[7]) {
|
|
63
|
+
// link
|
|
64
|
+
tokens.push({ type: TOKEN.LINK, text: m[8], href: m[9] });
|
|
65
|
+
} else if (m[10]) {
|
|
66
|
+
// event_ref
|
|
67
|
+
tokens.push({
|
|
68
|
+
type: TOKEN.EVENT_REF,
|
|
69
|
+
eventId: m[11],
|
|
70
|
+
label: m[12] || m[11],
|
|
71
|
+
});
|
|
72
|
+
} else if (m[13]) {
|
|
73
|
+
// cite_ref
|
|
74
|
+
tokens.push({
|
|
75
|
+
type: TOKEN.CITE_REF,
|
|
76
|
+
blockId: m[14],
|
|
77
|
+
label: m[15] || m[14],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
last = INLINE_RE.lastIndex;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Remaining plain text
|
|
85
|
+
if (last < text.length) {
|
|
86
|
+
tokens.push({ type: TOKEN.TEXT, text: text.slice(last) });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return tokens.length ? tokens : [{ type: TOKEN.TEXT, text }];
|
|
90
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ─── InlineRenderer.js ────────────────────────────────────────────────────────
|
|
2
|
+
// Renders inline token arrays to either:
|
|
3
|
+
// a) A DocumentFragment (DOM nodes) — used by Viewer and Editor live preview
|
|
4
|
+
// b) An HTML string — used for SSR / export
|
|
5
|
+
//
|
|
6
|
+
// Callbacks:
|
|
7
|
+
// onEventClick(eventId) — called when an [[event:]] chip is clicked
|
|
8
|
+
// onCiteClick(blockId) — called when a [[cite:]] chip is clicked
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
import { tokenizeInline, TOKEN } from './InlineLexer.js';
|
|
12
|
+
|
|
13
|
+
// ─── DOM rendering ────────────────────────────────────────────────────────────
|
|
14
|
+
export function renderInlineToDOM(text = '', { onEventClick, onCiteClick } = {}) {
|
|
15
|
+
const frag = document.createDocumentFragment();
|
|
16
|
+
const tokens = tokenizeInline(text);
|
|
17
|
+
|
|
18
|
+
for (const t of tokens) {
|
|
19
|
+
let node;
|
|
20
|
+
|
|
21
|
+
switch (t.type) {
|
|
22
|
+
case TOKEN.TEXT:
|
|
23
|
+
node = document.createTextNode(t.text);
|
|
24
|
+
break;
|
|
25
|
+
|
|
26
|
+
case TOKEN.BOLD:
|
|
27
|
+
node = document.createElement('strong');
|
|
28
|
+
node.textContent = t.text;
|
|
29
|
+
break;
|
|
30
|
+
|
|
31
|
+
case TOKEN.ITALIC:
|
|
32
|
+
node = document.createElement('em');
|
|
33
|
+
node.textContent = t.text;
|
|
34
|
+
break;
|
|
35
|
+
|
|
36
|
+
case TOKEN.CODE: {
|
|
37
|
+
node = document.createElement('code');
|
|
38
|
+
node.className = 'griot-inline-code';
|
|
39
|
+
node.textContent = t.text;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
case TOKEN.LINK: {
|
|
44
|
+
node = document.createElement('a');
|
|
45
|
+
node.href = t.href;
|
|
46
|
+
node.target = '_blank';
|
|
47
|
+
node.rel = 'noopener noreferrer';
|
|
48
|
+
node.className = 'griot-link';
|
|
49
|
+
node.textContent = t.text;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case TOKEN.EVENT_REF: {
|
|
54
|
+
node = document.createElement('button');
|
|
55
|
+
node.type = 'button';
|
|
56
|
+
node.className = 'griot-chip griot-chip--event';
|
|
57
|
+
node.dataset.eventId = t.eventId;
|
|
58
|
+
node.innerHTML = `<span class="griot-chip__icon">⏱</span><span class="griot-chip__label">${escHtml(t.label)}</span>`;
|
|
59
|
+
if (onEventClick) {
|
|
60
|
+
node.addEventListener('click', (e) => {
|
|
61
|
+
e.stopPropagation();
|
|
62
|
+
onEventClick(t.eventId);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
case TOKEN.CITE_REF: {
|
|
69
|
+
node = document.createElement('button');
|
|
70
|
+
node.type = 'button';
|
|
71
|
+
node.className = 'griot-chip griot-chip--cite';
|
|
72
|
+
node.dataset.blockId = t.blockId;
|
|
73
|
+
node.innerHTML = `<span class="griot-chip__icon">📖</span><span class="griot-chip__label">${escHtml(t.label)}</span>`;
|
|
74
|
+
if (onCiteClick) {
|
|
75
|
+
node.addEventListener('click', (e) => {
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
onCiteClick(t.blockId);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
default:
|
|
84
|
+
node = document.createTextNode(t.text ?? '');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
frag.appendChild(node);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return frag;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── HTML string rendering ────────────────────────────────────────────────────
|
|
94
|
+
export function renderInlineToHTML(text = '') {
|
|
95
|
+
const tokens = tokenizeInline(text);
|
|
96
|
+
let html = '';
|
|
97
|
+
|
|
98
|
+
for (const t of tokens) {
|
|
99
|
+
switch (t.type) {
|
|
100
|
+
case TOKEN.TEXT: html += escHtml(t.text); break;
|
|
101
|
+
case TOKEN.BOLD: html += `<strong>${escHtml(t.text)}</strong>`; break;
|
|
102
|
+
case TOKEN.ITALIC: html += `<em>${escHtml(t.text)}</em>`; break;
|
|
103
|
+
case TOKEN.CODE: html += `<code class="griot-inline-code">${escHtml(t.text)}</code>`; break;
|
|
104
|
+
case TOKEN.LINK: html += `<a class="griot-link" href="${escAttr(t.href)}" target="_blank" rel="noopener noreferrer">${escHtml(t.text)}</a>`; break;
|
|
105
|
+
case TOKEN.EVENT_REF: html += `<button type="button" class="griot-chip griot-chip--event" data-event-id="${escAttr(t.eventId)}"><span class="griot-chip__icon">⏱</span><span class="griot-chip__label">${escHtml(t.label)}</span></button>`; break;
|
|
106
|
+
case TOKEN.CITE_REF: html += `<button type="button" class="griot-chip griot-chip--cite" data-block-id="${escAttr(t.blockId)}"><span class="griot-chip__icon">📖</span><span class="griot-chip__label">${escHtml(t.label)}</span></button>`; break;
|
|
107
|
+
default: html += escHtml(t.text ?? '');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return html;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Escape helpers ───────────────────────────────────────────────────────────
|
|
115
|
+
export function escHtml(s) {
|
|
116
|
+
return String(s ?? '')
|
|
117
|
+
.replace(/&/g, '&')
|
|
118
|
+
.replace(/</g, '<')
|
|
119
|
+
.replace(/>/g, '>')
|
|
120
|
+
.replace(/"/g, '"')
|
|
121
|
+
.replace(/'/g, ''');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function escAttr(s) {
|
|
125
|
+
return String(s ?? '')
|
|
126
|
+
.replace(/&/g, '&')
|
|
127
|
+
.replace(/"/g, '"');
|
|
128
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ─── Viewer.js ────────────────────────────────────────────────────────────────
|
|
2
|
+
// Read-only renderer. Mounts into a container div (never an iframe).
|
|
3
|
+
// Plain div keeps event callbacks working without postMessage.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// const viewer = new Viewer(containerEl, {
|
|
7
|
+
// doc,
|
|
8
|
+
// books,
|
|
9
|
+
// onEventClick(eventId) {},
|
|
10
|
+
// onCiteClick(blockId) {},
|
|
11
|
+
// highlightBlockId: null,
|
|
12
|
+
// });
|
|
13
|
+
// viewer.setHighlight(blockId); // highlight + scroll to a block
|
|
14
|
+
// viewer.destroy();
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
import { anchorId, scrollToBlock } from '../core/Block.js';
|
|
18
|
+
import { renderBlock } from '../blocks/BlockRenderer.js';
|
|
19
|
+
|
|
20
|
+
export class Viewer {
|
|
21
|
+
constructor(container, options = {}) {
|
|
22
|
+
this._container = container;
|
|
23
|
+
this._options = options;
|
|
24
|
+
this._doc = options.doc ?? null;
|
|
25
|
+
this._books = options.books ?? [];
|
|
26
|
+
this._highlighted = options.highlightBlockId ?? null;
|
|
27
|
+
this._hlTimer = null;
|
|
28
|
+
|
|
29
|
+
container.classList.add('griot-viewer');
|
|
30
|
+
if (this._doc) this._render();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
setDoc(doc) {
|
|
36
|
+
this._doc = doc;
|
|
37
|
+
this._render();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setBooks(books) {
|
|
41
|
+
this._books = books;
|
|
42
|
+
this._render();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Scroll to block and briefly highlight it */
|
|
46
|
+
setHighlight(blockId, { scroll = true, behavior = 'smooth' } = {}) {
|
|
47
|
+
// Remove old highlight
|
|
48
|
+
clearTimeout(this._hlTimer);
|
|
49
|
+
if (this._highlighted) {
|
|
50
|
+
document.getElementById(anchorId(this._highlighted))
|
|
51
|
+
?.classList.remove('griot-block--highlight');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this._highlighted = blockId;
|
|
55
|
+
const el = document.getElementById(anchorId(blockId));
|
|
56
|
+
if (!el) return;
|
|
57
|
+
|
|
58
|
+
el.classList.add('griot-block--highlight');
|
|
59
|
+
if (scroll) scrollToBlock(blockId, behavior);
|
|
60
|
+
|
|
61
|
+
this._hlTimer = setTimeout(() => {
|
|
62
|
+
el.classList.remove('griot-block--highlight');
|
|
63
|
+
this._highlighted = null;
|
|
64
|
+
}, 2200);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
destroy() {
|
|
68
|
+
clearTimeout(this._hlTimer);
|
|
69
|
+
this._container.innerHTML = '';
|
|
70
|
+
this._container.classList.remove('griot-viewer');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
_render() {
|
|
76
|
+
this._container.innerHTML = '';
|
|
77
|
+
if (!this._doc) return;
|
|
78
|
+
|
|
79
|
+
const opts = {
|
|
80
|
+
books: this._books,
|
|
81
|
+
onEventClick: this._options.onEventClick,
|
|
82
|
+
onCiteClick: this._options.onCiteClick,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
for (const block of this._doc.blocks) {
|
|
86
|
+
const el = renderBlock(block, opts);
|
|
87
|
+
if (!el) continue;
|
|
88
|
+
if (block.id === this._highlighted) el.classList.add('griot-block--highlight');
|
|
89
|
+
this._container.appendChild(el);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|