@llui/lexical-loro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -0
  3. package/dist/agent-write.d.ts +123 -0
  4. package/dist/agent-write.d.ts.map +1 -0
  5. package/dist/agent-write.js +499 -0
  6. package/dist/agent-write.js.map +1 -0
  7. package/dist/binding.d.ts +122 -0
  8. package/dist/binding.d.ts.map +1 -0
  9. package/dist/binding.js +114 -0
  10. package/dist/binding.js.map +1 -0
  11. package/dist/index.d.ts +69 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +69 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/mapping.d.ts +115 -0
  16. package/dist/mapping.d.ts.map +1 -0
  17. package/dist/mapping.js +181 -0
  18. package/dist/mapping.js.map +1 -0
  19. package/dist/order.d.ts +124 -0
  20. package/dist/order.d.ts.map +1 -0
  21. package/dist/order.js +187 -0
  22. package/dist/order.js.map +1 -0
  23. package/dist/schema.d.ts +343 -0
  24. package/dist/schema.d.ts.map +1 -0
  25. package/dist/schema.js +363 -0
  26. package/dist/schema.js.map +1 -0
  27. package/dist/seed.d.ts +72 -0
  28. package/dist/seed.d.ts.map +1 -0
  29. package/dist/seed.js +72 -0
  30. package/dist/seed.js.map +1 -0
  31. package/dist/text.d.ts +167 -0
  32. package/dist/text.d.ts.map +1 -0
  33. package/dist/text.js +289 -0
  34. package/dist/text.js.map +1 -0
  35. package/dist/to-lexical.d.ts +119 -0
  36. package/dist/to-lexical.d.ts.map +1 -0
  37. package/dist/to-lexical.js +636 -0
  38. package/dist/to-lexical.js.map +1 -0
  39. package/dist/to-loro.d.ts +154 -0
  40. package/dist/to-loro.d.ts.map +1 -0
  41. package/dist/to-loro.js +718 -0
  42. package/dist/to-loro.js.map +1 -0
  43. package/dist/undo.d.ts +94 -0
  44. package/dist/undo.d.ts.map +1 -0
  45. package/dist/undo.js +200 -0
  46. package/dist/undo.js.map +1 -0
  47. package/package.json +64 -0
  48. package/src/agent-write.ts +613 -0
  49. package/src/binding.ts +185 -0
  50. package/src/index.ts +176 -0
  51. package/src/mapping.ts +206 -0
  52. package/src/order.ts +205 -0
  53. package/src/schema.ts +509 -0
  54. package/src/seed.ts +112 -0
  55. package/src/text.ts +357 -0
  56. package/src/to-lexical.ts +792 -0
  57. package/src/to-loro.ts +914 -0
  58. package/src/undo.ts +269 -0
