@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,718 @@
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
+ import { $getRoot, $getSelection, $isElementNode, $isRangeSelection, $isTextNode, COLLABORATION_TAG, SKIP_COLLAB_TAG, } from 'lexical';
85
+ import { LoroText } from 'loro-crdt';
86
+ import { ContainerNodeMap } from './mapping.js';
87
+ import { allocate, jitterFor } from './order.js';
88
+ import { containerId, containerIsLive, createElementChild, createTextChild, deleteChild, elementChildren, elementProps, elementType, isTextContainer, newUuid, orderedChildren, setChildPosition, } from './schema.js';
89
+ import { applyMarkOps, applyTextDiff, diffRunFormats, diffText, diffTextWithCursor, normalizeRuns, runsFromText, runsText, } from './text.js';
90
+ // ---------------------------------------------------------------------------
91
+ // Public surface
92
+ // ---------------------------------------------------------------------------
93
+ /** Commit origin stamped on every write this module makes. */
94
+ export const OUTBOUND_ORIGIN = 'lexical-loro';
95
+ /**
96
+ * Update tags that mean "this update did not originate with the local user, do
97
+ * not mirror it".
98
+ *
99
+ * `COLLABORATION_TAG` is our own inbound writeback (echo layer b);
100
+ * `SKIP_COLLAB_TAG` is Lexical's standard opt-out, which hosts use for local-only
101
+ * decoration. `HISTORIC_TAG` is NOT here — see the file header.
102
+ */
103
+ export const OUTBOUND_SKIP_TAGS = [COLLABORATION_TAG, SKIP_COLLAB_TAG];
104
+ /**
105
+ * Mirror one Lexical update into the Loro document and commit it.
106
+ *
107
+ * @returns the number of Loro write operations emitted. `0` means the update
108
+ * was a genuine no-op for the shared document — nothing was committed, so no
109
+ * peer sees an event. Tests assert on this to catch pruning regressions.
110
+ */
111
+ export function syncLexicalToLoro(target, update) {
112
+ const skipTags = target.skipTags ?? OUTBOUND_SKIP_TAGS;
113
+ for (const tag of skipTags) {
114
+ if (update.tags.has(tag))
115
+ return 0;
116
+ }
117
+ // Lexical normalizes (merges/splits) adjacent TextNodes behind our back and
118
+ // reports the casualties here. A merged-away key still in the registry would
119
+ // make a later lookup resolve to a node that no longer exists, so drop them
120
+ // before the walk; the walk re-links every run anchor it visits.
121
+ for (const key of update.normalizedNodes)
122
+ target.mapping.unlinkNode(key);
123
+ // Empty dirty sets mean no dirty walk ran (a wholesale state swap), so they
124
+ // cannot be trusted as a prune gate — see the file header.
125
+ const dirty = update.dirtyElements.size > 0 || update.dirtyLeaves.size > 0
126
+ ? new Set([...update.dirtyElements.keys(), ...update.dirtyLeaves])
127
+ : null;
128
+ return walk(target, update.editorState, update.prevEditorState._nodeMap, dirty);
129
+ }
130
+ /**
131
+ * Fill the Loro document from an editor state with no previous state to diff
132
+ * against — the bootstrapping peer's initial seed.
133
+ *
134
+ * Structurally a full-fidelity diff, so it is also idempotent: seeding a
135
+ * document that already matches emits nothing and returns `0`.
136
+ */
137
+ export function seedLoroFromLexical(target, editorState) {
138
+ return walk(target, editorState, null, null);
139
+ }
140
+ function walk(target, editorState, prevNodeMap, dirty) {
141
+ const context = {
142
+ doc: target.doc,
143
+ mapping: target.mapping,
144
+ prevNodeMap,
145
+ dirty,
146
+ jitter: jitterFor(target.doc.peerId),
147
+ caret: null,
148
+ ops: 0,
149
+ removed: false,
150
+ };
151
+ editorState.read(() => {
152
+ const root = $getRoot();
153
+ context.caret = readCaret();
154
+ target.mapping.link(containerId(target.root), root.getKey());
155
+ syncElement(target.root, root, context);
156
+ });
157
+ if (context.removed) {
158
+ const nodeMap = editorState._nodeMap;
159
+ target.mapping.sweep({
160
+ hasContainer: (id) => containerIsLive(target.doc, id),
161
+ hasNode: (key) => nodeMap.has(key),
162
+ });
163
+ }
164
+ if (context.ops > 0)
165
+ target.doc.commit({ origin: target.origin ?? OUTBOUND_ORIGIN });
166
+ return context.ops;
167
+ }
168
+ /** The collapsed-or-focus caret, if it sits in a text node. */
169
+ function readCaret() {
170
+ const selection = $getSelection();
171
+ if (!$isRangeSelection(selection))
172
+ return null;
173
+ const focus = selection.focus;
174
+ if (focus.type !== 'text')
175
+ return null;
176
+ return { key: focus.key, offset: focus.offset };
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // Elements
180
+ // ---------------------------------------------------------------------------
181
+ /** Lexical node props that are structure or bookkeeping, never document data. */
182
+ const NON_PROP_KEYS = new Set(['type', 'version', 'children']);
183
+ /**
184
+ * Mirror one Lexical node into its element container.
185
+ *
186
+ * @param fresh whether `container` was JUST created and is therefore empty.
187
+ *
188
+ * ── Why `fresh` cannot be inferred ─────────────────────────────────────────
189
+ *
190
+ * The `unchanged` fast path below rests on `prevNodeMap`, which is a fact about
191
+ * the LEXICAL side only: it says this node object was not rewritten in this
192
+ * update. It says NOTHING about whether the Loro container already mirrors it.
193
+ *
194
+ * `insertChild` builds a brand-new, EMPTY container and populates it through
195
+ * this function — and it does so precisely in the cases where Lexical did not
196
+ * touch the node (a remote peer deleted the container, or it was tombstoned by a
197
+ * concurrent delete+move, so the mirror must be rebuilt beneath an untouched
198
+ * subtree). There, `unchanged` is true and skipping `syncProps` leaves the new
199
+ * container's props map PERMANENTLY EMPTY: every peer's document agrees, so no
200
+ * later batch repairs it, yet each peer's editor fills the missing keys with
201
+ * whatever its local node happened to hold — identical documents, divergent
202
+ * editors. Measured; see `test/convergence-attack.test.ts`.
203
+ *
204
+ * The registry cannot stand in for this flag either: `insertChild` links the new
205
+ * ContainerID to the node before calling in, so a mapping check would report
206
+ * "already mirrored" for exactly the containers that are not.
207
+ */
208
+ function syncElement(container, node, context, fresh = false) {
209
+ const unchanged = !fresh && context.prevNodeMap !== null && context.prevNodeMap.get(node.getKey()) === node;
210
+ if (!unchanged)
211
+ syncProps(container, node, context);
212
+ if (!$isElementNode(node)) {
213
+ // A leaf mirrored as an element (LineBreakNode, LLuiDecoratorNode): its
214
+ // payload lives entirely in `props`, and its `children` list stays empty.
215
+ return;
216
+ }
217
+ const current = orderedChildren(container);
218
+ const desired = describeChildren(node);
219
+ // `unchanged` proves the child list itself did not change, so the expensive
220
+ // reconciliation (match / delete / re-position / insert) can be skipped
221
+ // outright — but only when the dirty sets are trustworthy enough to tell us
222
+ // which children still need descending into.
223
+ if (unchanged && context.dirty !== null && current.length === desired.length) {
224
+ if (descendUnchanged(current, desired, context))
225
+ return;
226
+ }
227
+ reconcileChildren(container, current, desired, context);
228
+ }
229
+ function syncProps(container, node, context) {
230
+ const props = elementProps(container);
231
+ const json = node.exportJSON();
232
+ const seen = new Set();
233
+ for (const [key, value] of Object.entries(json)) {
234
+ if (NON_PROP_KEYS.has(key) || value === undefined)
235
+ continue;
236
+ seen.add(key);
237
+ const next = toPropValue(key, value, node);
238
+ if (jsonEqual(props.get(key), next))
239
+ continue;
240
+ props.set(key, next);
241
+ context.ops++;
242
+ }
243
+ for (const key of props.keys()) {
244
+ if (seen.has(key))
245
+ continue;
246
+ props.delete(key);
247
+ context.ops++;
248
+ }
249
+ }
250
+ /**
251
+ * Narrow an `exportJSON` value to a storable {@link PropValue}.
252
+ *
253
+ * Lexical guarantees `exportJSON` is JSON-serializable, so anything that is not
254
+ * is a bug in a custom node — surfaced loudly here rather than as a document
255
+ * that silently fails to replicate.
256
+ */
257
+ function toPropValue(key, value, node) {
258
+ if (value === null)
259
+ return null;
260
+ switch (typeof value) {
261
+ case 'string':
262
+ case 'number':
263
+ case 'boolean':
264
+ return value;
265
+ case 'object': {
266
+ if (Array.isArray(value))
267
+ return value.map((item, i) => toPropValue(`${key}[${i}]`, item, node));
268
+ const out = {};
269
+ for (const [innerKey, innerValue] of Object.entries(value)) {
270
+ if (innerValue === undefined)
271
+ continue;
272
+ out[innerKey] = toPropValue(`${key}.${innerKey}`, innerValue, node);
273
+ }
274
+ return out;
275
+ }
276
+ default:
277
+ throw new Error(`lexical-loro: ${node.getType()}.exportJSON() returned a non-JSON value at '${key}' ` +
278
+ `(${typeof value}) — node props must be JSON-serializable to replicate`);
279
+ }
280
+ }
281
+ function jsonEqual(a, b) {
282
+ if (a === b)
283
+ return true;
284
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object')
285
+ return false;
286
+ if (Array.isArray(a) !== Array.isArray(b))
287
+ return false;
288
+ if (Array.isArray(a) && Array.isArray(b)) {
289
+ return a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]));
290
+ }
291
+ const left = a;
292
+ const right = b;
293
+ const keys = Object.keys(left);
294
+ if (keys.length !== Object.keys(right).length)
295
+ return false;
296
+ return keys.every((key) => key in right && jsonEqual(left[key], right[key]));
297
+ }
298
+ function describeChildren(node) {
299
+ const out = [];
300
+ let run = [];
301
+ const flush = () => {
302
+ if (run.length === 0)
303
+ return;
304
+ out.push({ kind: 'text', nodes: run, anchor: run[0].getKey() });
305
+ run = [];
306
+ };
307
+ for (const child of node.getChildren()) {
308
+ if ($isTextNode(child)) {
309
+ run.push(child);
310
+ }
311
+ else {
312
+ flush();
313
+ out.push({ kind: 'element', node: child, key: child.getKey() });
314
+ }
315
+ }
316
+ flush();
317
+ return out;
318
+ }
319
+ /** The registry key a desired child is addressed by. */
320
+ function desiredKey(child) {
321
+ return child.kind === 'text' ? child.anchor : child.key;
322
+ }
323
+ // ---------------------------------------------------------------------------
324
+ // Children: the unchanged-list fast path
325
+ // ---------------------------------------------------------------------------
326
+ /**
327
+ * Descend into a child list that is known not to have changed shape, visiting
328
+ * only what the dirty sets say could have changed.
329
+ *
330
+ * This is the typing path: a keystroke leaves every ancestor object identical,
331
+ * so the whole reconciliation is skipped at every level and the walk costs
332
+ * O(tree depth) rather than O(document).
333
+ *
334
+ * ── What "unchanged" does and does NOT prove ───────────────────────────────
335
+ *
336
+ * `unchanged` is a fact about the LEXICAL side only: this element's own child
337
+ * list did not change between the two editor states. It says nothing about the
338
+ * LORO side, which a peer can have restructured concurrently. So the fast path
339
+ * must still verify, per child, that the container sitting at that index is the
340
+ * one the registry already associates with this node — matching by POSITION
341
+ * alone is only sound in a single-writer world.
342
+ *
343
+ * Skipping that check writes one node's content into a different node's
344
+ * container. A randomized three-peer test caught the sharpest form: a remote
345
+ * peer deleted one block and inserted another, leaving the child COUNT equal, so
346
+ * every length check still passed while every index had shifted — and the local
347
+ * peer's next keystroke was applied to a container the remote peer had deleted,
348
+ * which loro-crdt rejects outright. Silent cross-writes would have been the
349
+ * quieter, worse outcome.
350
+ */
351
+ function descendUnchanged(current, desired, context) {
352
+ for (let i = 0; i < desired.length; i++) {
353
+ const child = desired[i];
354
+ const entry = current[i];
355
+ // The mirror disagrees with the fast path's premise; hand back to the
356
+ // identity-matching reconciliation, which handles the remote change.
357
+ if (!containerMatches(entry.container, child, context))
358
+ return false;
359
+ if (child.kind === 'text') {
360
+ if (entry.kind !== 'text' || !isTextContainer(entry.container))
361
+ return false;
362
+ // The parent object is unchanged, so the run's COMPOSITION is unchanged;
363
+ // per-node reference equality therefore proves the run is untouched.
364
+ if (child.nodes.every((n) => context.prevNodeMap?.get(n.getKey()) === n))
365
+ continue;
366
+ context.mapping.link(containerId(entry.container), child.anchor);
367
+ syncText(entry.container, child, context);
368
+ continue;
369
+ }
370
+ if (entry.kind !== 'element' || isTextContainer(entry.container))
371
+ return false;
372
+ if (context.dirty !== null && !context.dirty.has(child.key))
373
+ continue;
374
+ context.mapping.link(containerId(entry.container), child.key);
375
+ syncElement(entry.container, child.node, context);
376
+ }
377
+ return true;
378
+ }
379
+ /**
380
+ * Whether `container` is the one the registry already addresses for `child`.
381
+ *
382
+ * An UNMAPPED child is a mismatch, not a match: the registry is the only
383
+ * evidence that this position still means the same thing to both sides, and
384
+ * guessing in its absence is what the fast path must never do.
385
+ *
386
+ * ── There is no tombstone check here any more, and none is needed ──────────
387
+ *
388
+ * Under the `LoroMovableList` schema this also had to reject TOMBSTONES:
389
+ * containers still listed but deleted, produced when the list merged a
390
+ * concurrent DELETE and MOVE of the same element by keeping the moved element
391
+ * while the delete op marked the container deleted. loro-crdt then refused every
392
+ * write to an entry `toArray()` still reported.
393
+ *
394
+ * The carrier schema cannot reach that state. A delete removes the carrier's key
395
+ * from the `children` map outright, and a concurrent `pos` write does not
396
+ * resurrect it — the key is absent from `keys()` on EVERY peer, symmetrically,
397
+ * so there is nothing listed to write to. That is pinned by a test in
398
+ * `test/schema.test.ts`; it is the reason `isDeleted()` no longer appears on any
399
+ * outbound path.
400
+ */
401
+ function containerMatches(container, child, context) {
402
+ const mapped = context.mapping.containerId(desiredKey(child));
403
+ return mapped !== undefined && mapped === containerId(container);
404
+ }
405
+ // ---------------------------------------------------------------------------
406
+ // Children: full reconciliation
407
+ // ---------------------------------------------------------------------------
408
+ /**
409
+ * Reconcile a child list: match survivors, delete the rest, RE-POSITION what
410
+ * moved, create what is new, then descend.
411
+ *
412
+ * Matching is by IDENTITY, not by shape: an element child is matched through the
413
+ * registry (`NodeKey` → `ContainerID`), which is what lets a drag-reorder be
414
+ * recognised as a permutation rather than as a delete plus an insert. Text runs
415
+ * have no durable identity of their own (Lexical splits and merges them freely),
416
+ * so they are matched by ORDINAL among the text children — which keeps a run's
417
+ * `LoroText` alive across a split, a merge, or a re-typed anchor, and so keeps a
418
+ * peer's concurrent insertion into it.
419
+ *
420
+ * ── What replaced the movable-list machinery ───────────────────────────────
421
+ *
422
+ * This used to plan `LoroMovableList#move` ops, which meant simulating the
423
+ * list's evolving indices (`live`), re-indexing survivors against the compacted
424
+ * list, and converting each desired position into a from/to index pair. All of
425
+ * that is gone: a position is now an ABSOLUTE, order-carrying string, so a child
426
+ * is placed by writing one register and nothing else shifts.
427
+ *
428
+ * The longest-increasing-subsequence step SURVIVES, and deleting it would be a
429
+ * real regression. Without it, every reorder would rewrite every sibling's
430
+ * `pos`: O(n) ops instead of one, and — because a rewrite of the whole list is
431
+ * exactly the rebalance that constraint 3 in `order.ts` forbids — it would
432
+ * silently relocate any concurrent insert from another peer. With it, the
433
+ * longest already-correctly-ordered run of survivors keeps its positions and
434
+ * only genuinely displaced children are written.
435
+ */
436
+ function reconcileChildren(element, current, desired, context) {
437
+ const children = elementChildren(element);
438
+ const matched = matchChildren(current, desired, context);
439
+ const survivors = new Set(matched.filter((entry) => entry !== null));
440
+ // A LoroText that is losing its last content is EMPTIED rather than deleted
441
+ // when it would leave the element with no children at all. Deleting it would
442
+ // discard a peer's concurrent insertion into that exact container; keeping an
443
+ // empty run costs one dormant container and projects to zero text nodes.
444
+ if (desired.length === 0 && current.length === 1 && current[0].kind === 'text') {
445
+ const text = current[0].container;
446
+ if (isTextContainer(text)) {
447
+ context.mapping.unlinkContainer(containerId(text));
448
+ if (text.length > 0) {
449
+ text.delete(0, text.length);
450
+ context.ops++;
451
+ }
452
+ return;
453
+ }
454
+ }
455
+ // 1. Delete unmatched survivors. Order is irrelevant — a carrier is addressed
456
+ // by uuid, so removing one cannot shift another.
457
+ for (const entry of current) {
458
+ if (survivors.has(entry))
459
+ continue;
460
+ context.mapping.unlinkContainer(containerId(entry.container));
461
+ deleteChild(children, entry.uuid);
462
+ context.ops++;
463
+ context.removed = true;
464
+ }
465
+ // 2. Place everything: keep the already-ordered survivors, re-position the
466
+ // rest, and create what has no match.
467
+ const placed = placeChildren(children, desired, matched, context);
468
+ // 3. Descend. Newly created children were filled by `createChild`.
469
+ for (let i = 0; i < desired.length; i++) {
470
+ if (matched[i] === null)
471
+ continue;
472
+ const child = desired[i];
473
+ const container = placed[i];
474
+ context.mapping.link(containerId(container), desiredKey(child));
475
+ if (child.kind === 'text') {
476
+ if (isTextContainer(container))
477
+ syncText(container, child, context);
478
+ }
479
+ else if (!isTextContainer(container)) {
480
+ syncElement(container, child.node, context);
481
+ }
482
+ }
483
+ }
484
+ /**
485
+ * Assign every desired child a position, creating the ones that have no
486
+ * surviving carrier, and return the container now sitting at each index.
487
+ *
488
+ * The survivors whose positions are already in the desired relative order are
489
+ * left completely untouched (see the LIS note on {@link reconcileChildren});
490
+ * every other child is written into the gap between its nearest kept
491
+ * neighbours, one maximal gap at a time so that a multi-child insertion is
492
+ * allocated as ONE batch. Batching is what gives the run a peer-private
493
+ * sub-interval and stops two concurrent pastes from interleaving — constraint 1
494
+ * in `order.ts`.
495
+ *
496
+ * The kept positions are STRICTLY increasing (the subsequence is strict), so
497
+ * every interval handed to `allocate` is non-degenerate and the equal-position
498
+ * hazard of constraint 4 cannot arise on this path.
499
+ */
500
+ function placeChildren(children, desired, matched, context) {
501
+ const survivorIndices = [];
502
+ for (let i = 0; i < desired.length; i++)
503
+ if (matched[i] !== null)
504
+ survivorIndices.push(i);
505
+ // LIS works on ranks so that two survivors sharing a position can never both
506
+ // be kept — which is what keeps the intervals below strictly ordered.
507
+ const ranks = positionRanks(survivorIndices.map((i) => matched[i].pos));
508
+ const keep = new Set();
509
+ for (const index of longestIncreasingSubsequence(ranks))
510
+ keep.add(survivorIndices[index]);
511
+ const placed = new Array(desired.length);
512
+ for (const index of keep)
513
+ placed[index] = matched[index].container;
514
+ let i = 0;
515
+ while (i < desired.length) {
516
+ if (keep.has(i)) {
517
+ i++;
518
+ continue;
519
+ }
520
+ let end = i;
521
+ while (end < desired.length && !keep.has(end))
522
+ end++;
523
+ // `i - 1` and `end` are kept (or out of range), so both bounds are the
524
+ // positions of untouched survivors and `before < after` strictly.
525
+ const before = i === 0 ? null : matched[i - 1].pos;
526
+ const after = end === desired.length ? null : matched[end].pos;
527
+ const keys = allocate(before, after, end - i, context.jitter);
528
+ for (let j = i; j < end; j++) {
529
+ const key = keys[j - i];
530
+ const entry = matched[j] ?? null;
531
+ if (entry === null) {
532
+ placed[j] = createChild(children, desired[j], key, context);
533
+ continue;
534
+ }
535
+ setChildPosition(entry.carrier, key);
536
+ context.ops++;
537
+ placed[j] = entry.container;
538
+ }
539
+ i = end;
540
+ }
541
+ return placed;
542
+ }
543
+ /**
544
+ * Dense ranks for a list of positions: equal positions share a rank.
545
+ *
546
+ * Sharing a rank is the point. `longestIncreasingSubsequence` is STRICT, so two
547
+ * children that a concurrent insert left holding the same position can never
548
+ * both be kept, and every interval derived from the kept set stays usable.
549
+ */
550
+ function positionRanks(positions) {
551
+ const distinct = [...new Set(positions)].sort();
552
+ const rank = new Map(distinct.map((pos, index) => [pos, index]));
553
+ return positions.map((pos) => rank.get(pos));
554
+ }
555
+ /**
556
+ * For each desired child, the existing carrier it reuses, or `null` when it
557
+ * must be created.
558
+ */
559
+ function matchChildren(current, desired, context) {
560
+ const byId = new Map();
561
+ const textEntries = [];
562
+ for (const entry of current) {
563
+ if (entry.kind === 'text')
564
+ textEntries.push(entry);
565
+ else
566
+ byId.set(containerId(entry.container), entry);
567
+ }
568
+ const matched = new Array(desired.length).fill(null);
569
+ const claimed = new Set();
570
+ let nextText = 0;
571
+ for (let i = 0; i < desired.length; i++) {
572
+ const child = desired[i];
573
+ if (child.kind === 'text') {
574
+ // Ordinal matching among text children: runs have no stable identity, but
575
+ // their POSITION among the element's text runs is stable enough to keep
576
+ // the container (and therefore concurrent remote edits to it) alive.
577
+ const entry = textEntries[nextText];
578
+ if (entry === undefined)
579
+ continue;
580
+ nextText++;
581
+ matched[i] = entry;
582
+ claimed.add(entry);
583
+ continue;
584
+ }
585
+ const id = context.mapping.containerId(child.key);
586
+ if (id === undefined)
587
+ continue;
588
+ const entry = byId.get(id);
589
+ if (entry === undefined || claimed.has(entry))
590
+ continue;
591
+ // A container that no longer mirrors this node's type (Lexical replaced the
592
+ // node) must be rebuilt, not reused.
593
+ if (isTextContainer(entry.container))
594
+ continue;
595
+ if (elementType(entry.container) !== child.node.getType())
596
+ continue;
597
+ matched[i] = entry;
598
+ claimed.add(entry);
599
+ }
600
+ return matched;
601
+ }
602
+ /**
603
+ * The indices of a longest strictly-increasing subsequence of `values`.
604
+ *
605
+ * Patience sorting with a predecessor chain: O(n log n). Exported because it is
606
+ * the part of the reorder planner worth testing in isolation — the number of
607
+ * `pos` writes a drag-reorder costs is exactly `matched.length - lis.length`.
608
+ */
609
+ export function longestIncreasingSubsequence(values) {
610
+ if (values.length === 0)
611
+ return [];
612
+ // `tails[l]` is the index of the smallest tail among increasing
613
+ // subsequences of length l+1; `previous[i]` chains the reconstruction.
614
+ const tails = [];
615
+ const previous = new Array(values.length).fill(-1);
616
+ for (let i = 0; i < values.length; i++) {
617
+ let low = 0;
618
+ let high = tails.length;
619
+ while (low < high) {
620
+ const mid = (low + high) >> 1;
621
+ if (values[tails[mid]] < values[i])
622
+ low = mid + 1;
623
+ else
624
+ high = mid;
625
+ }
626
+ if (low > 0)
627
+ previous[i] = tails[low - 1];
628
+ tails[low] = i;
629
+ }
630
+ const out = new Array(tails.length);
631
+ let cursor = tails[tails.length - 1];
632
+ for (let i = tails.length - 1; i >= 0; i--) {
633
+ out[i] = cursor;
634
+ cursor = previous[cursor];
635
+ }
636
+ return out;
637
+ }
638
+ /**
639
+ * Create, attach and fill a brand-new child at position `pos`.
640
+ *
641
+ * The uuid is minted with `crypto.randomUUID`, which is REQUIRED rather than
642
+ * merely convenient: two peers minting the same uuid would collide on one slot
643
+ * of the `children` map, and its last-writer-wins would silently discard a whole
644
+ * block. See {@link newUuid}.
645
+ */
646
+ function createChild(children, child, pos, context) {
647
+ context.ops++;
648
+ const uuid = newUuid();
649
+ if (child.kind === 'text') {
650
+ // The carrier attaches the LoroText as it creates it, so the text is a live
651
+ // document container from the start — marks are document operations, and a
652
+ // detached LoroText cannot carry them.
653
+ const text = createTextChild(children, uuid, pos);
654
+ context.mapping.link(containerId(text), child.anchor);
655
+ syncText(text, child, context);
656
+ return text;
657
+ }
658
+ const element = createElementChild(children, uuid, pos, child.node.getType());
659
+ context.mapping.link(containerId(element), child.key);
660
+ // `fresh`: this container is empty, so the Lexical-side `unchanged` fast path
661
+ // must not be allowed to skip populating it. See {@link syncElement}.
662
+ syncElement(element, child.node, context, true);
663
+ return element;
664
+ }
665
+ // ---------------------------------------------------------------------------
666
+ // Text
667
+ // ---------------------------------------------------------------------------
668
+ /**
669
+ * Replay a text run's RESULTING state into its `LoroText`: reconcile the
670
+ * characters with a cursor-biased single-region diff, then replay the formats
671
+ * Lexical actually produced as explicit per-format `mark`/`unmark` ops.
672
+ *
673
+ * The second step is not redundant with the first. Loro's `expand` rule cannot
674
+ * reproduce Lexical's boundary behaviour for any table, uniform or per-format —
675
+ * see `text.ts` and the 51 tests in `test/expand-semantics.test.ts`. Replaying
676
+ * the resulting node state is what makes typing at the start of a formatted
677
+ * run, and toggling a format at a collapsed caret, come out right.
678
+ */
679
+ function syncText(text, child, context) {
680
+ const target = normalizeRuns(child.nodes.map((node) => ({ text: node.getTextContent(), format: node.getFormat() })));
681
+ const targetString = runsText(target);
682
+ const currentString = text.toString();
683
+ if (currentString !== targetString) {
684
+ const cursor = runCaret(child, context);
685
+ const diff = cursor === null
686
+ ? diffText(currentString, targetString)
687
+ : diffTextWithCursor(currentString, targetString, cursor);
688
+ applyTextDiff(text, diff);
689
+ if (diff.remove > 0)
690
+ context.ops++;
691
+ if (diff.insert !== '')
692
+ context.ops++;
693
+ }
694
+ const ops = diffRunFormats(runsFromText(text), target);
695
+ applyMarkOps(text, ops);
696
+ context.ops += ops.length;
697
+ }
698
+ /**
699
+ * The caret's offset within a run, in UTF-16 code units, or `null` when the
700
+ * caret is elsewhere.
701
+ *
702
+ * Without it a repeated-character insertion is placed at its leftmost possible
703
+ * position instead of where the user typed, which drags every remote caret and
704
+ * (through `expand`) can attach the wrong formatting.
705
+ */
706
+ function runCaret(child, context) {
707
+ const caret = context.caret;
708
+ if (caret === null)
709
+ return null;
710
+ let offset = 0;
711
+ for (const node of child.nodes) {
712
+ if (node.getKey() === caret.key)
713
+ return offset + caret.offset;
714
+ offset += node.getTextContentSize();
715
+ }
716
+ return null;
717
+ }
718
+ //# sourceMappingURL=to-loro.js.map