@llui/lexical-loro 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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -0
  3. package/dist/agent-write.d.ts +123 -0
  4. package/dist/agent-write.d.ts.map +1 -0
  5. package/dist/agent-write.js +499 -0
  6. package/dist/agent-write.js.map +1 -0
  7. package/dist/binding.d.ts +122 -0
  8. package/dist/binding.d.ts.map +1 -0
  9. package/dist/binding.js +114 -0
  10. package/dist/binding.js.map +1 -0
  11. package/dist/index.d.ts +69 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +69 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/mapping.d.ts +115 -0
  16. package/dist/mapping.d.ts.map +1 -0
  17. package/dist/mapping.js +181 -0
  18. package/dist/mapping.js.map +1 -0
  19. package/dist/order.d.ts +124 -0
  20. package/dist/order.d.ts.map +1 -0
  21. package/dist/order.js +187 -0
  22. package/dist/order.js.map +1 -0
  23. package/dist/schema.d.ts +343 -0
  24. package/dist/schema.d.ts.map +1 -0
  25. package/dist/schema.js +363 -0
  26. package/dist/schema.js.map +1 -0
  27. package/dist/seed.d.ts +72 -0
  28. package/dist/seed.d.ts.map +1 -0
  29. package/dist/seed.js +72 -0
  30. package/dist/seed.js.map +1 -0
  31. package/dist/text.d.ts +167 -0
  32. package/dist/text.d.ts.map +1 -0
  33. package/dist/text.js +289 -0
  34. package/dist/text.js.map +1 -0
  35. package/dist/to-lexical.d.ts +119 -0
  36. package/dist/to-lexical.d.ts.map +1 -0
  37. package/dist/to-lexical.js +636 -0
  38. package/dist/to-lexical.js.map +1 -0
  39. package/dist/to-loro.d.ts +154 -0
  40. package/dist/to-loro.d.ts.map +1 -0
  41. package/dist/to-loro.js +718 -0
  42. package/dist/to-loro.js.map +1 -0
  43. package/dist/undo.d.ts +94 -0
  44. package/dist/undo.d.ts.map +1 -0
  45. package/dist/undo.js +200 -0
  46. package/dist/undo.js.map +1 -0
  47. package/package.json +64 -0
  48. package/src/agent-write.ts +613 -0
  49. package/src/binding.ts +185 -0
  50. package/src/index.ts +176 -0
  51. package/src/mapping.ts +206 -0
  52. package/src/order.ts +205 -0
  53. package/src/schema.ts +509 -0
  54. package/src/seed.ts +112 -0
  55. package/src/text.ts +357 -0
  56. package/src/to-lexical.ts +792 -0
  57. package/src/to-loro.ts +914 -0
  58. package/src/undo.ts +269 -0