package/src/schema.ts ADDED
@@ -0,0 +1,509 @@
1
+ /**
2
+ * The Loro container schema that mirrors a Lexical `EditorState`.
3
+ *
4
+ * ── Shape ──────────────────────────────────────────────────────────────────
5
+ *
6
+ * doc.getMap('root') // ElementContainer
7
+ * 'type' -> string // node.getType()
8
+ * 'props' -> LoroMap<Record<string, PropValue>> // node props, LWW per key
9
+ * 'children' -> LoroMap<uuid, ChildCarrier> // UNORDERED; see below
10
+ *
11
+ * Every child is a CARRIER map, keyed in `children` by its own uuid:
12
+ *
13
+ * ChildCarrier = LoroMap {
14
+ * 'uuid' -> string // its own key, duplicated for reading
15
+ * 'pos' -> string // fractional index; see `order.ts`
16
+ * 'kind' -> 'element' | 'text'
17
+ * // kind === 'element' — the carrier IS the child's ElementContainer:
18
+ * 'type' -> string
19
+ * 'props' -> LoroMap
20
+ * 'children' -> LoroMap<uuid, ChildCarrier>
21
+ * // kind === 'text':
22
+ * 'text' -> LoroText // created ONCE, never recreated
23
+ * }
24
+ *
25
+ * Sibling order is NOT a list position. It is `sort by (pos, uuid)` — a pure
26
+ * function of replicated state, and therefore commutative by construction.
27
+ *
28
+ * ── Why fractional indexing, not a LoroMovableList ─────────────────────────
29
+ *
30
+ * This schema previously held children in a `LoroMovableList`, whose `move` op
31
+ * preserves container identity. That property is load-bearing here: ContainerID
32
+ * is our stable address for a `NodeKey` (see `mapping.ts`), so a child that is
33
+ * deleted and recreated instead of moved forces its whole subtree to be rebuilt
34
+ * with fresh NodeKeys — which DISPOSES every mounted `LLuiDecoratorNode` sub-app
35
+ * in it (`packages/lexical/src/decorator.ts` disposes on the 'destroyed'
36
+ * mutation). Block drag-reorder is a real operation in this editor.
37
+ *
38
+ * `LoroMovableList` was abandoned because loro-crdt 1.13.7 — the LATEST release,
39
+ * with no upgrade path — has TWO defects in it, both pinned by
40
+ * `test/loro-upstream.test.ts`:
41
+ *
42
+ * 1. A WASM PANIC. Uncatchable from JavaScript, and it leaves the document in
43
+ * an unspecified state, so there is no recovery path to write.
44
+ * 2. A SILENT CONVERGENCE FAILURE — two peers accept the same updates and
45
+ * render different documents, with nothing to detect it from.
46
+ *
47
+ * A plain `LoroList` plus uuid identity was evaluated and REJECTED: without a
48
+ * move op, a reorder is delete+recreate, which silently LOSES a peer's
49
+ * concurrent edit into the moved subtree. Convergent and unrepairable — strictly
50
+ * worse than the defects it was meant to route around.
51
+ *
52
+ * Fractional indexing keeps the property that mattered. A same-parent move is
53
+ * ONE last-writer-wins register write to `pos` (~87 bytes regardless of subtree
54
+ * size): no container is deleted, none is created, and every `ContainerID` —
55
+ * including every `LoroText` — is INVARIANT across reorder, text edits, and
56
+ * parent moves. So a concurrent edit into a moved subtree survives, and
57
+ * `mapping.ts` needs no notion of any of this.
58
+ *
59
+ * ── What this deliberately does NOT claim ──────────────────────────────────
60
+ *
61
+ * Three documented limits. Do not read the paragraph above as covering them:
62
+ *
63
+ * - CROSS-PARENT moves are still delete+recreate, and DO lose a concurrent edit
64
+ * into the moved subtree. The "concurrent edit preserved" property is
65
+ * SAME-PARENT ONLY. (This is not a regression: `LoroMovableList#move` is also
66
+ * confined to a single list.)
67
+ * - DELETE BEATS MOVE. A delete concurrent with a move wins and the block
68
+ * vanishes, in both delivery orders. Chosen deliberately: `LoroMovableList`
69
+ * does the opposite — it RESURRECTS a deliberately deleted block — and pays
70
+ * for it with defect 1 above. A tombstone mitigation was tried and REFUTED BY
71
+ * TEST (the delete flag and `pos` are different map keys, so both survive and
72
+ * nothing is resurrected). Do not re-add tombstones.
73
+ * - TWO CONCURRENT SPLITS of the same text run converge on a child COUNT, not
74
+ * on sensible text: ordinal text matching mints a fresh tail container on each
75
+ * peer, so the merged document duplicates a fragment. Pre-existing — the
76
+ * `LoroMovableList` binding produced the same duplication for the same history
77
+ * — and out of scope for the ordering model. Verified against real Lexical;
78
+ * see `test/convergence-attack.test.ts` for the concurrent-text histories.
79
+ *
80
+ * ── Why one LoroText per RUN, not per TextNode ─────────────────────────────
81
+ *
82
+ * A RUN is a MAXIMAL GROUP OF ADJACENT `TextNode`s — not one TextNode. That
83
+ * distinction is the schema's text unit and it is doing real work: Lexical
84
+ * splits and merges adjacent TextNodes freely (normalization), and a node
85
+ * boundary is a rendering detail, not user intent. Mirroring nodes 1:1 would
86
+ * make every normalization a structural CRDT edit and would let two peers'
87
+ * different-but-equivalent splits conflict.
88
+ *
89
+ * The most common "split" is not a structural change at all: bolding a middle
90
+ * sub-range makes Lexical split one TextNode into THREE, but all three are
91
+ * adjacent, so they coalesce back to ONE desired child. The carrier count and
92
+ * the `LoroText` ContainerID are unchanged and the format lands as a mark inside
93
+ * the existing text. Verified against real Lexical 0.48 by the D1 case in
94
+ * `test/to-loro.test.ts` ('run identity under Lexical normalization').
95
+ *
96
+ * ── Index units ────────────────────────────────────────────────────────────
97
+ *
98
+ * loro-crdt's JavaScript binding addresses `LoroText` in UTF-16 code units —
99
+ * the same unit as JavaScript string indices and therefore the same unit as
100
+ * Lexical offsets. NO conversion is required at this seam. (`convertPos` exists
101
+ * for unicode/utf8 interop; we never need it.) This is pinned by a test in
102
+ * `test/schema.test.ts` because it is an assumption the whole binding rests on
103
+ * and it is not stated in loro-crdt's type declarations.
104
+ */
105
+
106
+ import { LoroMap, LoroText, getType } from 'loro-crdt'
107
+ import type { Container, ContainerID, LoroDoc } from 'loro-crdt'
108
+
109
+ import { comparePositions } from './order.js'
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Container keys
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** Root map name on the `LoroDoc`. Mirrors Lexical's `RootNode`. */
116
+ export const ROOT_CONTAINER = 'root'
117
+
118
+ /** Key on an element map holding the Lexical node type (`node.getType()`). */
119
+ export const KEY_TYPE = 'type'
120
+
121
+ /** Key on an element map holding the scalar-prop sub-map. */
122
+ export const KEY_PROPS = 'props'
123
+
124
+ /** Key on an element map holding the child-carrier map. */
125
+ export const KEY_CHILDREN = 'children'
126
+
127
+ /**
128
+ * Key on a child carrier holding its own uuid.
129
+ *
130
+ * Duplicated from the `children` map key so a carrier read in isolation still
131
+ * knows its identity, and so the ordering tiebreak needs no parent lookup.
132
+ */
133
+ export const KEY_UUID = 'uuid'
134
+
135
+ /** Key on a child carrier holding its fractional index. See `order.ts`. */
136
+ export const KEY_POS = 'pos'
137
+
138
+ /**
139
+ * Key on a child carrier discriminating an element from a text run.
140
+ *
141
+ * Explicit rather than inferred from which other keys are present: a remote
142
+ * update can be applied partially, and a carrier whose `type` has not landed yet
143
+ * must be SKIPPED by the projection, not mistaken for a text run.
144
+ */
145
+ export const KEY_KIND = 'kind'
146
+
147
+ /** Key on a TEXT carrier holding its `LoroText`. */
148
+ export const KEY_TEXT = 'text'
149
+
150
+ /**
151
+ * `type` value used for an `LLuiDecoratorNode`. Its identity lives in
152
+ * `props.bridgeType`; its serialized payload in `props.data`.
153
+ */
154
+ export const DECORATOR_TYPE = 'llui-decorator'
155
+
156
+ /** `props` key naming which LLui bridge renders a decorator. */
157
+ export const KEY_BRIDGE_TYPE = 'bridgeType'
158
+
159
+ /** `props` key holding a decorator's JSON-serialized payload. */
160
+ export const KEY_DATA = 'data'
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Value + container types
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * A value storable in an element's `props` map: any JSON value.
168
+ *
169
+ * Most Lexical node props are scalars (`tag`, `format`, `indent`, …), but not
170
+ * all — `LLuiDecoratorNode.exportJSON()` emits `data: unknown`, an arbitrary
171
+ * JSON payload, and that payload is precisely what makes a decorator's mounted
172
+ * LLui sub-app reproducible on a peer. Loro stores a JSON value in a map slot as
173
+ * ONE last-writer-wins register, which is the same granularity a scalar gets, so
174
+ * widening the type costs nothing structurally.
175
+ *
176
+ * The LWW granularity is per KEY, not per nested field: two peers editing
177
+ * different fields of the same `data` object do not merge, the later write wins
178
+ * whole. Decorator payloads are small, opaque-to-us blobs, so that is the right
179
+ * trade; a decorator wanting field-level merging should model its state as its
180
+ * own Loro container rather than as a prop.
181
+ */
182
+ export type PropValue =
183
+ | string
184
+ | number
185
+ | boolean
186
+ | null
187
+ | PropValue[]
188
+ | { [key: string]: PropValue }
189
+
190
+ /** An element's prop map. Each key is independently last-writer-wins. */
191
+ export type PropsContainer = LoroMap<Record<string, PropValue>>
192
+
193
+ /**
194
+ * The map mirroring one Lexical `ElementNode` (or a `DecoratorNode` /
195
+ * `LineBreakNode`, which simply carry an empty `children` map).
196
+ *
197
+ * Every element except the ROOT is also a child carrier, so it additionally
198
+ * holds `uuid`, `pos` and `kind`. The root is reached through `doc.getMap` and
199
+ * has no siblings to be ordered among, so those keys are optional.
200
+ */
201
+ export type ElementContainer = LoroMap<ElementShape>
202
+
203
+ /** An element map's key set. */
204
+ export interface ElementShape extends Record<string, unknown> {
205
+ [KEY_TYPE]: string
206
+ [KEY_PROPS]: PropsContainer
207
+ [KEY_CHILDREN]: ChildrenContainer
208
+ }
209
+
210
+ /** What a child carrier wraps. */
211
+ export type ChildKind = 'element' | 'text'
212
+
213
+ /** A carrier holding one text run's `LoroText`. */
214
+ export type TextCarrier = LoroMap<TextCarrierShape>
215
+
216
+ /** A text carrier's key set. */
217
+ export interface TextCarrierShape extends Record<string, unknown> {
218
+ [KEY_UUID]: string
219
+ [KEY_POS]: string
220
+ [KEY_KIND]: 'text'
221
+ [KEY_TEXT]: LoroText
222
+ }
223
+
224
+ /**
225
+ * An element's children, keyed by uuid. UNORDERED — the rendered sequence comes
226
+ * from {@link orderedChildren}, never from iteration order.
227
+ */
228
+ export type ChildrenContainer = LoroMap<Record<string, ChildCarrier>>
229
+
230
+ /**
231
+ * The keys EVERY child carrier holds, whatever it wraps.
232
+ *
233
+ * Typed as its own shape rather than as `ElementContainer | TextCarrier` so that
234
+ * the ordering keys can be written without narrowing: a union of two generic
235
+ * `LoroMap` signatures is not callable, and the position write is the one
236
+ * operation that is genuinely common to both kinds.
237
+ */
238
+ export interface CarrierShape extends Record<string, unknown> {
239
+ [KEY_UUID]: string
240
+ [KEY_POS]: string
241
+ [KEY_KIND]: ChildKind
242
+ }
243
+
244
+ /** A carrier sitting in a `children` map, seen through its common keys. */
245
+ export type ChildCarrier = LoroMap<CarrierShape>
246
+
247
+ /**
248
+ * The container a child's IDENTITY is registered under in `mapping.ts`: the
249
+ * `LoroText` for a text run, the element map itself for an element.
250
+ *
251
+ * Both are invariant across every reorder, which is what lets the registry stay
252
+ * ignorant of the ordering model entirely.
253
+ */
254
+ export type ChildContainer = ElementContainer | LoroText
255
+
256
+ /** One child of an element, as the ordering projection sees it. */
257
+ export interface ChildEntry {
258
+ readonly uuid: string
259
+ readonly pos: string
260
+ readonly kind: ChildKind
261
+ /** The carrier map. For an element child this IS its {@link ElementContainer}. */
262
+ readonly carrier: ChildCarrier
263
+ /** The container this child is addressed by. See {@link ChildContainer}. */
264
+ readonly container: ChildContainer
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Text-style configuration
269
+ // ---------------------------------------------------------------------------
270
+
271
+ /** Loro's per-mark conflict-resolution rule. */
272
+ export type ExpandType = 'before' | 'after' | 'none' | 'both'
273
+
274
+ /**
275
+ * The `expand` rule applied UNIFORMLY to every text format.
276
+ *
277
+ * Read `test/expand-semantics.test.ts` before changing this. `expand` is NOT
278
+ * the mechanism that reproduces Lexical's boundary behaviour — a 51-test spike
279
+ * proved no uniform table can, and that no per-format table can either (the
280
+ * divergence set is identical for all 11 formats, because Lexical has no
281
+ * per-format inclusivity: its caret is uniformly left-biased). The Lexical→Loro
282
+ * direction replays RESULTING NODE STATE via explicit mark/unmark ops instead
283
+ * (see `diffRunFormats` in `text.ts`), which makes the local result correct
284
+ * regardless of `expand`.
285
+ *
286
+ * `expand` therefore governs exactly one thing: what happens to text a REMOTE
287
+ * peer inserts CONCURRENTLY at a mark boundary. `'after'` is the closest fit to
288
+ * Lexical's left-biased caret.
289
+ */
290
+ export const TEXT_MARK_EXPAND: ExpandType = 'after'
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Construction + access
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /** The Lexical node type of the root. Matches `RootNode.getType()`. */
297
+ export const ROOT_TYPE = 'root'
298
+
299
+ /**
300
+ * Configure a `LoroDoc` for this schema and return its root element container,
301
+ * creating the root's schema keys if they are missing.
302
+ *
303
+ * Every peer MUST call this. Two reasons, both load-bearing:
304
+ *
305
+ * 1. `configTextStyle` is LOCAL configuration, not replicated state. A peer that
306
+ * skips it resolves marks under different expand rules and diverges.
307
+ * 2. The root's `props`/`children` are created with `ensureMergeable*`, which
308
+ * derives a DETERMINISTIC ContainerID from the parent and key. Two peers may
309
+ * each initialize an empty document before ever hearing from one another; a
310
+ * plain `setContainer` would mint two different child containers and the map
311
+ * slot's last-writer-wins would silently discard one peer's entire document.
312
+ * `ensureMergeable*` makes both peers land on the same container, so their
313
+ * edits merge. (Only the ROOT needs this — every other element is created
314
+ * whole by a single peer and inserted as one op.)
315
+ */
316
+ export function initDoc(doc: LoroDoc, formats: readonly string[]): ElementContainer {
317
+ doc.configTextStyle(
318
+ Object.fromEntries(formats.map((format) => [format, { expand: TEXT_MARK_EXPAND }])),
319
+ )
320
+ const root = doc.getMap(ROOT_CONTAINER) as ElementContainer
321
+ // Write only what is missing. `initDoc` runs on every binding construction —
322
+ // and a shared `LoroDoc` may back more than one — so an unconditional `set`
323
+ // would queue a redundant local op that the next `import`/`commit` flushes to
324
+ // every peer as a spurious update.
325
+ if (root.get(KEY_TYPE) !== ROOT_TYPE) root.set(KEY_TYPE, ROOT_TYPE)
326
+ root.ensureMergeableMap(KEY_PROPS)
327
+ root.ensureMergeableMap(KEY_CHILDREN)
328
+ return root
329
+ }
330
+
331
+ /**
332
+ * A fresh child identity.
333
+ *
334
+ * MUST be random. Two peers minting the same uuid would collide on one slot of
335
+ * the `children` map, whose last-writer-wins would silently discard a whole
336
+ * block — the same class of data loss `initDoc`'s `ensureMergeable*` exists to
337
+ * prevent for the root.
338
+ */
339
+ export function newUuid(): string {
340
+ return crypto.randomUUID()
341
+ }
342
+
343
+ /**
344
+ * Create an element child inside `children` and return its ATTACHED container.
345
+ *
346
+ * The carrier IS the element container: an element needs a `pos` anyway, so
347
+ * wrapping it in a second map would cost an extra container and an extra
348
+ * dereference for nothing. Only the attached handle has a stable `ContainerID`,
349
+ * so always take identity from what this returns.
350
+ */
351
+ export function createElementChild(
352
+ children: ChildrenContainer,
353
+ uuid: string,
354
+ pos: string,
355
+ type: string,
356
+ ): ElementContainer {
357
+ const element = children.setContainer(uuid, new LoroMap()) as ElementContainer
358
+ element.set(KEY_UUID, uuid)
359
+ element.set(KEY_POS, pos)
360
+ element.set(KEY_KIND, 'element')
361
+ element.set(KEY_TYPE, type)
362
+ element.setContainer(KEY_PROPS, new LoroMap() as PropsContainer)
363
+ element.setContainer(KEY_CHILDREN, new LoroMap() as ChildrenContainer)
364
+ return element
365
+ }
366
+
367
+ /**
368
+ * Create a text child inside `children` and return its ATTACHED `LoroText`.
369
+ *
370
+ * The `LoroText` is created once, inside its carrier, and never moved or
371
+ * recreated — which is precisely what makes its `ContainerID` invariant across
372
+ * every reorder, and therefore what lets a peer's concurrent insertion into it
373
+ * survive a block move.
374
+ */
375
+ export function createTextChild(children: ChildrenContainer, uuid: string, pos: string): LoroText {
376
+ const carrier = children.setContainer(uuid, new LoroMap()) as TextCarrier
377
+ carrier.set(KEY_UUID, uuid)
378
+ carrier.set(KEY_POS, pos)
379
+ carrier.set(KEY_KIND, 'text')
380
+ return carrier.setContainer(KEY_TEXT, new LoroText())
381
+ }
382
+
383
+ /** Re-position a child — the whole cost of a same-parent move. */
384
+ export function setChildPosition(carrier: ChildCarrier, pos: string): void {
385
+ carrier.set(KEY_POS, pos)
386
+ }
387
+
388
+ /** Remove a child from its parent, container and all. */
389
+ export function deleteChild(children: ChildrenContainer, uuid: string): void {
390
+ children.delete(uuid)
391
+ }
392
+
393
+ /**
394
+ * An element's children in RENDERED order: sorted by `(pos, uuid)`.
395
+ *
396
+ * Malformed carriers are SKIPPED rather than thrown on. That is not defensive
397
+ * padding: a remote update can be applied while a carrier's keys are still
398
+ * arriving, and a partially-materialized child must not crash a render — it will
399
+ * appear on the next event, once its `pos` and `kind` have landed.
400
+ *
401
+ * Nothing here consults `isDeleted()`, and nothing may. Projection must depend
402
+ * ONLY on replicated state; a deleted carrier is simply absent from `keys()` on
403
+ * every peer, which is what makes this a pure function of the document.
404
+ */
405
+ export function orderedChildren(element: ElementContainer): ChildEntry[] {
406
+ const children = elementChildren(element)
407
+ const out: ChildEntry[] = []
408
+ for (const uuid of children.keys()) {
409
+ // Read as `unknown` and narrow by `instanceof`: the map's declared value
410
+ // type is a PROMISE about well-formed carriers, and this function's whole
411
+ // job is to hold that promise against a document that may not keep it yet.
412
+ const carrier: unknown = children.get(uuid)
413
+ if (!(carrier instanceof LoroMap)) continue
414
+ const pos = carrier.get(KEY_POS)
415
+ const kind = carrier.get(KEY_KIND)
416
+ if (typeof pos !== 'string') continue
417
+ if (kind === 'text') {
418
+ const text = carrier.get(KEY_TEXT)
419
+ if (!(text instanceof LoroText)) continue
420
+ out.push({ uuid, pos, kind, carrier: carrier as ChildCarrier, container: text })
421
+ } else if (kind === 'element') {
422
+ const container = carrier as ElementContainer
423
+ if (typeof container.get(KEY_TYPE) !== 'string') continue
424
+ out.push({ uuid, pos, kind, carrier: carrier as ChildCarrier, container })
425
+ }
426
+ }
427
+ return out.sort((x, y) => comparePositions(x.pos, x.uuid, y.pos, y.uuid))
428
+ }
429
+
430
+ /** How many well-formed children an element has. */
431
+ export function childCount(element: ElementContainer): number {
432
+ return orderedChildren(element).length
433
+ }
434
+
435
+ /** Read an element's Lexical node type. */
436
+ export function elementType(element: ElementContainer): string {
437
+ const type = element.get(KEY_TYPE)
438
+ if (typeof type !== 'string') {
439
+ throw new Error(`lexical-loro: element ${element.id} has no '${KEY_TYPE}' string`)
440
+ }
441
+ return type
442
+ }
443
+
444
+ /** Read an element's scalar-prop map. */
445
+ export function elementProps(element: ElementContainer): PropsContainer {
446
+ const props = element.get(KEY_PROPS)
447
+ if (!(props instanceof LoroMap)) {
448
+ throw new Error(`lexical-loro: element ${element.id} has no '${KEY_PROPS}' map`)
449
+ }
450
+ return props as PropsContainer
451
+ }
452
+
453
+ /** Read an element's child-carrier map. UNORDERED — see {@link orderedChildren}. */
454
+ export function elementChildren(element: ElementContainer): ChildrenContainer {
455
+ const children = element.get(KEY_CHILDREN)
456
+ if (!(children instanceof LoroMap)) {
457
+ throw new Error(`lexical-loro: element ${element.id} has no '${KEY_CHILDREN}' map`)
458
+ }
459
+ return children as ChildrenContainer
460
+ }
461
+
462
+ /** Narrow a child slot to an element container. */
463
+ export function isElementContainer(child: unknown): child is ElementContainer {
464
+ return child instanceof LoroMap
465
+ }
466
+
467
+ /** Narrow a child slot to a text run. */
468
+ export function isTextContainer(child: unknown): child is LoroText {
469
+ return child instanceof LoroText
470
+ }
471
+
472
+ /** True when this element mirrors an `LLuiDecoratorNode`. */
473
+ export function isDecoratorElement(element: ElementContainer): boolean {
474
+ return elementType(element) === DECORATOR_TYPE
475
+ }
476
+
477
+ /**
478
+ * Whether a container still exists in the document.
479
+ *
480
+ * `getContainerById` keeps returning a usable handle for a DELETED container, so
481
+ * `isDeleted()` is the real test. The kind narrowing is not defensive padding:
482
+ * `Container` includes `LoroCounter`, `LoroList` and `LoroTree`, which this
483
+ * schema never uses, and only its two kinds may enter the registry.
484
+ *
485
+ * This is a LOCAL liveness question — "is the registry entry stale?" — not a
486
+ * projection question. See {@link orderedChildren}.
487
+ */
488
+ export function containerIsLive(doc: LoroDoc, id: ContainerID): boolean {
489
+ const container = doc.getContainerById(id)
490
+ if (container instanceof LoroMap) return !container.isDeleted()
491
+ if (container instanceof LoroText) return !container.isDeleted()
492
+ return false
493
+ }
494
+
495
+ /**
496
+ * The `ContainerID` of an attached container — the STABLE, cross-peer address
497
+ * this binding maps to a per-session `NodeKey`. Throws on a detached container,
498
+ * which has no replicated identity and must never enter the mapping.
499
+ */
500
+ export function containerId(container: Container): ContainerID {
501
+ const attached = container.getAttached()
502
+ if (attached === undefined) {
503
+ throw new Error(
504
+ `lexical-loro: refusing to address a DETACHED ${getType(container)} container — ` +
505
+ 'insert it into its parent first and use the handle the insert returns',
506
+ )
507
+ }
508
+ return attached.id
509
+ }
package/src/seed.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Boot: seed an empty shared document, or adopt a populated one.
3
+ *
4
+ * This is the shortest file in the package and the one with the most damaging
5
+ * failure mode. Getting the branch backwards means the SECOND peer to open a
6
+ * document "seeds" it — writing its local default over content everyone else is
7
+ * already editing. So the decision is made against the SHARED document's own
8
+ * emptiness, never against the local editor's, and both orders are pinned by
9
+ * tests (`test/seed.test.ts`).
10
+ *
11
+ * ── Why the seed suppresses the outbound listener ──────────────────────────
12
+ *
13
+ * The seed runs as a Lexical update, which would ordinarily be mirrored by the
14
+ * registered outbound listener. It is tagged `COLLABORATION_TAG` so that does
15
+ * NOT happen, and `seedLoroFromLexical` writes the shared document explicitly
16
+ * instead. One write path rather than two means the seed cannot half-land if the
17
+ * binding's listener registration order ever changes, and the mapping is
18
+ * populated by the same walk that writes the containers.
19
+ *
20
+ * ── Concurrent seeding ─────────────────────────────────────────────────────
21
+ *
22
+ * Two peers can each find the shared document empty before hearing from one
23
+ * another. Nothing can arbitrate that without a coordinator, so the requirement
24
+ * is CONVERGENCE, not deduplication: both seeds merge and every peer ends up
25
+ * with both blocks. `initDoc`'s `ensureMergeable*` root containers are what make
26
+ * that merge possible — with a plain `setContainer` the map slot's
27
+ * last-writer-wins would silently discard one peer's entire document. Apps that
28
+ * need exactly one bootstrapper should elect one and pass `shouldBootstrap`
29
+ * accordingly.
30
+ */
31
+
32
+ import { $getRoot, COLLABORATION_TAG, HISTORY_MERGE_TAG, type LexicalEditor } from 'lexical'
33
+ import type { LoroDoc } from 'loro-crdt'
34
+
35
+ import type { ContainerNodeMap } from './mapping.js'
36
+ import { childCount, type ElementContainer } from './schema.js'
37
+ import { adoptLoroDocument } from './to-lexical.js'
38
+ import { seedLoroFromLexical } from './to-loro.js'
39
+
40
+ /** What {@link bootstrapDocument} did. */
41
+ export type BootstrapOutcome =
42
+ /** The shared document was empty and this peer filled it from `seed`. */
43
+ | 'seeded'
44
+ /** The shared document had content; the editor now mirrors it. */
45
+ | 'adopted'
46
+ /** Empty document, but this peer is not allowed to bootstrap it. */
47
+ | 'waiting'
48
+
49
+ export interface BootstrapTarget {
50
+ readonly doc: LoroDoc
51
+ /** The root element container, as returned by `initDoc`. */
52
+ readonly root: ElementContainer
53
+ readonly mapping: ContainerNodeMap
54
+ readonly editor: LexicalEditor
55
+ /**
56
+ * Fill an EMPTY editor with this peer's default content. Runs inside a Lexical
57
+ * update, at most once, and only when the shared document is empty.
58
+ */
59
+ readonly seed?: ((editor: LexicalEditor) => void) | undefined
60
+ /**
61
+ * Whether this peer may bootstrap an empty shared document. Default `true`.
62
+ * Set `false` on peers that join rather than create — with a real transport,
63
+ * "empty" before the first sync is indistinguishable from "genuinely empty",
64
+ * and a joining peer that seeds races the document it was about to receive.
65
+ */
66
+ readonly shouldBootstrap?: boolean | undefined
67
+ }
68
+
69
+ /** Whether the shared document holds any content at all. */
70
+ export function isSharedDocumentEmpty(root: ElementContainer): boolean {
71
+ return childCount(root) === 0
72
+ }
73
+
74
+ /**
75
+ * Bring the editor and the shared document into agreement at boot.
76
+ *
77
+ * Idempotent: calling it again on a populated document adopts (writing nothing
78
+ * and churning no NodeKeys), so a binding may safely call it on every sync
79
+ * event without tracking whether it already ran.
80
+ */
81
+ export function bootstrapDocument(target: BootstrapTarget): BootstrapOutcome {
82
+ if (!isSharedDocumentEmpty(target.root)) {
83
+ adoptLoroDocument(target)
84
+ return 'adopted'
85
+ }
86
+ if (target.shouldBootstrap === false) return 'waiting'
87
+
88
+ const seed = target.seed
89
+ if (seed !== undefined) {
90
+ target.editor.update(
91
+ () => {
92
+ // Only fill a genuinely empty editor: a remount over a live editor must
93
+ // not have its content replaced by the default.
94
+ if (!$getRoot().isEmpty()) return
95
+ seed(target.editor)
96
+ },
97
+ {
98
+ // Suppress the outbound listener — the shared document is written below,
99
+ // by one explicit path. `HISTORY_MERGE_TAG` keeps the seed out of the
100
+ // user's undo stack, so the first Cmd+Z cannot empty the document.
101
+ tag: [COLLABORATION_TAG, HISTORY_MERGE_TAG],
102
+ discrete: true,
103
+ },
104
+ )
105
+ }
106
+
107
+ seedLoroFromLexical(
108
+ { doc: target.doc, root: target.root, mapping: target.mapping },
109
+ target.editor.getEditorState(),
110
+ )
111
+ return 'seeded'
112
+ }