@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,154 @@
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 { type EditorState, type UpdateListenerPayload } from 'lexical';
85
+ import { type LoroDoc } from 'loro-crdt';
86
+ import { ContainerNodeMap } from './mapping.js';
87
+ import { type ElementContainer } from './schema.js';
88
+ /** Commit origin stamped on every write this module makes. */
89
+ export declare const OUTBOUND_ORIGIN = "lexical-loro";
90
+ /**
91
+ * Update tags that mean "this update did not originate with the local user, do
92
+ * not mirror it".
93
+ *
94
+ * `COLLABORATION_TAG` is our own inbound writeback (echo layer b);
95
+ * `SKIP_COLLAB_TAG` is Lexical's standard opt-out, which hosts use for local-only
96
+ * decoration. `HISTORIC_TAG` is NOT here — see the file header.
97
+ */
98
+ export declare const OUTBOUND_SKIP_TAGS: readonly string[];
99
+ /** The Loro side of the binding: the document, its root mirror, and the registry. */
100
+ export interface OutboundTarget {
101
+ readonly doc: LoroDoc;
102
+ /** The root element container, as returned by `initDoc`. */
103
+ readonly root: ElementContainer;
104
+ /** The ContainerID ↔ NodeKey registry, owned by the binding and mutated here. */
105
+ readonly mapping: ContainerNodeMap;
106
+ /** Commit origin. Defaults to {@link OUTBOUND_ORIGIN}. */
107
+ readonly origin?: string;
108
+ /** Tags that suppress the sync. Defaults to {@link OUTBOUND_SKIP_TAGS}. */
109
+ readonly skipTags?: readonly string[];
110
+ }
111
+ /**
112
+ * The slice of `UpdateListenerPayload` this direction consumes. Declared as a
113
+ * `Pick` so an update listener can pass its payload straight through.
114
+ *
115
+ * Register it with a BLOCK body, never an expression body:
116
+ *
117
+ * ```ts
118
+ * editor.registerUpdateListener((payload) => {
119
+ * syncLexicalToLoro(target, payload)
120
+ * })
121
+ * ```
122
+ *
123
+ * Lexical 0.48 stores whatever an update listener RETURNS and calls it as a
124
+ * cleanup before the next invocation (`triggerListeners` in `LexicalUpdates`),
125
+ * so the concise `(payload) => syncLexicalToLoro(target, payload)` hands it this
126
+ * function's op count and the second update dies with
127
+ * "unregister is not a function".
128
+ */
129
+ export type OutboundUpdate = Pick<UpdateListenerPayload, 'prevEditorState' | 'editorState' | 'dirtyElements' | 'dirtyLeaves' | 'normalizedNodes' | 'tags'>;
130
+ /**
131
+ * Mirror one Lexical update into the Loro document and commit it.
132
+ *
133
+ * @returns the number of Loro write operations emitted. `0` means the update
134
+ * was a genuine no-op for the shared document — nothing was committed, so no
135
+ * peer sees an event. Tests assert on this to catch pruning regressions.
136
+ */
137
+ export declare function syncLexicalToLoro(target: OutboundTarget, update: OutboundUpdate): number;
138
+ /**
139
+ * Fill the Loro document from an editor state with no previous state to diff
140
+ * against — the bootstrapping peer's initial seed.
141
+ *
142
+ * Structurally a full-fidelity diff, so it is also idempotent: seeding a
143
+ * document that already matches emits nothing and returns `0`.
144
+ */
145
+ export declare function seedLoroFromLexical(target: OutboundTarget, editorState: EditorState): number;
146
+ /**
147
+ * The indices of a longest strictly-increasing subsequence of `values`.
148
+ *
149
+ * Patience sorting with a predecessor chain: O(n log n). Exported because it is
150
+ * the part of the reorder planner worth testing in isolation — the number of
151
+ * `pos` writes a drag-reorder costs is exactly `matched.length - lis.length`.
152
+ */
153
+ export declare function longestIncreasingSubsequence(values: readonly number[]): number[];
154
+ //# sourceMappingURL=to-loro.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"to-loro.d.ts","sourceRoot":"","sources":["../src/to-loro.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkFG;AAEH,OAAO,EAQL,KAAK,WAAW,EAMhB,KAAK,qBAAqB,EAC3B,MAAM,SAAS,CAAA;AAChB,OAAO,EAA8B,KAAK,OAAO,EAAE,MAAM,WAAW,CAAA;AAEpE,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAA;AAE/C,OAAO,EAgBL,KAAK,gBAAgB,EAEtB,MAAM,aAAa,CAAA;AAgBpB,8DAA8D;AAC9D,eAAO,MAAM,eAAe,iBAAiB,CAAA;AAE7C;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,EAAE,SAAS,MAAM,EAAyC,CAAA;AAEzF,qFAAqF;AACrF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAA;IACrB,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAA;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAA;IAClC,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACtC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,cAAc,GAAG,IAAI,CAC/B,qBAAqB,EACrB,iBAAiB,GAAG,aAAa,GAAG,eAAe,GAAG,aAAa,GAAG,iBAAiB,GAAG,MAAM,CACjG,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,cAAc,GAAG,MAAM,CAoBxF;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,cAAc,EAAE,WAAW,EAAE,WAAW,GAAG,MAAM,CAE5F;AAqjBD;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CA0BhF"}