@0m0g1/griot 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +322 -127
- package/package.json +1 -1
- package/src/Griot.js +33 -35
- package/src/blocks/BlockRenderer.js +240 -93
- package/src/blocks/BlockSchema.js +32 -19
- package/src/editor/SlashMenu.js +197 -0
package/README.md
CHANGED
|
@@ -1,179 +1,374 @@
|
|
|
1
1
|
# Griot
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
3
|
+
A lightweight, extensible block editor and viewer for the web. Inspired by Notion, but built with plain JavaScript and zero dependencies. Griot provides a rich editing experience with:
|
|
4
|
+
|
|
5
|
+
- **Block-based editing** – paragraphs, headings, lists, callouts, code blocks, images, video, audio, tables, dividers, timeline references, book citations, and more.
|
|
6
|
+
- **Inline formatting** – bold, italic, underline, strikethrough, inline code, highlights, colored text, links, images, and custom event/cite chips.
|
|
7
|
+
- **Slash commands** – type `/` to insert any block.
|
|
8
|
+
- **Floating format toolbar** – appears when you select text.
|
|
9
|
+
- **Undo/redo** – with a built‑in history stack.
|
|
10
|
+
- **Read‑only viewer** – render the same document without editing controls.
|
|
11
|
+
- **Immutable document operations** – every change produces a new document object.
|
|
12
|
+
- **Schema‑driven** – all block types are defined in a single schema; easy to extend.
|
|
5
13
|
|
|
6
14
|
---
|
|
7
15
|
|
|
8
|
-
##
|
|
16
|
+
## Installation
|
|
9
17
|
|
|
10
18
|
```bash
|
|
11
|
-
|
|
12
|
-
npm install griot # (once published)
|
|
19
|
+
npm install griot
|
|
13
20
|
```
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
Or include it directly via ES module:
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<script type="module">
|
|
26
|
+
import { Editor, Viewer } from './path/to/griot.js';
|
|
27
|
+
// ...
|
|
28
|
+
</script>
|
|
18
29
|
```
|
|
19
30
|
|
|
20
31
|
---
|
|
21
32
|
|
|
22
|
-
## Quick
|
|
33
|
+
## Quick Start
|
|
23
34
|
|
|
24
35
|
### Editor
|
|
25
36
|
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
37
|
+
```html
|
|
38
|
+
<div id="editor-container"></div>
|
|
39
|
+
<script type="module">
|
|
40
|
+
import { Editor, createDocument } from 'griot';
|
|
41
|
+
|
|
42
|
+
const container = document.getElementById('editor-container');
|
|
43
|
+
const doc = createDocument([
|
|
44
|
+
{ id: 'b1', type: 'heading', text: 'Hello World', meta: { level: 1 } },
|
|
45
|
+
{ id: 'b2', type: 'paragraph', text: 'This is **editable** content.' },
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const editor = new Editor(container, {
|
|
49
|
+
doc,
|
|
50
|
+
books: [], // optional, for book citations
|
|
51
|
+
onChange: (newDoc) => {
|
|
52
|
+
console.log('Document changed:', newDoc);
|
|
53
|
+
},
|
|
54
|
+
onEventClick: (eventId) => {
|
|
55
|
+
console.log('Event clicked:', eventId);
|
|
56
|
+
},
|
|
57
|
+
onCiteClick: (blockId) => {
|
|
58
|
+
console.log('Citation clicked:', blockId);
|
|
59
|
+
},
|
|
60
|
+
onRequestBookPicker: (blockId, callback) => {
|
|
61
|
+
// Open your own book picker UI, then call callback with selection
|
|
62
|
+
callback({ bookId: 'book1', unitId: 'unit1', quote: '', note: '' });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Later, if you need to replace the document:
|
|
67
|
+
editor.setDoc(newDoc);
|
|
68
|
+
</script>
|
|
51
69
|
```
|
|
52
70
|
|
|
53
71
|
### Viewer
|
|
54
72
|
|
|
55
|
-
```
|
|
56
|
-
|
|
73
|
+
```html
|
|
74
|
+
<div id="viewer-container"></div>
|
|
75
|
+
<script type="module">
|
|
76
|
+
import { Viewer } from 'griot';
|
|
77
|
+
|
|
78
|
+
const container = document.getElementById('viewer-container');
|
|
79
|
+
const viewer = new Viewer(container, {
|
|
80
|
+
doc: myDocument,
|
|
81
|
+
books: myBooks,
|
|
82
|
+
onEventClick: (eventId) => { /* ... */ },
|
|
83
|
+
onCiteClick: (blockId) => { /* ... */ },
|
|
84
|
+
highlightBlockId: 'b2' // optional initial highlight
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Highlight and scroll to a block
|
|
88
|
+
viewer.setHighlight('b1');
|
|
89
|
+
</script>
|
|
90
|
+
```
|
|
57
91
|
|
|
58
|
-
|
|
59
|
-
doc,
|
|
60
|
-
books: [],
|
|
61
|
-
onEventClick(eventId) {
|
|
62
|
-
console.log('Open event:', eventId);
|
|
63
|
-
},
|
|
64
|
-
});
|
|
92
|
+
---
|
|
65
93
|
|
|
66
|
-
|
|
67
|
-
|
|
94
|
+
## Concepts
|
|
95
|
+
|
|
96
|
+
### Document
|
|
97
|
+
|
|
98
|
+
A Griot document is a plain object with an `id` and an array of blocks:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
interface Document {
|
|
102
|
+
id: string;
|
|
103
|
+
blocks: Block[];
|
|
104
|
+
}
|
|
68
105
|
```
|
|
69
106
|
|
|
70
|
-
|
|
107
|
+
### Block
|
|
108
|
+
|
|
109
|
+
Every block has at least `id`, `type`, and optionally `text` and `meta`. The `text` field is only present for block types that contain editable text (e.g. paragraphs, headings). All other block types store their data in `meta`.
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
interface Block {
|
|
113
|
+
id: string;
|
|
114
|
+
type: string;
|
|
115
|
+
text?: string | null;
|
|
116
|
+
meta: Record<string, any>;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Inline Markup
|
|
121
|
+
|
|
122
|
+
Within text blocks, you can use lightweight syntax:
|
|
123
|
+
|
|
124
|
+
| Syntax | Result |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `**bold**` | **bold** |
|
|
127
|
+
| `*italic*` | *italic* |
|
|
128
|
+
| `__underline__` | underline |
|
|
129
|
+
| `~~strikethrough~~` | ~~strikethrough~~ |
|
|
130
|
+
| `` `code` `` | `code` |
|
|
131
|
+
| `==highlight==` | highlight |
|
|
132
|
+
| `{#ff0000:red text}` or `{blue:text}` | colored text |
|
|
133
|
+
| `[link text](https://example.com)` | link |
|
|
134
|
+
| `` | image |
|
|
135
|
+
| `[[event:eventId\|label]]` | clickable chip → `onEventClick` |
|
|
136
|
+
| `[[cite:blockId\|label]]` | clickable chip → `onCiteClick` |
|
|
137
|
+
|
|
138
|
+
The inline parser is fully independent and can be used separately: `tokenizeInline()`, `renderInlineToDOM()`, `renderInlineToHTML()`.
|
|
139
|
+
|
|
140
|
+
### Block Schema
|
|
141
|
+
|
|
142
|
+
All block types are defined in `BlockSchema.js`. Each definition includes category, label, icon, slash label, whether it has text, default meta, and placeholder. You can extend the schema by adding new entries.
|
|
71
143
|
|
|
72
|
-
|
|
144
|
+
### History
|
|
73
145
|
|
|
74
|
-
|
|
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` |
|
|
146
|
+
The `History` class provides a simple linear undo/redo stack. The editor uses it internally; you can also use it standalone.
|
|
85
147
|
|
|
86
148
|
---
|
|
87
149
|
|
|
88
|
-
##
|
|
150
|
+
## API Reference
|
|
89
151
|
|
|
90
|
-
|
|
152
|
+
The public API is exposed through the main entry point (`griot.js`). Below are the most important exports.
|
|
91
153
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
154
|
+
### Core
|
|
155
|
+
|
|
156
|
+
| Export | Description |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `createBlock(type, overrides?)` | Create a new block with a unique id. |
|
|
159
|
+
| `cloneBlock(block, newId = true)` | Deep clone a block. |
|
|
160
|
+
| `isTextBlock(block)` | Check if a block stores a text string. |
|
|
161
|
+
| `isValidBlock(block)` | Minimal structural check. |
|
|
162
|
+
| `anchorId(blockId)` | Generate the DOM id used for a block element. |
|
|
163
|
+
| `scrollToBlock(blockId, behavior = 'smooth')` | Scroll to a block's element. |
|
|
164
|
+
| `TEXT_TYPES` | Set of block types that have a text field. |
|
|
165
|
+
| `ALL_TYPES` | Array of all known block type names. |
|
|
166
|
+
|
|
167
|
+
### Document Operations
|
|
168
|
+
|
|
169
|
+
All functions are immutable – they return a new document.
|
|
170
|
+
|
|
171
|
+
| Export | Description |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `createDocument(blocks?)` | Create a new document (with at least one paragraph). |
|
|
174
|
+
| `toJSON(doc)` / `fromJSON(json)` | Serialize / deserialize. |
|
|
175
|
+
| `getBlock(doc, id)` | Find a block by id. |
|
|
176
|
+
| `getBlockIndex(doc, id)` | Get index of a block. |
|
|
177
|
+
| `getBlockBefore(doc, id)` / `getBlockAfter(doc, id)` | Adjacent blocks. |
|
|
178
|
+
| `updateBlock(doc, id, patch)` | Update text and/or meta. |
|
|
179
|
+
| `insertBlockAfter(doc, afterId, newBlock)` | Insert block. |
|
|
180
|
+
| `insertBlockBefore(doc, beforeId, newBlock)` | Insert block. |
|
|
181
|
+
| `removeBlock(doc, id)` | Delete a block. |
|
|
182
|
+
| `moveBlock(doc, fromIdx, toIdx)` | Reorder blocks. |
|
|
183
|
+
| `splitBlock(doc, blockId, offset)` | Split a text block at offset. |
|
|
184
|
+
| `mergeBlockWithPrev(doc, blockId)` | Merge block into previous one. |
|
|
185
|
+
|
|
186
|
+
### Inline Parsing & Rendering
|
|
187
|
+
|
|
188
|
+
| Export | Description |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `tokenizeInline(text)` | Return an array of token objects. |
|
|
191
|
+
| `renderInlineToDOM(text, callbacks?)` | Render tokens into a DocumentFragment. |
|
|
192
|
+
| `renderInlineToHTML(text)` | Render tokens into an HTML string. |
|
|
193
|
+
| `escHtml(str)` / `escAttr(str)` | Escape helpers. |
|
|
194
|
+
| `TOKEN` | Enum of token types. |
|
|
195
|
+
|
|
196
|
+
### Block Rendering (for Viewer or custom use)
|
|
197
|
+
|
|
198
|
+
| Export | Description |
|
|
199
|
+
|---|---|
|
|
200
|
+
| `renderBlock(block, options)` | Render a single block to a DOM element. Used by Viewer. |
|
|
201
|
+
| `getBlockDef(type)` | Get the schema definition for a block type. |
|
|
202
|
+
| `getAllTypes()` | All registered block type names. |
|
|
203
|
+
| `getTypesByCategory(category)` | Filter types by category. |
|
|
204
|
+
| `defaultMeta(type)` | Get default meta for a type. |
|
|
205
|
+
| `resolveYouTube(src)` / `resolveVimeo` / `resolveSpotify` / `resolveSoundCloud` | Extract embed URLs from various sources. |
|
|
206
|
+
|
|
207
|
+
### Editor Classes
|
|
208
|
+
|
|
209
|
+
| Export | Description |
|
|
210
|
+
|---|---|
|
|
211
|
+
| `Editor` | Main editor class. See constructor options below. |
|
|
212
|
+
| `FormatToolbar` | Floating formatting toolbar (used internally, but can be used standalone). |
|
|
213
|
+
| `SlashMenu` | Slash command menu (used internally). |
|
|
214
|
+
| `DropHandler` | Handles drag & drop of files/images (not yet shown, but exported). |
|
|
215
|
+
|
|
216
|
+
**Editor constructor options:**
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
{
|
|
220
|
+
doc: Document; // initial document
|
|
221
|
+
books?: Book[]; // array of book objects for citations
|
|
222
|
+
onChange?: (doc: Document) => void; // called after every change (debounced)
|
|
223
|
+
onEventClick?: (eventId: string) => void;
|
|
224
|
+
onCiteClick?: (blockId: string) => void;
|
|
225
|
+
onRequestBookPicker?: (blockId: string, callback: (selection) => void) => void;
|
|
226
|
+
}
|
|
99
227
|
```
|
|
100
228
|
|
|
101
|
-
|
|
229
|
+
**Editor methods:**
|
|
102
230
|
|
|
103
|
-
|
|
231
|
+
- `setDoc(doc)` – replace the document.
|
|
232
|
+
- `setBooks(books)` – update the book list.
|
|
233
|
+
- `focus(blockId)` – focus a specific block.
|
|
234
|
+
- `destroy()` – clean up.
|
|
104
235
|
|
|
105
|
-
|
|
236
|
+
### Viewer
|
|
237
|
+
|
|
238
|
+
| Export | Description |
|
|
239
|
+
|---|---|
|
|
240
|
+
| `Viewer` | Read‑only renderer. |
|
|
241
|
+
|
|
242
|
+
**Viewer constructor options:**
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
106
245
|
{
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
]
|
|
246
|
+
doc?: Document;
|
|
247
|
+
books?: Book[];
|
|
248
|
+
onEventClick?: (eventId: string) => void;
|
|
249
|
+
onCiteClick?: (blockId: string) => void;
|
|
250
|
+
highlightBlockId?: string; // initial highlight
|
|
121
251
|
}
|
|
122
252
|
```
|
|
123
253
|
|
|
254
|
+
**Viewer methods:**
|
|
255
|
+
|
|
256
|
+
- `setDoc(doc)`
|
|
257
|
+
- `setBooks(books)`
|
|
258
|
+
- `setHighlight(blockId, options?)` – scroll to and briefly highlight a block.
|
|
259
|
+
- `destroy()`
|
|
260
|
+
|
|
261
|
+
### Keyboard Helpers
|
|
262
|
+
|
|
263
|
+
| Export | Description |
|
|
264
|
+
|---|---|
|
|
265
|
+
| `attachKeyboardHandler(el, blockId, callbacks)` | Attach editor keyboard shortcuts to a contenteditable element. |
|
|
266
|
+
| `getCursorOffset(el)` / `setCursorOffset(el, offset)` | Get/set caret position by character offset. |
|
|
267
|
+
| `getSelectionOffsets(el)` | Get start/end offsets of current selection. |
|
|
268
|
+
| `focusAtEnd(el)` / `focusAtStart(el)` | Move caret to end/start. |
|
|
269
|
+
|
|
270
|
+
### History
|
|
271
|
+
|
|
272
|
+
```javascript
|
|
273
|
+
import { History } from 'griot';
|
|
274
|
+
|
|
275
|
+
const history = new History(initialDoc);
|
|
276
|
+
history.push(newDoc);
|
|
277
|
+
history.undo(); // returns previous document
|
|
278
|
+
history.redo();
|
|
279
|
+
history.current; // current document
|
|
280
|
+
```
|
|
281
|
+
|
|
124
282
|
---
|
|
125
283
|
|
|
126
|
-
##
|
|
284
|
+
## Styling
|
|
127
285
|
|
|
128
|
-
|
|
286
|
+
Griot includes no CSS by design – you can style it to match your application. All elements have semantic class names prefixed with `griot-`. For a quick start, you can copy the example styles from the test page or browse the class names used in the source.
|
|
129
287
|
|
|
130
|
-
|
|
131
|
-
import { anchorId, scrollToBlock } from 'griot';
|
|
288
|
+
**Minimal recommended styles:**
|
|
132
289
|
|
|
133
|
-
|
|
134
|
-
|
|
290
|
+
- Make `.griot-editor-block__input[contenteditable]` look like a normal block.
|
|
291
|
+
- Add basic spacing and borders.
|
|
292
|
+
- Style the floating toolbar and slash menu as floating cards.
|
|
135
293
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Examples
|
|
297
|
+
|
|
298
|
+
### Basic Editor with Slash Menu and Toolbar
|
|
299
|
+
|
|
300
|
+
The editor includes the slash menu and format toolbar automatically. Just instantiate it.
|
|
301
|
+
|
|
302
|
+
### Using the Viewer with Highlight
|
|
303
|
+
|
|
304
|
+
```javascript
|
|
305
|
+
const viewer = new Viewer(container, { doc });
|
|
306
|
+
viewer.setHighlight('some-block-id');
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Custom Book Picker
|
|
310
|
+
|
|
311
|
+
When a `book_citation` block is added, the editor calls `onRequestBookPicker`. Implement your own modal or dropdown:
|
|
312
|
+
|
|
313
|
+
```javascript
|
|
314
|
+
onRequestBookPicker: (blockId, callback) => {
|
|
315
|
+
const book = prompt('Enter book ID:');
|
|
316
|
+
const unit = prompt('Enter unit ID:');
|
|
317
|
+
callback({ bookId: book, unitId: unit, quote: '', note: '' });
|
|
318
|
+
}
|
|
139
319
|
```
|
|
140
320
|
|
|
141
|
-
|
|
142
|
-
|
|
321
|
+
### Using Inline Renderer Standalone
|
|
322
|
+
|
|
323
|
+
```javascript
|
|
324
|
+
import { renderInlineToDOM } from 'griot';
|
|
325
|
+
|
|
326
|
+
const text = 'This is **bold** and [a link](https://example.com).';
|
|
327
|
+
const fragment = renderInlineToDOM(text, {
|
|
328
|
+
onEventClick: (id) => console.log(id),
|
|
329
|
+
onCiteClick: (id) => console.log(id)
|
|
330
|
+
});
|
|
331
|
+
document.getElementById('output').appendChild(fragment);
|
|
332
|
+
```
|
|
143
333
|
|
|
144
334
|
---
|
|
145
335
|
|
|
146
|
-
##
|
|
336
|
+
## Extending
|
|
147
337
|
|
|
148
|
-
###
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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)
|
|
338
|
+
### Adding a New Block Type
|
|
339
|
+
|
|
340
|
+
1. Add an entry in `BlockSchema.js` (or patch the schema at runtime).
|
|
341
|
+
2. Add a rendering case in `BlockRenderer.js`.
|
|
342
|
+
3. If the block has a special editor UI, add a case in `Editor._buildSpecialBlockUI()`.
|
|
343
|
+
4. (Optional) Add support in the slash menu – it reads from the schema automatically.
|
|
344
|
+
|
|
345
|
+
### Custom Inline Syntax
|
|
346
|
+
|
|
347
|
+
Modify `InlineLexer.js` by adding a new rule. Then update `InlineRenderer.js` to render the new token type.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Development
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
git clone https://github.com/yourname/griot.git
|
|
355
|
+
cd griot
|
|
356
|
+
npm install
|
|
357
|
+
npm run dev # serves test page at localhost:5000 (or similar)
|
|
179
358
|
```
|
|
359
|
+
|
|
360
|
+
The source is organised as:
|
|
361
|
+
|
|
362
|
+
- `src/core/` – block primitives, document operations, history.
|
|
363
|
+
- `src/blocks/` – block schema and renderer.
|
|
364
|
+
- `src/inline/` – inline lexer and renderer.
|
|
365
|
+
- `src/editor/` – editor UI, keyboard handling, slash menu, toolbar.
|
|
366
|
+
- `src/viewer/` – read-only renderer.
|
|
367
|
+
|
|
368
|
+
All public exports are aggregated in `src/griot.js`.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## License
|
|
373
|
+
|
|
374
|
+
MIT
|
package/package.json
CHANGED
package/src/Griot.js
CHANGED
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
// ─── Griot.js ─────────────────────────────────────────────────────────────────
|
|
2
|
-
// Public facade. Import from here — never from internal modules directly.
|
|
2
|
+
// Public facade. Import from here only — never from internal modules directly.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// tokenizeInline, renderInlineToDOM, renderInlineToHTML, TOKEN
|
|
20
|
-
//
|
|
21
|
-
// Schema
|
|
22
|
-
// getBlockDef, getAllTypes, defaultMeta, BlockSchema
|
|
4
|
+
// Classes Editor, Viewer, FormatToolbar, SlashMenu, DropHandler
|
|
5
|
+
// Document createDocument, createBlock, cloneBlock
|
|
6
|
+
// updateBlock, insertBlockAfter, insertBlockBefore,
|
|
7
|
+
// removeBlock, moveBlock, splitBlock, mergeBlockWithPrev,
|
|
8
|
+
// getBlock, getBlockIndex, getBlockBefore, getBlockAfter,
|
|
9
|
+
// toJSON, fromJSON
|
|
10
|
+
// Block anchorId, scrollToBlock, isTextBlock, isValidBlock
|
|
11
|
+
// TEXT_TYPES, ALL_TYPES
|
|
12
|
+
// Inline tokenizeInline, renderInlineToDOM, renderInlineToHTML,
|
|
13
|
+
// escHtml, escAttr, TOKEN
|
|
14
|
+
// Schema getBlockDef, getAllTypes, getTypesByCategory,
|
|
15
|
+
// defaultMeta, BlockSchema
|
|
16
|
+
// Keyboard attachKeyboardHandler, getCursorOffset, getSelectionOffsets,
|
|
17
|
+
// setCursorOffset, focusAtEnd, focusAtStart
|
|
18
|
+
// URL helpers resolveYouTube, resolveVimeo, resolveSpotify, resolveSoundCloud
|
|
23
19
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
20
|
|
|
25
|
-
// Core
|
|
26
21
|
export {
|
|
27
22
|
createBlock, cloneBlock, isTextBlock, isValidBlock,
|
|
28
|
-
anchorId, scrollToBlock,
|
|
29
|
-
TEXT_TYPES, ALL_TYPES,
|
|
23
|
+
anchorId, scrollToBlock, TEXT_TYPES, ALL_TYPES,
|
|
30
24
|
} from './core/Block.js';
|
|
31
25
|
|
|
32
26
|
export {
|
|
@@ -38,17 +32,21 @@ export {
|
|
|
38
32
|
|
|
39
33
|
export { History } from './core/History.js';
|
|
40
34
|
|
|
41
|
-
|
|
42
|
-
export {
|
|
43
|
-
export {
|
|
44
|
-
renderInlineToDOM, renderInlineToHTML, escHtml, escAttr,
|
|
45
|
-
} from './inline/InlineRenderer.js';
|
|
35
|
+
export { tokenizeInline, TOKEN } from './inline/InlineLexer.js';
|
|
36
|
+
export { renderInlineToDOM, renderInlineToHTML, escHtml, escAttr } from './inline/InlineRenderer.js';
|
|
46
37
|
|
|
47
|
-
|
|
48
|
-
export {
|
|
49
|
-
export {
|
|
50
|
-
|
|
38
|
+
export { getBlockDef, getAllTypes, getTypesByCategory, defaultMeta } from './blocks/BlockSchema.js';
|
|
39
|
+
export { default as BlockSchema } from './blocks/BlockSchema.js';
|
|
40
|
+
export { renderBlock, resolveYouTube, resolveVimeo, resolveSpotify, resolveSoundCloud } from './blocks/BlockRenderer.js';
|
|
41
|
+
|
|
42
|
+
export { Editor } from './editor/Editor.js';
|
|
43
|
+
export { FormatToolbar } from './editor/FormatToolbar.js';
|
|
44
|
+
export { SlashMenu } from './editor/SlashMenu.js';
|
|
45
|
+
export { DropHandler } from './editor/DropHandler.js';
|
|
46
|
+
export {
|
|
47
|
+
attachKeyboardHandler,
|
|
48
|
+
getCursorOffset, getSelectionOffsets, setCursorOffset,
|
|
49
|
+
focusAtEnd, focusAtStart,
|
|
50
|
+
} from './editor/Keyboard.js';
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
export { Editor } from './editor/Editor.js';
|
|
54
|
-
export { Viewer } from './viewer/Viewer.js';
|
|
52
|
+
export { Viewer } from './viewer/Viewer.js';
|
|
@@ -1,46 +1,48 @@
|
|
|
1
1
|
// ─── BlockRenderer.js ─────────────────────────────────────────────────────────
|
|
2
|
-
// Renders a single block
|
|
3
|
-
// Used by
|
|
2
|
+
// Renders a single block → DOM element.
|
|
3
|
+
// Used by Viewer (read-only) and Editor preview layer.
|
|
4
4
|
//
|
|
5
5
|
// Options:
|
|
6
|
-
// books
|
|
7
|
-
// onEventClick
|
|
8
|
-
// onCiteClick
|
|
9
|
-
// editable — if true, skips event listeners (Editor manages them)
|
|
6
|
+
// books — array of book objects
|
|
7
|
+
// onEventClick — (eventId) => void
|
|
8
|
+
// onCiteClick — (blockId) => void
|
|
10
9
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
10
|
|
|
12
|
-
import { anchorId }
|
|
11
|
+
import { anchorId } from '../core/Block.js';
|
|
13
12
|
import { renderInlineToDOM, renderInlineToHTML, escHtml, escAttr } from '../inline/InlineRenderer.js';
|
|
14
|
-
import { getBlockDef }
|
|
13
|
+
import { getBlockDef } from './BlockSchema.js';
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const LAYOUT_OPTIONS = ['grid','masonry','carousel','strip'];
|
|
16
|
+
|
|
17
|
+
// ─── Public ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export function renderBlock(block, opts = {}) {
|
|
20
|
+
const el = _render(block, opts);
|
|
19
21
|
if (el) {
|
|
20
|
-
el.id
|
|
21
|
-
el.dataset.blockId
|
|
22
|
+
el.id = anchorId(block.id);
|
|
23
|
+
el.dataset.blockId = block.id;
|
|
22
24
|
el.dataset.blockType = block.type;
|
|
23
25
|
}
|
|
24
26
|
return el;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
// ───
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
onCiteClick: opts.onCiteClick,
|
|
32
|
-
});
|
|
29
|
+
// ─── Dispatcher ───────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function il(text, opts) {
|
|
32
|
+
return renderInlineToDOM(text, { onEventClick: opts.onEventClick, onCiteClick: opts.onCiteClick });
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function _render(block, opts) {
|
|
36
|
-
const { text, meta = {}, type } = block;
|
|
36
|
+
const { text = '', meta = {}, type } = block;
|
|
37
37
|
|
|
38
38
|
switch (type) {
|
|
39
39
|
|
|
40
|
+
// ── Text ──────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
40
42
|
case 'paragraph': {
|
|
41
43
|
const el = document.createElement('p');
|
|
42
44
|
el.className = 'griot-block griot-paragraph';
|
|
43
|
-
if (text) el.appendChild(
|
|
45
|
+
if (text) el.appendChild(il(text, opts));
|
|
44
46
|
return el;
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -55,70 +57,185 @@ function _render(block, opts) {
|
|
|
55
57
|
case 'blockquote': {
|
|
56
58
|
const el = document.createElement('blockquote');
|
|
57
59
|
el.className = 'griot-block griot-blockquote';
|
|
58
|
-
if (text) el.appendChild(
|
|
60
|
+
if (text) el.appendChild(il(text, opts));
|
|
59
61
|
return el;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
case 'callout':
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
case 'callout':
|
|
65
|
+
case 'callout_warning':
|
|
66
|
+
case 'callout_tip':
|
|
67
|
+
case 'callout_danger': {
|
|
68
|
+
const ICONS = { callout:'💡', callout_warning:'⚠️', callout_tip:'✅', callout_danger:'🚨' };
|
|
69
|
+
const el = document.createElement('div');
|
|
70
|
+
const icon = document.createElement('span');
|
|
71
|
+
const body = document.createElement('div');
|
|
72
|
+
el.className = `griot-block griot-callout griot-callout--${type.replace('callout_','') || 'info'}`;
|
|
67
73
|
icon.className = 'griot-callout__icon';
|
|
68
74
|
body.className = 'griot-callout__body';
|
|
69
|
-
icon.textContent = meta.icon ?? '💡';
|
|
70
|
-
if (text) body.appendChild(
|
|
71
|
-
el.
|
|
72
|
-
el.appendChild(body);
|
|
75
|
+
icon.textContent = meta.icon ?? ICONS[type] ?? '💡';
|
|
76
|
+
if (text) body.appendChild(il(text, opts));
|
|
77
|
+
el.append(icon, body);
|
|
73
78
|
return el;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
case 'code': {
|
|
77
82
|
const pre = document.createElement('pre');
|
|
78
83
|
const code = document.createElement('code');
|
|
79
|
-
pre.className
|
|
80
|
-
if (meta.language) code.className = `language-${meta.language}`;
|
|
84
|
+
pre.className = 'griot-block griot-code';
|
|
85
|
+
if (meta.language) { pre.dataset.language = meta.language; code.className = `language-${meta.language}`; }
|
|
81
86
|
code.textContent = text ?? '';
|
|
82
87
|
pre.appendChild(code);
|
|
83
88
|
return pre;
|
|
84
89
|
}
|
|
85
90
|
|
|
86
|
-
case '
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
case 'list_ul':
|
|
92
|
+
case 'list_ol': {
|
|
93
|
+
const tag = type === 'list_ul' ? 'ul' : 'ol';
|
|
94
|
+
const el = document.createElement(tag);
|
|
95
|
+
el.className = `griot-block griot-list griot-list--${tag}`;
|
|
96
|
+
for (const item of (text ?? '').split('\n').filter(l => l.trim())) {
|
|
97
|
+
const li = document.createElement('li');
|
|
98
|
+
li.appendChild(il(item, opts));
|
|
99
|
+
el.appendChild(li);
|
|
100
|
+
}
|
|
89
101
|
return el;
|
|
90
102
|
}
|
|
91
103
|
|
|
104
|
+
// ── Media ─────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
92
106
|
case 'image': {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
const fig = document.createElement('figure');
|
|
108
|
+
fig.className = `griot-block griot-image griot-image--${meta.width ?? 'full'}`;
|
|
109
|
+
if (meta.uploading) {
|
|
110
|
+
const sp = document.createElement('div');
|
|
111
|
+
sp.className = 'griot-media-uploading';
|
|
112
|
+
sp.textContent = 'Uploading…';
|
|
113
|
+
fig.appendChild(sp);
|
|
114
|
+
} else if (meta.src) {
|
|
115
|
+
const img = document.createElement('img');
|
|
116
|
+
img.src = meta.src; img.alt = meta.alt ?? '';
|
|
117
|
+
fig.appendChild(img);
|
|
118
|
+
if (meta.caption) {
|
|
119
|
+
const cap = document.createElement('figcaption');
|
|
120
|
+
cap.textContent = meta.caption;
|
|
121
|
+
fig.appendChild(cap);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return fig;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case 'video': {
|
|
128
|
+
const fig = document.createElement('figure');
|
|
129
|
+
fig.className = 'griot-block griot-video';
|
|
130
|
+
const embed = meta.embedUrl ?? _ytEmbed(meta.src) ?? _vimeoEmbed(meta.src);
|
|
131
|
+
if (embed) {
|
|
132
|
+
const iframe = document.createElement('iframe');
|
|
133
|
+
iframe.src = embed; iframe.className = 'griot-video__iframe';
|
|
134
|
+
iframe.frameBorder = '0';
|
|
135
|
+
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
|
|
136
|
+
iframe.allowFullscreen = true;
|
|
137
|
+
fig.appendChild(iframe);
|
|
138
|
+
} else if (meta.src) {
|
|
139
|
+
const v = document.createElement('video');
|
|
140
|
+
v.src = meta.src; v.controls = true; v.className = 'griot-video__native';
|
|
141
|
+
fig.appendChild(v);
|
|
142
|
+
}
|
|
143
|
+
if (meta.caption) { const c = document.createElement('figcaption'); c.textContent = meta.caption; fig.appendChild(c); }
|
|
144
|
+
return fig;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'audio': {
|
|
148
|
+
const fig = document.createElement('figure');
|
|
149
|
+
fig.className = 'griot-block griot-audio';
|
|
150
|
+
const embed = meta.embedUrl ?? _spotifyEmbed(meta.src) ?? _scEmbed(meta.src);
|
|
151
|
+
if (embed) {
|
|
152
|
+
const iframe = document.createElement('iframe');
|
|
153
|
+
iframe.src = embed; iframe.className = 'griot-audio__iframe';
|
|
154
|
+
iframe.frameBorder = '0';
|
|
155
|
+
iframe.allow = 'autoplay; clipboard-write; encrypted-media; fullscreen';
|
|
156
|
+
fig.appendChild(iframe);
|
|
157
|
+
} else if (meta.src) {
|
|
158
|
+
const a = document.createElement('audio');
|
|
159
|
+
a.src = meta.src; a.controls = true; a.className = 'griot-audio__native';
|
|
160
|
+
fig.appendChild(a);
|
|
103
161
|
}
|
|
104
|
-
|
|
162
|
+
if (meta.caption) { const c = document.createElement('figcaption'); c.textContent = meta.caption; fig.appendChild(c); }
|
|
163
|
+
return fig;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case 'gallery': {
|
|
167
|
+
return _renderGallery(meta.items ?? [], meta.layout ?? 'grid', opts);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case 'embed': {
|
|
171
|
+
const fig = document.createElement('figure');
|
|
172
|
+
fig.className = 'griot-block griot-embed';
|
|
173
|
+
if (meta.src) {
|
|
174
|
+
const iframe = document.createElement('iframe');
|
|
175
|
+
iframe.src = meta.src;
|
|
176
|
+
iframe.style.height = `${meta.height ?? 400}px`;
|
|
177
|
+
iframe.className = 'griot-embed__iframe';
|
|
178
|
+
iframe.frameBorder = '0';
|
|
179
|
+
iframe.allow = 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media';
|
|
180
|
+
iframe.allowFullscreen = true;
|
|
181
|
+
fig.appendChild(iframe);
|
|
182
|
+
}
|
|
183
|
+
if (meta.caption) { const c = document.createElement('figcaption'); c.textContent = meta.caption; fig.appendChild(c); }
|
|
184
|
+
return fig;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Structure ─────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
case 'table': {
|
|
190
|
+
const headers = Array.isArray(meta.headers) ? meta.headers : [];
|
|
191
|
+
const rows = Array.isArray(meta.rows) ? meta.rows : [];
|
|
192
|
+
const colCount = Math.max(headers.length, ...rows.map(r => r.length), 1);
|
|
193
|
+
const wrap = document.createElement('div');
|
|
194
|
+
wrap.className = 'griot-block griot-table-wrap';
|
|
195
|
+
const table = document.createElement('table');
|
|
196
|
+
table.className = 'griot-table';
|
|
197
|
+
if (headers.length) {
|
|
198
|
+
const thead = document.createElement('thead');
|
|
199
|
+
const tr = document.createElement('tr');
|
|
200
|
+
for (let ci = 0; ci < colCount; ci++) {
|
|
201
|
+
const th = document.createElement('th');
|
|
202
|
+
th.appendChild(il(headers[ci] ?? '', opts));
|
|
203
|
+
tr.appendChild(th);
|
|
204
|
+
}
|
|
205
|
+
thead.appendChild(tr); table.appendChild(thead);
|
|
206
|
+
}
|
|
207
|
+
const tbody = document.createElement('tbody');
|
|
208
|
+
for (const row of rows) {
|
|
209
|
+
const tr = document.createElement('tr');
|
|
210
|
+
for (let ci = 0; ci < colCount; ci++) {
|
|
211
|
+
const td = document.createElement('td');
|
|
212
|
+
td.appendChild(il(row[ci] ?? '', opts));
|
|
213
|
+
tr.appendChild(td);
|
|
214
|
+
}
|
|
215
|
+
tbody.appendChild(tr);
|
|
216
|
+
}
|
|
217
|
+
table.appendChild(tbody); wrap.appendChild(table);
|
|
218
|
+
return wrap;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
case 'divider': {
|
|
222
|
+
const el = document.createElement('hr');
|
|
223
|
+
el.className = 'griot-block griot-divider';
|
|
224
|
+
return el;
|
|
105
225
|
}
|
|
106
226
|
|
|
107
227
|
case 'timeline_ref': {
|
|
108
228
|
const el = document.createElement('div');
|
|
109
229
|
el.className = 'griot-block griot-timeline-ref';
|
|
110
230
|
if (meta.eventId && opts.onEventClick) {
|
|
111
|
-
el.setAttribute('role',
|
|
112
|
-
el.tabIndex = 0;
|
|
231
|
+
el.setAttribute('role','button'); el.tabIndex = 0;
|
|
113
232
|
el.addEventListener('click', () => opts.onEventClick(meta.eventId));
|
|
114
|
-
el.addEventListener('keydown', (e)
|
|
115
|
-
if (e.key === 'Enter' || e.key === ' ') opts.onEventClick(meta.eventId);
|
|
116
|
-
});
|
|
233
|
+
el.addEventListener('keydown', e => { if (e.key==='Enter'||e.key===' ') opts.onEventClick(meta.eventId); });
|
|
117
234
|
}
|
|
118
235
|
el.innerHTML = `
|
|
119
236
|
<span class="griot-timeline-ref__icon">⏱</span>
|
|
120
237
|
<div class="griot-timeline-ref__body">
|
|
121
|
-
<div class="griot-timeline-ref__title">${escHtml(meta.eventTitle
|
|
238
|
+
<div class="griot-timeline-ref__title">${escHtml(meta.eventTitle||'Timeline Event')}</div>
|
|
122
239
|
${meta.note ? `<div class="griot-timeline-ref__note">${escHtml(meta.note)}</div>` : ''}
|
|
123
240
|
</div>
|
|
124
241
|
${meta.eventId ? '<span class="griot-timeline-ref__arrow">→</span>' : ''}
|
|
@@ -133,69 +250,99 @@ function _render(block, opts) {
|
|
|
133
250
|
default: {
|
|
134
251
|
const el = document.createElement('p');
|
|
135
252
|
el.className = 'griot-block griot-paragraph';
|
|
136
|
-
el.
|
|
253
|
+
if (text) el.appendChild(il(text, opts));
|
|
137
254
|
return el;
|
|
138
255
|
}
|
|
139
256
|
}
|
|
140
257
|
}
|
|
141
258
|
|
|
259
|
+
// ─── Gallery renderer ─────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function _renderGallery(items, layout, opts) {
|
|
262
|
+
const wrap = document.createElement('div');
|
|
263
|
+
wrap.className = `griot-block griot-gallery griot-gallery--${LAYOUT_OPTIONS.includes(layout)?layout:'grid'}`;
|
|
264
|
+
|
|
265
|
+
if (!items.length) {
|
|
266
|
+
wrap.innerHTML = `<div class="griot-gallery__empty">No images yet</div>`;
|
|
267
|
+
return wrap;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const [i, item] of items.entries()) {
|
|
271
|
+
const el = document.createElement('div');
|
|
272
|
+
el.className = 'griot-gallery__item';
|
|
273
|
+
const img = document.createElement('img');
|
|
274
|
+
img.src = item.src ?? item.url ?? '';
|
|
275
|
+
img.alt = item.alt ?? item.alt_text ?? '';
|
|
276
|
+
img.loading = 'lazy';
|
|
277
|
+
el.appendChild(img);
|
|
278
|
+
if (item.caption) {
|
|
279
|
+
const cap = document.createElement('p');
|
|
280
|
+
cap.className = 'griot-gallery__caption';
|
|
281
|
+
cap.textContent = item.caption;
|
|
282
|
+
el.appendChild(cap);
|
|
283
|
+
}
|
|
284
|
+
wrap.appendChild(el);
|
|
285
|
+
}
|
|
286
|
+
return wrap;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Citation renderer ────────────────────────────────────────────────────────
|
|
290
|
+
|
|
142
291
|
function _renderCitation(block, opts) {
|
|
143
292
|
const { meta = {} } = block;
|
|
144
293
|
const wrap = document.createElement('figure');
|
|
145
294
|
wrap.className = 'griot-block griot-citation';
|
|
146
295
|
|
|
147
|
-
if (!meta.bookId) {
|
|
148
|
-
wrap.innerHTML = `<div class="griot-citation__empty">📖 No source selected yet</div>`;
|
|
149
|
-
return wrap;
|
|
150
|
-
}
|
|
296
|
+
if (!meta.bookId) { wrap.innerHTML = `<div class="griot-citation__empty">📖 No source selected yet</div>`; return wrap; }
|
|
151
297
|
|
|
152
298
|
const book = (opts.books ?? []).find(b => b.id === meta.bookId);
|
|
153
299
|
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
|
-
}
|
|
300
|
+
if (!book || !unit) { wrap.innerHTML = `<div class="griot-citation__missing">📖 Source not found</div>`; return wrap; }
|
|
159
301
|
|
|
160
302
|
const inner = document.createElement('div');
|
|
161
303
|
inner.className = 'griot-citation__inner';
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
${
|
|
170
|
-
<span class="griot-citation__unit">${escHtml(unit.label)}</span>
|
|
304
|
+
inner.innerHTML = `
|
|
305
|
+
<div class="griot-citation__header">
|
|
306
|
+
<span class="griot-citation__book-icon">📖</span>
|
|
307
|
+
<span class="griot-citation__book-title">${escHtml(book.title)}</span>
|
|
308
|
+
${book.author ? `<span class="griot-citation__author">${escHtml(book.author)}</span>` : ''}
|
|
309
|
+
<span class="griot-citation__unit">${escHtml(unit.label)}</span>
|
|
310
|
+
</div>
|
|
311
|
+
${meta.quote ? `<blockquote class="griot-citation__quote">${escHtml(meta.quote)}</blockquote>` : ''}
|
|
171
312
|
`;
|
|
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
313
|
if (meta.note) {
|
|
184
314
|
const note = document.createElement('div');
|
|
185
315
|
note.className = 'griot-citation__note';
|
|
186
|
-
note.appendChild(
|
|
316
|
+
note.appendChild(renderInlineToDOM(meta.note, opts));
|
|
187
317
|
inner.appendChild(note);
|
|
188
318
|
}
|
|
189
|
-
|
|
190
319
|
wrap.appendChild(inner);
|
|
320
|
+
return wrap;
|
|
321
|
+
}
|
|
191
322
|
|
|
192
|
-
|
|
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
|
-
}
|
|
323
|
+
// ─── Embed URL helpers ────────────────────────────────────────────────────────
|
|
199
324
|
|
|
200
|
-
|
|
325
|
+
function _ytEmbed(src) {
|
|
326
|
+
if (!src) return null;
|
|
327
|
+
const m = src.match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|shorts\/|embed\/))([a-zA-Z0-9_-]{11})/);
|
|
328
|
+
return m ? `https://www.youtube.com/embed/${m[1]}?rel=0` : null;
|
|
329
|
+
}
|
|
330
|
+
function _vimeoEmbed(src) {
|
|
331
|
+
if (!src) return null;
|
|
332
|
+
const m = src.match(/vimeo\.com\/(\d+)/);
|
|
333
|
+
return m ? `https://player.vimeo.com/video/${m[1]}` : null;
|
|
201
334
|
}
|
|
335
|
+
function _spotifyEmbed(src) {
|
|
336
|
+
if (!src) return null;
|
|
337
|
+
const m = src.match(/open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/);
|
|
338
|
+
return m ? `https://open.spotify.com/embed/${m[1]}/${m[2]}` : null;
|
|
339
|
+
}
|
|
340
|
+
function _scEmbed(src) {
|
|
341
|
+
if (!src) return null;
|
|
342
|
+
if (src.includes('soundcloud.com/'))
|
|
343
|
+
return `https://w.soundcloud.com/player/?url=${encodeURIComponent(src)}&color=%236366f1&auto_play=false&hide_related=true&show_comments=false`;
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Export helpers for editor use
|
|
348
|
+
export { _ytEmbed as resolveYouTube, _vimeoEmbed as resolveVimeo, _spotifyEmbed as resolveSpotify, _scEmbed as resolveSoundCloud };
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
// ─── BlockSchema.js ───────────────────────────────────────────────────────────
|
|
2
|
+
// Single source of truth for all block types
|
|
3
|
+
// Fields: category, label, icon, slashLabel, hasText, hasInline, defaultMeta, placeholder
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
2
6
|
const SCHEMA = {
|
|
3
|
-
paragraph: { label:
|
|
4
|
-
heading: { label:
|
|
5
|
-
blockquote: { label:
|
|
6
|
-
callout: { label:
|
|
7
|
-
callout_warning: { label:
|
|
8
|
-
callout_tip: { label:
|
|
9
|
-
callout_danger: { label:
|
|
10
|
-
code: { label:
|
|
11
|
-
list_ul: { label:
|
|
12
|
-
list_ol: { label:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
7
|
+
paragraph: { category:'text', label:'Paragraph', icon:'¶', slashLabel:'Text', hasText:true, hasInline:true, defaultMeta:{}, placeholder:'Write something… **bold** *italic* `code` ==highlight==' },
|
|
8
|
+
heading: { category:'text', label:'Heading', icon:'H', slashLabel:'Heading', hasText:true, hasInline:false, defaultMeta:{ level:2 }, placeholder:'Heading…' },
|
|
9
|
+
blockquote: { category:'text', label:'Quote', icon:'❝', slashLabel:'Quote', hasText:true, hasInline:true, defaultMeta:{}, placeholder:'Quote…' },
|
|
10
|
+
callout: { category:'text', label:'Callout', icon:'💡', slashLabel:'Callout', hasText:true, hasInline:true, defaultMeta:{ icon:'💡' }, placeholder:'Callout text…' },
|
|
11
|
+
callout_warning: { category:'text', label:'Warning', icon:'⚠️', slashLabel:'Warning', hasText:true, hasInline:true, defaultMeta:{ icon:'⚠️' }, placeholder:'Warning message…' },
|
|
12
|
+
callout_tip: { category:'text', label:'Tip', icon:'✅', slashLabel:'Tip', hasText:true, hasInline:true, defaultMeta:{ icon:'✅' }, placeholder:'Tip or note…' },
|
|
13
|
+
callout_danger: { category:'text', label:'Danger', icon:'🚨', slashLabel:'Danger', hasText:true, hasInline:true, defaultMeta:{ icon:'🚨' }, placeholder:'Critical warning…' },
|
|
14
|
+
code: { category:'text', label:'Code', icon:'</>', slashLabel:'Code block', hasText:true, hasInline:false, defaultMeta:{ language:'' }, placeholder:'// code…' },
|
|
15
|
+
list_ul: { category:'text', label:'Bullet List', icon:'•', slashLabel:'Bullet list', hasText:true, hasInline:false, defaultMeta:{}, placeholder:'Item 1\nItem 2\nItem 3' },
|
|
16
|
+
list_ol: { category:'text', label:'Numbered List', icon:'1.', slashLabel:'Numbered list', hasText:true, hasInline:false, defaultMeta:{}, placeholder:'First item\nSecond item' },
|
|
17
|
+
|
|
18
|
+
image: { category:'media', label:'Image', icon:'🖼', slashLabel:'Image', hasText:false, hasInline:false, defaultMeta:{ src:'', alt:'', caption:'', width:'full' }, placeholder:null },
|
|
19
|
+
video: { category:'media', label:'Video', icon:'▶', slashLabel:'Video', hasText:false, hasInline:false, defaultMeta:{ src:'', caption:'', embedUrl:null, platform:null }, placeholder:null },
|
|
20
|
+
audio: { category:'media', label:'Audio', icon:'🎵', slashLabel:'Audio', hasText:false, hasInline:false, defaultMeta:{ src:'', caption:'', embedUrl:null, platform:null }, placeholder:null },
|
|
21
|
+
gallery: { category:'media', label:'Gallery', icon:'▦', slashLabel:'Gallery', hasText:false, hasInline:false, defaultMeta:{ items:[], layout:'grid' }, placeholder:null },
|
|
22
|
+
|
|
23
|
+
embed: { category:'embed', label:'Embed', icon:'⬡', slashLabel:'Embed / iframe', hasText:false, hasInline:false, defaultMeta:{ src:'', height:400, caption:'' }, placeholder:null },
|
|
24
|
+
|
|
25
|
+
table: { category:'structure', label:'Table', icon:'⊞', slashLabel:'Table', hasText:false, hasInline:false, defaultMeta:{ headers:['Column 1','Column 2'], rows:[['','']] }, placeholder:null },
|
|
26
|
+
divider: { category:'structure', label:'Divider', icon:'—', slashLabel:'Divider', hasText:false, hasInline:false, defaultMeta:{}, placeholder:null },
|
|
27
|
+
|
|
28
|
+
timeline_ref: { category:'structure', label:'Timeline Event', icon:'⏱', slashLabel:'Timeline event', hasText:false, hasInline:false, defaultMeta:{ eventId:'', eventTitle:'', note:'' }, placeholder:null },
|
|
29
|
+
book_citation: { category:'structure', label:'Book Citation', icon:'📖', slashLabel:'Book citation', hasText:false, hasInline:false, defaultMeta:{ bookId:'', unitId:'', quote:'', note:'' }, placeholder:null },
|
|
19
30
|
};
|
|
20
31
|
|
|
21
|
-
export function getBlockDef(type)
|
|
22
|
-
export function getAllTypes()
|
|
23
|
-
export function
|
|
32
|
+
export function getBlockDef(type) { return SCHEMA[type] ?? SCHEMA.paragraph; }
|
|
33
|
+
export function getAllTypes() { return Object.keys(SCHEMA); }
|
|
34
|
+
export function getTypesByCategory(cat) { return Object.entries(SCHEMA).filter(([,d]) => d.category === cat).map(([t]) => t); }
|
|
35
|
+
export function defaultMeta(type) { return { ...(SCHEMA[type]?.defaultMeta ?? {}) }; }
|
|
36
|
+
|
|
24
37
|
export default SCHEMA;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// ─── SlashMenu.js ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Slash-command palette. Triggered when '/' is typed at the very start of
|
|
3
|
+
// an empty block editable. Shows a searchable list of block types grouped
|
|
4
|
+
// by category. Selecting an item calls onSelect(type).
|
|
5
|
+
//
|
|
6
|
+
// Usage (inside Editor):
|
|
7
|
+
// const menu = new SlashMenu(editorContainerEl, onSelect);
|
|
8
|
+
// menu.show(anchorEl); // position near anchorEl
|
|
9
|
+
// menu.hide();
|
|
10
|
+
// menu.destroy();
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
import { getAllTypes, getBlockDef, getTypesByCategory } from '../blocks/BlockSchema.js';
|
|
14
|
+
|
|
15
|
+
const CATEGORIES = [
|
|
16
|
+
{ key: 'text', label: 'Text' },
|
|
17
|
+
{ key: 'media', label: 'Media' },
|
|
18
|
+
{ key: 'embed', label: 'Embed' },
|
|
19
|
+
{ key: 'structure', label: 'Structure' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export class SlashMenu {
|
|
23
|
+
constructor(container, onSelect) {
|
|
24
|
+
this._container = container;
|
|
25
|
+
this._onSelect = onSelect;
|
|
26
|
+
this._el = null;
|
|
27
|
+
this._query = '';
|
|
28
|
+
this._idx = 0;
|
|
29
|
+
this._items = []; // filtered list of type strings
|
|
30
|
+
this._visible = false;
|
|
31
|
+
|
|
32
|
+
this._build();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Public ────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Show menu anchored below anchorEl. */
|
|
38
|
+
show(anchorEl) {
|
|
39
|
+
this._query = '';
|
|
40
|
+
this._refresh();
|
|
41
|
+
this._visible = true;
|
|
42
|
+
this._el.style.display = 'block';
|
|
43
|
+
this._reposition(anchorEl);
|
|
44
|
+
this._el.querySelector('.griot-slash__search')?.focus();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Filter list to items matching query string (the text after '/'). */
|
|
48
|
+
filter(query) {
|
|
49
|
+
this._query = query;
|
|
50
|
+
this._refresh();
|
|
51
|
+
if (!this._items.length) { this.hide(); return; }
|
|
52
|
+
this._reposition(this._anchorEl);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
hide() {
|
|
56
|
+
this._visible = false;
|
|
57
|
+
if (this._el) this._el.style.display = 'none';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get visible() { return this._visible; }
|
|
61
|
+
|
|
62
|
+
/** Handle keydown while slash menu is open. Returns true if consumed. */
|
|
63
|
+
handleKey(e) {
|
|
64
|
+
if (!this._visible) return false;
|
|
65
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); this._move(1); return true; }
|
|
66
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); this._move(-1); return true; }
|
|
67
|
+
if (e.key === 'Enter') { e.preventDefault(); this._select(); return true; }
|
|
68
|
+
if (e.key === 'Escape') { e.preventDefault(); this.hide(); return true; }
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
destroy() {
|
|
73
|
+
this._el?.remove();
|
|
74
|
+
this._el = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Build ─────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
_build() {
|
|
80
|
+
const el = document.createElement('div');
|
|
81
|
+
el.className = 'griot-slash';
|
|
82
|
+
el.setAttribute('role', 'listbox');
|
|
83
|
+
el.setAttribute('aria-label', 'Block type');
|
|
84
|
+
|
|
85
|
+
const search = document.createElement('input');
|
|
86
|
+
search.type = 'text';
|
|
87
|
+
search.className = 'griot-slash__search';
|
|
88
|
+
search.placeholder = 'Search blocks…';
|
|
89
|
+
search.addEventListener('input', () => { this._query = search.value; this._refresh(); });
|
|
90
|
+
search.addEventListener('keydown', e => { this.handleKey(e); });
|
|
91
|
+
|
|
92
|
+
const list = document.createElement('div');
|
|
93
|
+
list.className = 'griot-slash__list';
|
|
94
|
+
|
|
95
|
+
el.append(search, list);
|
|
96
|
+
document.body.appendChild(el);
|
|
97
|
+
this._el = el;
|
|
98
|
+
this._list = list;
|
|
99
|
+
this._search = search;
|
|
100
|
+
|
|
101
|
+
// Hide on outside click
|
|
102
|
+
this._onDocClick = (e) => { if (this._visible && !el.contains(e.target)) this.hide(); };
|
|
103
|
+
document.addEventListener('mousedown', this._onDocClick);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_refresh() {
|
|
107
|
+
const q = this._query.toLowerCase().trim();
|
|
108
|
+
this._list.innerHTML = '';
|
|
109
|
+
this._items = [];
|
|
110
|
+
this._idx = 0;
|
|
111
|
+
|
|
112
|
+
const all = getAllTypes();
|
|
113
|
+
const filtered = q
|
|
114
|
+
? all.filter(t => {
|
|
115
|
+
const d = getBlockDef(t);
|
|
116
|
+
return d.slashLabel?.toLowerCase().includes(q) || t.includes(q) || d.label.toLowerCase().includes(q);
|
|
117
|
+
})
|
|
118
|
+
: all;
|
|
119
|
+
|
|
120
|
+
// Group by category when not searching
|
|
121
|
+
const groups = q
|
|
122
|
+
? [{ key: 'results', label: 'Results', types: filtered }]
|
|
123
|
+
: CATEGORIES.map(c => ({ ...c, types: filtered.filter(t => getBlockDef(t).category === c.key) })).filter(g => g.types.length);
|
|
124
|
+
|
|
125
|
+
for (const group of groups) {
|
|
126
|
+
const hdr = document.createElement('div');
|
|
127
|
+
hdr.className = 'griot-slash__group';
|
|
128
|
+
hdr.textContent = group.label;
|
|
129
|
+
this._list.appendChild(hdr);
|
|
130
|
+
|
|
131
|
+
for (const type of group.types) {
|
|
132
|
+
const def = getBlockDef(type);
|
|
133
|
+
const idx = this._items.length;
|
|
134
|
+
this._items.push(type);
|
|
135
|
+
|
|
136
|
+
const item = document.createElement('div');
|
|
137
|
+
item.className = 'griot-slash__item';
|
|
138
|
+
item.setAttribute('role', 'option');
|
|
139
|
+
item.dataset.idx = idx;
|
|
140
|
+
item.innerHTML = `<span class="griot-slash__item-icon">${def.icon}</span><span class="griot-slash__item-label">${def.slashLabel ?? def.label}</span>`;
|
|
141
|
+
|
|
142
|
+
item.addEventListener('mousedown', (e) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
this._idx = idx;
|
|
145
|
+
this._select();
|
|
146
|
+
});
|
|
147
|
+
item.addEventListener('mouseover', () => { this._idx = idx; this._highlight(); });
|
|
148
|
+
|
|
149
|
+
this._list.appendChild(item);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this._highlight();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_highlight() {
|
|
157
|
+
for (const el of this._list.querySelectorAll('.griot-slash__item')) {
|
|
158
|
+
el.classList.toggle('is-active', Number(el.dataset.idx) === this._idx);
|
|
159
|
+
}
|
|
160
|
+
// Scroll into view
|
|
161
|
+
const active = this._list.querySelector('.griot-slash__item.is-active');
|
|
162
|
+
active?.scrollIntoView({ block: 'nearest' });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_move(dir) {
|
|
166
|
+
this._idx = Math.max(0, Math.min(this._items.length - 1, this._idx + dir));
|
|
167
|
+
this._highlight();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_select() {
|
|
171
|
+
const type = this._items[this._idx];
|
|
172
|
+
if (type) { this.hide(); this._onSelect(type); }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_reposition(anchorEl) {
|
|
176
|
+
this._anchorEl = anchorEl;
|
|
177
|
+
if (!anchorEl || !this._el) return;
|
|
178
|
+
|
|
179
|
+
requestAnimationFrame(() => {
|
|
180
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
181
|
+
const menuH = this._el.offsetHeight || 280;
|
|
182
|
+
let top = rect.bottom + window.scrollY + 4;
|
|
183
|
+
let left = rect.left + window.scrollX;
|
|
184
|
+
|
|
185
|
+
// Flip above if too close to bottom
|
|
186
|
+
if (rect.bottom + menuH + 8 > window.innerHeight) {
|
|
187
|
+
top = rect.top + window.scrollY - menuH - 4;
|
|
188
|
+
}
|
|
189
|
+
// Clamp left
|
|
190
|
+
left = Math.min(left, window.scrollX + window.innerWidth - 280 - 8);
|
|
191
|
+
left = Math.max(left, window.scrollX + 8);
|
|
192
|
+
|
|
193
|
+
this._el.style.top = `${top}px`;
|
|
194
|
+
this._el.style.left = `${left}px`;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|