@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/src/core/Block.js CHANGED
@@ -1,63 +1,60 @@
1
1
  // ─── Block.js ─────────────────────────────────────────────────────────────────
2
- // Pure data. No DOM, no rendering. A block is a plain serialisable object.
3
- //
4
- // Shape:
5
- // { id, type, text?, meta:{} }
6
- //
7
- // meta holds type-specific fields:
8
- // heading → meta.level (1-6)
9
- // callout → meta.icon
10
- // code → meta.language
11
- // image → meta.src, meta.alt, meta.caption
12
- // divider → (no extra fields)
13
- // timeline_ref → meta.eventId, meta.eventTitle
14
- // book_citation → meta.bookId, meta.unitId, meta.quote, meta.note
2
+ // Pure block primitives. No schema dependency, no document awareness.
15
3
  // ─────────────────────────────────────────────────────────────────────────────
16
4
 
17
5
  let _seq = 0;
18
- const uid = (prefix = 'b') => `${prefix}_${Date.now()}_${(++_seq).toString(36)}`;
6
+ const uid = () => `b_${Date.now().toString(36)}_${(++_seq).toString(36)}`;
19
7
 
20
- // TEXT_TYPES — block types that carry a user-editable `text` field
8
+ /** Block types that carry a text field (editable as plain text). */
21
9
  export const TEXT_TYPES = new Set([
22
- 'paragraph', 'heading', 'blockquote', 'callout', 'code',
10
+ 'paragraph', 'heading', 'blockquote',
11
+ 'callout', 'callout_warning', 'callout_tip', 'callout_danger',
12
+ 'code', 'list_ul', 'list_ol',
23
13
  ]);
24
14
 
25
- // ALL_TYPES exhaustive list for validation
26
- export const ALL_TYPES = new Set([
27
- 'paragraph', 'heading', 'blockquote', 'callout', 'code',
28
- 'divider', 'image', 'timeline_ref', 'book_citation',
29
- ]);
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
- // ─── Factory ──────────────────────────────────────────────────────────────────
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('b'),
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
- export function cloneBlock(block) {
47
- return {
48
- ...block,
49
- id: uid('b'),
50
- meta: { ...block.meta },
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
- // ─── Predicates ───────────────────────────────────────────────────────────────
55
- export const isTextBlock = (b) => TEXT_TYPES.has(b?.type);
56
- export const isValidBlock = (b) => b && typeof b.id === 'string' && ALL_TYPES.has(b.type);
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
- // ─── Anchor ID (stable DOM id for deep-linking) ───────────────────────────────
59
- export const anchorId = (blockId) => `griot-${blockId}`;
60
- export const scrollToBlock = (blockId, behavior = 'smooth') => {
61
- document.getElementById(anchorId(blockId))
62
- ?.scrollIntoView({ behavior, block: 'center' });
63
- };
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
+ }
@@ -1,128 +1,93 @@
1
1
  // ─── Document.js ──────────────────────────────────────────────────────────────
2
- // An ordered list of blocks with CRUD operations.
3
- // All mutating methods return a NEW document (immutable updates) so History
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, cloneBlock, isValidBlock } from './Block.js';
6
+ import { createBlock } from './Block.js';
8
7
 
9
8
  let _docSeq = 0;
10
- const docUid = () => `doc_${Date.now()}_${(++_docSeq).toString(36)}`;
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
- // ─── Helpers ──────────────────────────────────────────────────────────────────
25
- function touch(doc) {
26
- return { ...doc, updatedAt: new Date().toISOString() };
27
- }
12
+ // ── Constructors ──────────────────────────────────────────────────────────────
28
13
 
29
- function withBlocks(doc, blocks) {
30
- return touch({ ...doc, blocks });
14
+ export function createDocument(blocks = []) {
15
+ return { id: docUid(), blocks: blocks.length ? blocks : [createBlock('paragraph')] };
31
16
  }
32
17
 
33
- // ─── Reads ────────────────────────────────────────────────────────────────────
34
- export function getBlock(doc, blockId) {
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
- export function getBlockIndex(doc, blockId) {
39
- return doc.blocks.findIndex(b => b.id === blockId);
40
- }
21
+ // ── Queries ───────────────────────────────────────────────────────────────────
41
22
 
42
- export function getBlockAfter(doc, blockId) {
43
- const i = getBlockIndex(doc, blockId);
44
- return i >= 0 && i < doc.blocks.length - 1 ? doc.blocks[i + 1] : null;
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
- export function getBlockBefore(doc, blockId) {
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
- // ─── Writes ───────────────────────────────────────────────────────────────────
53
- export function updateBlock(doc, blockId, patch) {
54
- return withBlocks(doc, doc.blocks.map(b =>
55
- b.id === blockId ? { ...b, ...patch, meta: { ...b.meta, ...(patch.meta ?? {}) } } : b
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, blockId, newBlock) {
60
- const i = getBlockIndex(doc, blockId);
61
- if (i < 0) return withBlocks(doc, [...doc.blocks, newBlock]);
62
- const next = [...doc.blocks];
63
- next.splice(i + 1, 0, newBlock);
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, blockId, newBlock) {
68
- const i = getBlockIndex(doc, blockId);
69
- if (i < 0) return withBlocks(doc, [newBlock, ...doc.blocks]);
70
- const next = [...doc.blocks];
71
- next.splice(i, 0, newBlock);
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, blockId) {
76
- if (doc.blocks.length <= 1) return doc; // never empty
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, fromIndex, toIndex) {
81
- if (fromIndex === toIndex) return doc;
82
- const next = [...doc.blocks];
83
- const [moved] = next.splice(fromIndex, 1);
84
- next.splice(toIndex, 0, moved);
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
- // Split a text block at a cursor offset — returns [docWithSplit, newBlockId]
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 = block.text.slice(0, offset);
94
- const after = block.text.slice(offset);
95
-
96
- const newBlock = createBlock('paragraph', { text: after });
97
- const updated = updateBlock(doc, blockId, { text: before });
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
- // Merge block into the one before it — returns [docWithMerge, prevBlockId, mergeOffset]
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 prev = getBlockBefore(doc, blockId);
106
- const curr = getBlock(doc, blockId);
107
- if (!prev || !curr) return [doc, null, 0];
108
- if (prev.text === null || curr.text === null) return [doc, null, 0];
109
-
110
- const mergeOffset = prev.text.length;
111
- const merged = updateBlock(doc, prev.id, { text: prev.text + curr.text });
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
+ }
@@ -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, { maxDepth = 100 } = {}) {
8
- this._stack = [initialDoc];
9
- this._cursor = 0;
10
- this._maxDepth = maxDepth;
8
+ constructor(initialDoc) {
9
+ this._stack = initialDoc ? [initialDoc] : [];
10
+ this._cursor = this._stack.length - 1;
11
11
  }
12
12
 
13
- // Current document
14
- get current() { return this._stack[this._cursor]; }
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
- // Push a new state (truncates any redo branch)
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
- return this;
27
+ if (this._cursor >= 0) this._stack[this._cursor] = doc;
28
+ else this.push(doc);
51
29
  }
52
30
 
53
- clear(doc) {
54
- this._stack = [doc];
55
- this._cursor = 0;
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
+ }