@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
@@ -0,0 +1,792 @@
1
+ /**
2
+ * Inbound sync: the Loro mirror → a Lexical `EditorState`.
3
+ *
4
+ * ── The one thing this must not do ─────────────────────────────────────────
5
+ *
6
+ * loro-prosemirror's inbound path replaces the whole document on every remote
7
+ * event (`sync-plugin.ts`). ProseMirror tolerates that. LEXICAL CANNOT: its
8
+ * `NodeKey` is a bare counter (`LexicalUtils.ts`: `'' + keyCounter++`), so a
9
+ * rebuild mints a fresh key for every node, which tears down all DOM, destroys
10
+ * the selection and IME composition, and — decisively for this stack — destroys
11
+ * and remounts EVERY `LLuiDecoratorNode`, because `packages/lexical/decorator.ts`
12
+ * disposes the mounted LLui sub-app on the 'destroyed' mutation.
13
+ *
14
+ * So this module is a PERSISTENT MIRROR: it resolves each remote change to an
15
+ * existing `NodeKey` through the registry in `mapping.ts` and MUTATES THAT NODE
16
+ * IN PLACE. Nodes nothing touched are never written, so their keys — and
17
+ * therefore their DOM, their mounts, and any selection inside them — survive by
18
+ * construction. That is the model `@lexical/yjs`'s `SyncEditorStates.ts` uses,
19
+ * and it is the only one that works here.
20
+ *
21
+ * ── Why it reconciles from STATE, not from the deltas ──────────────────────
22
+ *
23
+ * The events say WHERE the document changed; this module then reconciles those
24
+ * containers against Loro's CURRENT state rather than replaying each delta. Four
25
+ * reasons, all learned from the event shapes loro-crdt actually emits:
26
+ *
27
+ * 1. A batch that creates a subtree emits the parent's list insert AND a
28
+ * separate event for every descendant container. Replaying all of them
29
+ * double-applies; reconciling from state is idempotent, so the duplicate
30
+ * descriptions of one change collapse to one reconciliation.
31
+ * 2. A `LoroMovableList#move` arrives as an insert plus a delete carrying the
32
+ * SAME `ContainerID`. Reconciling by identity recognises that as a move —
33
+ * the whole point of the movable-list schema — where a delta replay would
34
+ * see a delete and rebuild the subtree.
35
+ * 3. A map deletion is reported as an `undefined` value in `MapDiff.updated`,
36
+ * which is indistinguishable from an absent key in most JS handling. Reading
37
+ * the map is unambiguous.
38
+ * 4. It is the mirror image of `to-loro.ts`, so both directions share one
39
+ * notion of "these two trees agree" (`text.ts`'s normalized runs) and cannot
40
+ * drift apart in their idea of what a difference is.
41
+ *
42
+ * The events are therefore used as a DIRTY SET, exactly as `to-loro.ts` uses
43
+ * Lexical's `dirtyElements`/`dirtyLeaves`: every element on the path from the
44
+ * root to a changed container is marked, and the walk descends only into marked
45
+ * children. A remote keystroke costs O(tree depth), not O(document).
46
+ *
47
+ * ── Selection ──────────────────────────────────────────────────────────────
48
+ *
49
+ * Untouched nodes keep their keys, so a caret outside the changed run needs no
50
+ * help at all. Only the case where a remote change lands INSIDE the exact text
51
+ * run holding the caret needs work: there the caret's offset is transformed
52
+ * through the same single-region diff used to update the run (a caret at or
53
+ * before the edit point does not move; one after it shifts by
54
+ * `insert.length - remove`; one inside a deleted span clamps to the edit point).
55
+ * That is deliberately narrow — reaching for cursors more broadly would mean
56
+ * writing selection on every remote event, which is its own source of jitter.
57
+ */
58
+
59
+ import {
60
+ $createTextNode,
61
+ $getRoot,
62
+ $getSelection,
63
+ $isElementNode,
64
+ $isRangeSelection,
65
+ $isTextNode,
66
+ $parseSerializedNode,
67
+ $setSelection,
68
+ COLLABORATION_TAG,
69
+ SKIP_SCROLL_INTO_VIEW_TAG,
70
+ type ElementNode,
71
+ type LexicalEditor,
72
+ type LexicalNode,
73
+ type NodeKey,
74
+ type PointType,
75
+ type SerializedLexicalNode,
76
+ type TextNode,
77
+ } from 'lexical'
78
+ import {
79
+ LoroMap,
80
+ type Container,
81
+ type ContainerID,
82
+ type LoroDoc,
83
+ type LoroEventBatch,
84
+ } from 'loro-crdt'
85
+
86
+ import { ContainerNodeMap } from './mapping.js'
87
+ import {
88
+ KEY_TYPE,
89
+ containerId,
90
+ containerIsLive,
91
+ elementProps,
92
+ elementType,
93
+ isTextContainer,
94
+ orderedChildren,
95
+ type ChildContainer,
96
+ type ElementContainer,
97
+ type PropValue,
98
+ } from './schema.js'
99
+ import { diffTextWithCursor, normalizeRuns, runsFromText, runsText, type TextRun } from './text.js'
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Public surface
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Tags stamped on every inbound writeback.
107
+ *
108
+ * `COLLABORATION_TAG` is echo layer (b): `to-loro.ts` skips updates carrying it,
109
+ * so our own writeback cannot bounce back into the shared document.
110
+ * `SKIP_SCROLL_INTO_VIEW_TAG` stops a peer's edit from yanking the local
111
+ * viewport.
112
+ *
113
+ * `PROGRAMMATIC_TAG` is deliberately ABSENT and must stay that way — echo layer
114
+ * (c). `packages/lexical/src/foreign.ts` treats that tag as "the host pushed new
115
+ * content: cancel pending outbound work and rebase", so a remote writeback
116
+ * carrying it would silently cancel the local user's in-flight debounced
117
+ * `onChange` and the host's persistence would go dark whenever a peer types.
118
+ */
119
+ export const INBOUND_TAGS: readonly string[] = [COLLABORATION_TAG, SKIP_SCROLL_INTO_VIEW_TAG]
120
+
121
+ /** The Lexical side of the binding, plus the shared document it mirrors. */
122
+ export interface InboundTarget {
123
+ readonly doc: LoroDoc
124
+ /** The root element container, as returned by `initDoc`. */
125
+ readonly root: ElementContainer
126
+ /** The ContainerID ↔ NodeKey registry, owned by the binding and mutated here. */
127
+ readonly mapping: ContainerNodeMap
128
+ readonly editor: LexicalEditor
129
+ /**
130
+ * Commit origins whose LOCAL batches must still be applied.
131
+ *
132
+ * Echo layer (a) drops `by: 'local'` batches — they are this peer's own
133
+ * outbound writes coming back round. But not every local batch is an echo:
134
+ *
135
+ * - the CRDT-aware undo manager (`undo.ts`) produces LOCAL batches from
136
+ * `undo()`/`redo()` (`UNDO_ORIGINS`), and
137
+ * - the agent-write reconciler (`agent-write.ts`) writes the document directly
138
+ * under {@link import('./agent-write.js').AGENT_WRITE_ORIGIN},
139
+ *
140
+ * neither of which came FROM the editor, so both MUST be applied to bounce into
141
+ * it (preserving `NodeKey`s and decorator mounts). `binding.ts` passes those
142
+ * origins here. A local batch whose origin is on this list is applied; every
143
+ * other local batch is still dropped as an echo.
144
+ */
145
+ readonly localOrigins?: readonly string[]
146
+ }
147
+
148
+ /**
149
+ * Apply one Loro event batch to the editor.
150
+ *
151
+ * @returns whether anything was applied. `false` means the batch was an echo of
152
+ * our own write, or described no change the editor could see.
153
+ */
154
+ export function applyLoroToLexical(target: InboundTarget, batch: LoroEventBatch): boolean {
155
+ // ── Echo layer (a) ──────────────────────────────────────────────────────
156
+ // A local batch is this peer's own outbound write completing its commit. Our
157
+ // outbound listener already produced it FROM the editor; feeding it back would
158
+ // re-enter `editor.update` from inside a Lexical update listener.
159
+ if (batch.by === 'local') {
160
+ const origins = target.localOrigins ?? []
161
+ if (batch.origin === undefined || !origins.includes(batch.origin)) return false
162
+ }
163
+ if (batch.events.length === 0) return false
164
+
165
+ const dirty = collectDirtyElements(target.doc, containerId(target.root), batch.events)
166
+ return $applyReconciliation(target, dirty)
167
+ }
168
+
169
+ /**
170
+ * Reconcile the ENTIRE shared document into the editor, with no dirty gate.
171
+ *
172
+ * Used at boot by a peer adopting a document it has no event history for (see
173
+ * `seed.ts`), and as the fallback whenever an event's container ancestry cannot
174
+ * be resolved. Full-fidelity and identity-preserving: adopting a document the
175
+ * editor already matches writes nothing and churns no NodeKeys.
176
+ */
177
+ export function adoptLoroDocument(target: InboundTarget): boolean {
178
+ return $applyReconciliation(target, null)
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // The dirty set
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /**
186
+ * Every element container on the path from the root to a container this batch
187
+ * touched — the inbound analogue of Lexical's `dirtyElements`.
188
+ *
189
+ * The walk goes UP from each event target via `parent()`, so it needs no path
190
+ * arithmetic and cannot be confused by indices that shifted within the batch.
191
+ *
192
+ * ── The fail-safe, and why it must be exactly this one ─────────────────────
193
+ *
194
+ * The gate is only sound if the editor already agreed with the document before
195
+ * the batch: a child that is not marked is left ALONE, so a single event whose
196
+ * location we resolve too narrowly leaves that subtree permanently stale, and
197
+ * nothing later repairs it (no future batch will mention it either). A narrowed
198
+ * dirty set is therefore not a small error — it is silent, unrecoverable
199
+ * divergence.
200
+ *
201
+ * So anything less than a walk that terminates at the ROOT WE MIRROR returns
202
+ * `null`, which drops the gate and falls back to a full structural pass —
203
+ * correct regardless of provenance, at the cost of one O(document) walk. An
204
+ * earlier version skipped events whose target reported `isDeleted()` instead,
205
+ * and a randomized three-peer test caught it: a container one peer deletes and
206
+ * another concurrently MOVES is resurrected by the merge, but reports itself
207
+ * deleted to the peer that removed it, so its events were dropped and that
208
+ * peer's editor silently stopped tracking the run for the rest of the session.
209
+ */
210
+ function collectDirtyElements(
211
+ doc: LoroDoc,
212
+ rootId: ContainerID,
213
+ events: LoroEventBatch['events'],
214
+ ): Set<ContainerID> | null {
215
+ const dirty = new Set<ContainerID>()
216
+ for (const event of events) {
217
+ let cursor: Container | undefined = doc.getContainerById(event.target)
218
+ let reached = false
219
+ while (cursor !== undefined) {
220
+ // An element map is the only container carrying `type`; `props` maps and
221
+ // `children` lists are addressed through the element that owns them.
222
+ if (cursor instanceof LoroMap && cursor.get(KEY_TYPE) !== undefined) dirty.add(cursor.id)
223
+ if (cursor.id === rootId) reached = true
224
+ cursor = cursor.parent()
225
+ }
226
+ if (!reached) return null
227
+ }
228
+ return dirty
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // The update
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /** Everything the walk threads through itself. */
236
+ interface Context {
237
+ readonly doc: LoroDoc
238
+ readonly mapping: ContainerNodeMap
239
+ /** `null` means "descend everywhere" — see {@link collectDirtyElements}. */
240
+ readonly dirty: Set<ContainerID> | null
241
+ /** Text points captured before the walk, to be re-placed after it. */
242
+ readonly points: CapturedPoint[]
243
+ /** Set when any Lexical node was written. */
244
+ changed: boolean
245
+ /** Set when a node was removed, so the registry is swept once at the end. */
246
+ removed: boolean
247
+ }
248
+
249
+ /** A selection endpoint that sat in a text run, in run-absolute UTF-16 units. */
250
+ interface CapturedPoint {
251
+ readonly point: PointType
252
+ /** The keys of the text nodes forming the run this point sits in. */
253
+ readonly runKeys: readonly NodeKey[]
254
+ /** Offset from the START of the run, not of the individual node. */
255
+ offset: number
256
+ /** Set once the run is rebuilt: where the point must land. */
257
+ resolved: { node: TextNode; offset: number } | null
258
+ }
259
+
260
+ function $applyReconciliation(target: InboundTarget, dirty: Set<ContainerID> | null): boolean {
261
+ const context: Context = {
262
+ doc: target.doc,
263
+ mapping: target.mapping,
264
+ dirty,
265
+ points: [],
266
+ changed: false,
267
+ removed: false,
268
+ }
269
+
270
+ target.editor.update(
271
+ () => {
272
+ capturePoints(context)
273
+ const root = $getRoot()
274
+ context.mapping.link(containerId(target.root), root.getKey())
275
+ $applyProps(target.root, root, context)
276
+ $reconcileChildren(target.root, root, context)
277
+ $restorePoints(context)
278
+ },
279
+ {
280
+ tag: [...INBOUND_TAGS],
281
+ skipTransforms: true,
282
+ // REQUIRED. Lexical merges and splits adjacent TextNodes behind our back
283
+ // and reports it via `normalizedNodes`; without a synchronous flush the
284
+ // ContainerID ↔ NodeKey mapping drifts and the document corrupts.
285
+ discrete: true,
286
+ },
287
+ )
288
+
289
+ if (context.removed) {
290
+ const nodeMap = target.editor.getEditorState()._nodeMap
291
+ target.mapping.sweep({
292
+ hasContainer: (id) => containerIsLive(target.doc, id),
293
+ hasNode: (key) => nodeMap.has(key),
294
+ })
295
+ }
296
+
297
+ return context.changed
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Props
302
+ // ---------------------------------------------------------------------------
303
+
304
+ /**
305
+ * Apply an element container's props to an existing node, in place.
306
+ *
307
+ * `updateFromJSON` is Lexical's own in-place applier and the exact inverse of
308
+ * the `exportJSON` the outbound direction writes, so this stays generic over
309
+ * custom node types instead of poking private `__`-prefixed fields the way
310
+ * `@lexical/yjs` does.
311
+ *
312
+ * @returns `false` when the node's type does NOT implement `updateFromJSON`
313
+ * faithfully — the props did not take, so the caller must replace the node. That
314
+ * is detected by re-reading `exportJSON`, never assumed, because a node whose
315
+ * props silently fail to apply is a permanently-diverged document.
316
+ */
317
+ function $applyProps(container: ElementContainer, node: LexicalNode, context: Context): boolean {
318
+ const props = elementProps(container).toJSON() as Record<string, PropValue>
319
+ if (!propsDiffer(props, node)) return true
320
+
321
+ node.updateFromJSON(props as Record<string, never>)
322
+ context.changed = true
323
+
324
+ if (!propsDiffer(props, node)) return true
325
+ // Fall through to replacement, but say why: a node type reaching this point
326
+ // has an `exportJSON` its `updateFromJSON` cannot round-trip, which costs its
327
+ // subtree's NodeKeys (and any decorator mount in it) on every remote change.
328
+ console.error(
329
+ `lexical-loro: '${node.getType()}'.updateFromJSON() does not apply every property its ` +
330
+ 'exportJSON() emits, so the node must be REPLACED on each remote change — which destroys ' +
331
+ 'its NodeKey, its DOM, and any mounted decorator sub-app. Implement updateFromJSON.',
332
+ )
333
+ return false
334
+ }
335
+
336
+ /**
337
+ * Whether any prop the shared document holds differs from the node's own.
338
+ *
339
+ * Reads through `getLatest()`, which is load-bearing rather than defensive:
340
+ * `updateFromJSON` writes through `getWritable()`, which CLONES the node, so the
341
+ * reference the caller is holding is stale the moment the write lands. Comparing
342
+ * against it would report the update as having failed and trigger a needless —
343
+ * and mount-destroying — node replacement.
344
+ */
345
+ function propsDiffer(props: Record<string, PropValue>, node: LexicalNode): boolean {
346
+ const current = node.getLatest().exportJSON() as Record<string, unknown>
347
+ for (const [key, value] of Object.entries(props)) {
348
+ if (!jsonEqual(current[key], value)) return true
349
+ }
350
+ return false
351
+ }
352
+
353
+ function jsonEqual(a: unknown, b: unknown): boolean {
354
+ if (a === b) return true
355
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
356
+ if (Array.isArray(a) !== Array.isArray(b)) return false
357
+ if (Array.isArray(a) && Array.isArray(b)) {
358
+ return a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]))
359
+ }
360
+ const left = a as Record<string, unknown>
361
+ const right = b as Record<string, unknown>
362
+ const keys = Object.keys(left)
363
+ if (keys.length !== Object.keys(right).length) return false
364
+ return keys.every((key) => key in right && jsonEqual(left[key], right[key]))
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Children
369
+ // ---------------------------------------------------------------------------
370
+
371
+ /**
372
+ * A Lexical element's children, grouped the way the schema stores them: one
373
+ * entry per non-text child, one per MAXIMAL RUN of adjacent text nodes.
374
+ *
375
+ * The grouping must match `to-loro.ts`'s `describeChildren` exactly — a run is
376
+ * the shared unit of identity, and disagreeing about where runs begin would make
377
+ * the two directions match different containers to the same nodes.
378
+ */
379
+ type Group = TextGroup | ElementGroup
380
+
381
+ interface TextGroup {
382
+ readonly kind: 'text'
383
+ readonly nodes: TextNode[]
384
+ /** The run's registry key: its FIRST node's `NodeKey`. */
385
+ readonly anchor: NodeKey
386
+ }
387
+
388
+ interface ElementGroup {
389
+ readonly kind: 'element'
390
+ readonly node: LexicalNode
391
+ readonly key: NodeKey
392
+ }
393
+
394
+ function groupChildren(node: ElementNode): Group[] {
395
+ const out: Group[] = []
396
+ let run: TextNode[] = []
397
+ const flush = (): void => {
398
+ if (run.length === 0) return
399
+ out.push({ kind: 'text', nodes: run, anchor: run[0]!.getKey() })
400
+ run = []
401
+ }
402
+ for (const child of node.getChildren()) {
403
+ if ($isTextNode(child)) run.push(child)
404
+ else {
405
+ flush()
406
+ out.push({ kind: 'element', node: child, key: child.getKey() })
407
+ }
408
+ }
409
+ flush()
410
+ return out
411
+ }
412
+
413
+ /**
414
+ * Reconcile a Lexical element's children against its container's child list.
415
+ *
416
+ * Matching is by IDENTITY through the registry: a container that already
417
+ * addresses a node reuses that node wherever it has moved to, which is what
418
+ * turns a remote reorder — one `pos` register write — into a Lexical reorder
419
+ * rather than a rebuild. Text runs additionally fall back to ordinal matching
420
+ * among the text
421
+ * children (mirroring the outbound direction), so a run whose anchor Lexical
422
+ * normalized away still finds its nodes instead of recreating them.
423
+ */
424
+ function $reconcileChildren(
425
+ container: ElementContainer,
426
+ node: ElementNode,
427
+ context: Context,
428
+ ): void {
429
+ const desired = liveChildren(container)
430
+ const groups = groupChildren(node)
431
+ const claimed = new Set<Group>()
432
+
433
+ const byKey = new Map<NodeKey, Group>()
434
+ for (const group of groups) {
435
+ byKey.set(group.kind === 'text' ? group.anchor : group.key, group)
436
+ }
437
+ const textGroups = groups.filter((group): group is TextGroup => group.kind === 'text')
438
+ let nextText = 0
439
+
440
+ const matched: (Group | undefined)[] = desired.map((child) => {
441
+ const key = context.mapping.nodeKey(containerId(child))
442
+ const found = key === undefined ? undefined : byKey.get(key)
443
+ if (found !== undefined && !claimed.has(found)) {
444
+ // A container whose node changed KIND (text ⇄ element) cannot be reused.
445
+ if (isTextContainer(child) === (found.kind === 'text')) {
446
+ claimed.add(found)
447
+ return found
448
+ }
449
+ }
450
+ if (!isTextContainer(child)) return undefined
451
+ while (nextText < textGroups.length && claimed.has(textGroups[nextText]!)) nextText++
452
+ const ordinal = textGroups[nextText]
453
+ if (ordinal === undefined) return undefined
454
+ nextText++
455
+ claimed.add(ordinal)
456
+ return ordinal
457
+ })
458
+
459
+ // 1. Build (or update in place) the node list this element should hold.
460
+ const ordered: LexicalNode[] = []
461
+ for (let i = 0; i < desired.length; i++) {
462
+ const child = desired[i]!
463
+ const group = matched[i]
464
+ if (isTextContainer(child)) {
465
+ const existing = group !== undefined && group.kind === 'text' ? group.nodes : []
466
+ const nodes = $reconcileTextRun(child, existing, context)
467
+ const id = containerId(child)
468
+ if (nodes.length === 0) context.mapping.unlinkContainer(id)
469
+ else context.mapping.link(id, nodes[0]!.getKey())
470
+ ordered.push(...nodes)
471
+ continue
472
+ }
473
+ const existing = group !== undefined && group.kind === 'element' ? group.node : undefined
474
+ ordered.push($reconcileElementChild(child, existing, context))
475
+ }
476
+
477
+ // 2. Remove whatever is no longer wanted. `preserveEmptyParent` is TRUE:
478
+ // Lexical's default removes a parent that this leaves empty, which would
479
+ // delete a block the shared document still holds.
480
+ const keep = new Set(ordered.map((child) => child.getKey()))
481
+ for (const group of groups) {
482
+ const nodes = group.kind === 'text' ? group.nodes : [group.node]
483
+ for (const child of nodes) {
484
+ if (keep.has(child.getKey())) continue
485
+ const id = context.mapping.containerId(child.getKey())
486
+ if (id !== undefined) context.mapping.unlinkNode(child.getKey())
487
+ child.remove(true)
488
+ context.changed = true
489
+ context.removed = true
490
+ }
491
+ }
492
+
493
+ // 3. Place everything in order. Lexical's insert helpers MOVE an
494
+ // already-attached node without minting a new key, so a reorder here costs
495
+ // no identity — which is the whole point of the movable-list schema.
496
+ let previous: LexicalNode | null = null
497
+ for (const child of ordered) {
498
+ const parent = child.getParent()
499
+ const attached = parent !== null && parent.is(node)
500
+ const actual = attached ? child.getPreviousSibling() : undefined
501
+ const inPlace =
502
+ attached && ((actual === null && previous === null) || (actual?.is(previous) ?? false))
503
+ if (!inPlace) {
504
+ if (previous === null) {
505
+ const first = node.getFirstChild()
506
+ if (first === null) node.append(child)
507
+ else first.insertBefore(child, false)
508
+ } else {
509
+ previous.insertAfter(child, false)
510
+ }
511
+ context.changed = true
512
+ }
513
+ previous = child
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Reconcile one element child: update it in place when it is the same node, or
519
+ * build a fresh subtree when there is nothing reusable.
520
+ */
521
+ function $reconcileElementChild(
522
+ container: ElementContainer,
523
+ existing: LexicalNode | undefined,
524
+ context: Context,
525
+ ): LexicalNode {
526
+ const id = containerId(container)
527
+ if (existing !== undefined && existing.getType() === elementType(container)) {
528
+ // Not dirty ⇒ nothing under this container changed, so do not descend.
529
+ if (context.dirty !== null && !context.dirty.has(id)) {
530
+ context.mapping.link(id, existing.getKey())
531
+ return existing
532
+ }
533
+ if ($applyProps(container, existing, context)) {
534
+ context.mapping.link(id, existing.getKey())
535
+ if ($isElementNode(existing)) $reconcileChildren(container, existing, context)
536
+ return existing
537
+ }
538
+ // Props could not be applied in place; fall through and rebuild.
539
+ }
540
+ context.changed = true
541
+ return $buildNode(container, context)
542
+ }
543
+
544
+ /**
545
+ * Build a fresh Lexical subtree from a container the registry cannot resolve —
546
+ * a block a peer just created.
547
+ *
548
+ * Nodes are constructed through `$parseSerializedNode`, the inverse of the
549
+ * `exportJSON` the outbound direction wrote, so custom node types (including
550
+ * `LLuiDecoratorNode`) round-trip through their own `importJSON` with no
551
+ * special-casing here.
552
+ */
553
+ function $buildNode(container: ElementContainer, context: Context): LexicalNode {
554
+ const serialized = {
555
+ ...(elementProps(container).toJSON() as Record<string, unknown>),
556
+ type: elementType(container),
557
+ version: 1,
558
+ } as SerializedLexicalNode
559
+ const node = $parseSerializedNode(serialized)
560
+ context.mapping.link(containerId(container), node.getKey())
561
+
562
+ if ($isElementNode(node)) {
563
+ for (const child of liveChildren(container)) $appendChild(node, child, context)
564
+ }
565
+ return node
566
+ }
567
+
568
+ /**
569
+ * An element's children, in the order the shared document renders them.
570
+ *
571
+ * The ordering is `sort by (pos, uuid)` over the child carriers — see
572
+ * `order.ts`. This is the ONE place the inbound walk touches the ordering model
573
+ * at all: every carrier is dereferenced to the container the registry addresses
574
+ * it by (the `LoroText` for a run, the element map for an element), and from
575
+ * there this module is exactly as it was under the list schema.
576
+ *
577
+ * ── PROJECTION MUST DEPEND ONLY ON REPLICATED STATE ────────────────────────
578
+ *
579
+ * `orderedChildren` consults no `isDeleted()`, and nothing here may add one.
580
+ * Under the previous `LoroMovableList` schema an earlier version of this
581
+ * function filtered "tombstones" out, reasoning that a shared rule keeps both
582
+ * peers showing the same thing. It does not, and cannot: `isDeleted()` is
583
+ * PEER-LOCAL bookkeeping, not replicated state. The peer that issued a delete
584
+ * kept reporting `true` while every other peer reported `false`, for the same
585
+ * ContainerID in the same list, so it rendered one block fewer than everybody
586
+ * else — permanently, since no later batch ever mentioned the container again.
587
+ *
588
+ * The carrier schema removes the temptation rather than relying on the rule
589
+ * being remembered: a deleted carrier's key is absent from `keys()` on every
590
+ * peer, symmetrically, so the projection is a pure function of the document by
591
+ * construction.
592
+ */
593
+ function liveChildren(container: ElementContainer): ChildContainer[] {
594
+ return orderedChildren(container).map((entry) => entry.container)
595
+ }
596
+
597
+ function $appendChild(parent: ElementNode, child: ChildContainer, context: Context): void {
598
+ if (isTextContainer(child)) {
599
+ const nodes = buildTextNodes(runsFromText(child))
600
+ if (nodes.length === 0) return
601
+ context.mapping.link(containerId(child), nodes[0]!.getKey())
602
+ for (const node of nodes) parent.append(node)
603
+ return
604
+ }
605
+ parent.append($buildNode(child, context))
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Text runs
610
+ // ---------------------------------------------------------------------------
611
+
612
+ function buildTextNodes(runs: readonly TextRun[]): TextNode[] {
613
+ return runs.map((run) => {
614
+ const node = $createTextNode(run.text)
615
+ if (run.format !== 0) node.setFormat(run.format)
616
+ return node
617
+ })
618
+ }
619
+
620
+ /**
621
+ * Reconcile one text run in place, reusing the existing `TextNode`s.
622
+ *
623
+ * The FIRST node is the run's registry anchor, so keeping it is what keeps the
624
+ * container's address stable across the edit. Nodes are reused positionally and
625
+ * only written when their text or format actually differs, which is what keeps a
626
+ * caret in an unchanged part of the run untouched.
627
+ *
628
+ * An EMPTY container yields zero nodes rather than one empty `TextNode`: the
629
+ * outbound direction EMPTIES a run's last `LoroText` instead of deleting it (so
630
+ * a peer's concurrent insertion into that exact container survives), and an
631
+ * empty run must project as no text at all.
632
+ */
633
+ function $reconcileTextRun(
634
+ text: import('loro-crdt').LoroText,
635
+ existing: readonly TextNode[],
636
+ context: Context,
637
+ ): TextNode[] {
638
+ const target = runsFromText(text)
639
+ const current = normalizeRuns(
640
+ existing.map((node) => ({ text: node.getTextContent(), format: node.getFormat() })),
641
+ )
642
+ if (runsEqual(current, target)) return [...existing]
643
+
644
+ $transformPoints(current, target, existing, context)
645
+
646
+ const out: TextNode[] = []
647
+ for (let i = 0; i < target.length; i++) {
648
+ const run = target[i]!
649
+ const node = existing[i]
650
+ if (node === undefined) {
651
+ out.push(buildTextNodes([run])[0]!)
652
+ continue
653
+ }
654
+ if (node.getTextContent() !== run.text) node.setTextContent(run.text)
655
+ if (node.getFormat() !== run.format) node.setFormat(run.format)
656
+ out.push(node)
657
+ }
658
+ for (let i = target.length; i < existing.length; i++) {
659
+ const node = existing[i]!
660
+ context.mapping.unlinkNode(node.getKey())
661
+ node.remove(true)
662
+ context.removed = true
663
+ }
664
+ context.changed = true
665
+
666
+ $resolvePoints(target, out, context)
667
+ return out
668
+ }
669
+
670
+ function runsEqual(a: readonly TextRun[], b: readonly TextRun[]): boolean {
671
+ return (
672
+ a.length === b.length &&
673
+ a.every((run, i) => run.text === b[i]!.text && run.format === b[i]!.format)
674
+ )
675
+ }
676
+
677
+ // ---------------------------------------------------------------------------
678
+ // Selection
679
+ // ---------------------------------------------------------------------------
680
+
681
+ /**
682
+ * Record where the caret sits, as a run-absolute offset.
683
+ *
684
+ * Only TEXT points are captured. An element point addresses a `NodeKey` this
685
+ * module preserves, so it survives on its own; a text point is the one thing a
686
+ * remote edit to the same run can invalidate.
687
+ */
688
+ function capturePoints(context: Context): void {
689
+ const selection = $getSelection()
690
+ if (!$isRangeSelection(selection)) return
691
+ for (const point of [selection.anchor, selection.focus]) {
692
+ if (point.type !== 'text') continue
693
+ const node = point.getNode()
694
+ if (!$isTextNode(node)) continue
695
+ const run = runNodesFor(node)
696
+ let offset = 0
697
+ for (const sibling of run) {
698
+ if (sibling.is(node)) break
699
+ offset += sibling.getTextContentSize()
700
+ }
701
+ context.points.push({
702
+ point,
703
+ runKeys: run.map((n) => n.getKey()),
704
+ offset: offset + point.offset,
705
+ resolved: null,
706
+ })
707
+ }
708
+ }
709
+
710
+ /** The maximal run of adjacent text nodes containing `node`. */
711
+ function runNodesFor(node: TextNode): TextNode[] {
712
+ const run: TextNode[] = [node]
713
+ for (let prev = node.getPreviousSibling(); $isTextNode(prev); prev = prev.getPreviousSibling()) {
714
+ run.unshift(prev)
715
+ }
716
+ for (let next = node.getNextSibling(); $isTextNode(next); next = next.getNextSibling()) {
717
+ run.push(next)
718
+ }
719
+ return run
720
+ }
721
+
722
+ /**
723
+ * Move a captured caret through the text change about to be applied.
724
+ *
725
+ * The change is described as ONE region (`diffTextWithCursor`, biased to the
726
+ * caret so a repeated-character edit is attributed where the user actually is):
727
+ * a caret at or before the region is unmoved, one after it shifts by
728
+ * `insert.length - remove`, and one inside a deleted span clamps to the region's
729
+ * start — which is where a user would expect to be left standing.
730
+ */
731
+ function $transformPoints(
732
+ current: readonly TextRun[],
733
+ target: readonly TextRun[],
734
+ existing: readonly TextNode[],
735
+ context: Context,
736
+ ): void {
737
+ const affected = context.points.filter((captured) =>
738
+ existing.some((node) => captured.runKeys.includes(node.getKey())),
739
+ )
740
+ if (affected.length === 0) return
741
+
742
+ const before = runsText(current)
743
+ const after = runsText(target)
744
+ for (const captured of affected) {
745
+ const diff = diffTextWithCursor(before, after, captured.offset)
746
+ if (captured.offset <= diff.index) continue
747
+ if (captured.offset <= diff.index + diff.remove) captured.offset = diff.index
748
+ else captured.offset += diff.insert.length - diff.remove
749
+ }
750
+ }
751
+
752
+ /** Locate each affected caret inside the rebuilt run. */
753
+ function $resolvePoints(
754
+ target: readonly TextRun[],
755
+ nodes: readonly TextNode[],
756
+ context: Context,
757
+ ): void {
758
+ for (const captured of context.points) {
759
+ if (!nodes.some((node) => captured.runKeys.includes(node.getKey()))) continue
760
+ if (nodes.length === 0) continue
761
+ let remaining = Math.max(0, Math.min(captured.offset, runsText(target).length))
762
+ let resolved: { node: TextNode; offset: number } | null = null
763
+ for (const node of nodes) {
764
+ const size = node.getTextContentSize()
765
+ if (remaining <= size) {
766
+ resolved = { node, offset: remaining }
767
+ break
768
+ }
769
+ remaining -= size
770
+ }
771
+ captured.resolved = resolved ?? {
772
+ node: nodes[nodes.length - 1]!,
773
+ offset: nodes[nodes.length - 1]!.getTextContentSize(),
774
+ }
775
+ }
776
+ }
777
+
778
+ /** Re-place every caret whose run this pass rebuilt. */
779
+ function $restorePoints(context: Context): void {
780
+ for (const captured of context.points) {
781
+ const resolved = captured.resolved
782
+ if (resolved === null) continue
783
+ captured.point.set(resolved.node.getKey(), resolved.offset, 'text')
784
+ }
785
+ // Writing a point mutates the live selection in place; nothing else is needed,
786
+ // but re-setting it makes Lexical mark the selection dirty so the change is
787
+ // reconciled to the DOM.
788
+ const selection = $getSelection()
789
+ if (selection !== null && context.points.some((captured) => captured.resolved !== null)) {
790
+ $setSelection(selection.clone())
791
+ }
792
+ }