@pylonsync/loro 0.22.43 → 0.22.46

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.
@@ -0,0 +1,637 @@
1
+ /**
2
+ * Ranges of a ProseMirror document bound with ProseMirrorSyncPlugin, held as
3
+ * two Loro cursors each: comment threads, review findings, highlights. The
4
+ * start binds to the range's first character and the end to its last, so a
5
+ * range stays on its words while anyone edits around it, grows when someone
6
+ * types inside it, and does not take in text typed just before or after it.
7
+ * Ranges may overlap (they are decorations, not marks).
8
+ *
9
+ * // When a person comments on the selection:
10
+ * const anchor = encodeRange(view.state, doc, containerId, from, to);
11
+ * // store anchor.from and anchor.to with the thread
12
+ *
13
+ * // In the editor:
14
+ * plugins: [ProseMirrorRangesPlugin({ doc, containerId, onReanchor: (id, anchor) => save(id, anchor) })]
15
+ * setAnchoredRanges(view, threads.map((t) => ({ id: t.id, from: t.from, to: t.to, className: "thread" })));
16
+ *
17
+ * // For a margin: where each range is now, or `gone`.
18
+ * resolveRanges(view.state, doc, containerId, ranges)
19
+ *
20
+ * The binding writes some edits as a delete and an insert: splitting or
21
+ * joining paragraphs moves text into new characters, and a cursor on a moved
22
+ * character no longer names it. The editor that makes such an edit still
23
+ * knows where each range went, from its own transaction: it keeps drawing
24
+ * the range there and calls `onReanchor` with new cursors (an undo too).
25
+ * Store them in place of the old ones, so every other editor finds the range
26
+ * again. Until then, other editors draw a range only where its cursors find
27
+ * it, so never on characters that replaced its own.
28
+ */
29
+ import type { ContainerID, LoroDoc } from "loro-crdt";
30
+ import { loroSyncPluginKey } from "loro-prosemirror";
31
+ import type { Node } from "prosemirror-model";
32
+ import { type EditorState, Plugin, PluginKey, type Transaction } from "prosemirror-state";
33
+ import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
34
+ import { decodeCursor, encodeCursor } from "./cursors";
35
+ import { cursorToPosition, positionToCursor } from "./sync";
36
+
37
+ /** A range as encoded cursors (see {@link encodeRange}). */
38
+ export interface EncodedRange {
39
+ from: string | null;
40
+ to: string | null;
41
+ }
42
+
43
+ /** A range to draw. */
44
+ export interface AnchoredRange extends EncodedRange {
45
+ id: string;
46
+ /** CSS class of the decoration, such as "thread-anchor". */
47
+ className?: string;
48
+ /** Extra attributes on the decoration, such as `data-thread-id`. */
49
+ attributes?: Record<string, string>;
50
+ }
51
+
52
+ /** Where a range is now. */
53
+ export interface ResolvedRange {
54
+ id: string;
55
+ from: number;
56
+ to: number;
57
+ /** All of the range's text was deleted. `from` and `to` are where it was. */
58
+ gone: boolean;
59
+ }
60
+
61
+ type Span = readonly [number, number];
62
+
63
+ /** What is left of a range's own text after the edits since it was last found. */
64
+ interface Followed {
65
+ spans: Span[];
66
+ /** The last edit it was followed through was made here (typing, undo): this editor stores new cursors. */
67
+ local: boolean;
68
+ }
69
+
70
+ interface RangesState {
71
+ ranges: AnchoredRange[];
72
+ /** Each range followed through this editor's own edits since `followedAt`. */
73
+ followed: Map<string, Followed>;
74
+ /** The import count when they were followed (see `imports`). */
75
+ followedAt: number;
76
+ /** Loro holds every edit this state shows (the binding has written them). */
77
+ synced: boolean;
78
+ }
79
+
80
+ export const proseMirrorRangesKey = new PluginKey<RangesState>("pylonAnchoredRanges");
81
+
82
+ /** The positions of the first and last text in [from, to), or null when there is no text. */
83
+ function textBounds(root: Node, from: number, to: number): Span | null {
84
+ let start = -1;
85
+ let end = -1;
86
+ root.nodesBetween(from, to, (node, pos) => {
87
+ if (!node.isText) return true;
88
+ const a = Math.max(from, pos);
89
+ const b = Math.min(to, pos + node.nodeSize);
90
+ if (a < b) {
91
+ if (start < 0) start = a;
92
+ end = b;
93
+ }
94
+ return false;
95
+ });
96
+ return start < 0 ? null : [start, end];
97
+ }
98
+
99
+ /**
100
+ * The document range [from, to) as encoded cursors. The range is first
101
+ * narrowed to its text, so a selection that starts at the end of one
102
+ * paragraph starts at the next one's text. A range with no text has null
103
+ * cursors.
104
+ */
105
+ export function encodeRange(
106
+ state: EditorState,
107
+ doc: LoroDoc,
108
+ containerId: ContainerID,
109
+ from: number,
110
+ to: number,
111
+ ): EncodedRange {
112
+ const bounds = textBounds(state.doc, Math.min(from, to), Math.max(from, to));
113
+ if (!bounds) return { from: null, to: null };
114
+ const start = positionToCursor(state.doc, bounds[0], doc, containerId, { bind: "right" });
115
+ const end = positionToCursor(state.doc, bounds[1], doc, containerId);
116
+ return { from: start ? encodeCursor(start) : null, to: end ? encodeCursor(end) : null };
117
+ }
118
+
119
+ /** Where a stored cursor is now, and whether its character was deleted. */
120
+ function locate(
121
+ encoded: string | null | undefined,
122
+ state: EditorState,
123
+ doc: LoroDoc,
124
+ containerId: ContainerID,
125
+ ): { pos: number; moved: boolean } | null {
126
+ if (!encoded) return null;
127
+ const sync = loroSyncPluginKey.getState(state) as { mapping?: Map<ContainerID, Node | Node[]> } | undefined;
128
+ const cursor = decodeCursor(encoded);
129
+ if (!sync?.mapping || !cursor) return null;
130
+ const inDoc = (pos: number | null) => pos != null && pos >= 0 && pos <= state.doc.content.size;
131
+ try {
132
+ const exact = cursorToPosition(cursor, doc, containerId, sync.mapping);
133
+ if (inDoc(exact)) return { pos: exact!, moved: false };
134
+ const near = cursorToPosition(cursor, doc, containerId, sync.mapping, { acceptMoved: true });
135
+ return inDoc(near) ? { pos: near!, moved: true } : null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /** The range between its cursors when both characters are still there. */
142
+ function exactSpan(range: EncodedRange, state: EditorState, doc: LoroDoc, containerId: ContainerID): Span | null {
143
+ const from = locate(range.from, state, doc, containerId);
144
+ const to = locate(range.to, state, doc, containerId);
145
+ return from && to && !from.moved && !to.moved && to.pos > from.pos ? [from.pos, to.pos] : null;
146
+ }
147
+
148
+ /**
149
+ * Where each range is in `state` now, from its cursors alone. When neither
150
+ * end's character was deleted, the range is between them. When one was, it
151
+ * runs from the other end to where the deleted character was. When both
152
+ * were, or a cursor cannot be read, it is `gone`.
153
+ *
154
+ * The plugin does better for the editor that made an edit: it follows the
155
+ * range through the transaction (see the module comment).
156
+ */
157
+ export function resolveRanges(
158
+ state: EditorState,
159
+ doc: LoroDoc,
160
+ containerId: ContainerID,
161
+ ranges: Array<Pick<AnchoredRange, "id" | "from" | "to">>,
162
+ ): ResolvedRange[] {
163
+ return ranges.map((r) => {
164
+ const from = locate(r.from, state, doc, containerId);
165
+ const to = locate(r.to, state, doc, containerId);
166
+ const at = from?.pos ?? to?.pos ?? 0;
167
+ if (!from || !to || (from.moved && to.moved) || to.pos <= from.pos) return { id: r.id, from: at, to: at, gone: true };
168
+ return { id: r.id, from: from.pos, to: to.pos, gone: false };
169
+ });
170
+ }
171
+
172
+ /** A paragraph break in flattened text. It has no position of its own. */
173
+ const BREAK = "\u2029";
174
+
175
+ /** The text in [from, to) of `root`, one entry per UTF-16 unit, with a break before each textblock that starts inside. */
176
+ function flattenRegion(root: Node, from: number, to: number): { text: string[]; pos: number[] } {
177
+ const text: string[] = [];
178
+ const pos: number[] = [];
179
+ root.nodesBetween(from, to, (node, at) => {
180
+ if (node.isTextblock && at >= from) {
181
+ text.push(BREAK);
182
+ pos.push(-1);
183
+ }
184
+ if (!node.isText || !node.text) return true;
185
+ for (let i = Math.max(0, from - at); i < node.text.length && at + i < to; i++) {
186
+ text.push(node.text[i]!);
187
+ pos.push(at + i);
188
+ }
189
+ return false;
190
+ });
191
+ return { text, pos };
192
+ }
193
+
194
+ /** Edit distance past which a replace is not diffed: its text counts as replaced. */
195
+ const DIFF_LIMIT = 400;
196
+
197
+ /**
198
+ * The pairs of equal entries in a shortest edit script from `a` to `b`
199
+ * (Myers' diff), or null when finding it needs more than DIFF_LIMIT edits
200
+ * between the common prefix and suffix.
201
+ */
202
+ export function diffMatches(a: readonly string[], b: readonly string[]): Array<[number, number]> | null {
203
+ // The common prefix and suffix are matched first. When one side is all of
204
+ // its middle (a pure insert or delete), there is no script to search: the
205
+ // bound is for the search.
206
+ let prefix = 0;
207
+ while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++;
208
+ let suffix = 0;
209
+ while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix++;
210
+ const A = a.slice(prefix, a.length - suffix);
211
+ const B = b.slice(prefix, b.length - suffix);
212
+ const matches: Array<[number, number]> = [];
213
+ for (let i = 0; i < prefix; i++) matches.push([i, i]);
214
+ const n = A.length;
215
+ const m = B.length;
216
+ if (n > 0 && m > 0) {
217
+ const max = Math.min(n + m, DIFF_LIMIT);
218
+ const off = max + 1;
219
+ const v = new Int32Array(2 * max + 3);
220
+ const trace: Int32Array[] = [];
221
+ let found = false;
222
+ for (let d = 0; d <= max && !found; d++) {
223
+ trace.push(v.slice());
224
+ for (let k = -d; k <= d; k += 2) {
225
+ let x = k === -d || (k !== d && v[off + k - 1]! < v[off + k + 1]!) ? v[off + k + 1]! : v[off + k - 1]! + 1;
226
+ let y = x - k;
227
+ while (x < n && y < m && A[x] === B[y]) {
228
+ x++;
229
+ y++;
230
+ }
231
+ v[off + k] = x;
232
+ if (x >= n && y >= m) {
233
+ found = true;
234
+ break;
235
+ }
236
+ }
237
+ }
238
+ if (!found) return null;
239
+ const middle: Array<[number, number]> = [];
240
+ let x = n;
241
+ let y = m;
242
+ for (let d = trace.length - 1; d >= 0; d--) {
243
+ const t = trace[d]!;
244
+ const k = x - y;
245
+ const prevK = k === -d || (k !== d && t[off + k - 1]! < t[off + k + 1]!) ? k + 1 : k - 1;
246
+ const prevX = d === 0 ? 0 : t[off + prevK]!;
247
+ const prevY = d === 0 ? 0 : prevX - prevK;
248
+ while (x > prevX && y > prevY) {
249
+ middle.push([prefix + x - 1, prefix + y - 1]);
250
+ x--;
251
+ y--;
252
+ }
253
+ x = prevX;
254
+ y = prevY;
255
+ }
256
+ for (let i = middle.length - 1; i >= 0; i--) matches.push(middle[i]!);
257
+ }
258
+ for (let i = suffix; i > 0; i--) matches.push([a.length - i, b.length - i]);
259
+ return matches;
260
+ }
261
+
262
+ /** Runs of consecutive positions, as spans. */
263
+ function runs(positions: number[]): Span[] {
264
+ const sorted = [...positions].sort((x, y) => x - y);
265
+ const out: Span[] = [];
266
+ for (const p of sorted) {
267
+ const last = out[out.length - 1];
268
+ if (last && last[1] === p) out[out.length - 1] = [last[0], p + 1];
269
+ else out.push([p, p + 1]);
270
+ }
271
+ return out;
272
+ }
273
+
274
+ /**
275
+ * The spans left of `spans` after the edits in `tr`: deleted text is cut
276
+ * out, and each end maps toward the inside, so text typed at an edge stays
277
+ * out.
278
+ *
279
+ * With `diff`, text a step replaces is matched against the text it was
280
+ * replaced with, character by character, and kept where it is unchanged.
281
+ * Use it for an undo: it writes earlier text back as new characters and
282
+ * reaches the editor as one replace, which can span text that is still in
283
+ * place. Not for someone else's edit: there Loro knows which characters were
284
+ * replaced, and a diff would keep a range on new text that looks the same.
285
+ */
286
+ export function mapSpans(spans: readonly Span[], tr: Transaction, options: { diff?: boolean } = {}): Span[] {
287
+ let out = spans.map((s) => [s[0], s[1]] as Span);
288
+ tr.steps.forEach((step, i) => {
289
+ const map = step.getMap();
290
+ const before = tr.docs[i]!;
291
+ const after = tr.docs[i + 1] ?? tr.doc;
292
+ const replaced: Array<{ a: number; b: number; na: number; nb: number }> = [];
293
+ map.forEach((a, b, na, nb) => {
294
+ if (b > a) replaced.push({ a, b, na, nb });
295
+ });
296
+ const kept: Span[] = [];
297
+ for (const span of out) {
298
+ let pieces: Span[] = [span];
299
+ for (const r of replaced) {
300
+ pieces = pieces.flatMap(([x, y]) => {
301
+ if (r.b <= x || r.a >= y) return [[x, y] as Span];
302
+ const left: Span[] = r.a > x ? [[x, r.a]] : [];
303
+ const right: Span[] = r.b < y ? [[r.b, y]] : [];
304
+ if (options.diff) {
305
+ const from = Math.max(x, r.a);
306
+ const to = Math.min(y, r.b);
307
+ const old = flattenRegion(before, r.a, r.b);
308
+ const now = flattenRegion(after, r.na, r.nb);
309
+ const matches = diffMatches(old.text, now.text);
310
+ if (matches) {
311
+ const moved = matches
312
+ .filter(([oi, ni]) => old.pos[oi]! >= from && old.pos[oi]! < to && now.pos[ni]! >= 0)
313
+ .map(([, ni]) => now.pos[ni]!);
314
+ // Already in the new document's positions: kept apart from the mapping below.
315
+ for (const run of runs(moved)) kept.push(run);
316
+ }
317
+ }
318
+ return [...left, ...right];
319
+ });
320
+ }
321
+ for (const [x, y] of pieces) {
322
+ const nx = map.map(x, 1);
323
+ const ny = map.map(y, -1);
324
+ if (ny > nx) kept.push([nx, ny]);
325
+ }
326
+ }
327
+ out = kept.sort((p, q) => p[0] - q[0]);
328
+ });
329
+ return out;
330
+ }
331
+
332
+ /** Draw these ranges. Replaces the previous list. */
333
+ export function setAnchoredRanges(view: EditorView, ranges: AnchoredRange[]): void {
334
+ view.dispatch(view.state.tr.setMeta(proseMirrorRangesKey, ranges).setMeta("addToHistory", false));
335
+ }
336
+
337
+ export interface ProseMirrorRangesOptions {
338
+ doc: LoroDoc;
339
+ containerId: ContainerID;
340
+ /**
341
+ * This editor moved a range's characters (a split, a join, an undo) and
342
+ * has new cursors for it. Store them in place of the range's old ones.
343
+ * Null cursors mean this editor deleted the range's text while its old
344
+ * cursors still found text that remains.
345
+ */
346
+ onReanchor?: (id: string, range: EncodedRange) => void;
347
+ }
348
+
349
+ type Source = "cursors" | "followed" | "near";
350
+
351
+ interface Resolution {
352
+ spans: Map<string, { span: Span; source: Source }>;
353
+ /** Ranges this editor followed through its own edit: their cursors need replacing. */
354
+ reanchor: Map<string, Span>;
355
+ /** Ranges whose text this editor deleted while their cursors still find text: clear their cursors. */
356
+ cleared: Set<string>;
357
+ }
358
+
359
+ const REFRESH = "pylonAnchoredRangesRefresh";
360
+ /** An undo that left the editor's document as it was: the ranges stay where they were drawn. */
361
+ const UNDO_IN_PLACE = "pylonAnchoredRangesUndoInPlace";
362
+ const key = (r: EncodedRange) => `${r.from ?? ""} ${r.to ?? ""}`;
363
+
364
+ export function ProseMirrorRangesPlugin({ doc, containerId, onReanchor }: ProseMirrorRangesOptions): Plugin {
365
+ // A Loro change can leave the editor's document as it was (the same text
366
+ // deleted and typed again, as new characters) and still move the ranges,
367
+ // and new cursors change where a range resolves, so the drawn set is kept
368
+ // for one state and version.
369
+ let version = 0;
370
+ // Changes to the field from outside the editor (imports, app code),
371
+ // counted. Spans followed before the latest one may name characters it
372
+ // replaced, so they no longer count.
373
+ let imports = 0;
374
+ // The last change to the field that the binding may bring into the editor:
375
+ // "undo" for this editor's undo and redo (Loro commits them with origin
376
+ // "undo"), "other" for anything else (an import, or app code writing the
377
+ // field directly). The editor's own edits are written by the binding,
378
+ // which does not bring them back.
379
+ let lastChange: "undo" | "other" = "other";
380
+ let cache: { state: EditorState; version: number; imports: number; resolution: Resolution; set: DecorationSet } | null = null;
381
+ const current = (value: RangesState) => (value.followedAt === imports ? value.followed : null);
382
+
383
+ // Cursors this editor made, by range. They stand in for the range's
384
+ // cursors while the app still sets the ones they replaced (or any earlier
385
+ // ones this editor made: saves can come back out of order). Cursors the app
386
+ // got from elsewhere end the stand-in.
387
+ const replaced = new Map<string, { known: Set<string>; now: EncodedRange }>();
388
+ const cursorsOf = (range: AnchoredRange): EncodedRange => {
389
+ const r = replaced.get(range.id);
390
+ if (!r) return range;
391
+ if (r.known.has(key(range))) return r.now;
392
+ replaced.delete(range.id);
393
+ return range;
394
+ };
395
+
396
+ function resolve(state: EditorState): Resolution {
397
+ const pluginState = proseMirrorRangesKey.getState(state)!;
398
+ const { ranges } = pluginState;
399
+ const followed = current(pluginState);
400
+ const out: Resolution = { spans: new Map(), reanchor: new Map(), cleared: new Set() };
401
+ const near = resolveRanges(state, doc, containerId, ranges.map((r) => ({ id: r.id, ...cursorsOf(r) })));
402
+ ranges.forEach((range, i) => {
403
+ const exact = exactSpan(cursorsOf(range), state, doc, containerId);
404
+ const own = followed?.get(range.id);
405
+ // After this editor's own edit, its transaction says where the range
406
+ // went. The binding writes the smallest text change, which can keep
407
+ // other characters than the ones the person edited (deleting the first
408
+ // of two equal words keeps the first word's characters).
409
+ if (own) {
410
+ // Narrowed to its text, as encodeRange stores it: a span can keep the
411
+ // edge of a paragraph whose text was deleted.
412
+ const span =
413
+ own.spans.length === 0 ? null : textBounds(state.doc, own.spans[0]![0], own.spans[own.spans.length - 1]![1]);
414
+ // All of its text was deleted here: gone, wherever its cursors land.
415
+ // When they still find text (both ends, or one), other editors would
416
+ // draw it there: its cursors are cleared.
417
+ if (!span) {
418
+ if (!near[i]!.gone) out.cleared.add(range.id);
419
+ return;
420
+ }
421
+ if (exact && exact[0] === span[0] && exact[1] === span[1]) {
422
+ return void out.spans.set(range.id, { span: exact, source: "cursors" });
423
+ }
424
+ out.spans.set(range.id, { span, source: "followed" });
425
+ out.reanchor.set(range.id, span);
426
+ return;
427
+ }
428
+ if (exact) return void out.spans.set(range.id, { span: exact, source: "cursors" });
429
+ const f = near[i]!;
430
+ if (!f.gone) out.spans.set(range.id, { span: [f.from, f.to], source: "near" });
431
+ });
432
+ return out;
433
+ }
434
+
435
+ /**
436
+ * Where each range is in `state`, to follow through the next transaction:
437
+ * from the drawn set when it is for this state (it was resolved while Loro
438
+ * matched it), else from the cursors when Loro holds this state's edits,
439
+ * else from what was followed so far. A range drawn only near its cursors
440
+ * is not followed: it would store a guess.
441
+ */
442
+ function startingSpans(state: EditorState, value: RangesState): Map<string, Span[]> {
443
+ const base = new Map<string, Span[]>();
444
+ // An import since the last draw may have replaced the characters the
445
+ // drawn set and the followed spans point at: start from the cursors.
446
+ const drawn = cache && cache.state === state && cache.imports === imports ? cache.resolution.spans : null;
447
+ const followedNow = current(value);
448
+ for (const range of value.ranges) {
449
+ const d = drawn?.get(range.id);
450
+ if (drawn) {
451
+ if (d && d.source !== "near") base.set(range.id, d.source === "followed" ? followedNow!.get(range.id)!.spans : [d.span]);
452
+ else if (!d && followedNow?.has(range.id)) base.set(range.id, []);
453
+ continue;
454
+ }
455
+ // What this editor followed comes first: between transactions of one
456
+ // dispatch, the cursors can disagree with it (see resolve).
457
+ const followed = followedNow?.get(range.id)?.spans;
458
+ if (followed) {
459
+ base.set(range.id, followed);
460
+ continue;
461
+ }
462
+ const exact = value.synced ? exactSpan(cursorsOf(range), state, doc, containerId) : null;
463
+ if (exact) base.set(range.id, [exact]);
464
+ }
465
+ return base;
466
+ }
467
+
468
+ const draw = (state: EditorState) => {
469
+ if (cache && cache.state === state && cache.version === version) return cache;
470
+ const { ranges } = proseMirrorRangesKey.getState(state)!;
471
+ const byId = new Map(ranges.map((r) => [r.id, r]));
472
+ const resolution = resolve(state);
473
+ const decos = [...resolution.spans].map(([id, { span }]) => {
474
+ const range = byId.get(id)!;
475
+ return Decoration.inline(
476
+ span[0],
477
+ span[1],
478
+ { class: range.className ?? "pylon-anchored-range", ...(range.attributes ?? {}) },
479
+ { rangeId: id },
480
+ );
481
+ });
482
+ cache = { state, version, imports, resolution, set: DecorationSet.create(state.doc, decos) };
483
+ return cache;
484
+ };
485
+
486
+ return new Plugin<RangesState>({
487
+ key: proseMirrorRangesKey,
488
+ state: {
489
+ init: () => ({ ranges: [], followed: new Map(), followedAt: 0, synced: true }),
490
+ apply: (tr: Transaction, value, oldState) => {
491
+ const ranges = tr.getMeta(proseMirrorRangesKey) as AnchoredRange[] | undefined;
492
+ if (Array.isArray(ranges)) {
493
+ // Keep following a range while its cursors are the ones it had, or
494
+ // ones this editor made. Other cursors are a new anchor.
495
+ const before = new Map(value.ranges.map((r) => [r.id, key(r)]));
496
+ const followed = new Map<string, Followed>();
497
+ const was = current(value);
498
+ for (const range of ranges) {
499
+ const own = was?.get(range.id);
500
+ const k = key(range);
501
+ if (own && (before.get(range.id) === k || replaced.get(range.id)?.known.has(k))) followed.set(range.id, own);
502
+ }
503
+ return { ranges, followed, followedAt: imports, synced: value.synced };
504
+ }
505
+ // A Loro import that left the editor's document as it was: the
506
+ // cursors decide.
507
+ if (tr.getMeta(REFRESH)) return { ...value, followed: new Map() };
508
+ const inPlace = tr.getMeta(UNDO_IN_PLACE) as Map<string, Span> | undefined;
509
+ if (inPlace) {
510
+ const followed = new Map<string, Followed>();
511
+ for (const [id, span] of inPlace) followed.set(id, { spans: [span], local: true });
512
+ return { ...value, followed, followedAt: imports, synced: true };
513
+ }
514
+ const sync = (tr.getMeta(loroSyncPluginKey) as { type?: string } | undefined)?.type;
515
+ if (tr.docChanged) {
516
+ const fromLoro = sync === "non-local-updates";
517
+ const undoing = fromLoro && lastChange === "undo";
518
+ // Someone else's edit: their editor follows the ranges and stores
519
+ // new cursors. Here the cursors decide; a range whose characters
520
+ // they moved is hidden until the new cursors arrive.
521
+ if (fromLoro && undoing !== true) return { ...value, followed: new Map(), synced: true };
522
+ // This editor's own edit, or its undo (which reaches the editor from
523
+ // Loro, already applied).
524
+ const followed = new Map<string, Followed>();
525
+ for (const [id, spans] of startingSpans(oldState, value)) {
526
+ // An undo writes earlier text back as new characters, and reaches the
527
+ // editor as one replace: match the restored text to what it replaces.
528
+ followed.set(id, { spans: mapSpans(spans, tr, { diff: fromLoro }), local: true });
529
+ }
530
+ return { ...value, followed, followedAt: imports, synced: fromLoro };
531
+ }
532
+ // The binding wrote this editor's edits to Loro.
533
+ if (sync === "doc-changed") return { ...value, synced: true };
534
+ return value;
535
+ },
536
+ },
537
+ view(view) {
538
+ // Registered before the binding's own listener on the field (which it
539
+ // adds after a timeout), so it runs first for each change: the
540
+ // transaction the binding then builds knows who made the change.
541
+ const field = doc.getContainerById(containerId) as
542
+ | { subscribe(f: (e: { by: string; origin?: string }) => void): () => void }
543
+ | undefined;
544
+ // The editor's state when an import into the field arrived, before the
545
+ // binding brought it in. The doc listener, which Loro calls after the
546
+ // field's listeners (a test holds this), compares: an unchanged state
547
+ // means the import changed text without changing the editor's document
548
+ // (the same text deleted and typed again), so the ranges are redrawn.
549
+ let beforeImport: EditorState | null = null;
550
+ // The same for an undo, with where the ranges were drawn before it.
551
+ let beforeUndo: { state: EditorState; spans: Map<string, Span> } | null = null;
552
+ let needsRefresh = false;
553
+ let scheduled = false;
554
+ const unsubscribeField = field?.subscribe((event) => {
555
+ const local = event.by === "local";
556
+ // The binding writes this editor's edits with origin "loroSyncPlugin"
557
+ // and its setup with "sys:init" (loro-prosemirror, sync.ts).
558
+ if (local && (event.origin === "loroSyncPlugin" || event.origin === "sys:init")) return;
559
+ lastChange = local && event.origin === "undo" ? "undo" : "other";
560
+ // The drawn set counts only when no outside change came since it was drawn.
561
+ if (lastChange === "undo" && cache && cache.state === view.state && cache.imports === imports) {
562
+ const spans = new Map<string, Span>();
563
+ for (const [id, d] of cache.resolution.spans) if (d.source !== "near") spans.set(id, d.span);
564
+ beforeUndo = { state: view.state, spans };
565
+ }
566
+ if (lastChange === "other") {
567
+ // From outside the editor: an import, or app code writing the field.
568
+ imports += 1;
569
+ beforeImport = view.state;
570
+ }
571
+ });
572
+ const unsubscribe = doc.subscribe(() => {
573
+ version += 1;
574
+ const undo = beforeUndo;
575
+ beforeUndo = null;
576
+ // An undo that rewrote text as it was: the binding made no transaction.
577
+ // Dispatched now, before anything else can change the field or the
578
+ // selection: the drawn spans are for exactly this state.
579
+ if (undo && view.state === undo.state && undo.spans.size > 0 && !view.isDestroyed) {
580
+ view.dispatch(view.state.tr.setMeta(UNDO_IN_PLACE, undo.spans).setMeta("addToHistory", false));
581
+ }
582
+ if (!beforeImport) return;
583
+ if (view.state === beforeImport) needsRefresh = true;
584
+ beforeImport = null;
585
+ if (!needsRefresh || scheduled) return;
586
+ scheduled = true;
587
+ queueMicrotask(() => {
588
+ scheduled = false;
589
+ const refresh = needsRefresh;
590
+ needsRefresh = false;
591
+ if (!refresh || view.isDestroyed || proseMirrorRangesKey.getState(view.state)!.ranges.length === 0) return;
592
+ view.dispatch(view.state.tr.setMeta(REFRESH, true).setMeta("addToHistory", false));
593
+ });
594
+ });
595
+ const reanchor = () => {
596
+ const { state } = view;
597
+ const pluginState = proseMirrorRangesKey.getState(state)!;
598
+ if (!pluginState.synced) return;
599
+ const { resolution } = draw(state);
600
+ let changed = false;
601
+ const updates: Array<[string, EncodedRange]> = [
602
+ ...[...resolution.reanchor].map(([id, [from, to]]) => [id, encodeRange(state, doc, containerId, from, to)] as [string, EncodedRange]),
603
+ // Null cursors: the range's text was deleted.
604
+ ...[...resolution.cleared].map((id) => [id, { from: null, to: null }] as [string, EncodedRange]),
605
+ ];
606
+ for (const [id, now] of updates) {
607
+ const range = pluginState.ranges.find((r) => r.id === id);
608
+ if (!range) continue;
609
+ if (resolution.reanchor.has(id) && (!now.from || !now.to)) continue;
610
+ // Nothing new to store.
611
+ if (key(now) === key(cursorsOf(range))) continue;
612
+ const entry = replaced.get(id) ?? { known: new Set([key(range)]), now };
613
+ entry.known.add(key(now));
614
+ entry.now = now;
615
+ replaced.set(id, entry);
616
+ changed = true;
617
+ onReanchor?.(id, now);
618
+ }
619
+ // The new cursors change how the ranges resolve.
620
+ if (changed) version += 1;
621
+ };
622
+ return {
623
+ update: reanchor,
624
+ destroy: () => {
625
+ unsubscribe();
626
+ unsubscribeField?.();
627
+ },
628
+ };
629
+ },
630
+ props: {
631
+ decorations(state) {
632
+ if (proseMirrorRangesKey.getState(state)!.ranges.length === 0) return DecorationSet.empty;
633
+ return draw(state).set;
634
+ },
635
+ },
636
+ });
637
+ }