@ai-composer/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +6 -0
- package/dist/commands/builtins.d.ts +41 -0
- package/dist/commands/builtins.d.ts.map +1 -0
- package/dist/commands/builtins.js +116 -0
- package/dist/commands/builtins.js.map +1 -0
- package/dist/commands/registry.d.ts +27 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +29 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/editor.d.ts +185 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +558 -0
- package/dist/editor.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/events/event-bus.d.ts +19 -0
- package/dist/events/event-bus.d.ts.map +1 -0
- package/dist/events/event-bus.js +51 -0
- package/dist/events/event-bus.js.map +1 -0
- package/dist/events/events.d.ts +94 -0
- package/dist/events/events.d.ts.map +1 -0
- package/dist/events/events.js +6 -0
- package/dist/events/events.js.map +1 -0
- package/dist/factory.d.ts +17 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +18 -0
- package/dist/factory.js.map +1 -0
- package/dist/history/history.d.ts +48 -0
- package/dist/history/history.d.ts.map +1 -0
- package/dist/history/history.js +71 -0
- package/dist/history/history.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/model/document.d.ts +54 -0
- package/dist/model/document.d.ts.map +1 -0
- package/dist/model/document.js +177 -0
- package/dist/model/document.js.map +1 -0
- package/dist/model/nodes.d.ts +80 -0
- package/dist/model/nodes.d.ts.map +1 -0
- package/dist/model/nodes.js +64 -0
- package/dist/model/nodes.js.map +1 -0
- package/dist/model/selection.d.ts +40 -0
- package/dist/model/selection.d.ts.map +1 -0
- package/dist/model/selection.js +79 -0
- package/dist/model/selection.js.map +1 -0
- package/dist/nodes/registry.d.ts +39 -0
- package/dist/nodes/registry.d.ts.map +1 -0
- package/dist/nodes/registry.js +93 -0
- package/dist/nodes/registry.js.map +1 -0
- package/dist/package.json +21 -0
- package/dist/plugins/registry.d.ts +53 -0
- package/dist/plugins/registry.d.ts.map +1 -0
- package/dist/plugins/registry.js +102 -0
- package/dist/plugins/registry.js.map +1 -0
- package/dist/serialization/builtins.d.ts +30 -0
- package/dist/serialization/builtins.d.ts.map +1 -0
- package/dist/serialization/builtins.js +102 -0
- package/dist/serialization/builtins.js.map +1 -0
- package/dist/serialization/index.d.ts +4 -0
- package/dist/serialization/index.d.ts.map +1 -0
- package/dist/serialization/index.js +3 -0
- package/dist/serialization/index.js.map +1 -0
- package/dist/serialization/registry.d.ts +17 -0
- package/dist/serialization/registry.d.ts.map +1 -0
- package/dist/serialization/registry.js +35 -0
- package/dist/serialization/registry.js.map +1 -0
- package/dist/serialization/serializers.d.ts +12 -0
- package/dist/serialization/serializers.d.ts.map +1 -0
- package/dist/serialization/serializers.js +3 -0
- package/dist/serialization/serializers.js.map +1 -0
- package/dist/state/state.d.ts +54 -0
- package/dist/state/state.d.ts.map +1 -0
- package/dist/state/state.js +6 -0
- package/dist/state/state.js.map +1 -0
- package/dist/triggers/engine.d.ts +102 -0
- package/dist/triggers/engine.d.ts.map +1 -0
- package/dist/triggers/engine.js +235 -0
- package/dist/triggers/engine.js.map +1 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/html.d.ts +3 -0
- package/dist/utils/html.d.ts.map +1 -0
- package/dist/utils/html.js +10 -0
- package/dist/utils/html.js.map +1 -0
- package/package.json +20 -0
- package/project.json +56 -0
- package/src/commands/builtins.ts +142 -0
- package/src/commands/registry.ts +53 -0
- package/src/commands.test.ts +118 -0
- package/src/editor.test.ts +311 -0
- package/src/editor.ts +826 -0
- package/src/errors.ts +25 -0
- package/src/events/event-bus.test.ts +62 -0
- package/src/events/event-bus.ts +72 -0
- package/src/events/events.ts +120 -0
- package/src/factory.ts +21 -0
- package/src/history/history.ts +108 -0
- package/src/index.ts +201 -0
- package/src/model/document.test.ts +130 -0
- package/src/model/document.ts +207 -0
- package/src/model/nodes.ts +151 -0
- package/src/model/selection.ts +107 -0
- package/src/nodes/registry.ts +142 -0
- package/src/plugins/registry.ts +163 -0
- package/src/serialization/builtins.ts +123 -0
- package/src/serialization/index.ts +20 -0
- package/src/serialization/registry.ts +49 -0
- package/src/serialization/serializers.ts +15 -0
- package/src/state/state.ts +57 -0
- package/src/triggers/engine.ts +317 -0
- package/src/triggers.test.ts +138 -0
- package/src/types.ts +42 -0
- package/src/utils/html.ts +9 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +15 -0
- package/tsconfig.spec.json +16 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createMentionNode, createTextNode } from './nodes';
|
|
3
|
+
import {
|
|
4
|
+
createDocument,
|
|
5
|
+
documentLength,
|
|
6
|
+
deleteDocumentRange,
|
|
7
|
+
documentsEqual,
|
|
8
|
+
findNodeIndex,
|
|
9
|
+
insertNodesAt,
|
|
10
|
+
isEmptyDocument,
|
|
11
|
+
nodeLength,
|
|
12
|
+
normalizeNodes,
|
|
13
|
+
} from './document';
|
|
14
|
+
import { createPosition, createSelection, fromGlobalOffset, toGlobalOffset } from './selection';
|
|
15
|
+
|
|
16
|
+
describe('document model', () => {
|
|
17
|
+
it('creates an empty document', () => {
|
|
18
|
+
const doc = createDocument();
|
|
19
|
+
expect(doc.nodes).toEqual([]);
|
|
20
|
+
expect(isEmptyDocument(doc)).toBe(true);
|
|
21
|
+
expect(documentLength(doc)).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('coerces metadata and keeps node order', () => {
|
|
25
|
+
const doc = createDocument([createTextNode('hi'), createMentionNode({ id: 'u1', label: 'Ada' })], {
|
|
26
|
+
sessionId: 's1',
|
|
27
|
+
});
|
|
28
|
+
expect(doc.metadata).toEqual({ sessionId: 's1' });
|
|
29
|
+
expect(doc.nodes.map((n) => n.type)).toEqual(['text', 'mention']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('measures node length (atomic nodes are length 1)', () => {
|
|
33
|
+
expect(nodeLength(createTextNode('hello'))).toBe(5);
|
|
34
|
+
expect(nodeLength(createMentionNode({ id: 'u1', label: 'Ada Lovelace' }))).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('merges adjacent text nodes and drops empty ones', () => {
|
|
38
|
+
const merged = normalizeNodes([
|
|
39
|
+
createTextNode('ab'),
|
|
40
|
+
createTextNode(''),
|
|
41
|
+
createTextNode('cd'),
|
|
42
|
+
createMentionNode({ id: 'u1', label: 'Ada' }),
|
|
43
|
+
createTextNode('ef'),
|
|
44
|
+
]);
|
|
45
|
+
expect(merged).toHaveLength(3);
|
|
46
|
+
expect(merged[0]).toMatchObject({ type: 'text', text: 'abcd' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('round-trips global offsets', () => {
|
|
50
|
+
const doc = createDocument([
|
|
51
|
+
createTextNode('ab'),
|
|
52
|
+
createMentionNode({ id: 'u1', label: 'Ada' }),
|
|
53
|
+
createTextNode('cd'),
|
|
54
|
+
]);
|
|
55
|
+
// 0:a 1:b 2:|chip| 3:c 4:d
|
|
56
|
+
expect(toGlobalOffset(doc, createPosition(0, 2))).toBe(2);
|
|
57
|
+
expect(toGlobalOffset(doc, createPosition(1, 1))).toBe(3);
|
|
58
|
+
expect(toGlobalOffset(doc, createPosition(2, 0))).toBe(3);
|
|
59
|
+
expect(fromGlobalOffset(doc, 3)).toEqual(createPosition(2, 0));
|
|
60
|
+
expect(fromGlobalOffset(doc, 5)).toEqual(createPosition(2, 2));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('deletes a range spanning nodes and reports the caret', () => {
|
|
64
|
+
const doc = createDocument([
|
|
65
|
+
createTextNode('hello '),
|
|
66
|
+
createMentionNode({ id: 'u1', label: 'Ada' }),
|
|
67
|
+
createTextNode(' world'),
|
|
68
|
+
]);
|
|
69
|
+
// (0,5)=global 5 ("hello|"), (2,1)=global 8 (inside " world" after its space)
|
|
70
|
+
const result = deleteDocumentRange(doc, {
|
|
71
|
+
start: createPosition(0, 5),
|
|
72
|
+
end: createPosition(2, 1),
|
|
73
|
+
});
|
|
74
|
+
// chip + the leading space of " world" are gone; text runs merge
|
|
75
|
+
expect(result.document.nodes.map((n) => (n.type === 'text' ? n.text : n.type))).toEqual([
|
|
76
|
+
'helloworld',
|
|
77
|
+
]);
|
|
78
|
+
// caret sits where the deleted range began (global offset 5)
|
|
79
|
+
expect(toGlobalOffset(result.document, result.caret)).toBe(5);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('deletes an atomic node fully when the range covers it', () => {
|
|
83
|
+
const doc = createDocument([
|
|
84
|
+
createTextNode('a'),
|
|
85
|
+
createMentionNode({ id: 'u1', label: 'Ada' }),
|
|
86
|
+
createTextNode('b'),
|
|
87
|
+
]);
|
|
88
|
+
const result = deleteDocumentRange(doc, {
|
|
89
|
+
start: createPosition(1, 0),
|
|
90
|
+
end: createPosition(1, 1),
|
|
91
|
+
});
|
|
92
|
+
// adjacent text runs merge once the chip between them disappears
|
|
93
|
+
expect(result.document.nodes.map((n) => (n.type === 'text' ? n.text : n.type))).toEqual(['ab']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('inserts nodes at a position, splitting text nodes', () => {
|
|
97
|
+
const doc = createDocument([createTextNode('hello')]);
|
|
98
|
+
const mention = createMentionNode({ id: 'u1', label: 'Ada' });
|
|
99
|
+
const result = insertNodesAt(doc, createPosition(0, 2), [mention]);
|
|
100
|
+
expect(result.document.nodes).toHaveLength(3);
|
|
101
|
+
expect(result.document.nodes[0]).toMatchObject({ type: 'text', text: 'he' });
|
|
102
|
+
expect(result.document.nodes[1]).toBe(mention);
|
|
103
|
+
expect(toGlobalOffset(result.document, result.caret)).toBe(3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('finds nodes by key', () => {
|
|
107
|
+
const mention = createMentionNode({ id: 'u1', label: 'Ada' });
|
|
108
|
+
const doc = createDocument([createTextNode('hi '), mention]);
|
|
109
|
+
expect(findNodeIndex(doc, mention.key)).toBe(1);
|
|
110
|
+
expect(findNodeIndex(doc, 'missing')).toBe(-1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('compares documents structurally (keys matter)', () => {
|
|
114
|
+
const text = createTextNode('x');
|
|
115
|
+
const a = createDocument([text]);
|
|
116
|
+
// same node instances → equal
|
|
117
|
+
expect(documentsEqual(a, createDocument([text]))).toBe(true);
|
|
118
|
+
expect(documentsEqual(a, a)).toBe(true);
|
|
119
|
+
// different keys, same text → not equal (node identity is part of the value)
|
|
120
|
+
expect(documentsEqual(a, createDocument([createTextNode('x')]))).toBe(false);
|
|
121
|
+
expect(documentsEqual(a, createDocument([createTextNode('y')]))).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps selection usable on empty documents', () => {
|
|
125
|
+
const doc = createDocument();
|
|
126
|
+
expect(fromGlobalOffset(doc, 0)).toEqual(createPosition(0, 0));
|
|
127
|
+
expect(toGlobalOffset(doc, createPosition(0, 0))).toBe(0);
|
|
128
|
+
expect(createSelection(createPosition(0, 0)).anchor).toEqual(createPosition(0, 0));
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer document — the authoritative, framework-neutral value of an editor
|
|
3
|
+
* (model-first architecture, ADR-0001). Documents are treated as immutable:
|
|
4
|
+
* edit helpers return new documents and never mutate node objects.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createNodeKey,
|
|
9
|
+
createTextNode,
|
|
10
|
+
isAIComposerNode,
|
|
11
|
+
type AIComposerNode,
|
|
12
|
+
} from './nodes';
|
|
13
|
+
import { fromGlobalOffset, toGlobalOffset, type Position, type DocumentRange } from './selection';
|
|
14
|
+
|
|
15
|
+
export interface AIComposerDocument {
|
|
16
|
+
nodes: AIComposerNode[];
|
|
17
|
+
metadata?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createDocument(nodes: AIComposerNode[] = [], metadata?: Record<string, unknown>): AIComposerDocument {
|
|
21
|
+
const document: AIComposerDocument = { nodes: normalizeNodes(nodes) };
|
|
22
|
+
if (metadata !== undefined) document.metadata = metadata;
|
|
23
|
+
return document;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Coerce common inputs into a document. */
|
|
27
|
+
export function coerceDocument(value: AIComposerDocument | AIComposerNode[] | string | null | undefined): AIComposerDocument {
|
|
28
|
+
if (value === null || value === undefined) return createDocument([]);
|
|
29
|
+
if (typeof value === 'string') return createDocument(value ? [createTextNode(value)] : []);
|
|
30
|
+
if (Array.isArray(value)) return createDocument(value);
|
|
31
|
+
return createDocument(value.nodes ?? [], value.metadata);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Merge adjacent text nodes and drop empty text runs. Returns a new array;
|
|
36
|
+
* input nodes are never mutated.
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeNodes(nodes: AIComposerNode[]): AIComposerNode[] {
|
|
39
|
+
const result: AIComposerNode[] = [];
|
|
40
|
+
for (const node of nodes) {
|
|
41
|
+
if (node.type === 'text') {
|
|
42
|
+
if (node.text === '') continue;
|
|
43
|
+
const last = result[result.length - 1];
|
|
44
|
+
if (last && last.type === 'text') {
|
|
45
|
+
result[result.length - 1] = { ...last, text: last.text + node.text };
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
result.push(node);
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function nodeLength(node: AIComposerNode): number {
|
|
55
|
+
return node.type === 'text' ? node.text.length : 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function totalLength(nodes: AIComposerNode[]): number {
|
|
59
|
+
return nodes.reduce((sum, node) => sum + nodeLength(node), 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function documentLength(document: AIComposerDocument): number {
|
|
63
|
+
return totalLength(document.nodes);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* True when there is nothing meaningful to show or submit: no chips and no
|
|
68
|
+
* text beyond whitespace. ContentEditable hosts keep a layout `<br>` after
|
|
69
|
+
* the user deletes everything — that artifact must read as empty or the
|
|
70
|
+
* placeholder never comes back.
|
|
71
|
+
*/
|
|
72
|
+
export function isEmptyDocument(document: AIComposerDocument): boolean {
|
|
73
|
+
let sawChip = false;
|
|
74
|
+
let sawText = false;
|
|
75
|
+
for (const node of document.nodes) {
|
|
76
|
+
if (node.type !== 'text') sawChip = true;
|
|
77
|
+
else if (node.text.trim().length > 0) sawText = true;
|
|
78
|
+
}
|
|
79
|
+
return !sawChip && !sawText;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function findNodeIndex(document: AIComposerDocument, key: string): number {
|
|
83
|
+
return document.nodes.findIndex((node) => node.key === key);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function findNode(document: AIComposerDocument, key: string): { node: AIComposerNode; index: number } | null {
|
|
87
|
+
const index = findNodeIndex(document, key);
|
|
88
|
+
return index >= 0 ? { node: document.nodes[index], index } : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getNodesOfType<T extends AIComposerNode>(document: AIComposerDocument, type: T['type']): T[] {
|
|
92
|
+
return document.nodes.filter((node): node is T => node.type === type);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function cloneDocument(document: AIComposerDocument): AIComposerDocument {
|
|
96
|
+
return {
|
|
97
|
+
nodes: document.nodes.map((node) => ({ ...node })),
|
|
98
|
+
...(document.metadata ? { metadata: { ...document.metadata } } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function documentsEqual(a: AIComposerDocument, b: AIComposerDocument): boolean {
|
|
103
|
+
if (a === b) return true;
|
|
104
|
+
return JSON.stringify(a.nodes) === JSON.stringify(b.nodes);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Replace unknown/unregistered node types with a readable text node so that
|
|
109
|
+
* documents from external sources (paste, storage, setValue) can never inject
|
|
110
|
+
* unrenderable nodes.
|
|
111
|
+
*/
|
|
112
|
+
export function sanitizeDocument(
|
|
113
|
+
document: AIComposerDocument,
|
|
114
|
+
isKnownType: (type: string) => boolean,
|
|
115
|
+
): AIComposerDocument {
|
|
116
|
+
const nodes = document.nodes.map((node) => {
|
|
117
|
+
if (isKnownType(node.type) && isAIComposerNode(node)) return node;
|
|
118
|
+
if (node.type === 'text' && typeof (node as { text?: unknown }).text === 'string') {
|
|
119
|
+
return createTextNode((node as { text: string }).text);
|
|
120
|
+
}
|
|
121
|
+
return createTextNode(JSON.stringify(node));
|
|
122
|
+
});
|
|
123
|
+
return createDocument(nodes, document.metadata);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Edit primitives (pure)
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/** Delete `range` (inclusive) and report the caret position after deletion. */
|
|
131
|
+
export function deleteDocumentRange(
|
|
132
|
+
document: AIComposerDocument,
|
|
133
|
+
range: DocumentRange,
|
|
134
|
+
): { document: AIComposerDocument; caret: Position } {
|
|
135
|
+
const startOffset = toGlobalOffset(document, range.start);
|
|
136
|
+
const endOffset = toGlobalOffset(document, range.end);
|
|
137
|
+
const low = Math.min(startOffset, endOffset);
|
|
138
|
+
const high = Math.max(startOffset, endOffset);
|
|
139
|
+
|
|
140
|
+
const nodes: AIComposerNode[] = [];
|
|
141
|
+
let cursor = 0;
|
|
142
|
+
for (const node of document.nodes) {
|
|
143
|
+
const length = nodeLength(node);
|
|
144
|
+
const nodeStart = cursor;
|
|
145
|
+
const nodeEnd = cursor + length;
|
|
146
|
+
|
|
147
|
+
if (nodeEnd <= low || nodeStart >= high) {
|
|
148
|
+
nodes.push(node);
|
|
149
|
+
cursor = nodeEnd;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (node.type === 'text') {
|
|
154
|
+
const keepBefore = Math.max(0, low - nodeStart);
|
|
155
|
+
const keepAfter = Math.max(0, nodeEnd - high);
|
|
156
|
+
if (keepBefore > 0) nodes.push({ ...node, text: node.text.slice(0, keepBefore) });
|
|
157
|
+
if (keepAfter > 0) nodes.push(createTextNode(node.text.slice(node.text.length - keepAfter)));
|
|
158
|
+
}
|
|
159
|
+
// atomic nodes overlapping the range are removed entirely
|
|
160
|
+
cursor = nodeEnd;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const next: AIComposerDocument = { nodes, ...(document.metadata ? { metadata: document.metadata } : {}) };
|
|
164
|
+
const normalized: AIComposerDocument = { ...next, nodes: normalizeNodes(next.nodes) };
|
|
165
|
+
return { document: normalized, caret: fromGlobalOffset(normalized, low) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Insert `inserted` at `position` (splitting a text node when needed). */
|
|
169
|
+
export function insertNodesAt(
|
|
170
|
+
document: AIComposerDocument,
|
|
171
|
+
position: Position,
|
|
172
|
+
inserted: AIComposerNode[],
|
|
173
|
+
): { document: AIComposerDocument; caret: Position } {
|
|
174
|
+
if (inserted.length === 0) return { document, caret: position };
|
|
175
|
+
|
|
176
|
+
const index = Math.min(Math.max(position.nodeIndex, 0), document.nodes.length);
|
|
177
|
+
const target = document.nodes[index];
|
|
178
|
+
const result: AIComposerNode[] = [...document.nodes.slice(0, index)];
|
|
179
|
+
|
|
180
|
+
if (target && target.type === 'text') {
|
|
181
|
+
const offset = Math.min(Math.max(position.offset, 0), target.text.length);
|
|
182
|
+
if (offset === 0) {
|
|
183
|
+
result.push(...inserted, target);
|
|
184
|
+
} else if (offset === target.text.length) {
|
|
185
|
+
result.push(target, ...inserted);
|
|
186
|
+
} else {
|
|
187
|
+
result.push(
|
|
188
|
+
{ ...target, text: target.text.slice(0, offset) },
|
|
189
|
+
...inserted,
|
|
190
|
+
createTextNode(target.text.slice(offset), createNodeKey('text')),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
} else if (target) {
|
|
194
|
+
// atomic target: offset 0 = before the chip, 1 = after it
|
|
195
|
+
if (position.offset > 0) result.push(target, ...inserted);
|
|
196
|
+
else result.push(...inserted, target);
|
|
197
|
+
} else {
|
|
198
|
+
result.push(...inserted);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
result.push(...document.nodes.slice(index + 1));
|
|
202
|
+
|
|
203
|
+
const next: AIComposerDocument = { nodes: result, ...(document.metadata ? { metadata: document.metadata } : {}) };
|
|
204
|
+
const normalized: AIComposerDocument = { ...next, nodes: normalizeNodes(next.nodes) };
|
|
205
|
+
const caretOffset = toGlobalOffset(document, position) + totalLength(inserted);
|
|
206
|
+
return { document: normalized, caret: fromGlobalOffset(normalized, caretOffset) };
|
|
207
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node model.
|
|
3
|
+
*
|
|
4
|
+
* A composer document is a flat list of nodes (see ADR-0002): text runs and
|
|
5
|
+
* atomic "chip" nodes (mention, command, variable, attachment, custom).
|
|
6
|
+
*
|
|
7
|
+
* Every node carries a stable instance `key`. It is NOT the entity id —
|
|
8
|
+
* {@link MentionNode}.id identifies the mentioned entity, `key` identifies
|
|
9
|
+
* this particular node instance and stays stable across clones, undo/redo
|
|
10
|
+
* and DOM round-trips (React keys, DOM↔model mapping).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Stable node instance key. */
|
|
14
|
+
export type NodeKey = string;
|
|
15
|
+
|
|
16
|
+
export interface BaseNode {
|
|
17
|
+
key: NodeKey;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TextNode extends BaseNode {
|
|
21
|
+
type: 'text';
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MentionNode extends BaseNode {
|
|
26
|
+
type: 'mention';
|
|
27
|
+
/** Entity id (user, ticket, …) — not the node instance key. */
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
metadata?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CommandNode extends BaseNode {
|
|
34
|
+
type: 'command';
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
metadata?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface VariableNode extends BaseNode {
|
|
41
|
+
type: 'variable';
|
|
42
|
+
name: string;
|
|
43
|
+
value?: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AttachmentNode extends BaseNode {
|
|
47
|
+
type: 'attachment';
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
mimeType: string;
|
|
51
|
+
size?: number;
|
|
52
|
+
metadata?: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Application/plugin-defined node. Rendering & serialization come from the owning NodeDefinition. */
|
|
56
|
+
export interface CustomNode extends BaseNode {
|
|
57
|
+
type: 'custom';
|
|
58
|
+
plugin: string;
|
|
59
|
+
nodeType: string;
|
|
60
|
+
data: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type AIComposerNode = TextNode | MentionNode | CommandNode | VariableNode | AttachmentNode | CustomNode;
|
|
64
|
+
|
|
65
|
+
/** Anything that is not a text run is indivisible (length 1) for edits and selection. */
|
|
66
|
+
export type AtomicNode = Exclude<AIComposerNode, TextNode>;
|
|
67
|
+
|
|
68
|
+
export type AIComposerNodeType = AIComposerNode['type'];
|
|
69
|
+
|
|
70
|
+
let sequence = 0;
|
|
71
|
+
|
|
72
|
+
/** Generate a unique node key, e.g. `mention_lx3a_07_f2k9q1`. */
|
|
73
|
+
export function createNodeKey(prefix = 'n'): NodeKey {
|
|
74
|
+
sequence += 1;
|
|
75
|
+
return `${prefix}_${Date.now().toString(36)}_${sequence.toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Factories
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export function createTextNode(text: string, key: NodeKey = createNodeKey('text')): TextNode {
|
|
83
|
+
return { type: 'text', key, text };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createMentionNode(
|
|
87
|
+
input: Omit<MentionNode, 'type' | 'key'>,
|
|
88
|
+
key: NodeKey = createNodeKey('mention'),
|
|
89
|
+
): MentionNode {
|
|
90
|
+
return { type: 'mention', key, ...input };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createCommandNode(
|
|
94
|
+
input: Omit<CommandNode, 'type' | 'key'>,
|
|
95
|
+
key: NodeKey = createNodeKey('command'),
|
|
96
|
+
): CommandNode {
|
|
97
|
+
return { type: 'command', key, ...input };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createVariableNode(
|
|
101
|
+
input: { name: string; value?: unknown },
|
|
102
|
+
key: NodeKey = createNodeKey('variable'),
|
|
103
|
+
): VariableNode {
|
|
104
|
+
return { type: 'variable', key, name: input.name, ...(input.value !== undefined ? { value: input.value } : {}) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createAttachmentNode(
|
|
108
|
+
input: Omit<AttachmentNode, 'type' | 'key'>,
|
|
109
|
+
key: NodeKey = createNodeKey('attachment'),
|
|
110
|
+
): AttachmentNode {
|
|
111
|
+
return { type: 'attachment', key, ...input };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createCustomNode(
|
|
115
|
+
input: { plugin: string; nodeType: string; data: unknown },
|
|
116
|
+
key: NodeKey = createNodeKey(input.nodeType || 'custom'),
|
|
117
|
+
): CustomNode {
|
|
118
|
+
return { type: 'custom', key, plugin: input.plugin, nodeType: input.nodeType, data: input.data };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Assign a key to a hand-built node that lacks one (e.g. from parsed JSON). */
|
|
122
|
+
export function ensureNodeKey<T extends AIComposerNode>(node: T): T {
|
|
123
|
+
return node.key ? node : { ...node, key: createNodeKey(node.type) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Guards
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export function isTextNode(node: AIComposerNode): node is TextNode {
|
|
131
|
+
return node.type === 'text';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function isAtomicNode(node: AIComposerNode): node is AtomicNode {
|
|
135
|
+
return node.type !== 'text';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getNodeType(node: AIComposerNode): AIComposerNodeType {
|
|
139
|
+
return node.type;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Lightweight structural validation for documents coming from views / storage. */
|
|
143
|
+
export function isAIComposerNode(value: unknown): value is AIComposerNode {
|
|
144
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
145
|
+
const node = value as Partial<AIComposerNode>;
|
|
146
|
+
return (
|
|
147
|
+
typeof node.type === 'string' &&
|
|
148
|
+
typeof node.key === 'string' &&
|
|
149
|
+
(node.type !== 'text' || typeof (node as Partial<TextNode>).text === 'string')
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection model — framework-neutral positions against the document model,
|
|
3
|
+
* never raw DOM Range objects (see ADR-0001).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AIComposerDocument } from './document';
|
|
7
|
+
import { nodeLength } from './document';
|
|
8
|
+
|
|
9
|
+
/** Caret or selection endpoint. For atomic nodes offset is 0 (before) or 1 (after). */
|
|
10
|
+
export interface Position {
|
|
11
|
+
nodeIndex: number;
|
|
12
|
+
offset: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SelectionState {
|
|
16
|
+
anchor: Position;
|
|
17
|
+
focus: Position;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DocumentRange {
|
|
21
|
+
start: Position;
|
|
22
|
+
end: Position;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const createPosition = (nodeIndex: number, offset = 0): Position => ({ nodeIndex, offset });
|
|
26
|
+
|
|
27
|
+
export const createSelection = (
|
|
28
|
+
anchor: Position = createPosition(0, 0),
|
|
29
|
+
focus: Position = anchor,
|
|
30
|
+
): SelectionState => ({ anchor, focus });
|
|
31
|
+
|
|
32
|
+
export function equalsPosition(a: Position, b: Position): boolean {
|
|
33
|
+
return a.nodeIndex === b.nodeIndex && a.offset === b.offset;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function equalsSelection(a: SelectionState, b: SelectionState): boolean {
|
|
37
|
+
return equalsPosition(a.anchor, b.anchor) && equalsPosition(a.focus, b.focus);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Total order over positions: by node index, then offset. */
|
|
41
|
+
export function comparePositions(a: Position, b: Position): number {
|
|
42
|
+
if (a.nodeIndex !== b.nodeIndex) return a.nodeIndex < b.nodeIndex ? -1 : 1;
|
|
43
|
+
if (a.offset === b.offset) return 0;
|
|
44
|
+
return a.offset < b.offset ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const isCollapsed = (selection: SelectionState): boolean =>
|
|
48
|
+
equalsPosition(selection.anchor, selection.focus);
|
|
49
|
+
|
|
50
|
+
/** Anchor→focus ordering regardless of selection direction. */
|
|
51
|
+
export function getRange(selection: SelectionState): DocumentRange {
|
|
52
|
+
return comparePositions(selection.anchor, selection.focus) <= 0
|
|
53
|
+
? { start: selection.anchor, end: selection.focus }
|
|
54
|
+
: { start: selection.focus, end: selection.anchor };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const getStart = (selection: SelectionState): Position => getRange(selection).start;
|
|
58
|
+
export const getEnd = (selection: SelectionState): Position => getRange(selection).end;
|
|
59
|
+
|
|
60
|
+
/** Convert a model position into a document-wide character offset. */
|
|
61
|
+
export function toGlobalOffset(document: AIComposerDocument, position: Position): number {
|
|
62
|
+
let offset = 0;
|
|
63
|
+
for (let i = 0; i < position.nodeIndex && i < document.nodes.length; i += 1) {
|
|
64
|
+
offset += nodeLength(document.nodes[i]);
|
|
65
|
+
}
|
|
66
|
+
const node = document.nodes[position.nodeIndex];
|
|
67
|
+
if (!node) return offset;
|
|
68
|
+
if (node.type === 'text') return offset + Math.min(Math.max(position.offset, 0), node.text.length);
|
|
69
|
+
return offset + (position.offset > 0 ? 1 : 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Convert a document-wide offset back into a node-relative position. */
|
|
73
|
+
export function fromGlobalOffset(document: AIComposerDocument, globalOffset: number): Position {
|
|
74
|
+
let remaining = Math.max(0, globalOffset);
|
|
75
|
+
for (let i = 0; i < document.nodes.length; i += 1) {
|
|
76
|
+
const length = nodeLength(document.nodes[i]);
|
|
77
|
+
if (remaining < length || (remaining === length && i === document.nodes.length - 1)) {
|
|
78
|
+
return createPosition(i, remaining);
|
|
79
|
+
}
|
|
80
|
+
remaining -= length;
|
|
81
|
+
}
|
|
82
|
+
return createPosition(document.nodes.length, 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function clampPosition(document: AIComposerDocument, position: Position): Position {
|
|
86
|
+
const maxIndex = document.nodes.length;
|
|
87
|
+
const nodeIndex = Math.min(Math.max(position.nodeIndex, 0), maxIndex);
|
|
88
|
+
if (nodeIndex === maxIndex) {
|
|
89
|
+
// At (or beyond) the end boundary: clamp onto the last node's end.
|
|
90
|
+
if (maxIndex === 0) return createPosition(0, 0);
|
|
91
|
+
const last = document.nodes[maxIndex - 1];
|
|
92
|
+
return createPosition(maxIndex - 1, nodeLength(last));
|
|
93
|
+
}
|
|
94
|
+
const node = document.nodes[nodeIndex];
|
|
95
|
+
if (!node) return createPosition(nodeIndex, 0);
|
|
96
|
+
const maxOffset = nodeLength(node);
|
|
97
|
+
return createPosition(nodeIndex, Math.min(Math.max(position.offset, 0), maxOffset));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function clampSelection(document: AIComposerDocument, selection: SelectionState): SelectionState {
|
|
101
|
+
return createSelection(clampPosition(document, selection.anchor), clampPosition(document, selection.focus));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function globalRange(document: AIComposerDocument, selection: SelectionState): { start: number; end: number } {
|
|
105
|
+
const { start, end } = getRange(selection);
|
|
106
|
+
return { start: toGlobalOffset(document, start), end: toGlobalOffset(document, end) };
|
|
107
|
+
}
|