@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/stream.ts ADDED
@@ -0,0 +1,209 @@
1
+ import {
2
+ $getRoot,
3
+ $isElementNode,
4
+ $isTextNode,
5
+ type LexicalEditor,
6
+ type NodeKey,
7
+ type SerializedEditorState,
8
+ } from 'lexical';
9
+ import type { LoroDoc, TreeID } from 'loro-crdt';
10
+
11
+ import { createLoroBinding } from './binding';
12
+ import { $reconcileRoot, type Frontiers, type Json, lcs, pairable } from './reconcile';
13
+ import { stable } from './stable';
14
+
15
+ export interface FrameFocus {
16
+ offset: number;
17
+ path: number[];
18
+ }
19
+
20
+ export interface Frame {
21
+ focus: FrameFocus | null;
22
+ root: Json;
23
+ }
24
+
25
+ export interface StreamStep {
26
+ cursor: { id: TreeID; offset: number } | null;
27
+ update: Uint8Array;
28
+ }
29
+
30
+ const TYPING_FRAME_BUDGET = 120;
31
+ const MAX_FRAMES_PER_BLOCK = 24;
32
+
33
+ interface Item {
34
+ next: Json;
35
+ old: Json | null;
36
+ }
37
+
38
+ interface Gap {
39
+ dels: Json[];
40
+ items: Item[];
41
+ matched: Json | null;
42
+ }
43
+
44
+ const isFlat = (block: Json | null) =>
45
+ !block || (block.children ?? []).every((child) => child.type === 'text');
46
+
47
+ const textOf = (block: Json | null) =>
48
+ (block?.children ?? []).map((child) => child.text ?? '').join('');
49
+
50
+ function sliceRuns(runs: Json[], start: number, end: number): Json[] {
51
+ const out: Json[] = [];
52
+ let offset = 0;
53
+ for (const run of runs) {
54
+ const text = run.text ?? '';
55
+ const from = Math.max(start, offset);
56
+ const to = Math.min(end, offset + text.length);
57
+ if (to > from) out.push({ ...run, text: text.slice(from - offset, to - offset) });
58
+ offset += text.length;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function typingSpan(item: Item) {
64
+ const before = textOf(item.old);
65
+ const after = textOf(item.next);
66
+ const limit = Math.min(before.length, after.length);
67
+ let prefix = 0;
68
+ while (prefix < limit && before[prefix] === after[prefix]) prefix++;
69
+ let suffix = 0;
70
+ while (
71
+ suffix < limit - prefix &&
72
+ before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
73
+ ) {
74
+ suffix++;
75
+ }
76
+ return { after, prefix, suffix, middle: after.length - prefix - suffix };
77
+ }
78
+
79
+ function pairGap(dels: Json[], adds: Json[]): Item[] {
80
+ const pending = [...dels];
81
+ return adds.map((next) => {
82
+ const blockId = next.$?.blockId;
83
+ let slot = blockId === undefined ? -1 : pending.findIndex((old) => old.$?.blockId === blockId);
84
+ if (slot === -1) slot = pending.findIndex((old) => pairable(old, next));
85
+ return { next, old: slot === -1 ? null : pending.splice(slot, 1)[0]! };
86
+ });
87
+ }
88
+
89
+ export function planFrames(base: Json, target: Json): Frame[] {
90
+ const before = base.children ?? [];
91
+ const after = target.children ?? [];
92
+ const gaps: Gap[] = [];
93
+ let ci = 0;
94
+ let tj = 0;
95
+ for (const [i, j] of [
96
+ ...lcs(before.map(stable), after.map(stable)),
97
+ [before.length, after.length] as [number, number],
98
+ ]) {
99
+ gaps.push({
100
+ dels: before.slice(ci, i),
101
+ items: pairGap(before.slice(ci, i), after.slice(tj, j)),
102
+ matched: after[j] ?? null,
103
+ });
104
+ ci = i + 1;
105
+ tj = j + 1;
106
+ }
107
+
108
+ const typingItems = gaps
109
+ .flatMap((gap) => gap.items)
110
+ .filter((item) => isFlat(item.old) && isFlat(item.next) && typingSpan(item).middle > 0);
111
+ const perBlock = Math.min(
112
+ MAX_FRAMES_PER_BLOCK,
113
+ Math.max(1, Math.floor(TYPING_FRAME_BUDGET / Math.max(1, typingItems.length))),
114
+ );
115
+
116
+ const regions = gaps.map((gap) => [...gap.dels]);
117
+ const frames: Frame[] = [];
118
+ const render = () => ({
119
+ ...target,
120
+ children: gaps.flatMap((gap, g) => [...regions[g]!, ...(gap.matched ? [gap.matched] : [])]),
121
+ });
122
+ const regionStart = (g: number) =>
123
+ gaps.slice(0, g).reduce((sum, gap, k) => sum + regions[k]!.length + (gap.matched ? 1 : 0), 0);
124
+ const push = (focus: FrameFocus | null) => frames.push({ focus, root: render() });
125
+
126
+ gaps.forEach((gap, g) => {
127
+ const kept = gap.items.flatMap((item) => (item.old ? [item.old] : []));
128
+ if (stable(kept) !== stable(regions[g])) {
129
+ regions[g] = kept;
130
+ push(null);
131
+ }
132
+ gap.items.forEach((item, k) => {
133
+ const region = regions[g]!;
134
+ const place = (block: Json, offset: number, first: boolean) => {
135
+ region.splice(k, first && !item.old ? 0 : 1, block);
136
+ push({ offset, path: [regionStart(g) + k] });
137
+ };
138
+ const span = typingSpan(item);
139
+ if (!isFlat(item.old) || !isFlat(item.next) || span.middle === 0) {
140
+ place(item.next, item.old ? span.prefix : 0, true);
141
+ return;
142
+ }
143
+ const runs = item.next.children ?? [];
144
+ const count = Math.min(perBlock, span.middle);
145
+ const chunk = Math.ceil(span.middle / count);
146
+ let first = true;
147
+ for (let revealed = chunk; revealed < span.middle; revealed += chunk) {
148
+ const cut = span.prefix + revealed;
149
+ place(
150
+ {
151
+ ...item.next,
152
+ children: [
153
+ ...sliceRuns(runs, 0, cut),
154
+ ...sliceRuns(runs, span.after.length - span.suffix, span.after.length),
155
+ ],
156
+ },
157
+ cut,
158
+ first,
159
+ );
160
+ first = false;
161
+ }
162
+ place(item.next, span.prefix + span.middle, first);
163
+ });
164
+ });
165
+ return frames;
166
+ }
167
+
168
+ function $locate(focus: FrameFocus | null): { key: NodeKey; offset: number } | null {
169
+ if (!focus) return null;
170
+ const block = $getRoot().getChildAtIndex(focus.path[0]!);
171
+ if (!block) return null;
172
+ if (!$isElementNode(block)) return { key: block.getKey(), offset: 0 };
173
+ let remaining = focus.offset;
174
+ const texts = block.getChildren().filter($isTextNode);
175
+ for (const text of texts) {
176
+ const size = text.getTextContentSize();
177
+ if (remaining <= size) return { key: text.getKey(), offset: remaining };
178
+ remaining -= size;
179
+ }
180
+ const last = texts.at(-1);
181
+ return last
182
+ ? { key: last.getKey(), offset: last.getTextContentSize() }
183
+ : { key: block.getKey(), offset: 0 };
184
+ }
185
+
186
+ export function streamAtVersion(
187
+ doc: LoroDoc,
188
+ frontiers: Frontiers,
189
+ createEditor: () => LexicalEditor,
190
+ base: SerializedEditorState,
191
+ target: SerializedEditorState,
192
+ commitMessage?: string,
193
+ ): { frontiers: Frontiers; steps: StreamStep[] } {
194
+ const fork = doc.forkAt(frontiers);
195
+ const editor = createEditor();
196
+ const binding = createLoroBinding(editor, fork, { commitMessage });
197
+ const steps: StreamStep[] = [];
198
+ let from = fork.oplogVersion();
199
+ for (const frame of planFrames(base.root as unknown as Json, target.root as unknown as Json)) {
200
+ editor.update(() => $reconcileRoot(frame.root as never), { discrete: true });
201
+ const at = editor.getEditorState().read(() => $locate(frame.focus));
202
+ const id = at ? binding.idOf(at.key) : undefined;
203
+ const update = fork.export({ from, mode: 'update' });
204
+ from = fork.oplogVersion();
205
+ steps.push({ cursor: id && at ? { id, offset: at.offset } : null, update });
206
+ }
207
+ binding.dispose();
208
+ return { frontiers: fork.frontiers(), steps };
209
+ }