@0m0g1/griot 0.1.2 → 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 +29 -86
- package/src/core/Block.js +42 -45
- package/src/core/Document.js +63 -98
- package/src/core/History.js +17 -42
- package/src/editor/Editor.js +405 -138
- package/src/editor/FormatToolbar.js +138 -0
- package/src/editor/Keyboard.js +92 -106
- package/src/editor/SlashMenu.js +197 -0
- package/src/inline/InlineLexer.js +69 -72
- package/src/inline/InlineRenderer.js +110 -95
package/src/core/Block.js
CHANGED
|
@@ -1,63 +1,60 @@
|
|
|
1
1
|
// ─── Block.js ─────────────────────────────────────────────────────────────────
|
|
2
|
-
// Pure
|
|
3
|
-
//
|
|
4
|
-
// Shape:
|
|
5
|
-
// { id, type, text?, meta:{} }
|
|
6
|
-
//
|
|
7
|
-
// meta holds type-specific fields:
|
|
8
|
-
// heading → meta.level (1-6)
|
|
9
|
-
// callout → meta.icon
|
|
10
|
-
// code → meta.language
|
|
11
|
-
// image → meta.src, meta.alt, meta.caption
|
|
12
|
-
// divider → (no extra fields)
|
|
13
|
-
// timeline_ref → meta.eventId, meta.eventTitle
|
|
14
|
-
// book_citation → meta.bookId, meta.unitId, meta.quote, meta.note
|
|
2
|
+
// Pure block primitives. No schema dependency, no document awareness.
|
|
15
3
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
4
|
|
|
17
5
|
let _seq = 0;
|
|
18
|
-
const uid = (
|
|
6
|
+
const uid = () => `b_${Date.now().toString(36)}_${(++_seq).toString(36)}`;
|
|
19
7
|
|
|
20
|
-
|
|
8
|
+
/** Block types that carry a text field (editable as plain text). */
|
|
21
9
|
export const TEXT_TYPES = new Set([
|
|
22
|
-
'paragraph', 'heading', 'blockquote',
|
|
10
|
+
'paragraph', 'heading', 'blockquote',
|
|
11
|
+
'callout', 'callout_warning', 'callout_tip', 'callout_danger',
|
|
12
|
+
'code', 'list_ul', 'list_ol',
|
|
23
13
|
]);
|
|
24
14
|
|
|
25
|
-
|
|
26
|
-
export const ALL_TYPES =
|
|
27
|
-
'paragraph', 'heading', 'blockquote',
|
|
28
|
-
'
|
|
29
|
-
|
|
15
|
+
/** All known block types (informational; canonical list is BlockSchema). */
|
|
16
|
+
export const ALL_TYPES = [
|
|
17
|
+
'paragraph', 'heading', 'blockquote',
|
|
18
|
+
'callout', 'callout_warning', 'callout_tip', 'callout_danger',
|
|
19
|
+
'code', 'list_ul', 'list_ol', 'table',
|
|
20
|
+
'divider', 'image', 'video', 'timeline_ref', 'book_citation',
|
|
21
|
+
];
|
|
30
22
|
|
|
31
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Create a new block with a fresh unique id.
|
|
25
|
+
* @param {string} type
|
|
26
|
+
* @param {{ id?, text?, meta? }} [overrides]
|
|
27
|
+
*/
|
|
32
28
|
export function createBlock(type = 'paragraph', overrides = {}) {
|
|
33
|
-
if (!ALL_TYPES.has(type)) {
|
|
34
|
-
console.warn(`[Griot] Unknown block type "${type}", defaulting to paragraph`);
|
|
35
|
-
type = 'paragraph';
|
|
36
|
-
}
|
|
37
29
|
return {
|
|
38
|
-
id: uid(
|
|
30
|
+
id: overrides.id ?? uid(),
|
|
39
31
|
type,
|
|
40
|
-
text: TEXT_TYPES.has(type) ? '' : null,
|
|
41
|
-
meta: {},
|
|
42
|
-
...overrides,
|
|
32
|
+
text: TEXT_TYPES.has(type) ? (overrides.text ?? '') : null,
|
|
33
|
+
meta: overrides.meta ?? {},
|
|
43
34
|
};
|
|
44
35
|
}
|
|
45
36
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
37
|
+
/** Deep-clone a block. Pass newId=false to keep the same id. */
|
|
38
|
+
export function cloneBlock(block, newId = true) {
|
|
39
|
+
return { ...block, id: newId ? uid() : block.id, meta: { ...block.meta } };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True if this block type stores a text string. */
|
|
43
|
+
export function isTextBlock(block) {
|
|
44
|
+
return TEXT_TYPES.has(block?.type);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Minimal structural validity check. */
|
|
48
|
+
export function isValidBlock(block) {
|
|
49
|
+
return Boolean(block && typeof block.id === 'string' && typeof block.type === 'string');
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
export
|
|
56
|
-
|
|
52
|
+
/** DOM id attribute used to anchor/locate a block element. */
|
|
53
|
+
export function anchorId(blockId) {
|
|
54
|
+
return `griot-block-${blockId}`;
|
|
55
|
+
}
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
export
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
?.scrollIntoView({ behavior, block: 'center' });
|
|
63
|
-
};
|
|
57
|
+
/** Smooth-scroll (or jump) to a block's DOM element. */
|
|
58
|
+
export function scrollToBlock(blockId, behavior = 'smooth') {
|
|
59
|
+
document.getElementById(anchorId(blockId))?.scrollIntoView({ behavior, block: 'center' });
|
|
60
|
+
}
|
package/src/core/Document.js
CHANGED
|
@@ -1,128 +1,93 @@
|
|
|
1
1
|
// ─── Document.js ──────────────────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// can cheaply snapshot the state.
|
|
2
|
+
// Immutable document operations. Every function returns a NEW document object.
|
|
3
|
+
// Document shape: { id: string, blocks: Block[] }
|
|
5
4
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
5
|
|
|
7
|
-
import { createBlock
|
|
6
|
+
import { createBlock } from './Block.js';
|
|
8
7
|
|
|
9
8
|
let _docSeq = 0;
|
|
10
|
-
const docUid
|
|
11
|
-
|
|
12
|
-
// ─── Factory ──────────────────────────────────────────────────────────────────
|
|
13
|
-
export function createDocument(title = 'Untitled', blocks = null) {
|
|
14
|
-
return {
|
|
15
|
-
id: docUid(),
|
|
16
|
-
version: 1,
|
|
17
|
-
title,
|
|
18
|
-
createdAt: new Date().toISOString(),
|
|
19
|
-
updatedAt: new Date().toISOString(),
|
|
20
|
-
blocks: blocks ?? [createBlock('heading', { text: title, meta: { level: 1 } })],
|
|
21
|
-
};
|
|
22
|
-
}
|
|
9
|
+
const docUid = () => `doc_${Date.now().toString(36)}_${++_docSeq}`;
|
|
10
|
+
const withBlks = (doc, blocks) => ({ ...doc, blocks });
|
|
23
11
|
|
|
24
|
-
//
|
|
25
|
-
function touch(doc) {
|
|
26
|
-
return { ...doc, updatedAt: new Date().toISOString() };
|
|
27
|
-
}
|
|
12
|
+
// ── Constructors ──────────────────────────────────────────────────────────────
|
|
28
13
|
|
|
29
|
-
function
|
|
30
|
-
return
|
|
14
|
+
export function createDocument(blocks = []) {
|
|
15
|
+
return { id: docUid(), blocks: blocks.length ? blocks : [createBlock('paragraph')] };
|
|
31
16
|
}
|
|
32
17
|
|
|
33
|
-
|
|
34
|
-
export
|
|
35
|
-
return doc.blocks.find(b => b.id === blockId) ?? null;
|
|
36
|
-
}
|
|
18
|
+
export const toJSON = (doc) => JSON.stringify(doc);
|
|
19
|
+
export const fromJSON = (j) => (typeof j === 'string' ? JSON.parse(j) : j);
|
|
37
20
|
|
|
38
|
-
|
|
39
|
-
return doc.blocks.findIndex(b => b.id === blockId);
|
|
40
|
-
}
|
|
21
|
+
// ── Queries ───────────────────────────────────────────────────────────────────
|
|
41
22
|
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
23
|
+
export const getBlock = (doc, id) => doc.blocks.find(b => b.id === id) ?? null;
|
|
24
|
+
export const getBlockIndex = (doc, id) => doc.blocks.findIndex(b => b.id === id);
|
|
25
|
+
export const getBlockBefore = (doc, id) => { const i = getBlockIndex(doc, id); return i > 0 ? doc.blocks[i - 1] : null; };
|
|
26
|
+
export const getBlockAfter = (doc, id) => { const i = getBlockIndex(doc, id); return (i >= 0 && i < doc.blocks.length - 1) ? doc.blocks[i + 1] : null; };
|
|
46
27
|
|
|
47
|
-
|
|
48
|
-
const i = getBlockIndex(doc, blockId);
|
|
49
|
-
return i > 0 ? doc.blocks[i - 1] : null;
|
|
50
|
-
}
|
|
28
|
+
// ── Mutations (always return a new doc) ───────────────────────────────────────
|
|
51
29
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
30
|
+
export function updateBlock(doc, id, patch) {
|
|
31
|
+
return withBlks(doc, doc.blocks.map(b => {
|
|
32
|
+
if (b.id !== id) return b;
|
|
33
|
+
const u = { ...b };
|
|
34
|
+
if ('text' in patch) u.text = patch.text;
|
|
35
|
+
if ('type' in patch) u.type = patch.type;
|
|
36
|
+
if ('meta' in patch) u.meta = { ...b.meta, ...patch.meta };
|
|
37
|
+
return u;
|
|
38
|
+
}));
|
|
57
39
|
}
|
|
58
40
|
|
|
59
|
-
export function insertBlockAfter(doc,
|
|
60
|
-
const i = getBlockIndex(doc,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return withBlocks(doc, next);
|
|
41
|
+
export function insertBlockAfter(doc, afterId, newBlock) {
|
|
42
|
+
const i = getBlockIndex(doc, afterId);
|
|
43
|
+
const blocks = [...doc.blocks];
|
|
44
|
+
blocks.splice(i < 0 ? blocks.length : i + 1, 0, newBlock);
|
|
45
|
+
return withBlks(doc, blocks);
|
|
65
46
|
}
|
|
66
47
|
|
|
67
|
-
export function insertBlockBefore(doc,
|
|
68
|
-
const i = getBlockIndex(doc,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return withBlocks(doc, next);
|
|
48
|
+
export function insertBlockBefore(doc, beforeId, newBlock) {
|
|
49
|
+
const i = getBlockIndex(doc, beforeId);
|
|
50
|
+
const blocks = [...doc.blocks];
|
|
51
|
+
blocks.splice(i < 0 ? 0 : i, 0, newBlock);
|
|
52
|
+
return withBlks(doc, blocks);
|
|
73
53
|
}
|
|
74
54
|
|
|
75
|
-
export function removeBlock(doc,
|
|
76
|
-
|
|
77
|
-
return withBlocks(doc, doc.blocks.filter(b => b.id !== blockId));
|
|
55
|
+
export function removeBlock(doc, id) {
|
|
56
|
+
return withBlks(doc, doc.blocks.filter(b => b.id !== id));
|
|
78
57
|
}
|
|
79
58
|
|
|
80
|
-
export function moveBlock(doc,
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return withBlocks(doc, next);
|
|
59
|
+
export function moveBlock(doc, fromIdx, toIdx) {
|
|
60
|
+
const blocks = [...doc.blocks];
|
|
61
|
+
const [item] = blocks.splice(fromIdx, 1);
|
|
62
|
+
blocks.splice(toIdx, 0, item);
|
|
63
|
+
return withBlks(doc, blocks);
|
|
86
64
|
}
|
|
87
65
|
|
|
88
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Split a text block at `offset`.
|
|
68
|
+
* Headings split into a paragraph for the new block.
|
|
69
|
+
* @returns {[newDoc, newBlockId | null]}
|
|
70
|
+
*/
|
|
89
71
|
export function splitBlock(doc, blockId, offset) {
|
|
90
72
|
const block = getBlock(doc, blockId);
|
|
91
73
|
if (!block || block.text === null) return [doc, null];
|
|
92
74
|
|
|
93
|
-
const before
|
|
94
|
-
const after
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
const final = insertBlockAfter(updated, blockId, newBlock);
|
|
99
|
-
|
|
100
|
-
return [final, newBlock.id];
|
|
75
|
+
const before = block.text.slice(0, offset);
|
|
76
|
+
const after = block.text.slice(offset);
|
|
77
|
+
const newType = block.type === 'heading' ? 'paragraph' : block.type;
|
|
78
|
+
const nb = createBlock(newType, { text: after, meta: { ...block.meta } });
|
|
79
|
+
return [insertBlockAfter(updateBlock(doc, blockId, { text: before }), blockId, nb), nb.id];
|
|
101
80
|
}
|
|
102
81
|
|
|
103
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Merge a block into the previous one.
|
|
84
|
+
* @returns {[newDoc, prevBlockId | null, mergeOffset]}
|
|
85
|
+
*/
|
|
104
86
|
export function mergeBlockWithPrev(doc, blockId) {
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
if (!prev ||
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const removed = removeBlock(merged, blockId);
|
|
113
|
-
|
|
114
|
-
return [removed, prev.id, mergeOffset];
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ─── Serialise / deserialise ──────────────────────────────────────────────────
|
|
118
|
-
export function toJSON(doc) {
|
|
119
|
-
return JSON.stringify(doc, null, 2);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function fromJSON(json) {
|
|
123
|
-
const data = typeof json === 'string' ? JSON.parse(json) : json;
|
|
124
|
-
if (!data?.blocks || !Array.isArray(data.blocks)) {
|
|
125
|
-
throw new Error('[Griot] fromJSON: invalid document — missing blocks array');
|
|
126
|
-
}
|
|
127
|
-
return data;
|
|
128
|
-
}
|
|
87
|
+
const block = getBlock(doc, blockId);
|
|
88
|
+
const prev = getBlockBefore(doc, blockId);
|
|
89
|
+
if (!prev || prev.text === null || block?.text === null) return [doc, null, 0];
|
|
90
|
+
const offset = prev.text.length;
|
|
91
|
+
const merged = updateBlock(doc, prev.id, { text: prev.text + (block.text ?? '') });
|
|
92
|
+
return [removeBlock(merged, blockId), prev.id, offset];
|
|
93
|
+
}
|
package/src/core/History.js
CHANGED
|
@@ -1,58 +1,33 @@
|
|
|
1
1
|
// ─── History.js ───────────────────────────────────────────────────────────────
|
|
2
|
-
// Linear undo/redo stack.
|
|
3
|
-
// Works with immutable document snapshots — just push the whole doc each time.
|
|
2
|
+
// Linear undo/redo stack. Stores immutable document snapshots.
|
|
4
3
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
4
|
|
|
5
|
+
const MAX_HISTORY = 200;
|
|
6
|
+
|
|
6
7
|
export class History {
|
|
7
|
-
constructor(initialDoc
|
|
8
|
-
this._stack
|
|
9
|
-
this._cursor
|
|
10
|
-
this._maxDepth = maxDepth;
|
|
8
|
+
constructor(initialDoc) {
|
|
9
|
+
this._stack = initialDoc ? [initialDoc] : [];
|
|
10
|
+
this._cursor = this._stack.length - 1;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
get canUndo() { return this._cursor > 0; }
|
|
17
|
-
get canRedo() { return this._cursor < this._stack.length - 1; }
|
|
13
|
+
get current() { return this._stack[this._cursor] ?? null; }
|
|
14
|
+
canUndo() { return this._cursor > 0; }
|
|
15
|
+
canRedo() { return this._cursor < this._stack.length - 1; }
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
/** Push a new snapshot, discarding any redo future. */
|
|
20
18
|
push(doc) {
|
|
21
|
-
// Truncate future if we're not at the top
|
|
22
19
|
this._stack = this._stack.slice(0, this._cursor + 1);
|
|
23
20
|
this._stack.push(doc);
|
|
24
|
-
|
|
25
|
-
// Enforce max depth
|
|
26
|
-
if (this._stack.length > this._maxDepth) {
|
|
27
|
-
this._stack = this._stack.slice(this._stack.length - this._maxDepth);
|
|
28
|
-
}
|
|
29
|
-
|
|
21
|
+
if (this._stack.length > MAX_HISTORY) this._stack.shift();
|
|
30
22
|
this._cursor = this._stack.length - 1;
|
|
31
|
-
return this;
|
|
32
23
|
}
|
|
33
24
|
|
|
34
|
-
undo
|
|
35
|
-
if (!this.canUndo) return this.current;
|
|
36
|
-
this._cursor--;
|
|
37
|
-
return this.current;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
redo() {
|
|
41
|
-
if (!this.canRedo) return this.current;
|
|
42
|
-
this._cursor++;
|
|
43
|
-
return this.current;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Replace current snapshot without branching (for transient changes
|
|
47
|
-
// like typing individual characters — debounce the push externally)
|
|
25
|
+
/** Replace the current snapshot in-place (no new undo point). */
|
|
48
26
|
replace(doc) {
|
|
49
|
-
this._stack[this._cursor] = doc;
|
|
50
|
-
|
|
27
|
+
if (this._cursor >= 0) this._stack[this._cursor] = doc;
|
|
28
|
+
else this.push(doc);
|
|
51
29
|
}
|
|
52
30
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return this;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
31
|
+
undo() { if (this.canUndo()) this._cursor--; return this.current; }
|
|
32
|
+
redo() { if (this.canRedo()) this._cursor++; return this.current; }
|
|
33
|
+
}
|