@haklex/rich-collab-loro 0.44.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/src/binding.ts ADDED
@@ -0,0 +1,282 @@
1
+ import {
2
+ $getNodeByKey,
3
+ $getRoot,
4
+ $getSelection,
5
+ $isElementNode,
6
+ $isRangeSelection,
7
+ $isTextNode,
8
+ $parseSerializedNode,
9
+ type ElementNode,
10
+ type LexicalEditor,
11
+ type LexicalNode,
12
+ type NodeKey,
13
+ type SerializedLexicalNode,
14
+ type TextNode,
15
+ } from 'lexical';
16
+ import { type LoroDoc, LoroText, type LoroTreeNode, type TreeID } from 'loro-crdt';
17
+
18
+ import { transformOffset } from './offset';
19
+ import { $updateInPlace } from './reconcile';
20
+ import { stable } from './stable';
21
+
22
+ export const COLLAB_TAG = 'collab';
23
+ export const TREE_NAME = 'lexical';
24
+ const TEXT_KEY = '#text';
25
+
26
+ export interface LoroBinding {
27
+ dispose: () => void;
28
+ idOf: (key: NodeKey) => TreeID | undefined;
29
+ import: (bytes: Uint8Array) => void;
30
+ keyOf: (id: TreeID) => NodeKey | undefined;
31
+ }
32
+
33
+ export interface LoroBindingOptions {
34
+ commitMessage?: string;
35
+ }
36
+
37
+ type Props = Record<string, unknown>;
38
+
39
+ function sameValue(a: unknown, b: unknown): boolean {
40
+ return a === b || stable(a) === stable(b);
41
+ }
42
+
43
+ function nodeProps(node: LexicalNode): Props {
44
+ const { children: _children, ...props } = node.exportJSON() as Props;
45
+ if ($isTextNode(node)) delete props.text;
46
+ return props;
47
+ }
48
+
49
+ function storedProps(treeNode: LoroTreeNode): Props {
50
+ const { [TEXT_KEY]: _text, ...props } = treeNode.data.toJSON() as Props;
51
+ return props;
52
+ }
53
+
54
+ export function createLoroBinding(
55
+ editor: LexicalEditor,
56
+ doc: LoroDoc,
57
+ options: LoroBindingOptions = {},
58
+ ): LoroBinding {
59
+ const tree = doc.getTree(TREE_NAME);
60
+ tree.enableFractionalIndex(0);
61
+ const keyToId = new Map<NodeKey, TreeID>();
62
+ const idToKey = new Map<TreeID, NodeKey>();
63
+
64
+ const link = (key: NodeKey, id: TreeID) => {
65
+ const previousId = keyToId.get(key);
66
+ if (previousId !== undefined) idToKey.delete(previousId);
67
+ const previousKey = idToKey.get(id);
68
+ if (previousKey !== undefined) keyToId.delete(previousKey);
69
+ keyToId.set(key, id);
70
+ idToKey.set(id, key);
71
+ };
72
+
73
+ const alive = (id: TreeID | undefined): id is TreeID =>
74
+ id !== undefined && tree.has(id) && !tree.isNodeDeleted(id);
75
+
76
+ // Peers may register different classes for one type (headless vs edit nodes) whose
77
+ // exportJSON disagree; writing only this peer's own prop changes stops them from
78
+ // endlessly overwriting each other's fields.
79
+ const synced = new Map<NodeKey, Props>();
80
+ const readBaseline = new Map<TreeID, string>();
81
+
82
+ const writeNode = (node: LexicalNode, treeNode: LoroTreeNode) => {
83
+ const data = treeNode.data;
84
+ const props = nodeProps(node);
85
+ const previous = synced.get(node.getKey()) ?? {};
86
+ let wrote = false;
87
+ for (const key of new Set([...Object.keys(previous), ...Object.keys(props)])) {
88
+ const value = props[key];
89
+ if (sameValue(previous[key], value) && (value === undefined || data.get(key) !== undefined)) {
90
+ continue;
91
+ }
92
+ if (value === undefined) {
93
+ if (data.get(key) !== undefined) {
94
+ data.delete(key);
95
+ wrote = true;
96
+ }
97
+ } else if (!sameValue(data.get(key), value)) {
98
+ data.set(key, value as never);
99
+ wrote = true;
100
+ }
101
+ }
102
+ synced.set(node.getKey(), props);
103
+ if (wrote) readBaseline.set(treeNode.id, stable(storedProps(treeNode)));
104
+ if ($isTextNode(node)) {
105
+ const text = data.getOrCreateContainer(TEXT_KEY, new LoroText());
106
+ const next = node.getTextContent();
107
+ if (text.toString() !== next) text.update(next);
108
+ }
109
+ };
110
+
111
+ const writeChildren = (parent: ElementNode, parentId: TreeID, visited: Set<TreeID>) => {
112
+ parent.getChildren().forEach((child, index) => {
113
+ let id = keyToId.get(child.getKey());
114
+ if (alive(id)) {
115
+ const current = tree.getNodeByID(id)!;
116
+ if (current.parent()?.id !== parentId || current.index() !== index) {
117
+ tree.move(id, parentId, index);
118
+ }
119
+ } else {
120
+ id = tree.createNode(parentId, index).id;
121
+ link(child.getKey(), id);
122
+ }
123
+ visited.add(id);
124
+ writeNode(child, tree.getNodeByID(id)!);
125
+ if ($isElementNode(child)) writeChildren(child, id, visited);
126
+ });
127
+ };
128
+
129
+ // ponytail: full-tree walk per update; switch to dirtyElements/dirtyLeaves if long drafts lag
130
+ const pushToDoc = () => {
131
+ editor.getEditorState().read(() => {
132
+ let rootId = keyToId.get('root');
133
+ if (!alive(rootId)) {
134
+ rootId = tree.roots()[0]?.id ?? tree.createNode().id;
135
+ link('root', rootId);
136
+ }
137
+ const visited = new Set<TreeID>([rootId]);
138
+ writeNode($getRoot(), tree.getNodeByID(rootId)!);
139
+ writeChildren($getRoot(), rootId, visited);
140
+ for (const treeNode of tree.nodes()) {
141
+ if (!visited.has(treeNode.id) && alive(treeNode.id)) {
142
+ tree.delete(treeNode.id);
143
+ const key = idToKey.get(treeNode.id);
144
+ if (key !== undefined) keyToId.delete(key);
145
+ idToKey.delete(treeNode.id);
146
+ }
147
+ }
148
+ });
149
+ doc.commit({ message: options.commitMessage });
150
+ };
151
+
152
+ const applyText = (node: TextNode, next: string) => {
153
+ const previous = node.getTextContent();
154
+ if (previous === next) return;
155
+ const selection = $getSelection();
156
+ const points = $isRangeSelection(selection)
157
+ ? [selection.anchor, selection.focus].filter(
158
+ (point) => point.type === 'text' && point.key === node.getKey(),
159
+ )
160
+ : [];
161
+ const offsets = points.map((point) => transformOffset(previous, next, point.offset));
162
+ node.setTextContent(next);
163
+ points.forEach((point, index) => point.set(node.getKey(), offsets[index], 'text'));
164
+ };
165
+
166
+ const readNode = (treeNode: LoroTreeNode): LexicalNode => {
167
+ const node = readNodeUnsynced(treeNode);
168
+ synced.set(node.getKey(), nodeProps(node));
169
+ return node;
170
+ };
171
+
172
+ const readNodeUnsynced = (treeNode: LoroTreeNode): LexicalNode => {
173
+ const props = storedProps(treeNode);
174
+ const text = $isTextLike(props) ? String(treeNode.data.get(TEXT_KEY) ?? '') : undefined;
175
+ const key = idToKey.get(treeNode.id);
176
+ const existing = key === undefined ? null : $getNodeByKey(key);
177
+ if (existing && existing.getType() === props.type) {
178
+ if ($isTextNode(existing)) {
179
+ if (!sameValue(nodeProps(existing), props)) {
180
+ existing.updateFromJSON({ ...props, text: existing.getTextContent() } as never);
181
+ }
182
+ applyText(existing, text ?? '');
183
+ return existing;
184
+ }
185
+ const storedKey = stable(props);
186
+ if (readBaseline.get(treeNode.id) === storedKey || sameValue(nodeProps(existing), props)) {
187
+ readBaseline.set(treeNode.id, storedKey);
188
+ return existing;
189
+ }
190
+ if ($updateInPlace(existing, props as never)) {
191
+ readBaseline.set(treeNode.id, storedKey);
192
+ return existing;
193
+ }
194
+ readBaseline.set(treeNode.id, storedKey);
195
+ const replacement = $parseSerializedNode({
196
+ ...props,
197
+ children: [],
198
+ } as unknown as SerializedLexicalNode);
199
+ existing.replace(replacement, $isElementNode(existing) && $isElementNode(replacement));
200
+ link(replacement.getKey(), treeNode.id);
201
+ return replacement;
202
+ }
203
+ const created = $parseSerializedNode({
204
+ ...props,
205
+ ...(text === undefined ? {} : { text }),
206
+ children: [],
207
+ } as unknown as SerializedLexicalNode);
208
+ readBaseline.set(treeNode.id, stable(props));
209
+ link(created.getKey(), treeNode.id);
210
+ return created;
211
+ };
212
+
213
+ const readChildren = (parent: ElementNode, treeNode: LoroTreeNode, visited: Set<NodeKey>) => {
214
+ (treeNode.children() ?? []).forEach((child, index) => {
215
+ const node = readNode(child);
216
+ visited.add(node.getKey());
217
+ const current = parent.getChildAtIndex(index);
218
+ if (!current || !current.is(node)) {
219
+ if (index === 0) {
220
+ const first = parent.getFirstChild();
221
+ if (first) first.insertBefore(node);
222
+ else parent.append(node);
223
+ } else {
224
+ parent.getChildAtIndex(index - 1)!.insertAfter(node);
225
+ }
226
+ }
227
+ if ($isElementNode(node)) readChildren(node, child, visited);
228
+ });
229
+ };
230
+
231
+ const $collectDescendants = (node: ElementNode, out: LexicalNode[]) => {
232
+ for (const child of node.getChildren()) {
233
+ out.push(child);
234
+ if ($isElementNode(child)) $collectDescendants(child, out);
235
+ }
236
+ };
237
+
238
+ const pullFromDoc = () => {
239
+ editor.update(
240
+ () => {
241
+ const rootTreeNode = tree.roots()[0];
242
+ if (!rootTreeNode) return;
243
+ link('root', rootTreeNode.id);
244
+ const rootProps = storedProps(rootTreeNode);
245
+ if (rootProps.type === 'root' && !sameValue(nodeProps($getRoot()), rootProps)) {
246
+ $getRoot().updateFromJSON(rootProps as never);
247
+ }
248
+ synced.set('root', nodeProps($getRoot()));
249
+ const visited = new Set<NodeKey>(['root']);
250
+ readChildren($getRoot(), rootTreeNode, visited);
251
+ const all: LexicalNode[] = [];
252
+ $collectDescendants($getRoot(), all);
253
+ for (const node of all) {
254
+ if (!visited.has(node.getKey()) && node.isAttached()) node.remove();
255
+ }
256
+ },
257
+ { discrete: true, tag: COLLAB_TAG },
258
+ );
259
+ };
260
+
261
+ if (tree.roots().length > 0) pullFromDoc();
262
+ else pushToDoc();
263
+
264
+ const unregister = editor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => {
265
+ if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return;
266
+ pushToDoc();
267
+ });
268
+
269
+ return {
270
+ dispose: unregister,
271
+ idOf: (key) => keyToId.get(key),
272
+ keyOf: (id) => idToKey.get(id),
273
+ import: (bytes) => {
274
+ doc.import(bytes);
275
+ pullFromDoc();
276
+ },
277
+ };
278
+ }
279
+
280
+ function $isTextLike(props: Props): boolean {
281
+ return typeof props.format === 'number' && typeof props.detail === 'number' && 'mode' in props;
282
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export {
2
+ COLLAB_TAG,
3
+ createLoroBinding,
4
+ type LoroBinding,
5
+ type LoroBindingOptions,
6
+ TREE_NAME,
7
+ } from './binding';
8
+ export { $reconcileRoot, editAtVersion } from './reconcile';
9
+ export {
10
+ type Frame,
11
+ type FrameFocus,
12
+ planFrames,
13
+ streamAtVersion,
14
+ type StreamStep,
15
+ } from './stream';
package/src/offset.ts ADDED
@@ -0,0 +1,15 @@
1
+ export function transformOffset(previous: string, next: string, offset: number): number {
2
+ const limit = Math.min(previous.length, next.length);
3
+ let prefix = 0;
4
+ while (prefix < limit && previous[prefix] === next[prefix]) prefix++;
5
+ let suffix = 0;
6
+ while (
7
+ suffix < limit - prefix &&
8
+ previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix]
9
+ ) {
10
+ suffix++;
11
+ }
12
+ if (offset <= prefix) return offset;
13
+ if (offset >= previous.length - suffix) return offset + next.length - previous.length;
14
+ return next.length - suffix;
15
+ }
@@ -0,0 +1,174 @@
1
+ import {
2
+ $getRoot,
3
+ $isElementNode,
4
+ $isTextNode,
5
+ $parseSerializedNode,
6
+ type ElementNode,
7
+ type LexicalEditor,
8
+ type LexicalNode,
9
+ type SerializedEditorState,
10
+ type SerializedLexicalNode,
11
+ } from 'lexical';
12
+ import type { LoroDoc } from 'loro-crdt';
13
+
14
+ import { createLoroBinding } from './binding';
15
+ import { stable } from './stable';
16
+
17
+ export type Json = SerializedLexicalNode & {
18
+ $?: { blockId?: string };
19
+ children?: Json[];
20
+ text?: string;
21
+ };
22
+
23
+ export type Frontiers = ReturnType<LoroDoc['frontiers']>;
24
+
25
+ const DERIVED_KEYS: Record<string, string[]> = { listitem: ['value'] };
26
+
27
+ function withoutDerived(json: Json): Record<string, unknown> {
28
+ const { children: _children, text: _text, ...rest } = json;
29
+ for (const key of DERIVED_KEYS[json.type] ?? []) delete (rest as Record<string, unknown>)[key];
30
+ return rest;
31
+ }
32
+
33
+ function shallow(json: Json): string {
34
+ return stable(withoutDerived(json));
35
+ }
36
+
37
+ function $serialize(node: LexicalNode): Json {
38
+ const json = node.exportJSON() as Json;
39
+ if ($isElementNode(node)) json.children = node.getChildren().map($serialize);
40
+ return json;
41
+ }
42
+
43
+ // ponytail: O(n·m) LCS per element; fine for article-sized blocks, switch to Myers if docs grow huge
44
+ export function lcs(a: string[], b: string[]): Array<[number, number]> {
45
+ const table = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
46
+ for (let i = a.length - 1; i >= 0; i--) {
47
+ for (let j = b.length - 1; j >= 0; j--) {
48
+ table[i][j] =
49
+ a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
50
+ }
51
+ }
52
+ const pairs: Array<[number, number]> = [];
53
+ let i = 0;
54
+ let j = 0;
55
+ while (i < a.length && j < b.length) {
56
+ if (a[i] === b[j]) pairs.push([i++, j++]);
57
+ else if (table[i + 1][j] >= table[i][j + 1]) i++;
58
+ else j++;
59
+ }
60
+ return pairs;
61
+ }
62
+
63
+ // Replacing a node deletes its Loro tree node, and with it any child a peer
64
+ // inserted concurrently; update in place whenever the node class can absorb the change.
65
+ export function $updateInPlace(node: LexicalNode, target: Json): boolean {
66
+ const props = withoutDerived(target);
67
+ node.updateFromJSON(props as never);
68
+ return shallow(node.exportJSON() as Json) === shallow(target);
69
+ }
70
+
71
+ function $update(node: LexicalNode, current: Json, target: Json): LexicalNode {
72
+ if ($isTextNode(node)) {
73
+ if (shallow(current) !== shallow(target)) {
74
+ node.updateFromJSON({ ...target, text: node.getTextContent() } as never);
75
+ }
76
+ if (node.getTextContent() !== target.text) node.setTextContent(target.text ?? '');
77
+ return node;
78
+ }
79
+ if ($isElementNode(node)) {
80
+ let element: ElementNode = node;
81
+ if (shallow(current) !== shallow(target) && !$updateInPlace(node, target)) {
82
+ const replacement = $parseSerializedNode({ ...target, children: [] } as Json);
83
+ if (!$isElementNode(replacement)) return node;
84
+ node.replace(replacement, true);
85
+ element = replacement;
86
+ }
87
+ $reconcileChildren(element, target.children ?? []);
88
+ return element;
89
+ }
90
+ if (stable(current) === stable(target) || $updateInPlace(node, target)) return node;
91
+ const replacement = $parseSerializedNode(target);
92
+ node.replace(replacement);
93
+ return replacement;
94
+ }
95
+
96
+ export function pairable(current: Json, target: Json): boolean {
97
+ if (current.type !== target.type) return false;
98
+ const a = current.$?.blockId;
99
+ const b = target.$?.blockId;
100
+ return a === undefined || b === undefined || a === b;
101
+ }
102
+
103
+ function $reconcileChildren(parent: ElementNode, targets: Json[]): void {
104
+ const current = parent.getChildren();
105
+ const currentJson = current.map($serialize);
106
+ const pairs = lcs(currentJson.map(stable), targets.map(stable));
107
+ const result: LexicalNode[] = [];
108
+ let ci = 0;
109
+ let tj = 0;
110
+ for (const [i, j] of [...pairs, [current.length, targets.length] as [number, number]]) {
111
+ const pending = Array.from({ length: i - ci }, (_, k) => ci + k);
112
+ for (let t = tj; t < j; t++) {
113
+ const target = targets[t];
114
+ const blockId = target.$?.blockId;
115
+ let slot =
116
+ blockId === undefined
117
+ ? -1
118
+ : pending.findIndex(
119
+ (c) => currentJson[c].$?.blockId === blockId && pairable(currentJson[c], target),
120
+ );
121
+ if (slot === -1) slot = pending.findIndex((c) => pairable(currentJson[c], target));
122
+ if (slot === -1) {
123
+ result.push($parseSerializedNode(target));
124
+ } else {
125
+ const [c] = pending.splice(slot, 1);
126
+ result.push($update(current[c], currentJson[c], target));
127
+ }
128
+ }
129
+ if (i < current.length) result.push(current[i]);
130
+ ci = i + 1;
131
+ tj = j + 1;
132
+ }
133
+ const kept = new Set(result.map((node) => node.getKey()));
134
+ for (const node of parent.getChildren()) {
135
+ if (!kept.has(node.getKey())) node.remove(true);
136
+ }
137
+ result.forEach((node, index) => {
138
+ const at = parent.getChildAtIndex(index);
139
+ if (at && at.is(node)) return;
140
+ if (index === 0) {
141
+ const first = parent.getFirstChild();
142
+ if (first) first.insertBefore(node);
143
+ else parent.append(node);
144
+ } else {
145
+ result[index - 1].insertAfter(node);
146
+ }
147
+ });
148
+ }
149
+
150
+ export function $reconcileRoot(target: SerializedEditorState['root']): void {
151
+ const root = $getRoot();
152
+ const targetJson = target as unknown as Json;
153
+ if (shallow($serialize(root)) !== shallow(targetJson)) root.updateFromJSON(target);
154
+ const normalized = (targetJson.children ?? []).map((child) =>
155
+ $serialize($parseSerializedNode(child)),
156
+ );
157
+ $reconcileChildren(root, normalized);
158
+ }
159
+
160
+ export function editAtVersion(
161
+ doc: LoroDoc,
162
+ frontiers: Frontiers,
163
+ createEditor: () => LexicalEditor,
164
+ target: SerializedEditorState,
165
+ commitMessage?: string,
166
+ ): Uint8Array {
167
+ const fork = doc.forkAt(frontiers);
168
+ const editor = createEditor();
169
+ const binding = createLoroBinding(editor, fork, { commitMessage });
170
+ const from = fork.oplogVersion();
171
+ editor.update(() => $reconcileRoot(target.root), { discrete: true });
172
+ binding.dispose();
173
+ return fork.export({ from, mode: 'update' });
174
+ }
package/src/stable.ts ADDED
@@ -0,0 +1,11 @@
1
+ export function stable(value: unknown): string {
2
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
3
+ if (value && typeof value === 'object') {
4
+ return `{${Object.keys(value)
5
+ .filter((key) => (value as Record<string, unknown>)[key] !== undefined)
6
+ .sort()
7
+ .map((key) => `${JSON.stringify(key)}:${stable((value as Record<string, unknown>)[key])}`)
8
+ .join(',')}}`;
9
+ }
10
+ return JSON.stringify(value);
11
+ }