@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 CHANGED
@@ -1,179 +1,374 @@
1
1
  # Griot
2
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.
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
- ## Install
16
+ ## Installation
9
17
 
10
18
  ```bash
11
- # Copy src/ into your project, or:
12
- npm install griot # (once published)
19
+ npm install griot
13
20
  ```
14
21
 
15
- ```js
16
- import '@0m0g1/griot/css'; // styles
17
- import { Editor, Viewer, createDocument } from 'griot';
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 start
33
+ ## Quick Start
23
34
 
24
35
  ### Editor
25
36
 
26
- ```js
27
- import { Editor, createDocument } from 'griot';
28
- import '@0m0g1/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
- });
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
- ```js
56
- import { Viewer } from 'griot';
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
- const viewer = new Viewer(document.querySelector('#viewer'), {
59
- doc,
60
- books: [],
61
- onEventClick(eventId) {
62
- console.log('Open event:', eventId);
63
- },
64
- });
92
+ ---
65
93
 
66
- // Jump to a block (e.g. from a timeline citation)
67
- viewer.setHighlight('b_abc123');
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
+ | `![alt text](image.jpg)` | 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
- ## Block types
144
+ ### History
73
145
 
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` |
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
- ## Inline syntax
150
+ ## API Reference
89
151
 
90
- Works inside any block with `hasInline: true` (paragraph, blockquote, callout, note fields):
152
+ The public API is exposed through the main entry point (`griot.js`). Below are the most important exports.
91
153
 
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
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
- ## Document format (`.griot.json`)
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
- ```json
236
+ ### Viewer
237
+
238
+ | Export | Description |
239
+ |---|---|
240
+ | `Viewer` | Read‑only renderer. |
241
+
242
+ **Viewer constructor options:**
243
+
244
+ ```typescript
106
245
  {
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
- ]
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
- ## Deep-link anchors
284
+ ## Styling
127
285
 