package/src/to-loro.ts ADDED
@@ -0,0 +1,914 @@
1
+ /**
2
+ * Outbound sync: a Lexical `EditorState` → the Loro mirror.
3
+ *
4
+ * ── What this must get right ───────────────────────────────────────────────
5
+ *
6
+ * Converging is the easy half. The hard requirement is emitting a MINIMAL,
7
+ * IDENTITY-PRESERVING op set, because in this stack a container that is
8
+ * recreated instead of updated has visible, expensive consequences:
9
+ *
10
+ * - a recreated `LoroText` discards a peer's concurrent insertion into it;
11
+ * - a recreated element `LoroMap` mints a new `ContainerID`, which (via the
12
+ * registry in `mapping.ts`) forces the peer's whole subtree to be rebuilt
13
+ * with fresh `NodeKey`s — tearing down every mounted `LLuiDecoratorNode`
14
+ * sub-app in it (`packages/lexical/src/decorator.ts` disposes on the
15
+ * 'destroyed' mutation) along with selection and IME state.
16
+ *
17
+ * So: a reorder emits ONE `pos` register write per displaced block (never a
18
+ * delete+recreate — see `order.ts` and the schema header), text edits emit a
19
+ * cursor-biased single-region diff, and format changes emit explicit per-format
20
+ * `mark`/`unmark` ops. Every function here returns the number of writes it made,
21
+ * and `syncLexicalToLoro` returns the total — the tests assert on it, which is
22
+ * how a regression in the pruning shows up as a failure rather than as a
23
+ * silently slower, mount-destroying binding.
24
+ *
25
+ * ── Pruning: what is actually sound in Lexical ─────────────────────────────
26
+ *
27
+ * loro-prosemirror prunes with reference equality because ProseMirror's tree is
28
+ * PERSISTENT: changing a leaf rebuilds every ancestor, so `parent === parent`
29
+ * really does mean "this whole subtree is unchanged".
30
+ *
31
+ * LEXICAL IS NOT LIKE THAT, and assuming it is produces permanently stale
32
+ * remote documents. `getWritable()` (`LexicalNode.ts`) clones ONLY the node
33
+ * being written; ancestors are left alone and merely recorded in
34
+ * `dirtyElements` by `internalMarkParentElementsAsDirty` (`LexicalUtils.ts`).
35
+ * A paragraph whose text node was just rewritten is therefore the SAME object
36
+ * in both editor states. Reference equality prunes a subtree in ProseMirror; in
37
+ * Lexical it proves only that the node's OWN properties and its OWN child list
38
+ * are unchanged.
39
+ *
40
+ * This module therefore uses two different, individually sound facts:
41
+ *
42
+ * 1. `prevNodeMap.get(key) === node` ⟹ this node's props and the identity and
43
+ * order of its children are unchanged. Used to skip the prop diff and the
44
+ * whole children RECONCILIATION (matching, deletes, moves, inserts) and
45
+ * descend positionally instead. Sound because any child list change goes
46
+ * through the parent's `getWritable()`.
47
+ * 2. `dirtyElements ∪ dirtyLeaves` contains every changed node AND every
48
+ * ancestor of one. Used to decide which children to descend into at all.
49
+ * Sound because `internalMarkNodeAsDirty` walks the parent chain to the
50
+ * root.
51
+ *
52
+ * Fact 2 only holds when a dirty walk actually ran. `editor.setEditorState()`
53
+ * — which Lexical's own history uses for undo/redo, and which the LLui host
54
+ * push path uses — swaps the state wholesale with EMPTY dirty sets while
55
+ * potentially SHARING node objects with the previous state. Trusting empty
56
+ * dirty sets there would silently sync nothing. So when both sets are empty the
57
+ * dirty gate is dropped entirely and the walk falls back to a full structural
58
+ * diff against Loro, which is correct regardless of provenance and costs only
59
+ * on that rare path. Combined, a keystroke costs O(tree depth); a wholesale
60
+ * state swap costs O(document) and still emits only the ops that differ.
61
+ *
62
+ * ── Echo suppression ───────────────────────────────────────────────────────
63
+ *
64
+ * This is layer (b) of the three: an update we ourselves wrote back into
65
+ * Lexical carries `COLLABORATION_TAG` and must not bounce back out. See
66
+ * `index.ts` for the full three-layer contract — in particular that this
67
+ * binding must NEVER emit `PROGRAMMATIC_TAG`.
68
+ *
69
+ * `HISTORIC_TAG` is deliberately NOT suppressed, and must stay that way — even
70
+ * though this binding DOES ship CRDT-aware undo. The reason it can be left
71
+ * unsuppressed is that the Loro undo owner (`undo.ts`) never routes through
72
+ * Lexical's history: `manager.undo()` mutates the shared document directly, and
73
+ * the resulting writeback into the editor carries `COLLABORATION_TAG` (echo
74
+ * layer b), not `HISTORIC_TAG`. So the CRDT undo path emits no historic update
75
+ * for this module to see. `lexicalForeign` also forces `@lexical/history` off
76
+ * whenever `externalUndo` is present, so a shipped app produces no `HISTORIC_TAG`
77
+ * update at all. Suppressing it here would be inert in that configuration and
78
+ * actively WRONG for a host that deliberately runs `@lexical/history` without an
79
+ * `externalUndo` owner (as `test/harden.test.ts` does): there a historic update
80
+ * is a genuine local edit no peer has seen, and dropping it would make that
81
+ * undo invisible to everyone else. This differs from `@lexical/yjs`, where undo
82
+ * IS a historic writeback of the CRDT's own and re-syncing it would echo.
83
+ */
84
+
85
+ import {
86
+ $getRoot,
87
+ $getSelection,
88
+ $isElementNode,
89
+ $isRangeSelection,
90
+ $isTextNode,
91
+ COLLABORATION_TAG,
92
+ SKIP_COLLAB_TAG,
93
+ type EditorState,
94
+ type ElementNode,
95
+ type LexicalNode,
96
+ type NodeKey,
97
+ type NodeMap,
98
+ type TextNode,
99
+ type UpdateListenerPayload,
100
+ } from 'lexical'
101
+ import { LoroText, type ContainerID, type LoroDoc } from 'loro-crdt'
102
+
103
+ import { ContainerNodeMap } from './mapping.js'
104
+ import { allocate, jitterFor } from './order.js'
105
+ import {
106
+ containerId,
107
+ containerIsLive,
108
+ createElementChild,
109
+ createTextChild,
110
+ deleteChild,
111
+ elementChildren,
112
+ elementProps,
113
+ elementType,
114
+ isTextContainer,
115
+ newUuid,
116
+ orderedChildren,
117
+ setChildPosition,
118
+ type ChildContainer,
119
+ type ChildEntry,
120
+ type ChildrenContainer,
121
+ type ElementContainer,
122
+ type PropValue,
123
+ } from './schema.js'
124
+ import {
125
+ applyMarkOps,
126
+ applyTextDiff,
127
+ diffRunFormats,
128
+ diffText,
129
+ diffTextWithCursor,
130
+ normalizeRuns,
131
+ runsFromText,
132
+ runsText,
133
+ } from './text.js'
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Public surface
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /** Commit origin stamped on every write this module makes. */
140
+ export const OUTBOUND_ORIGIN = 'lexical-loro'
141
+
142
+ /**
143
+ * Update tags that mean "this update did not originate with the local user, do
144
+ * not mirror it".
145
+ *
146
+ * `COLLABORATION_TAG` is our own inbound writeback (echo layer b);
147
+ * `SKIP_COLLAB_TAG` is Lexical's standard opt-out, which hosts use for local-only
148
+ * decoration. `HISTORIC_TAG` is NOT here — see the file header.
149
+ */
150
+ export const OUTBOUND_SKIP_TAGS: readonly string[] = [COLLABORATION_TAG, SKIP_COLLAB_TAG]
151
+
152
+ /** The Loro side of the binding: the document, its root mirror, and the registry. */
153
+ export interface OutboundTarget {
154
+ readonly doc: LoroDoc
155
+ /** The root element container, as returned by `initDoc`. */
156
+ readonly root: ElementContainer
157
+ /** The ContainerID ↔ NodeKey registry, owned by the binding and mutated here. */
158
+ readonly mapping: ContainerNodeMap
159
+ /** Commit origin. Defaults to {@link OUTBOUND_ORIGIN}. */
160
+ readonly origin?: string
161
+ /** Tags that suppress the sync. Defaults to {@link OUTBOUND_SKIP_TAGS}. */
162
+ readonly skipTags?: readonly string[]
163
+ }
164
+
165
+ /**
166
+ * The slice of `UpdateListenerPayload` this direction consumes. Declared as a
167
+ * `Pick` so an update listener can pass its payload straight through.
168
+ *
169
+ * Register it with a BLOCK body, never an expression body:
170
+ *
171
+ * ```ts
172
+ * editor.registerUpdateListener((payload) => {
173
+ * syncLexicalToLoro(target, payload)
174
+ * })
175
+ * ```
176
+ *
177
+ * Lexical 0.48 stores whatever an update listener RETURNS and calls it as a
178
+ * cleanup before the next invocation (`triggerListeners` in `LexicalUpdates`),
179
+ * so the concise `(payload) => syncLexicalToLoro(target, payload)` hands it this
180
+ * function's op count and the second update dies with
181
+ * "unregister is not a function".
182
+ */
183
+ export type OutboundUpdate = Pick<
184
+ UpdateListenerPayload,
185
+ 'prevEditorState' | 'editorState' | 'dirtyElements' | 'dirtyLeaves' | 'normalizedNodes' | 'tags'
186
+ >
187
+
188
+ /**
189
+ * Mirror one Lexical update into the Loro document and commit it.
190
+ *
191
+ * @returns the number of Loro write operations emitted. `0` means the update
192
+ * was a genuine no-op for the shared document — nothing was committed, so no
193
+ * peer sees an event. Tests assert on this to catch pruning regressions.
194
+ */
195
+ export function syncLexicalToLoro(target: OutboundTarget, update: OutboundUpdate): number {
196
+ const skipTags = target.skipTags ?? OUTBOUND_SKIP_TAGS
197
+ for (const tag of skipTags) {
198
+ if (update.tags.has(tag)) return 0
199
+ }
200
+
201
+ // Lexical normalizes (merges/splits) adjacent TextNodes behind our back and
202
+ // reports the casualties here. A merged-away key still in the registry would
203
+ // make a later lookup resolve to a node that no longer exists, so drop them
204
+ // before the walk; the walk re-links every run anchor it visits.
205
+ for (const key of update.normalizedNodes) target.mapping.unlinkNode(key)
206
+
207
+ // Empty dirty sets mean no dirty walk ran (a wholesale state swap), so they
208
+ // cannot be trusted as a prune gate — see the file header.
209
+ const dirty =
210
+ update.dirtyElements.size > 0 || update.dirtyLeaves.size > 0
211
+ ? new Set<NodeKey>([...update.dirtyElements.keys(), ...update.dirtyLeaves])
212
+ : null
213
+
214
+ return walk(target, update.editorState, update.prevEditorState._nodeMap, dirty)
215
+ }
216
+
217
+ /**
218
+ * Fill the Loro document from an editor state with no previous state to diff
219
+ * against — the bootstrapping peer's initial seed.
220
+ *
221
+ * Structurally a full-fidelity diff, so it is also idempotent: seeding a
222
+ * document that already matches emits nothing and returns `0`.
223
+ */
224
+ export function seedLoroFromLexical(target: OutboundTarget, editorState: EditorState): number {
225
+ return walk(target, editorState, null, null)
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Walk context
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /** The caret, resolved once per update, used to disambiguate text diffs. */
233
+ interface Caret {
234
+ readonly key: NodeKey
235
+ readonly offset: number
236
+ }
237
+
238
+ interface Context {
239
+ readonly doc: LoroDoc
240
+ readonly mapping: ContainerNodeMap
241
+ /** `null` on a seed: nothing may be pruned by node identity. */
242
+ readonly prevNodeMap: NodeMap | null
243
+ /** `null` when the dirty sets are untrustworthy: descend everywhere. */
244
+ readonly dirty: Set<NodeKey> | null
245
+ /**
246
+ * This peer's fractional-index jitter digit, derived from the Loro peer id.
247
+ *
248
+ * Applied only to multi-child BATCHES, which is what stops two peers' pastes
249
+ * at the same spot from interleaving pairwise. See constraints 1 and 2 in
250
+ * `order.ts`.
251
+ */
252
+ readonly jitter: string
253
+ /** Resolved inside the state read, before the walk starts. */
254
+ caret: Caret | null
255
+ /** Loro writes emitted so far. */
256
+ ops: number
257
+ /** Set when a container was deleted, so the registry is swept once at the end. */
258
+ removed: boolean
259
+ }
260
+
261
+ function walk(
262
+ target: OutboundTarget,
263
+ editorState: EditorState,
264
+ prevNodeMap: NodeMap | null,
265
+ dirty: Set<NodeKey> | null,
266
+ ): number {
267
+ const context: Context = {
268
+ doc: target.doc,
269
+ mapping: target.mapping,
270
+ prevNodeMap,
271
+ dirty,
272
+ jitter: jitterFor(target.doc.peerId),
273
+ caret: null,
274
+ ops: 0,
275
+ removed: false,
276
+ }
277
+
278
+ editorState.read(() => {
279
+ const root = $getRoot()
280
+ context.caret = readCaret()
281
+ target.mapping.link(containerId(target.root), root.getKey())
282
+ syncElement(target.root, root, context)
283
+ })
284
+
285
+ if (context.removed) {
286
+ const nodeMap = editorState._nodeMap
287
+ target.mapping.sweep({
288
+ hasContainer: (id) => containerIsLive(target.doc, id),
289
+ hasNode: (key) => nodeMap.has(key),
290
+ })
291
+ }
292
+
293
+ if (context.ops > 0) target.doc.commit({ origin: target.origin ?? OUTBOUND_ORIGIN })
294
+ return context.ops
295
+ }
296
+
297
+ /** The collapsed-or-focus caret, if it sits in a text node. */
298
+ function readCaret(): Caret | null {
299
+ const selection = $getSelection()
300
+ if (!$isRangeSelection(selection)) return null
301
+ const focus = selection.focus
302
+ if (focus.type !== 'text') return null
303
+ return { key: focus.key, offset: focus.offset }
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Elements
308
+ // ---------------------------------------------------------------------------
309
+
310
+ /** Lexical node props that are structure or bookkeeping, never document data. */
311
+ const NON_PROP_KEYS: ReadonlySet<string> = new Set(['type', 'version', 'children'])
312
+
313
+ /**
314
+ * Mirror one Lexical node into its element container.
315
+ *
316
+ * @param fresh whether `container` was JUST created and is therefore empty.
317
+ *
318
+ * ── Why `fresh` cannot be inferred ─────────────────────────────────────────
319
+ *
320
+ * The `unchanged` fast path below rests on `prevNodeMap`, which is a fact about
321
+ * the LEXICAL side only: it says this node object was not rewritten in this
322
+ * update. It says NOTHING about whether the Loro container already mirrors it.
323
+ *
324
+ * `insertChild` builds a brand-new, EMPTY container and populates it through
325
+ * this function — and it does so precisely in the cases where Lexical did not
326
+ * touch the node (a remote peer deleted the container, or it was tombstoned by a
327
+ * concurrent delete+move, so the mirror must be rebuilt beneath an untouched
328
+ * subtree). There, `unchanged` is true and skipping `syncProps` leaves the new
329
+ * container's props map PERMANENTLY EMPTY: every peer's document agrees, so no
330
+ * later batch repairs it, yet each peer's editor fills the missing keys with
331
+ * whatever its local node happened to hold — identical documents, divergent
332
+ * editors. Measured; see `test/convergence-attack.test.ts`.
333
+ *
334
+ * The registry cannot stand in for this flag either: `insertChild` links the new
335
+ * ContainerID to the node before calling in, so a mapping check would report
336
+ * "already mirrored" for exactly the containers that are not.
337
+ */
338
+ function syncElement(
339
+ container: ElementContainer,
340
+ node: LexicalNode,
341
+ context: Context,
342
+ fresh = false,
343
+ ): void {
344
+ const unchanged =
345
+ !fresh && context.prevNodeMap !== null && context.prevNodeMap.get(node.getKey()) === node
346
+
347
+ if (!unchanged) syncProps(container, node, context)
348
+
349
+ if (!$isElementNode(node)) {
350
+ // A leaf mirrored as an element (LineBreakNode, LLuiDecoratorNode): its
351
+ // payload lives entirely in `props`, and its `children` list stays empty.
352
+ return
353
+ }
354
+
355
+ const current = orderedChildren(container)
356
+ const desired = describeChildren(node)
357
+
358
+ // `unchanged` proves the child list itself did not change, so the expensive
359
+ // reconciliation (match / delete / re-position / insert) can be skipped
360
+ // outright — but only when the dirty sets are trustworthy enough to tell us
361
+ // which children still need descending into.
362
+ if (unchanged && context.dirty !== null && current.length === desired.length) {
363
+ if (descendUnchanged(current, desired, context)) return
364
+ }
365
+
366
+ reconcileChildren(container, current, desired, context)
367
+ }
368
+
369
+ function syncProps(container: ElementContainer, node: LexicalNode, context: Context): void {
370
+ const props = elementProps(container)
371
+ const json = node.exportJSON() as Record<string, unknown>
372
+ const seen = new Set<string>()
373
+
374
+ for (const [key, value] of Object.entries(json)) {
375
+ if (NON_PROP_KEYS.has(key) || value === undefined) continue
376
+ seen.add(key)
377
+ const next = toPropValue(key, value, node)
378
+ if (jsonEqual(props.get(key), next)) continue
379
+ props.set(key, next)
380
+ context.ops++
381
+ }
382
+
383
+ for (const key of props.keys()) {
384
+ if (seen.has(key)) continue
385
+ props.delete(key)
386
+ context.ops++
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Narrow an `exportJSON` value to a storable {@link PropValue}.
392
+ *
393
+ * Lexical guarantees `exportJSON` is JSON-serializable, so anything that is not
394
+ * is a bug in a custom node — surfaced loudly here rather than as a document
395
+ * that silently fails to replicate.
396
+ */
397
+ function toPropValue(key: string, value: unknown, node: LexicalNode): PropValue {
398
+ if (value === null) return null
399
+ switch (typeof value) {
400
+ case 'string':
401
+ case 'number':
402
+ case 'boolean':
403
+ return value
404
+ case 'object': {
405
+ if (Array.isArray(value))
406
+ return value.map((item, i) => toPropValue(`${key}[${i}]`, item, node))
407
+ const out: Record<string, PropValue> = {}
408
+ for (const [innerKey, innerValue] of Object.entries(value as Record<string, unknown>)) {
409
+ if (innerValue === undefined) continue
410
+ out[innerKey] = toPropValue(`${key}.${innerKey}`, innerValue, node)
411
+ }
412
+ return out
413
+ }
414
+ default:
415
+ throw new Error(
416
+ `lexical-loro: ${node.getType()}.exportJSON() returned a non-JSON value at '${key}' ` +
417
+ `(${typeof value}) — node props must be JSON-serializable to replicate`,
418
+ )
419
+ }
420
+ }
421
+
422
+ function jsonEqual(a: unknown, b: unknown): boolean {
423
+ if (a === b) return true
424
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
425
+ if (Array.isArray(a) !== Array.isArray(b)) return false
426
+ if (Array.isArray(a) && Array.isArray(b)) {
427
+ return a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]))
428
+ }
429
+ const left = a as Record<string, unknown>
430
+ const right = b as Record<string, unknown>
431
+ const keys = Object.keys(left)
432
+ if (keys.length !== Object.keys(right).length) return false
433
+ return keys.every((key) => key in right && jsonEqual(left[key], right[key]))
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Children: the desired shape
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /**
441
+ * What a Lexical element's children should look like in Loro: one entry per
442
+ * non-text child, and one per MAXIMAL RUN of adjacent text nodes (the schema's
443
+ * text unit — see `schema.ts`).
444
+ */
445
+ type DesiredChild = DesiredText | DesiredElement
446
+
447
+ interface DesiredText {
448
+ readonly kind: 'text'
449
+ readonly nodes: readonly TextNode[]
450
+ /**
451
+ * The run's registry key: the first text node's `NodeKey`.
452
+ *
453
+ * A run has no single node to name it, so it is addressed by its FIRST node
454
+ * and the inbound direction walks forward while `$isTextNode` to recover the
455
+ * rest. The anchor is re-linked on every visit, which is what keeps it valid
456
+ * across Lexical's normalization (which may destroy the previous anchor).
457
+ */
458
+ readonly anchor: NodeKey
459
+ }
460
+
461
+ interface DesiredElement {
462
+ readonly kind: 'element'
463
+ readonly node: LexicalNode
464
+ readonly key: NodeKey
465
+ }
466
+
467
+ function describeChildren(node: ElementNode): DesiredChild[] {
468
+ const out: DesiredChild[] = []
469
+ let run: TextNode[] = []
470
+ const flush = (): void => {
471
+ if (run.length === 0) return
472
+ out.push({ kind: 'text', nodes: run, anchor: run[0]!.getKey() })
473
+ run = []
474
+ }
475
+ for (const child of node.getChildren()) {
476
+ if ($isTextNode(child)) {
477
+ run.push(child)
478
+ } else {
479
+ flush()
480
+ out.push({ kind: 'element', node: child, key: child.getKey() })
481
+ }
482
+ }
483
+ flush()
484
+ return out
485
+ }
486
+
487
+ /** The registry key a desired child is addressed by. */
488
+ function desiredKey(child: DesiredChild): NodeKey {
489
+ return child.kind === 'text' ? child.anchor : child.key
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Children: the unchanged-list fast path
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /**
497
+ * Descend into a child list that is known not to have changed shape, visiting
498
+ * only what the dirty sets say could have changed.
499
+ *
500
+ * This is the typing path: a keystroke leaves every ancestor object identical,
501
+ * so the whole reconciliation is skipped at every level and the walk costs
502
+ * O(tree depth) rather than O(document).
503
+ *
504
+ * ── What "unchanged" does and does NOT prove ───────────────────────────────
505
+ *
506
+ * `unchanged` is a fact about the LEXICAL side only: this element's own child
507
+ * list did not change between the two editor states. It says nothing about the
508
+ * LORO side, which a peer can have restructured concurrently. So the fast path
509
+ * must still verify, per child, that the container sitting at that index is the
510
+ * one the registry already associates with this node — matching by POSITION
511
+ * alone is only sound in a single-writer world.
512
+ *
513
+ * Skipping that check writes one node's content into a different node's
514
+ * container. A randomized three-peer test caught the sharpest form: a remote
515
+ * peer deleted one block and inserted another, leaving the child COUNT equal, so
516
+ * every length check still passed while every index had shifted — and the local
517
+ * peer's next keystroke was applied to a container the remote peer had deleted,
518
+ * which loro-crdt rejects outright. Silent cross-writes would have been the
519
+ * quieter, worse outcome.
520
+ */
521
+ function descendUnchanged(
522
+ current: readonly ChildEntry[],
523
+ desired: readonly DesiredChild[],
524
+ context: Context,
525
+ ): boolean {
526
+ for (let i = 0; i < desired.length; i++) {
527
+ const child = desired[i]!
528
+ const entry = current[i]!
529
+ // The mirror disagrees with the fast path's premise; hand back to the
530
+ // identity-matching reconciliation, which handles the remote change.
531
+ if (!containerMatches(entry.container, child, context)) return false
532
+ if (child.kind === 'text') {
533
+ if (entry.kind !== 'text' || !isTextContainer(entry.container)) return false
534
+ // The parent object is unchanged, so the run's COMPOSITION is unchanged;
535
+ // per-node reference equality therefore proves the run is untouched.
536
+ if (child.nodes.every((n) => context.prevNodeMap?.get(n.getKey()) === n)) continue
537
+ context.mapping.link(containerId(entry.container), child.anchor)
538
+ syncText(entry.container, child, context)
539
+ continue
540
+ }
541
+ if (entry.kind !== 'element' || isTextContainer(entry.container)) return false
542
+ if (context.dirty !== null && !context.dirty.has(child.key)) continue
543
+ context.mapping.link(containerId(entry.container), child.key)
544
+ syncElement(entry.container, child.node, context)
545
+ }
546
+ return true
547
+ }
548
+
549
+ /**
550
+ * Whether `container` is the one the registry already addresses for `child`.
551
+ *
552
+ * An UNMAPPED child is a mismatch, not a match: the registry is the only
553
+ * evidence that this position still means the same thing to both sides, and
554
+ * guessing in its absence is what the fast path must never do.
555
+ *
556
+ * ── There is no tombstone check here any more, and none is needed ──────────
557
+ *
558
+ * Under the `LoroMovableList` schema this also had to reject TOMBSTONES:
559
+ * containers still listed but deleted, produced when the list merged a
560
+ * concurrent DELETE and MOVE of the same element by keeping the moved element
561
+ * while the delete op marked the container deleted. loro-crdt then refused every
562
+ * write to an entry `toArray()` still reported.
563
+ *
564
+ * The carrier schema cannot reach that state. A delete removes the carrier's key
565
+ * from the `children` map outright, and a concurrent `pos` write does not
566
+ * resurrect it — the key is absent from `keys()` on EVERY peer, symmetrically,
567
+ * so there is nothing listed to write to. That is pinned by a test in
568
+ * `test/schema.test.ts`; it is the reason `isDeleted()` no longer appears on any
569
+ * outbound path.
570
+ */
571
+ function containerMatches(
572
+ container: ChildContainer,
573
+ child: DesiredChild,
574
+ context: Context,
575
+ ): boolean {
576
+ const mapped = context.mapping.containerId(desiredKey(child))
577
+ return mapped !== undefined && mapped === containerId(container)
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // Children: full reconciliation
582
+ // ---------------------------------------------------------------------------
583
+
584
+ /**
585
+ * Reconcile a child list: match survivors, delete the rest, RE-POSITION what
586
+ * moved, create what is new, then descend.
587
+ *
588
+ * Matching is by IDENTITY, not by shape: an element child is matched through the
589
+ * registry (`NodeKey` → `ContainerID`), which is what lets a drag-reorder be
590
+ * recognised as a permutation rather than as a delete plus an insert. Text runs
591
+ * have no durable identity of their own (Lexical splits and merges them freely),
592
+ * so they are matched by ORDINAL among the text children — which keeps a run's
593
+ * `LoroText` alive across a split, a merge, or a re-typed anchor, and so keeps a
594
+ * peer's concurrent insertion into it.
595
+ *
596
+ * ── What replaced the movable-list machinery ───────────────────────────────
597
+ *
598
+ * This used to plan `LoroMovableList#move` ops, which meant simulating the
599
+ * list's evolving indices (`live`), re-indexing survivors against the compacted
600
+ * list, and converting each desired position into a from/to index pair. All of
601
+ * that is gone: a position is now an ABSOLUTE, order-carrying string, so a child
602
+ * is placed by writing one register and nothing else shifts.
603
+ *
604
+ * The longest-increasing-subsequence step SURVIVES, and deleting it would be a
605
+ * real regression. Without it, every reorder would rewrite every sibling's
606
+ * `pos`: O(n) ops instead of one, and — because a rewrite of the whole list is
607
+ * exactly the rebalance that constraint 3 in `order.ts` forbids — it would
608
+ * silently relocate any concurrent insert from another peer. With it, the
609
+ * longest already-correctly-ordered run of survivors keeps its positions and
610
+ * only genuinely displaced children are written.
611
+ */
612
+ function reconcileChildren(
613
+ element: ElementContainer,
614
+ current: readonly ChildEntry[],
615
+ desired: readonly DesiredChild[],
616
+ context: Context,
617
+ ): void {
618
+ const children = elementChildren(element)
619
+ const matched = matchChildren(current, desired, context)
620
+ const survivors = new Set(matched.filter((entry): entry is ChildEntry => entry !== null))
621
+
622
+ // A LoroText that is losing its last content is EMPTIED rather than deleted
623
+ // when it would leave the element with no children at all. Deleting it would
624
+ // discard a peer's concurrent insertion into that exact container; keeping an
625
+ // empty run costs one dormant container and projects to zero text nodes.
626
+ if (desired.length === 0 && current.length === 1 && current[0]!.kind === 'text') {
627
+ const text = current[0]!.container
628
+ if (isTextContainer(text)) {
629
+ context.mapping.unlinkContainer(containerId(text))
630
+ if (text.length > 0) {
631
+ text.delete(0, text.length)
632
+ context.ops++
633
+ }
634
+ return
635
+ }
636
+ }
637
+
638
+ // 1. Delete unmatched survivors. Order is irrelevant — a carrier is addressed
639
+ // by uuid, so removing one cannot shift another.
640
+ for (const entry of current) {
641
+ if (survivors.has(entry)) continue
642
+ context.mapping.unlinkContainer(containerId(entry.container))
643
+ deleteChild(children, entry.uuid)
644
+ context.ops++
645
+ context.removed = true
646
+ }
647
+
648
+ // 2. Place everything: keep the already-ordered survivors, re-position the
649
+ // rest, and create what has no match.
650
+ const placed = placeChildren(children, desired, matched, context)
651
+
652
+ // 3. Descend. Newly created children were filled by `createChild`.
653
+ for (let i = 0; i < desired.length; i++) {
654
+ if (matched[i] === null) continue
655
+ const child = desired[i]!
656
+ const container = placed[i]!
657
+ context.mapping.link(containerId(container), desiredKey(child))
658
+ if (child.kind === 'text') {
659
+ if (isTextContainer(container)) syncText(container, child, context)
660
+ } else if (!isTextContainer(container)) {
661
+ syncElement(container, child.node, context)
662
+ }
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Assign every desired child a position, creating the ones that have no
668
+ * surviving carrier, and return the container now sitting at each index.
669
+ *
670
+ * The survivors whose positions are already in the desired relative order are
671
+ * left completely untouched (see the LIS note on {@link reconcileChildren});
672
+ * every other child is written into the gap between its nearest kept
673
+ * neighbours, one maximal gap at a time so that a multi-child insertion is
674
+ * allocated as ONE batch. Batching is what gives the run a peer-private
675
+ * sub-interval and stops two concurrent pastes from interleaving — constraint 1
676
+ * in `order.ts`.
677
+ *
678
+ * The kept positions are STRICTLY increasing (the subsequence is strict), so
679
+ * every interval handed to `allocate` is non-degenerate and the equal-position
680
+ * hazard of constraint 4 cannot arise on this path.
681
+ */
682
+ function placeChildren(
683
+ children: ChildrenContainer,
684
+ desired: readonly DesiredChild[],
685
+ matched: readonly (ChildEntry | null)[],
686
+ context: Context,
687
+ ): ChildContainer[] {
688
+ const survivorIndices: number[] = []
689
+ for (let i = 0; i < desired.length; i++) if (matched[i] !== null) survivorIndices.push(i)
690
+
691
+ // LIS works on ranks so that two survivors sharing a position can never both
692
+ // be kept — which is what keeps the intervals below strictly ordered.
693
+ const ranks = positionRanks(survivorIndices.map((i) => matched[i]!.pos))
694
+ const keep = new Set<number>()
695
+ for (const index of longestIncreasingSubsequence(ranks)) keep.add(survivorIndices[index]!)
696
+
697
+ const placed = new Array<ChildContainer>(desired.length)
698
+ for (const index of keep) placed[index] = matched[index]!.container
699
+
700
+ let i = 0
701
+ while (i < desired.length) {
702
+ if (keep.has(i)) {
703
+ i++
704
+ continue
705
+ }
706
+ let end = i
707
+ while (end < desired.length && !keep.has(end)) end++
708
+ // `i - 1` and `end` are kept (or out of range), so both bounds are the
709
+ // positions of untouched survivors and `before < after` strictly.
710
+ const before = i === 0 ? null : matched[i - 1]!.pos
711
+ const after = end === desired.length ? null : matched[end]!.pos
712
+ const keys = allocate(before, after, end - i, context.jitter)
713
+
714
+ for (let j = i; j < end; j++) {
715
+ const key = keys[j - i]!
716
+ const entry = matched[j] ?? null
717
+ if (entry === null) {
718
+ placed[j] = createChild(children, desired[j]!, key, context)
719
+ continue
720
+ }
721
+ setChildPosition(entry.carrier, key)
722
+ context.ops++
723
+ placed[j] = entry.container
724
+ }
725
+ i = end
726
+ }
727
+
728
+ return placed
729
+ }
730
+
731
+ /**
732
+ * Dense ranks for a list of positions: equal positions share a rank.
733
+ *
734
+ * Sharing a rank is the point. `longestIncreasingSubsequence` is STRICT, so two
735
+ * children that a concurrent insert left holding the same position can never
736
+ * both be kept, and every interval derived from the kept set stays usable.
737
+ */
738
+ function positionRanks(positions: readonly string[]): number[] {
739
+ const distinct = [...new Set(positions)].sort()
740
+ const rank = new Map(distinct.map((pos, index) => [pos, index]))
741
+ return positions.map((pos) => rank.get(pos)!)
742
+ }
743
+
744
+ /**
745
+ * For each desired child, the existing carrier it reuses, or `null` when it
746
+ * must be created.
747
+ */
748
+ function matchChildren(
749
+ current: readonly ChildEntry[],
750
+ desired: readonly DesiredChild[],
751
+ context: Context,
752
+ ): (ChildEntry | null)[] {
753
+ const byId = new Map<ContainerID, ChildEntry>()
754
+ const textEntries: ChildEntry[] = []
755
+ for (const entry of current) {
756
+ if (entry.kind === 'text') textEntries.push(entry)
757
+ else byId.set(containerId(entry.container), entry)
758
+ }
759
+
760
+ const matched = new Array<ChildEntry | null>(desired.length).fill(null)
761
+ const claimed = new Set<ChildEntry>()
762
+ let nextText = 0
763
+
764
+ for (let i = 0; i < desired.length; i++) {
765
+ const child = desired[i]!
766
+ if (child.kind === 'text') {
767
+ // Ordinal matching among text children: runs have no stable identity, but
768
+ // their POSITION among the element's text runs is stable enough to keep
769
+ // the container (and therefore concurrent remote edits to it) alive.
770
+ const entry = textEntries[nextText]
771
+ if (entry === undefined) continue
772
+ nextText++
773
+ matched[i] = entry
774
+ claimed.add(entry)
775
+ continue
776
+ }
777
+ const id = context.mapping.containerId(child.key)
778
+ if (id === undefined) continue
779
+ const entry = byId.get(id)
780
+ if (entry === undefined || claimed.has(entry)) continue
781
+ // A container that no longer mirrors this node's type (Lexical replaced the
782
+ // node) must be rebuilt, not reused.
783
+ if (isTextContainer(entry.container)) continue
784
+ if (elementType(entry.container) !== child.node.getType()) continue
785
+ matched[i] = entry
786
+ claimed.add(entry)
787
+ }
788
+ return matched
789
+ }
790
+
791
+ /**
792
+ * The indices of a longest strictly-increasing subsequence of `values`.
793
+ *
794
+ * Patience sorting with a predecessor chain: O(n log n). Exported because it is
795
+ * the part of the reorder planner worth testing in isolation — the number of
796
+ * `pos` writes a drag-reorder costs is exactly `matched.length - lis.length`.
797
+ */
798
+ export function longestIncreasingSubsequence(values: readonly number[]): number[] {
799
+ if (values.length === 0) return []
800
+ // `tails[l]` is the index of the smallest tail among increasing
801
+ // subsequences of length l+1; `previous[i]` chains the reconstruction.
802
+ const tails: number[] = []
803
+ const previous = new Array<number>(values.length).fill(-1)
804
+
805
+ for (let i = 0; i < values.length; i++) {
806
+ let low = 0
807
+ let high = tails.length
808
+ while (low < high) {
809
+ const mid = (low + high) >> 1
810
+ if (values[tails[mid]!]! < values[i]!) low = mid + 1
811
+ else high = mid
812
+ }
813
+ if (low > 0) previous[i] = tails[low - 1]!
814
+ tails[low] = i
815
+ }
816
+
817
+ const out = new Array<number>(tails.length)
818
+ let cursor = tails[tails.length - 1]!
819
+ for (let i = tails.length - 1; i >= 0; i--) {
820
+ out[i] = cursor
821
+ cursor = previous[cursor]!
822
+ }
823
+ return out
824
+ }
825
+
826
+ /**
827
+ * Create, attach and fill a brand-new child at position `pos`.
828
+ *
829
+ * The uuid is minted with `crypto.randomUUID`, which is REQUIRED rather than
830
+ * merely convenient: two peers minting the same uuid would collide on one slot
831
+ * of the `children` map, and its last-writer-wins would silently discard a whole
832
+ * block. See {@link newUuid}.
833
+ */
834
+ function createChild(
835
+ children: ChildrenContainer,
836
+ child: DesiredChild,
837
+ pos: string,
838
+ context: Context,
839
+ ): ChildContainer {
840
+ context.ops++
841
+ const uuid = newUuid()
842
+ if (child.kind === 'text') {
843
+ // The carrier attaches the LoroText as it creates it, so the text is a live
844
+ // document container from the start — marks are document operations, and a
845
+ // detached LoroText cannot carry them.
846
+ const text = createTextChild(children, uuid, pos)
847
+ context.mapping.link(containerId(text), child.anchor)
848
+ syncText(text, child, context)
849
+ return text
850
+ }
851
+ const element = createElementChild(children, uuid, pos, child.node.getType())
852
+ context.mapping.link(containerId(element), child.key)
853
+ // `fresh`: this container is empty, so the Lexical-side `unchanged` fast path
854
+ // must not be allowed to skip populating it. See {@link syncElement}.
855
+ syncElement(element, child.node, context, true)
856
+ return element
857
+ }
858
+
859
+ // ---------------------------------------------------------------------------
860
+ // Text
861
+ // ---------------------------------------------------------------------------
862
+
863
+ /**
864
+ * Replay a text run's RESULTING state into its `LoroText`: reconcile the
865
+ * characters with a cursor-biased single-region diff, then replay the formats
866
+ * Lexical actually produced as explicit per-format `mark`/`unmark` ops.
867
+ *
868
+ * The second step is not redundant with the first. Loro's `expand` rule cannot
869
+ * reproduce Lexical's boundary behaviour for any table, uniform or per-format —
870
+ * see `text.ts` and the 51 tests in `test/expand-semantics.test.ts`. Replaying
871
+ * the resulting node state is what makes typing at the start of a formatted
872
+ * run, and toggling a format at a collapsed caret, come out right.
873
+ */
874
+ function syncText(text: LoroText, child: DesiredText, context: Context): void {
875
+ const target = normalizeRuns(
876
+ child.nodes.map((node) => ({ text: node.getTextContent(), format: node.getFormat() })),
877
+ )
878
+ const targetString = runsText(target)
879
+ const currentString = text.toString()
880
+
881
+ if (currentString !== targetString) {
882
+ const cursor = runCaret(child, context)
883
+ const diff =
884
+ cursor === null
885
+ ? diffText(currentString, targetString)
886
+ : diffTextWithCursor(currentString, targetString, cursor)
887
+ applyTextDiff(text, diff)
888
+ if (diff.remove > 0) context.ops++
889
+ if (diff.insert !== '') context.ops++
890
+ }
891
+
892
+ const ops = diffRunFormats(runsFromText(text), target)
893
+ applyMarkOps(text, ops)
894
+ context.ops += ops.length
895
+ }
896
+
897
+ /**
898
+ * The caret's offset within a run, in UTF-16 code units, or `null` when the
899
+ * caret is elsewhere.
900
+ *
901
+ * Without it a repeated-character insertion is placed at its leftmost possible
902
+ * position instead of where the user typed, which drags every remote caret and
903
+ * (through `expand`) can attach the wrong formatting.
904
+ */
905
+ function runCaret(child: DesiredText, context: Context): number | null {
906
+ const caret = context.caret
907
+ if (caret === null) return null
908
+ let offset = 0
909
+ for (const node of child.nodes) {
910
+ if (node.getKey() === caret.key) return offset + caret.offset
911
+ offset += node.getTextContentSize()
912
+ }
913
+ return null
914
+ }