@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
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Griot
|
|
2
|
+
|
|
3
|
+
A self-contained block-based rich text editor and renderer.
|
|
4
|
+
Built for structured historical document authoring — works standalone or embedded inside a larger app.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
# Copy src/ into your project, or:
|
|
12
|
+
npm install griot # (once published)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import 'griot/css'; // styles
|
|
17
|
+
import { Editor, Viewer, createDocument } from 'griot';
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
### Editor
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { Editor, createDocument } from 'griot';
|
|
28
|
+
import 'griot/css';
|
|
29
|
+
|
|
30
|
+
const doc = createDocument('My Article');
|
|
31
|
+
|
|
32
|
+
const editor = new Editor(document.querySelector('#editor'), {
|
|
33
|
+
doc,
|
|
34
|
+
books: [], // optional: parsed books for citations
|
|
35
|
+
onChange(updatedDoc) {
|
|
36
|
+
localStorage.setItem('draft', JSON.stringify(updatedDoc));
|
|
37
|
+
},
|
|
38
|
+
onEventClick(eventId) {
|
|
39
|
+
// e.g. AppShell.handleSelectItemById(eventId)
|
|
40
|
+
console.log('Open timeline event:', eventId);
|
|
41
|
+
},
|
|
42
|
+
onCiteClick(blockId) {
|
|
43
|
+
// scroll viewer to that block
|
|
44
|
+
viewer.setHighlight(blockId);
|
|
45
|
+
},
|
|
46
|
+
onRequestBookPicker(blockId, callback) {
|
|
47
|
+
// Open your SourcePicker UI, then call:
|
|
48
|
+
// callback({ bookId, unitId, quote, note })
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Viewer
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import { Viewer } from 'griot';
|
|
57
|
+
|
|
58
|
+
const viewer = new Viewer(document.querySelector('#viewer'), {
|
|
59
|
+
doc,
|
|
60
|
+
books: [],
|
|
61
|
+
onEventClick(eventId) {
|
|
62
|
+
console.log('Open event:', eventId);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Jump to a block (e.g. from a timeline citation)
|
|
67
|
+
viewer.setHighlight('b_abc123');
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Block types
|
|
73
|
+
|
|
74
|
+
| Type | Icon | Text field | Notes |
|
|
75
|
+
|---|---|---|---|
|
|
76
|
+
| `paragraph` | ¶ | ✓ | Inline syntax supported |
|
|
77
|
+
| `heading` | H | ✓ | `meta.level` 1–6 |
|
|
78
|
+
| `blockquote` | ❝ | ✓ | Inline syntax supported |
|
|
79
|
+
| `callout` | 💡 | ✓ | `meta.icon` for the emoji |
|
|
80
|
+
| `code` | </> | ✓ | No inline parsing. `meta.language` for highlight |
|
|
81
|
+
| `divider` | — | — | Horizontal rule |
|
|
82
|
+
| `image` | 🖼 | — | `meta.src`, `meta.alt`, `meta.caption` |
|
|
83
|
+
| `timeline_ref` | ⏱ | — | `meta.eventId`, `meta.eventTitle`, `meta.note` |
|
|
84
|
+
| `book_citation` | 📖 | — | `meta.bookId`, `meta.unitId`, `meta.quote`, `meta.note` |
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Inline syntax
|
|
89
|
+
|
|
90
|
+
Works inside any block with `hasInline: true` (paragraph, blockquote, callout, note fields):
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
**bold**
|
|
94
|
+
*italic*
|
|
95
|
+
`inline code`
|
|
96
|
+
[link text](https://example.com)
|
|
97
|
+
[[event:rome_founding|The Founding of Rome]] → timeline event chip
|
|
98
|
+
[[cite:b_abc123|See Chapter 2]] → citation cross-reference
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Document format (`.griot.json`)
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"version": 1,
|
|
108
|
+
"id": "doc_abc",
|
|
109
|
+
"title": "The Fall of Rome",
|
|
110
|
+
"createdAt": "2025-01-01T00:00:00.000Z",
|
|
111
|
+
"updatedAt": "2025-01-01T00:00:00.000Z",
|
|
112
|
+
"blocks": [
|
|
113
|
+
{ "id": "b_1", "type": "heading", "text": "The Fall of Rome", "meta": { "level": 1 } },
|
|
114
|
+
{ "id": "b_2", "type": "paragraph", "text": "In **476 CE** the last emperor [[event:fall_of_rome|was deposed]].", "meta": {} },
|
|
115
|
+
{ "id": "b_3", "type": "book_citation", "text": null, "meta": {
|
|
116
|
+
"bookId": "book_xyz", "unitId": "unit_abc",
|
|
117
|
+
"quote": "The barbarians had long served in Roman armies.",
|
|
118
|
+
"note": "Essential context for understanding the transition."
|
|
119
|
+
}}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Deep-link anchors
|
|
127
|
+
|
|
128
|
+
Every rendered block gets `id="griot-{blockId}"` in the DOM.
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
import { anchorId, scrollToBlock } from 'griot';
|
|
132
|
+
|
|
133
|
+
// Get the DOM id for a block
|
|
134
|
+
anchorId('b_abc123') // → "griot-b_abc123"
|
|
135
|
+
|
|
136
|
+
// Scroll to a block (viewer or editor)
|
|
137
|
+
scrollToBlock('b_abc123');
|
|
138
|
+
scrollToBlock('b_abc123', 'instant');
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This is the contract for timeline → article navigation:
|
|
142
|
+
store `{ docId, blockId }` on a citation, then call `scrollToBlock(blockId)` when the timeline jumps to it.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## API reference
|
|
147
|
+
|
|
148
|
+
### `Editor`
|
|
149
|
+
| Method | Description |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `new Editor(el, options)` | Mount editor into `el` |
|
|
152
|
+
| `editor.doc` | Current document (read-only) |
|
|
153
|
+
| `editor.setDoc(doc)` | Replace document |
|
|
154
|
+
| `editor.setBooks(books)` | Update available books |
|
|
155
|
+
| `editor.focus(blockId)` | Focus a specific block |
|
|
156
|
+
| `editor.destroy()` | Unmount and clean up |
|
|
157
|
+
|
|
158
|
+
### `Viewer`
|
|
159
|
+
| Method | Description |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `new Viewer(el, options)` | Mount viewer into `el` |
|
|
162
|
+
| `viewer.setDoc(doc)` | Replace document |
|
|
163
|
+
| `viewer.setBooks(books)` | Update available books |
|
|
164
|
+
| `viewer.setHighlight(blockId)` | Scroll to + briefly highlight a block |
|
|
165
|
+
| `viewer.destroy()` | Unmount and clean up |
|
|
166
|
+
|
|
167
|
+
### Document helpers
|
|
168
|
+
```js
|
|
169
|
+
createDocument(title)
|
|
170
|
+
createBlock(type, overrides)
|
|
171
|
+
updateBlock(doc, blockId, patch)
|
|
172
|
+
insertBlockAfter(doc, blockId, newBlock)
|
|
173
|
+
removeBlock(doc, blockId)
|
|
174
|
+
splitBlock(doc, blockId, offset) // returns [newDoc, newBlockId]
|
|
175
|
+
mergeBlockWithPrev(doc, blockId) // returns [newDoc, prevId, offset]
|
|
176
|
+
moveBlock(doc, fromIndex, toIndex)
|
|
177
|
+
toJSON(doc)
|
|
178
|
+
fromJSON(jsonStringOrObject)
|
|
179
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@0m0g1/griot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A self-contained block-based rich text editor and renderer built for historical document authoring.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/Griot.js",
|
|
7
|
+
"module": "./src/Griot.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/Griot.js",
|
|
10
|
+
"./css": "./src/griot.css"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/"
|
|
14
|
+
],
|
|
15
|
+
"keywords": ["rich-text", "editor", "blocks", "history", "document"],
|
|
16
|
+
"license": "MIT"
|
|
17
|
+
}
|
package/src/Griot.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// ─── Griot.js ─────────────────────────────────────────────────────────────────
|
|
2
|
+
// Public facade. Import from here — never from internal modules directly.
|
|
3
|
+
//
|
|
4
|
+
// Named exports cover every public surface:
|
|
5
|
+
//
|
|
6
|
+
// Classes
|
|
7
|
+
// Editor, Viewer
|
|
8
|
+
//
|
|
9
|
+
// Document model
|
|
10
|
+
// createDocument, createBlock, cloneBlock
|
|
11
|
+
// updateBlock, insertBlockAfter, insertBlockBefore,
|
|
12
|
+
// removeBlock, splitBlock, mergeBlockWithPrev, moveBlock,
|
|
13
|
+
// getBlock, getBlockIndex, toJSON, fromJSON
|
|
14
|
+
//
|
|
15
|
+
// Block helpers
|
|
16
|
+
// anchorId, scrollToBlock, isTextBlock
|
|
17
|
+
//
|
|
18
|
+
// Inline
|
|
19
|
+
// tokenizeInline, renderInlineToDOM, renderInlineToHTML, TOKEN
|
|
20
|
+
//
|
|
21
|
+
// Schema
|
|
22
|
+
// getBlockDef, getAllTypes, defaultMeta, BlockSchema
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
// Core
|
|
26
|
+
export {
|
|
27
|
+
createBlock, cloneBlock, isTextBlock, isValidBlock,
|
|
28
|
+
anchorId, scrollToBlock,
|
|
29
|
+
TEXT_TYPES, ALL_TYPES,
|
|
30
|
+
} from './core/Block.js';
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
createDocument, toJSON, fromJSON,
|
|
34
|
+
getBlock, getBlockIndex, getBlockAfter, getBlockBefore,
|
|
35
|
+
updateBlock, insertBlockAfter, insertBlockBefore,
|
|
36
|
+
removeBlock, moveBlock, splitBlock, mergeBlockWithPrev,
|
|
37
|
+
} from './core/Document.js';
|
|
38
|
+
|
|
39
|
+
export { History } from './core/History.js';
|
|
40
|
+
|
|
41
|
+
// Inline
|
|
42
|
+
export { tokenizeInline, TOKEN } from './inline/InlineLexer.js';
|
|
43
|
+
export {
|
|
44
|
+
renderInlineToDOM, renderInlineToHTML, escHtml, escAttr,
|
|
45
|
+
} from './inline/InlineRenderer.js';
|
|
46
|
+
|
|
47
|
+
// Blocks
|
|
48
|
+
export { getBlockDef, getAllTypes, defaultMeta } from './blocks/BlockSchema.js';
|
|
49
|
+
export { default as BlockSchema } from './blocks/BlockSchema.js';
|
|
50
|
+
export { renderBlock } from './blocks/BlockRenderer.js';
|
|
51
|
+
|
|
52
|
+
// Editor / Viewer
|
|
53
|
+
export { Editor } from './editor/Editor.js';
|
|
54
|
+
export { Viewer } from './viewer/Viewer.js';
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// ─── BlockRenderer.js ─────────────────────────────────────────────────────────
|
|
2
|
+
// Renders a single block to a DOM element.
|
|
3
|
+
// Used by both Viewer (read-only) and Editor (preview layer).
|
|
4
|
+
//
|
|
5
|
+
// Options:
|
|
6
|
+
// books — array of parsed book objects (for book_citation)
|
|
7
|
+
// onEventClick — (eventId) => void
|
|
8
|
+
// onCiteClick — (blockId) => void
|
|
9
|
+
// editable — if true, skips event listeners (Editor manages them)
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
import { anchorId } from '../core/Block.js';
|
|
13
|
+
import { renderInlineToDOM, renderInlineToHTML, escHtml, escAttr } from '../inline/InlineRenderer.js';
|
|
14
|
+
import { getBlockDef } from './BlockSchema.js';
|
|
15
|
+
|
|
16
|
+
// ─── Public entry point ───────────────────────────────────────────────────────
|
|
17
|
+
export function renderBlock(block, { books = [], onEventClick, onCiteClick } = {}) {
|
|
18
|
+
const el = _render(block, { books, onEventClick, onCiteClick });
|
|
19
|
+
if (el) {
|
|
20
|
+
el.id = anchorId(block.id);
|
|
21
|
+
el.dataset.blockId = block.id;
|
|
22
|
+
el.dataset.blockType = block.type;
|
|
23
|
+
}
|
|
24
|
+
return el;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Internal ─────────────────────────────────────────────────────────────────
|
|
28
|
+
function inlineDOM(text, opts) {
|
|
29
|
+
return renderInlineToDOM(text, {
|
|
30
|
+
onEventClick: opts.onEventClick,
|
|
31
|
+
onCiteClick: opts.onCiteClick,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _render(block, opts) {
|
|
36
|
+
const { text, meta = {}, type } = block;
|
|
37
|
+
|
|
38
|
+
switch (type) {
|
|
39
|
+
|
|
40
|
+
case 'paragraph': {
|
|
41
|
+
const el = document.createElement('p');
|
|
42
|
+
el.className = 'griot-block griot-paragraph';
|
|
43
|
+
if (text) el.appendChild(inlineDOM(text, opts));
|
|
44
|
+
return el;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case 'heading': {
|
|
48
|
+
const level = Math.max(1, Math.min(6, meta.level ?? 2));
|
|
49
|
+
const el = document.createElement(`h${level}`);
|
|
50
|
+
el.className = `griot-block griot-heading griot-heading--${level}`;
|
|
51
|
+
el.textContent = text ?? '';
|
|
52
|
+
return el;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
case 'blockquote': {
|
|
56
|
+
const el = document.createElement('blockquote');
|
|
57
|
+
el.className = 'griot-block griot-blockquote';
|
|
58
|
+
if (text) el.appendChild(inlineDOM(text, opts));
|
|
59
|
+
return el;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
case 'callout': {
|
|
63
|
+
const el = document.createElement('div');
|
|
64
|
+
const icon = document.createElement('span');
|
|
65
|
+
const body = document.createElement('div');
|
|
66
|
+
el.className = 'griot-block griot-callout';
|
|
67
|
+
icon.className = 'griot-callout__icon';
|
|
68
|
+
body.className = 'griot-callout__body';
|
|
69
|
+
icon.textContent = meta.icon ?? '💡';
|
|
70
|
+
if (text) body.appendChild(inlineDOM(text, opts));
|
|
71
|
+
el.appendChild(icon);
|
|
72
|
+
el.appendChild(body);
|
|
73
|
+
return el;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case 'code': {
|
|
77
|
+
const pre = document.createElement('pre');
|
|
78
|
+
const code = document.createElement('code');
|
|
79
|
+
pre.className = 'griot-block griot-code';
|
|
80
|
+
if (meta.language) code.className = `language-${meta.language}`;
|
|
81
|
+
code.textContent = text ?? '';
|
|
82
|
+
pre.appendChild(code);
|
|
83
|
+
return pre;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case 'divider': {
|
|
87
|
+
const el = document.createElement('hr');
|
|
88
|
+
el.className = 'griot-block griot-divider';
|
|
89
|
+
return el;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case 'image': {
|
|
93
|
+
const figure = document.createElement('figure');
|
|
94
|
+
const img = document.createElement('img');
|
|
95
|
+
figure.className = 'griot-block griot-image';
|
|
96
|
+
img.src = meta.src ?? '';
|
|
97
|
+
img.alt = meta.alt ?? '';
|
|
98
|
+
figure.appendChild(img);
|
|
99
|
+
if (meta.caption) {
|
|
100
|
+
const cap = document.createElement('figcaption');
|
|
101
|
+
cap.textContent = meta.caption;
|
|
102
|
+
figure.appendChild(cap);
|
|
103
|
+
}
|
|
104
|
+
return figure;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'timeline_ref': {
|
|
108
|
+
const el = document.createElement('div');
|
|
109
|
+
el.className = 'griot-block griot-timeline-ref';
|
|
110
|
+
if (meta.eventId && opts.onEventClick) {
|
|
111
|
+
el.setAttribute('role', 'button');
|
|
112
|
+
el.tabIndex = 0;
|
|
113
|
+
el.addEventListener('click', () => opts.onEventClick(meta.eventId));
|
|
114
|
+
el.addEventListener('keydown', (e) => {
|
|
115
|
+
if (e.key === 'Enter' || e.key === ' ') opts.onEventClick(meta.eventId);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
el.innerHTML = `
|
|
119
|
+
<span class="griot-timeline-ref__icon">⏱</span>
|
|
120
|
+
<div class="griot-timeline-ref__body">
|
|
121
|
+
<div class="griot-timeline-ref__title">${escHtml(meta.eventTitle || 'Timeline Event')}</div>
|
|
122
|
+
${meta.note ? `<div class="griot-timeline-ref__note">${escHtml(meta.note)}</div>` : ''}
|
|
123
|
+
</div>
|
|
124
|
+
${meta.eventId ? '<span class="griot-timeline-ref__arrow">→</span>' : ''}
|
|
125
|
+
`;
|
|
126
|
+
return el;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case 'book_citation': {
|
|
130
|
+
return _renderCitation(block, opts);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
default: {
|
|
134
|
+
const el = document.createElement('p');
|
|
135
|
+
el.className = 'griot-block griot-paragraph';
|
|
136
|
+
el.textContent = text ?? '';
|
|
137
|
+
return el;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _renderCitation(block, opts) {
|
|
143
|
+
const { meta = {} } = block;
|
|
144
|
+
const wrap = document.createElement('figure');
|
|
145
|
+
wrap.className = 'griot-block griot-citation';
|
|
146
|
+
|
|
147
|
+
if (!meta.bookId) {
|
|
148
|
+
wrap.innerHTML = `<div class="griot-citation__empty">📖 No source selected yet</div>`;
|
|
149
|
+
return wrap;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const book = (opts.books ?? []).find(b => b.id === meta.bookId);
|
|
153
|
+
const unit = book?.units?.find(u => u.id === meta.unitId);
|
|
154
|
+
|
|
155
|
+
if (!book || !unit) {
|
|
156
|
+
wrap.innerHTML = `<div class="griot-citation__missing">📖 Source not found — book may have been removed</div>`;
|
|
157
|
+
return wrap;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const inner = document.createElement('div');
|
|
161
|
+
inner.className = 'griot-citation__inner';
|
|
162
|
+
|
|
163
|
+
// Header
|
|
164
|
+
const hdr = document.createElement('div');
|
|
165
|
+
hdr.className = 'griot-citation__header';
|
|
166
|
+
hdr.innerHTML = `
|
|
167
|
+
<span class="griot-citation__book-icon">📖</span>
|
|
168
|
+
<span class="griot-citation__book-title">${escHtml(book.title)}</span>
|
|
169
|
+
${book.author ? `<span class="griot-citation__author">${escHtml(book.author)}</span>` : ''}
|
|
170
|
+
<span class="griot-citation__unit">${escHtml(unit.label)}</span>
|
|
171
|
+
`;
|
|
172
|
+
inner.appendChild(hdr);
|
|
173
|
+
|
|
174
|
+
// Quote
|
|
175
|
+
if (meta.quote) {
|
|
176
|
+
const q = document.createElement('blockquote');
|
|
177
|
+
q.className = 'griot-citation__quote';
|
|
178
|
+
q.textContent = meta.quote;
|
|
179
|
+
inner.appendChild(q);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Note (supports inline syntax)
|
|
183
|
+
if (meta.note) {
|
|
184
|
+
const note = document.createElement('div');
|
|
185
|
+
note.className = 'griot-citation__note';
|
|
186
|
+
note.appendChild(inlineDOM(meta.note, opts));
|
|
187
|
+
inner.appendChild(note);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
wrap.appendChild(inner);
|
|
191
|
+
|
|
192
|
+
// Content preview
|
|
193
|
+
if (unit.content) {
|
|
194
|
+
const preview = document.createElement('div');
|
|
195
|
+
preview.className = 'griot-citation__preview';
|
|
196
|
+
preview.textContent = unit.content.slice(0, 180) + (unit.content.length > 180 ? '…' : '');
|
|
197
|
+
wrap.appendChild(preview);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return wrap;
|
|
201
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ─── BlockSchema.js ───────────────────────────────────────────────────────────
|
|
2
|
+
// The single source of truth for what block types exist, their labels,
|
|
3
|
+
// icons, whether they have text, and any default meta values.
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const SCHEMA = {
|
|
7
|
+
paragraph: {
|
|
8
|
+
label: 'Paragraph',
|
|
9
|
+
icon: '¶',
|
|
10
|
+
hasText: true,
|
|
11
|
+
hasInline: true,
|
|
12
|
+
defaultMeta: {},
|
|
13
|
+
placeholder: 'Write something… **bold** *italic* `code` [[event:id|label]]',
|
|
14
|
+
},
|
|
15
|
+
heading: {
|
|
16
|
+
label: 'Heading',
|
|
17
|
+
icon: 'H',
|
|
18
|
+
hasText: true,
|
|
19
|
+
hasInline: false, // headings render as plain text, no inline chips
|
|
20
|
+
defaultMeta: { level: 2 },
|
|
21
|
+
placeholder: 'Heading…',
|
|
22
|
+
},
|
|
23
|
+
blockquote: {
|
|
24
|
+
label: 'Quote',
|
|
25
|
+
icon: '❝',
|
|
26
|
+
hasText: true,
|
|
27
|
+
hasInline: true,
|
|
28
|
+
defaultMeta: {},
|
|
29
|
+
placeholder: 'Quote…',
|
|
30
|
+
},
|
|
31
|
+
callout: {
|
|
32
|
+
label: 'Callout',
|
|
33
|
+
icon: '💡',
|
|
34
|
+
hasText: true,
|
|
35
|
+
hasInline: true,
|
|
36
|
+
defaultMeta: { icon: '💡' },
|
|
37
|
+
placeholder: 'Callout text…',
|
|
38
|
+
},
|
|
39
|
+
code: {
|
|
40
|
+
label: 'Code',
|
|
41
|
+
icon: '</>',
|
|
42
|
+
hasText: true,
|
|
43
|
+
hasInline: false, // code blocks are raw, no inline parsing
|
|
44
|
+
defaultMeta: { language: '' },
|
|
45
|
+
placeholder: '// code…',
|
|
46
|
+
},
|
|
47
|
+
divider: {
|
|
48
|
+
label: 'Divider',
|
|
49
|
+
icon: '—',
|
|
50
|
+
hasText: false,
|
|
51
|
+
hasInline: false,
|
|
52
|
+
defaultMeta: {},
|
|
53
|
+
placeholder: null,
|
|
54
|
+
},
|
|
55
|
+
image: {
|
|
56
|
+
label: 'Image',
|
|
57
|
+
icon: '🖼',
|
|
58
|
+
hasText: false,
|
|
59
|
+
hasInline: false,
|
|
60
|
+
defaultMeta: { src: '', alt: '', caption: '' },
|
|
61
|
+
placeholder: null,
|
|
62
|
+
},
|
|
63
|
+
timeline_ref: {
|
|
64
|
+
label: 'Timeline Event',
|
|
65
|
+
icon: '⏱',
|
|
66
|
+
hasText: false,
|
|
67
|
+
hasInline: false,
|
|
68
|
+
defaultMeta: { eventId: '', eventTitle: '', note: '' },
|
|
69
|
+
placeholder: null,
|
|
70
|
+
},
|
|
71
|
+
book_citation: {
|
|
72
|
+
label: 'Book Citation',
|
|
73
|
+
icon: '📖',
|
|
74
|
+
hasText: false,
|
|
75
|
+
hasInline: false,
|
|
76
|
+
defaultMeta: { bookId: '', unitId: '', quote: '', note: '' },
|
|
77
|
+
placeholder: null,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function getBlockDef(type) {
|
|
82
|
+
return SCHEMA[type] ?? SCHEMA.paragraph;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getAllTypes() {
|
|
86
|
+
return Object.keys(SCHEMA);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Returns default meta for a type (shallow copy)
|
|
90
|
+
export function defaultMeta(type) {
|
|
91
|
+
return { ...(SCHEMA[type]?.defaultMeta ?? {}) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default SCHEMA;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ─── Block.js ─────────────────────────────────────────────────────────────────
|
|
2
|
+
// Pure data. No DOM, no rendering. A block is a plain serialisable object.
|
|
3
|
+
//
|
|
4
|
+
// Shape:
|
|
5
|
+
// { id, type, text?, meta:{} }
|
|
6
|
+
//
|
|
7
|
+
// meta holds type-specific fields:
|
|
8
|
+
// heading → meta.level (1-6)
|
|
9
|
+
// callout → meta.icon
|
|
10
|
+
// code → meta.language
|
|
11
|
+
// image → meta.src, meta.alt, meta.caption
|
|
12
|
+
// divider → (no extra fields)
|
|
13
|
+
// timeline_ref → meta.eventId, meta.eventTitle
|
|
14
|
+
// book_citation → meta.bookId, meta.unitId, meta.quote, meta.note
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
let _seq = 0;
|
|
18
|
+
const uid = (prefix = 'b') => `${prefix}_${Date.now()}_${(++_seq).toString(36)}`;
|
|
19
|
+
|
|
20
|
+
// TEXT_TYPES — block types that carry a user-editable `text` field
|
|
21
|
+
export const TEXT_TYPES = new Set([
|
|
22
|
+
'paragraph', 'heading', 'blockquote', 'callout', 'code',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
// ALL_TYPES — exhaustive list for validation
|
|
26
|
+
export const ALL_TYPES = new Set([
|
|
27
|
+
'paragraph', 'heading', 'blockquote', 'callout', 'code',
|
|
28
|
+
'divider', 'image', 'timeline_ref', 'book_citation',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// ─── Factory ──────────────────────────────────────────────────────────────────
|
|
32
|
+
export function createBlock(type = 'paragraph', overrides = {}) {
|
|
33
|
+
if (!ALL_TYPES.has(type)) {
|
|
34
|
+
console.warn(`[Griot] Unknown block type "${type}", defaulting to paragraph`);
|
|
35
|
+
type = 'paragraph';
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
id: uid('b'),
|
|
39
|
+
type,
|
|
40
|
+
text: TEXT_TYPES.has(type) ? '' : null,
|
|
41
|
+
meta: {},
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function cloneBlock(block) {
|
|
47
|
+
return {
|
|
48
|
+
...block,
|
|
49
|
+
id: uid('b'),
|
|
50
|
+
meta: { ...block.meta },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Predicates ───────────────────────────────────────────────────────────────
|
|
55
|
+
export const isTextBlock = (b) => TEXT_TYPES.has(b?.type);
|
|
56
|
+
export const isValidBlock = (b) => b && typeof b.id === 'string' && ALL_TYPES.has(b.type);
|
|
57
|
+
|
|
58
|
+
// ─── Anchor ID (stable DOM id for deep-linking) ───────────────────────────────
|
|
59
|
+
export const anchorId = (blockId) => `griot-${blockId}`;
|
|
60
|
+
export const scrollToBlock = (blockId, behavior = 'smooth') => {
|
|
61
|
+
document.getElementById(anchorId(blockId))
|
|
62
|
+
?.scrollIntoView({ behavior, block: 'center' });
|
|
63
|
+
};
|