128
- Every rendered block gets `id="griot-{blockId}"` in the DOM.
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
- ```js
131
- import { anchorId, scrollToBlock } from 'griot';
288
+ **Minimal recommended styles:**
132
289
 
133
- // Get the DOM id for a block
134
- anchorId('b_abc123') // "griot-b_abc123"
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
- // Scroll to a block (viewer or editor)
137
- scrollToBlock('b_abc123');
138
- scrollToBlock('b_abc123', 'instant');
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
- 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.
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
- ## API reference
336
+ ## Extending
147
337
 
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)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0m0g1/griot",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A self-contained block-based rich text editor and renderer built for historical document authoring.",
5
5
  "type": "module",
6
6
  "main": "./src/Griot.js",
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
- // 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
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
- // Inline
42
- export { tokenizeInline, TOKEN } from './inline/InlineLexer.js';
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
- // 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';
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
- // Editor / Viewer
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 to a DOM element.
3
- // Used by both Viewer (read-only) and Editor (preview layer).
2
+ // Renders a single block DOM element.
3
+ // Used by Viewer (read-only) and Editor preview layer.
4
4
  //
5
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)
6
+ // books — array of book objects
7
+ // onEventClick — (eventId) => void
8
+ // onCiteClick — (blockId) => void
10
9
  // ─────────────────────────────────────────────────────────────────────────────
11
10
 
12
- import { anchorId } from '../core/Block.js';
11
+ import { anchorId } from '../core/Block.js';
13
12
  import { renderInlineToDOM, renderInlineToHTML, escHtml, escAttr } from '../inline/InlineRenderer.js';
14
- import { getBlockDef } from './BlockSchema.js';
13
+ import { getBlockDef } from './BlockSchema.js';
15
14
 
16
- // ─── Public entry point ───────────────────────────────────────────────────────
17
- export function renderBlock(block, { books = [], onEventClick, onCiteClick } = {}) {
18
- const el = _render(block, { books, onEventClick, onCiteClick });
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 = anchorId(block.id);
21
- el.dataset.blockId = block.id;
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
- // ─── Internal ─────────────────────────────────────────────────────────────────
28
- function inlineDOM(text, opts) {
29
- return renderInlineToDOM(text, {
30
- onEventClick: opts.onEventClick,
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(inlineDOM(text, opts));
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(inlineDOM(text, opts));
60
+ if (text) el.appendChild(il(text, opts));
59
61
  return el;
60
62
  }
61
63
 
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';
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(inlineDOM(text, opts));
71
- el.appendChild(icon);
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 = 'griot-block griot-code';
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 'divider': {
87
- const el = document.createElement('hr');
88
- el.className = 'griot-block griot-divider';
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 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);
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
- return figure;
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', 'button');
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 || 'Timeline Event')}</div>
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.textContent = text ?? '';
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
- // 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>
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(inlineDOM(meta.note, opts));
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
- // 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
- }
323
+ // ─── Embed URL helpers ────────────────────────────────────────────────────────
199
324
 
200
- return wrap;
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: 'Paragraph', icon: '¶', hasText: true, hasInline: true, defaultMeta: {}, placeholder: 'Write something… **bold** *italic* `code` ==highlight== [[event:id|label]]' },
4
- heading: { label: 'Heading', icon: 'H', hasText: true, hasInline: false, defaultMeta: { level: 2 }, placeholder: 'Heading…' },
5
- blockquote: { label: 'Quote', icon: '❝', hasText: true, hasInline: true, defaultMeta: {}, placeholder: 'Quote…' },
6
- callout: { label: 'Callout', icon: '💡', hasText: true, hasInline: true, defaultMeta: { icon: '💡' }, placeholder: 'Callout text…' },
7
- callout_warning: { label: 'Warning', icon: '⚠️', hasText: true, hasInline: true, defaultMeta: { icon: '⚠️' }, placeholder: 'Warning message…' },
8
- callout_tip: { label: 'Tip', icon: '✅', hasText: true, hasInline: true, defaultMeta: { icon: '✅' }, placeholder: 'Tip or note…' },
9
- callout_danger: { label: 'Danger', icon: '🚨', hasText: true, hasInline: true, defaultMeta: { icon: '🚨' }, placeholder: 'Critical warning…' },
10
- code: { label: 'Code', icon: '</>', hasText: true, hasInline: false, defaultMeta: { language: '' }, placeholder: '// code…' },
11
- list_ul: { label: 'Bullet List', icon: '•', hasText: true, hasInline: false, defaultMeta: {}, placeholder: 'Item 1\nItem 2\nItem 3' },
12
- list_ol: { label: 'Numbered List', icon: '1.', hasText: true, hasInline: false, defaultMeta: {}, placeholder: 'First item\nSecond item' },
13
- table: { label: 'Table', icon: '⊞', hasText: false, hasInline: false, defaultMeta: { headers: ['Column 1', 'Column 2'], rows: [['', '']] }, placeholder: null },
14
- divider: { label: 'Divider', icon: '', hasText: false, hasInline: false, defaultMeta: {}, placeholder: null },
15
- image: { label: 'Image', icon: '🖼', hasText: false, hasInline: false, defaultMeta: { src: '', alt: '', caption: '' }, placeholder: null },
16
- video: { label: 'Video', icon: '', hasText: false, hasInline: false, defaultMeta: { src: '', caption: '' }, placeholder: null },
17
- timeline_ref: { label: 'Timeline Event', icon: '', hasText: false, hasInline: false, defaultMeta: { eventId: '', eventTitle: '', note: '' }, placeholder: null },
18
- book_citation: { label: 'Book Citation', icon: '📖', hasText: false, hasInline: false, defaultMeta: { bookId: '', unitId: '', quote: '', note: '' }, placeholder: null },
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) { return SCHEMA[type] ?? SCHEMA.paragraph; }
22
- export function getAllTypes() { return Object.keys(SCHEMA); }
23
- export function defaultMeta(type) { return { ...(SCHEMA[type]?.defaultMeta ?? {}) }; }
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
+ }