@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,613 @@
1
+ /**
2
+ * Agent-write: reconcile an AGENT-AUTHORED full-document rewrite into an existing
3
+ * Loro document without tearing down history.
4
+ *
5
+ * ── The problem ────────────────────────────────────────────────────────────
6
+ *
7
+ * An LLM rewrites a note's WHOLE markdown. The naive route — reparse the new
8
+ * markdown into the live bound editor (`root.clear()` + rebuild) — mints a fresh
9
+ * `NodeKey` for every node, so the outbound sync (`to-loro.ts`) matches nothing
10
+ * through the `NodeKey → ContainerID` registry and recreates EVERY container.
11
+ * A concurrent edit from another window merges into a container this side just
12
+ * deleted and is lost; a mounted `LLuiDecoratorNode` sub-app under any block is
13
+ * torn down. Measured at 0% ContainerID survival even for IDENTICAL markdown.
14
+ *
15
+ * ── The design ─────────────────────────────────────────────────────────────
16
+ *
17
+ * Reconcile a PARSED TARGET TREE directly against the existing Loro document,
18
+ * matching existing child carriers to target children by CONTENT rather than by
19
+ * NodeKey. Unchanged blocks keep their `ContainerID`s (and therefore their
20
+ * `NodeKey`s and decorator mounts on the inbound bounce); a text-changed block
21
+ * keeps its `LoroText` and diffs the characters; only genuinely new/removed/moved
22
+ * blocks touch the ordering. This is the analog of loro-prosemirror's
23
+ * content-equality match (`eqLoroObjNode`), living where the reconciler already
24
+ * is.
25
+ *
26
+ * This is a SIBLING to the outbound sync ({@link import('./to-loro.js')}), not a
27
+ * replacement: it writes the Loro document directly under {@link
28
+ * AGENT_WRITE_ORIGIN}, and the existing inbound path ({@link
29
+ * import('./to-lexical.js')}) replicates that change into any live editor bound
30
+ * to the same document — preserving `NodeKey`s and decorator mounts on the
31
+ * bounce, and self-healing the `ContainerID ↔ NodeKey` mapping there. For that
32
+ * bounce to happen, {@link AGENT_WRITE_ORIGIN} must be on the inbound target's
33
+ * list of local origins to apply; `binding.ts` wires that.
34
+ *
35
+ * ── What this deliberately does NOT consult ────────────────────────────────
36
+ *
37
+ * The `ContainerNodeMap` registry is intentionally NOT read here — content
38
+ * matching is the whole point, and the mapping self-heals on the inbound bounce.
39
+ * (Contrast `to-loro.ts`, whose whole job is IDENTITY matching through that
40
+ * registry.)
41
+ *
42
+ * ── Where the markdown parse lives ─────────────────────────────────────────
43
+ *
44
+ * The markdown → target-tree PARSE is the CALLER's job, because it is
45
+ * caller-specific: the caller owns its custom nodes and its own `@lexical/markdown`
46
+ * transformer set, and that transformer set defines the tree. This module pulls
47
+ * neither `@lexical/markdown` nor `@lexical/headless` (both would otherwise become
48
+ * runtime dependencies of a binding whose core — the reconciler — needs neither).
49
+ * The caller parses markdown headlessly with its own transformers and projects the
50
+ * resulting Lexical tree with {@link projectTarget} (or {@link
51
+ * targetFromEditorState}), then hands the plain, serializable {@link TargetElement}
52
+ * to {@link reconcileTargetIntoLoro}. The reconciler is the reusable core; the
53
+ * markdown parse is not.
54
+ */
55
+
56
+ import {
57
+ $getRoot,
58
+ $isElementNode,
59
+ $isTextNode,
60
+ type EditorState,
61
+ type ElementNode,
62
+ type LexicalNode,
63
+ type TextNode,
64
+ } from 'lexical'
65
+ import type { LoroDoc, LoroText } from 'loro-crdt'
66
+
67
+ import { allocate, jitterFor } from './order.js'
68
+ import {
69
+ createElementChild,
70
+ createTextChild,
71
+ deleteChild,
72
+ elementChildren,
73
+ elementProps,
74
+ elementType,
75
+ isTextContainer,
76
+ newUuid,
77
+ orderedChildren,
78
+ setChildPosition,
79
+ type ChildContainer,
80
+ type ChildEntry,
81
+ type ChildrenContainer,
82
+ type ElementContainer,
83
+ type PropValue,
84
+ } from './schema.js'
85
+ import {
86
+ applyMarkOps,
87
+ applyTextDiff,
88
+ diffRunFormats,
89
+ diffText,
90
+ normalizeRuns,
91
+ runsFromText,
92
+ runsText,
93
+ type TextRun,
94
+ } from './text.js'
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // The target tree
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /** A maximal run of adjacent text nodes — the schema's text unit (see `schema.ts`). */
101
+ export interface TargetText {
102
+ readonly kind: 'text'
103
+ readonly runs: readonly TextRun[]
104
+ }
105
+
106
+ /**
107
+ * A Lexical element (paragraph/heading/list/…) or a leaf mirrored as an element
108
+ * (`LineBreakNode`, `LLuiDecoratorNode`) whose payload lives entirely in `props`.
109
+ */
110
+ export interface TargetElement {
111
+ readonly kind: 'element'
112
+ readonly type: string
113
+ readonly props: Readonly<Record<string, PropValue>>
114
+ readonly children: readonly TargetChild[]
115
+ }
116
+
117
+ /** One child of a target element: a text run or a nested element. */
118
+ export type TargetChild = TargetText | TargetElement
119
+
120
+ /** Lexical node props that are structure/bookkeeping, never document data. */
121
+ const NON_PROP_KEYS: ReadonlySet<string> = new Set(['type', 'version', 'children'])
122
+
123
+ /**
124
+ * Normalize an `exportJSON` value into a stored {@link PropValue}, dropping
125
+ * `undefined` exactly as `to-loro.ts`'s `syncProps` does — so a target block's
126
+ * props signature equals what the Loro container already holds.
127
+ */
128
+ function toProp(value: unknown): PropValue {
129
+ if (value === null) return null
130
+ switch (typeof value) {
131
+ case 'string':
132
+ case 'number':
133
+ case 'boolean':
134
+ return value
135
+ case 'object': {
136
+ if (Array.isArray(value)) return value.map(toProp)
137
+ const out: Record<string, PropValue> = {}
138
+ for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
139
+ if (inner === undefined) continue
140
+ out[key] = toProp(inner)
141
+ }
142
+ return out
143
+ }
144
+ default:
145
+ throw new Error(
146
+ `lexical-loro: agent-write cannot store a non-JSON prop value (${typeof value})`,
147
+ )
148
+ }
149
+ }
150
+
151
+ /** The document-data props of a Lexical node, mirroring `to-loro.ts`'s `syncProps`. */
152
+ function propsOf(node: LexicalNode): Record<string, PropValue> {
153
+ const json = node.exportJSON() as Record<string, unknown>
154
+ const out: Record<string, PropValue> = {}
155
+ for (const [key, value] of Object.entries(json)) {
156
+ if (NON_PROP_KEYS.has(key) || value === undefined) continue
157
+ out[key] = toProp(value)
158
+ }
159
+ return out
160
+ }
161
+
162
+ /**
163
+ * Project one Lexical element (or element-mirrored leaf) to a {@link TargetElement}.
164
+ *
165
+ * MUST be called inside a Lexical read (`editorState.read(() => …)`), because it
166
+ * reads node content. Uses only `lexical` (a peer dependency) — never
167
+ * `@lexical/markdown`. See {@link targetFromEditorState} for the common wrapper.
168
+ */
169
+ export function projectTarget(node: LexicalNode): TargetElement {
170
+ const props = propsOf(node)
171
+ if (!$isElementNode(node)) return { kind: 'element', type: node.getType(), props, children: [] }
172
+
173
+ const children: TargetChild[] = []
174
+ let run: TextNode[] = []
175
+ const flush = (): void => {
176
+ if (run.length === 0) return
177
+ children.push({
178
+ kind: 'text',
179
+ runs: normalizeRuns(run.map((n) => ({ text: n.getTextContent(), format: n.getFormat() }))),
180
+ })
181
+ run = []
182
+ }
183
+ for (const child of (node as ElementNode).getChildren()) {
184
+ if ($isTextNode(child)) {
185
+ run.push(child)
186
+ } else {
187
+ flush()
188
+ children.push(projectTarget(child))
189
+ }
190
+ }
191
+ flush()
192
+ return { kind: 'element', type: node.getType(), props, children }
193
+ }
194
+
195
+ /**
196
+ * Project the root of an `EditorState` to a {@link TargetElement}, doing the read
197
+ * for you.
198
+ *
199
+ * The caller owns the markdown → editor-state parse (its own headless editor and
200
+ * `@lexical/markdown` transformer set); this projects the parsed tree into the
201
+ * plain, serializable shape {@link reconcileTargetIntoLoro} consumes. Uses only
202
+ * `lexical`.
203
+ */
204
+ export function targetFromEditorState(state: EditorState): TargetElement {
205
+ let target: TargetElement | undefined
206
+ state.read(() => {
207
+ target = projectTarget($getRoot())
208
+ })
209
+ if (target === undefined)
210
+ throw new Error('lexical-loro: agent-write failed to project editor state')
211
+ return target
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Content signatures
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /** Canonical, sort-stable JSON of a prop value, so equal content compares equal. */
219
+ function stableProps(value: PropValue): string {
220
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
221
+ if (Array.isArray(value)) return `[${value.map(stableProps).join(',')}]`
222
+ const keys = Object.keys(value).sort()
223
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableProps(value[k]!)}`).join(',')}}`
224
+ }
225
+
226
+ function runsSignature(runs: readonly TextRun[]): string {
227
+ return `T|${JSON.stringify(normalizeRuns(runs).map((r) => [r.text, r.format]))}`
228
+ }
229
+
230
+ /** The content signature of a target child — identical content ⇒ identical string. */
231
+ function targetSignature(child: TargetChild): string {
232
+ if (child.kind === 'text') return runsSignature(child.runs)
233
+ return `E|${child.type}|${stableProps(child.props)}|[${child.children
234
+ .map(targetSignature)
235
+ .join(',')}]`
236
+ }
237
+
238
+ /** The content signature of an existing carrier — the mirror of {@link targetSignature}. */
239
+ function entrySignature(entry: ChildEntry): string {
240
+ if (entry.kind === 'text' && isTextContainer(entry.container)) {
241
+ return runsSignature(runsFromText(entry.container))
242
+ }
243
+ const container = entry.container as ElementContainer
244
+ const props = elementProps(container).toJSON() as Record<string, PropValue>
245
+ return `E|${elementType(container)}|${stableProps(props)}|[${orderedChildren(container)
246
+ .map(entrySignature)
247
+ .join(',')}]`
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Matching
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /** How a target child was resolved against the existing carriers. */
255
+ type MatchTag = 'exact' | 'reuse' | 'new'
256
+
257
+ interface Match {
258
+ /** The reused carrier, or `null` when the child must be created. */
259
+ readonly entry: ChildEntry | null
260
+ readonly tag: MatchTag
261
+ }
262
+
263
+ /** The Lexical type a target element maps a carrier to (text runs share `'#text'`). */
264
+ function targetKindKey(child: TargetChild): string {
265
+ return child.kind === 'text' ? '#text' : `E:${child.type}`
266
+ }
267
+
268
+ function entryKindKey(entry: ChildEntry): string {
269
+ return entry.kind === 'text' ? '#text' : `E:${elementType(entry.container as ElementContainer)}`
270
+ }
271
+
272
+ /** An existing carrier paired with its rendered index, for the position bias. */
273
+ interface Carrier {
274
+ readonly entry: ChildEntry
275
+ readonly index: number
276
+ }
277
+
278
+ /**
279
+ * Match desired children to existing carriers by CONTENT, in two passes:
280
+ *
281
+ * 1. EXACT signature — an unchanged subtree keeps its whole identity and needs
282
+ * no descent. Within a group of byte-identical carriers the match is biased
283
+ * toward the SAME POSITION: each target claims the same-signature carrier
284
+ * whose rendered index is nearest the target's own index. This is the
285
+ * duplicate-block mitigation — a still-identical sibling keeps its OWN carrier
286
+ * rather than an arbitrary same-content one, so the block the user CHANGED is
287
+ * the one whose carrier is freed for reuse.
288
+ * 2. SAME-KIND fallback — a leftover carrier of the same type/text-kind is
289
+ * reused for a changed block, so a text edit diffs its `LoroText` and an
290
+ * element edit recurses, rather than deleting and recreating.
291
+ *
292
+ * Whatever is still unmatched is created (desired) or deleted (existing).
293
+ *
294
+ * ── The residual, stated honestly ──────────────────────────────────────────
295
+ *
296
+ * Position bias reduces, but CANNOT eliminate, mis-assignment among TRUE
297
+ * duplicates. When the count of a byte-identical group changes (e.g. one of three
298
+ * identical paragraphs is deleted), content is by definition insufficient to say
299
+ * WHICH carrier the user meant to keep, and position proximity is only a guess;
300
+ * the trailing carrier is dropped regardless of intent. `NodeKey` identity — which
301
+ * the agent path does not have, since the agent hands us markdown, not an edited
302
+ * editor state — is the only complete answer. See the duplicate-block tests.
303
+ */
304
+ function matchChildren(current: readonly ChildEntry[], desired: readonly TargetChild[]): Match[] {
305
+ const matched = new Array<Match>(desired.length).fill({ entry: null, tag: 'new' })
306
+ const claimed = new Set<ChildEntry>()
307
+
308
+ // Pass 1: exact content signature, biased toward the same rendered position.
309
+ const bySig = new Map<string, Carrier[]>()
310
+ for (let index = 0; index < current.length; index++) {
311
+ const entry = current[index]!
312
+ const sig = entrySignature(entry)
313
+ const bucket = bySig.get(sig)
314
+ if (bucket === undefined) bySig.set(sig, [{ entry, index }])
315
+ else bucket.push({ entry, index })
316
+ }
317
+ for (let i = 0; i < desired.length; i++) {
318
+ const bucket = bySig.get(targetSignature(desired[i]!))
319
+ if (bucket === undefined || bucket.length === 0) continue
320
+ // Take the same-content carrier nearest this target's index (position bias),
321
+ // ties resolved toward the earlier carrier.
322
+ let best = 0
323
+ let bestDistance = Math.abs(bucket[0]!.index - i)
324
+ for (let b = 1; b < bucket.length; b++) {
325
+ const distance = Math.abs(bucket[b]!.index - i)
326
+ if (distance < bestDistance) {
327
+ best = b
328
+ bestDistance = distance
329
+ }
330
+ }
331
+ const [carrier] = bucket.splice(best, 1)
332
+ matched[i] = { entry: carrier!.entry, tag: 'exact' }
333
+ claimed.add(carrier!.entry)
334
+ }
335
+
336
+ // Pass 2: same-kind fallback for changed blocks.
337
+ const byKind = new Map<string, ChildEntry[]>()
338
+ for (const entry of current) {
339
+ if (claimed.has(entry)) continue
340
+ const key = entryKindKey(entry)
341
+ const bucket = byKind.get(key)
342
+ if (bucket === undefined) byKind.set(key, [entry])
343
+ else bucket.push(entry)
344
+ }
345
+ for (let i = 0; i < desired.length; i++) {
346
+ if (matched[i]!.entry !== null) continue
347
+ const entry = byKind.get(targetKindKey(desired[i]!))?.shift()
348
+ if (entry === undefined) continue
349
+ matched[i] = { entry, tag: 'reuse' }
350
+ claimed.add(entry)
351
+ }
352
+
353
+ return matched
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Placement (fractional-index, batch, never-rebalance)
358
+ // ---------------------------------------------------------------------------
359
+
360
+ interface Context {
361
+ readonly doc: LoroDoc
362
+ readonly jitter: string
363
+ ops: number
364
+ }
365
+
366
+ /**
367
+ * Longest strictly-increasing subsequence indices — the reorder planner, so a
368
+ * move rewrites only genuinely displaced `pos` keys. Mirrors `to-loro.ts`.
369
+ */
370
+ function longestIncreasingSubsequence(values: readonly number[]): number[] {
371
+ if (values.length === 0) return []
372
+ const tails: number[] = []
373
+ const previous = new Array<number>(values.length).fill(-1)
374
+ for (let i = 0; i < values.length; i++) {
375
+ let low = 0
376
+ let high = tails.length
377
+ while (low < high) {
378
+ const mid = (low + high) >> 1
379
+ if (values[tails[mid]!]! < values[i]!) low = mid + 1
380
+ else high = mid
381
+ }
382
+ if (low > 0) previous[i] = tails[low - 1]!
383
+ tails[low] = i
384
+ }
385
+ const out = new Array<number>(tails.length)
386
+ let cursor = tails[tails.length - 1]!
387
+ for (let i = tails.length - 1; i >= 0; i--) {
388
+ out[i] = cursor
389
+ cursor = previous[cursor]!
390
+ }
391
+ return out
392
+ }
393
+
394
+ /** Dense ranks so equal positions share a rank and the strict LIS keeps at most one. */
395
+ function positionRanks(positions: readonly string[]): number[] {
396
+ const distinct = [...new Set(positions)].sort()
397
+ const rank = new Map(distinct.map((pos, index) => [pos, index]))
398
+ return positions.map((pos) => rank.get(pos)!)
399
+ }
400
+
401
+ /**
402
+ * Assign every desired child a position: keep the already-ordered survivors,
403
+ * re-position the rest, create the ones with no carrier — one maximal gap at a
404
+ * time so a multi-block insertion is allocated as ONE batch (never interleaves a
405
+ * concurrent paste, and never rebalances). Mirrors `to-loro.ts`'s `placeChildren`.
406
+ */
407
+ function placeChildren(
408
+ children: ChildrenContainer,
409
+ desired: readonly TargetChild[],
410
+ matched: readonly Match[],
411
+ context: Context,
412
+ ): ChildContainer[] {
413
+ const survivorIndices: number[] = []
414
+ for (let i = 0; i < desired.length; i++) if (matched[i]!.entry !== null) survivorIndices.push(i)
415
+
416
+ const ranks = positionRanks(survivorIndices.map((i) => matched[i]!.entry!.pos))
417
+ const keep = new Set<number>()
418
+ for (const index of longestIncreasingSubsequence(ranks)) keep.add(survivorIndices[index]!)
419
+
420
+ const placed = new Array<ChildContainer>(desired.length)
421
+ for (const index of keep) placed[index] = matched[index]!.entry!.container
422
+
423
+ let i = 0
424
+ while (i < desired.length) {
425
+ if (keep.has(i)) {
426
+ i++
427
+ continue
428
+ }
429
+ let end = i
430
+ while (end < desired.length && !keep.has(end)) end++
431
+ const before = i === 0 ? null : matched[i - 1]!.entry!.pos
432
+ const after = end === desired.length ? null : matched[end]!.entry!.pos
433
+ const keys = allocate(before, after, end - i, context.jitter)
434
+ for (let j = i; j < end; j++) {
435
+ const key = keys[j - i]!
436
+ const entry = matched[j]!.entry
437
+ if (entry === null) {
438
+ placed[j] = createChild(children, desired[j]!, key, context)
439
+ continue
440
+ }
441
+ setChildPosition(entry.carrier, key)
442
+ context.ops++
443
+ placed[j] = entry.container
444
+ }
445
+ i = end
446
+ }
447
+ return placed
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Reconcile
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /**
455
+ * Commit origin stamped on the single agent-write commit.
456
+ *
457
+ * Distinct from `to-loro.ts`'s `OUTBOUND_ORIGIN` so the inbound path can tell an
458
+ * agent write apart from an echo of its own outbound write: `binding.ts` lists
459
+ * this origin among the LOCAL batches the inbound path must still APPLY, which is
460
+ * what bounces an agent write into a live editor (preserving `NodeKey`s and
461
+ * decorator mounts).
462
+ */
463
+ export const AGENT_WRITE_ORIGIN = 'agent-write'
464
+
465
+ /**
466
+ * Reconcile a parsed target tree into an existing Loro document, preserving the
467
+ * `ContainerID`s of unchanged and text-edited blocks, and commit under `origin`.
468
+ *
469
+ * A SIBLING to `syncLexicalToLoro` — it writes Loro directly rather than mirroring
470
+ * a Lexical update, matches by CONTENT rather than by `NodeKey`, and does NOT
471
+ * consult the `ContainerNodeMap` (which self-heals on the inbound bounce).
472
+ *
473
+ * @param doc the shared document.
474
+ * @param root its root element container, as returned by `initDoc`.
475
+ * @param target the desired tree, from {@link targetFromEditorState} /
476
+ * {@link projectTarget} (the caller owns the markdown parse).
477
+ * @param origin the commit origin. Defaults to {@link AGENT_WRITE_ORIGIN}; keep
478
+ * it on the inbound target's applied-local-origins list, or a live
479
+ * editor bound to the same doc will not see the change.
480
+ * @returns the number of Loro write ops emitted. `0` means the target already
481
+ * matched the document — nothing committed, no peer sees an event.
482
+ */
483
+ export function reconcileTargetIntoLoro(
484
+ doc: LoroDoc,
485
+ root: ElementContainer,
486
+ target: TargetElement,
487
+ origin: string = AGENT_WRITE_ORIGIN,
488
+ ): number {
489
+ const context: Context = { doc, jitter: jitterFor(doc.peerId), ops: 0 }
490
+ reconcileElement(root, target, context)
491
+ if (context.ops > 0) doc.commit({ origin })
492
+ return context.ops
493
+ }
494
+
495
+ function reconcileElement(
496
+ container: ElementContainer,
497
+ target: TargetElement,
498
+ context: Context,
499
+ ): void {
500
+ syncProps(container, target.props, context)
501
+ reconcileChildren(container, orderedChildren(container), target.children, context)
502
+ }
503
+
504
+ function syncProps(
505
+ container: ElementContainer,
506
+ target: Readonly<Record<string, PropValue>>,
507
+ context: Context,
508
+ ): void {
509
+ const props = elementProps(container)
510
+ const seen = new Set<string>()
511
+ for (const [key, value] of Object.entries(target)) {
512
+ seen.add(key)
513
+ if (jsonEqual(props.get(key), value)) continue
514
+ props.set(key, value)
515
+ context.ops++
516
+ }
517
+ for (const key of props.keys()) {
518
+ if (seen.has(key)) continue
519
+ props.delete(key)
520
+ context.ops++
521
+ }
522
+ }
523
+
524
+ function reconcileChildren(
525
+ container: ElementContainer,
526
+ current: readonly ChildEntry[],
527
+ desired: readonly TargetChild[],
528
+ context: Context,
529
+ ): void {
530
+ const children = elementChildren(container)
531
+ const matched = matchChildren(current, desired)
532
+ const survivors = new Set(matched.map((m) => m.entry).filter((e): e is ChildEntry => e !== null))
533
+
534
+ // Delete carriers no target child reused. A carrier is addressed by uuid, so
535
+ // deleting one cannot shift another.
536
+ for (const entry of current) {
537
+ if (survivors.has(entry)) continue
538
+ deleteChild(children, entry.uuid)
539
+ context.ops++
540
+ }
541
+
542
+ const placed = placeChildren(children, desired, matched, context)
543
+
544
+ // Descend: exact matches are unchanged (no work); reused carriers get a text
545
+ // diff or a recursive element reconcile.
546
+ for (let i = 0; i < desired.length; i++) {
547
+ const match = matched[i]!
548
+ if (match.tag !== 'reuse') continue
549
+ const child = desired[i]!
550
+ const containerAt = placed[i]!
551
+ if (child.kind === 'text') {
552
+ if (isTextContainer(containerAt)) syncText(containerAt, child.runs, context)
553
+ } else if (!isTextContainer(containerAt)) {
554
+ reconcileElement(containerAt, child, context)
555
+ }
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Fill a text run's `LoroText` to `runs`: diff the characters (no caret — an
561
+ * agent edit has none), then replay the resulting formats as `mark`/`unmark`.
562
+ * Mirrors `to-loro.ts`'s `syncText` minus the cursor bias.
563
+ */
564
+ function syncText(text: LoroText, runs: readonly TextRun[], context: Context): void {
565
+ const target = normalizeRuns(runs.map((r) => ({ text: r.text, format: r.format })))
566
+ const targetString = runsText(target)
567
+ if (text.toString() !== targetString) {
568
+ const diff = diffText(text.toString(), targetString)
569
+ applyTextDiff(text, diff)
570
+ if (diff.remove > 0) context.ops++
571
+ if (diff.insert !== '') context.ops++
572
+ }
573
+ const ops = diffRunFormats(runsFromText(text), target)
574
+ applyMarkOps(text, ops)
575
+ context.ops += ops.length
576
+ }
577
+
578
+ /** Create, attach and fill a brand-new child at position `pos`. */
579
+ function createChild(
580
+ children: ChildrenContainer,
581
+ child: TargetChild,
582
+ pos: string,
583
+ context: Context,
584
+ ): ChildContainer {
585
+ context.ops++
586
+ const uuid = newUuid()
587
+ if (child.kind === 'text') {
588
+ const text = createTextChild(children, uuid, pos)
589
+ syncText(text, child.runs, context)
590
+ return text
591
+ }
592
+ const element = createElementChild(children, uuid, pos, child.type)
593
+ reconcileElement(element, child, context)
594
+ return element
595
+ }
596
+
597
+ // ---------------------------------------------------------------------------
598
+ // Shared
599
+ // ---------------------------------------------------------------------------
600
+
601
+ function jsonEqual(a: unknown, b: unknown): boolean {
602
+ if (a === b) return true
603
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
604
+ if (Array.isArray(a) !== Array.isArray(b)) return false
605
+ if (Array.isArray(a) && Array.isArray(b)) {
606
+ return a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]))
607
+ }
608
+ const left = a as Record<string, unknown>
609
+ const right = b as Record<string, unknown>
610
+ const keys = Object.keys(left)
611
+ if (keys.length !== Object.keys(right).length) return false
612
+ return keys.every((key) => key in right && jsonEqual(left[key], right[key]))
613
+ }