@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.
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/dist/agent-write.d.ts +123 -0
- package/dist/agent-write.d.ts.map +1 -0
- package/dist/agent-write.js +499 -0
- package/dist/agent-write.js.map +1 -0
- package/dist/binding.d.ts +122 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +114 -0
- package/dist/binding.js.map +1 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/mapping.d.ts +115 -0
- package/dist/mapping.d.ts.map +1 -0
- package/dist/mapping.js +181 -0
- package/dist/mapping.js.map +1 -0
- package/dist/order.d.ts +124 -0
- package/dist/order.d.ts.map +1 -0
- package/dist/order.js +187 -0
- package/dist/order.js.map +1 -0
- package/dist/schema.d.ts +343 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +363 -0
- package/dist/schema.js.map +1 -0
- package/dist/seed.d.ts +72 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +72 -0
- package/dist/seed.js.map +1 -0
- package/dist/text.d.ts +167 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +289 -0
- package/dist/text.js.map +1 -0
- package/dist/to-lexical.d.ts +119 -0
- package/dist/to-lexical.d.ts.map +1 -0
- package/dist/to-lexical.js +636 -0
- package/dist/to-lexical.js.map +1 -0
- package/dist/to-loro.d.ts +154 -0
- package/dist/to-loro.d.ts.map +1 -0
- package/dist/to-loro.js +718 -0
- package/dist/to-loro.js.map +1 -0
- package/dist/undo.d.ts +94 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +200 -0
- package/dist/undo.js.map +1 -0
- package/package.json +64 -0
- package/src/agent-write.ts +613 -0
- package/src/binding.ts +185 -0
- package/src/index.ts +176 -0
- package/src/mapping.ts +206 -0
- package/src/order.ts +205 -0
- package/src/schema.ts +509 -0
- package/src/seed.ts +112 -0
- package/src/text.ts +357 -0
- package/src/to-lexical.ts +792 -0
- package/src/to-loro.ts +914 -0
- package/src/undo.ts +269 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Franco Ponticelli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# @llui/lexical-loro
|
|
2
|
+
|
|
3
|
+
Loro CRDT binding for the LLui ↔ Lexical editor.
|
|
4
|
+
|
|
5
|
+
> **Status: document sync, both directions, plus peer-scoped CRDT undo — usable
|
|
6
|
+
> end to end.** No presence and no remote cursors yet. Transport is yours to
|
|
7
|
+
> supply. See [Scope](#scope).
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { markdownEditor } from '@llui/markdown-editor'
|
|
13
|
+
import { loroCollab } from '@llui/lexical-loro'
|
|
14
|
+
import { LoroDoc } from 'loro-crdt'
|
|
15
|
+
|
|
16
|
+
const doc = new LoroDoc()
|
|
17
|
+
// …attach your transport to `doc` (subscribeLocalUpdates / import / export)…
|
|
18
|
+
|
|
19
|
+
markdownEditor({
|
|
20
|
+
defaultValue: '# Hello',
|
|
21
|
+
collab: (hooks) => loroCollab({ doc, seed: hooks.seed, shouldBootstrap: isCreator }),
|
|
22
|
+
})
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`loroCollab` returns `{ register, externalUndo, doc, root, mapping, bootstrap }`.
|
|
26
|
+
`register` **and** `externalUndo` together satisfy `@llui/markdown-editor`'s
|
|
27
|
+
`CollabBinding` structurally, so that package needs no Loro dependency of its own
|
|
28
|
+
— and, because `externalUndo` is present, the editor turns its built-in
|
|
29
|
+
`@lexical/history` stack off automatically and uses this binding's peer-scoped
|
|
30
|
+
undo instead. Wiring `loroCollab` directly (without `@llui/markdown-editor`),
|
|
31
|
+
pass both to `lexicalForeign`:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
const collab = loroCollab({ doc })
|
|
35
|
+
lexicalForeign({
|
|
36
|
+
seedMode: 'deferred',
|
|
37
|
+
register: collab.register,
|
|
38
|
+
externalUndo: collab.externalUndo,
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**There is no built-in transport, deliberately.** `LoroDoc` already exposes the
|
|
43
|
+
whole wire surface, so a transport is a dozen lines against your own websocket or
|
|
44
|
+
provider — and shipping one here would mean owning a connection lifecycle this
|
|
45
|
+
package cannot test honestly. Call `collab.bootstrap(editor)` again after your
|
|
46
|
+
transport's first sync: an unsynced document looks empty, and seeding one races
|
|
47
|
+
the content about to arrive.
|
|
48
|
+
|
|
49
|
+
## Design
|
|
50
|
+
|
|
51
|
+
The two directions are mirror images, and both are built around one rule.
|
|
52
|
+
|
|
53
|
+
**Never rebuild what you can mutate.** Lexical's `NodeKey` is a bare counter, so
|
|
54
|
+
recreating a node instead of updating it tears down its DOM, its selection, its
|
|
55
|
+
IME composition — and disposes the mounted LLui sub-app of every
|
|
56
|
+
`LLuiDecoratorNode` inside it (`packages/lexical/src/decorator.ts` disposes on
|
|
57
|
+
the `'destroyed'` mutation). So:
|
|
58
|
+
|
|
59
|
+
- `to-lexical.ts` resolves each remote change to an existing `NodeKey` through
|
|
60
|
+
the registry and mutates that node in place. It never replaces the document,
|
|
61
|
+
which is what loro-prosemirror's inbound path does — ProseMirror tolerates
|
|
62
|
+
that; Lexical cannot.
|
|
63
|
+
- `to-loro.ts` emits one `pos` register write per moved block for a reorder
|
|
64
|
+
(`n - LIS(n)` writes — the longest increasing subsequence stays put), a
|
|
65
|
+
cursor-biased single-region diff for a text edit, and explicit per-format
|
|
66
|
+
`mark`/`unmark` ops for formatting. It returns its op count, and the tests
|
|
67
|
+
assert on it — so a pruning regression fails a test instead of quietly becoming
|
|
68
|
+
a slower, mount-destroying binding.
|
|
69
|
+
- `mapping.ts` holds the `ContainerID ↔ NodeKey` bijection the whole thing rests
|
|
70
|
+
on: `NodeKey` is per-session, `ContainerID` is the stable cross-peer address.
|
|
71
|
+
|
|
72
|
+
### How sibling order works: fractional indexing
|
|
73
|
+
|
|
74
|
+
Children are **not** stored in a list. Each child — element **or** text run — is a
|
|
75
|
+
carrier `LoroMap` holding `{ uuid, pos, kind, … }`, filed in its parent's
|
|
76
|
+
`children` map under its own random `uuid`; a text carrier holds its `LoroText`
|
|
77
|
+
under a `text` key, created **once** and never recreated. The rendered order is a
|
|
78
|
+
pure projection of replicated state: **sort by `(pos, uuid)`**.
|
|
79
|
+
|
|
80
|
+
`pos` is a fractional index — a base-62 string key with one always available
|
|
81
|
+
strictly between any two distinct keys — so a **same-parent move is one
|
|
82
|
+
last-writer-wins write to `pos`**. Nothing is deleted, nothing is recreated, and
|
|
83
|
+
nothing inside the moved subtree is touched. Hence `ContainerID`s (and therefore
|
|
84
|
+
`NodeKey`s, and therefore mounted decorator sub-apps and local carets) survive a
|
|
85
|
+
remote reorder; a move costs under 400 bytes regardless of subtree size; and a
|
|
86
|
+
concurrent edit _into_ a moved block is preserved.
|
|
87
|
+
|
|
88
|
+
Because order is derived by sorting replicated fields, peers holding the same
|
|
89
|
+
state cannot disagree about it — convergence is true by construction.
|
|
90
|
+
|
|
91
|
+
#### Why not `LoroMovableList`
|
|
92
|
+
|
|
93
|
+
That was this package's original design. It was abandoned because `loro-crdt`
|
|
94
|
+
1.13.7 (the latest release) has two defects in concurrent move/delete handling:
|
|
95
|
+
an uncatchable WASM panic, and a silent convergence failure where peers that have
|
|
96
|
+
exchanged full snapshots both ways still render different orders. Both are pinned
|
|
97
|
+
as `it.fails` in `test/loro-upstream.test.ts` — kept as the recorded rationale for
|
|
98
|
+
the schema, and to turn red if a future release fixes them.
|
|
99
|
+
|
|
100
|
+
#### Accepted tradeoffs
|
|
101
|
+
|
|
102
|
+
Real costs, deliberately chosen, each demonstrated in `test/constraints.test.ts`:
|
|
103
|
+
|
|
104
|
+
- **The concurrent-edit guarantee is same-parent only.** A **cross-parent** move
|
|
105
|
+
is still delete + recreate and **does** lose a concurrent edit into the moved
|
|
106
|
+
subtree. Not a regression — `LoroMovableList#move` is also single-list.
|
|
107
|
+
- **Delete beats move.** A delete concurrent with a move of the same block wins
|
|
108
|
+
and the block vanishes, convergently, in both delivery orders. A tombstone
|
|
109
|
+
mitigation was tried and **refuted by test**: the delete flag and `pos` are
|
|
110
|
+
separate map keys, so both survive and nothing is rescued. Do not re-add
|
|
111
|
+
tombstones.
|
|
112
|
+
- **`pos` keys are never rebalanced, and must not be.** A peer that inserted
|
|
113
|
+
concurrently computed its key against the _old_ keys, so after a rebalance its
|
|
114
|
+
block lands somewhere unrelated to what the user pointed at — convergent, and
|
|
115
|
+
silently wrong about intent. It is also unnecessary: growth is linear and
|
|
116
|
+
bounded (2000 adversarial same-spot inserts reach a 401-character key).
|
|
117
|
+
- **Two concurrent splits of the same text run garble the text.** Ordinal text
|
|
118
|
+
matching mints a fresh tail container on each peer, so the merge duplicates a
|
|
119
|
+
fragment. **Pre-existing** — the `LoroMovableList` binding produced the same
|
|
120
|
+
result on the same history — and a property of text matching, not of the
|
|
121
|
+
ordering model. Not fixed here.
|
|
122
|
+
|
|
123
|
+
### Why text formats cannot be one Loro value
|
|
124
|
+
|
|
125
|
+
`TextNode.__format` is a bitmask (`IS_BOLD = 1`, `IS_ITALIC = 1 << 1`, …).
|
|
126
|
+
Storing it as a single CRDT value makes two peers concurrently toggling **bold**
|
|
127
|
+
and **italic** a last-writer-wins conflict that silently drops one. They are
|
|
128
|
+
therefore independent, named Loro marks, which converge to the union.
|
|
129
|
+
|
|
130
|
+
Loro's `expand` rule cannot reproduce Lexical's boundary behaviour — a 51-test
|
|
131
|
+
spike (`test/expand-semantics.test.ts`) proved no uniform table works and no
|
|
132
|
+
per-format table can, because Lexical's caret is uniformly left-biased. So the
|
|
133
|
+
outbound direction replays the RESULTING node state as explicit mark/unmark ops,
|
|
134
|
+
and `expand` governs only what happens to text a remote peer inserts
|
|
135
|
+
concurrently at a mark boundary.
|
|
136
|
+
|
|
137
|
+
### Echo suppression
|
|
138
|
+
|
|
139
|
+
Three layers, all required; `binding.ts` documents what breaks without each. The
|
|
140
|
+
one with no code to enforce it: this binding **never emits `PROGRAMMATIC_TAG`**,
|
|
141
|
+
because `packages/lexical/src/foreign.ts` reads that tag as "the host pushed
|
|
142
|
+
content — cancel pending outbound work", which would make the host's persistence
|
|
143
|
+
go dark whenever a peer types.
|
|
144
|
+
|
|
145
|
+
## Agent write (content-keyed rewrite)
|
|
146
|
+
|
|
147
|
+
When an LLM rewrites a note's **whole markdown**, the naive route — reparse into
|
|
148
|
+
the live editor, which does `root.clear()` + rebuild — mints a fresh `NodeKey`
|
|
149
|
+
for every node, so the outbound sync matches nothing through the registry and
|
|
150
|
+
**recreates every container**. A concurrent edit from another window merges into
|
|
151
|
+
a container this side just deleted and is lost, and every mounted decorator
|
|
152
|
+
sub-app is torn down. Measured at **0% ContainerID survival even for identical
|
|
153
|
+
markdown**.
|
|
154
|
+
|
|
155
|
+
`reconcileTargetIntoLoro` is the supported alternative — a **sibling** to
|
|
156
|
+
`syncLexicalToLoro`, not a replacement. It reconciles a parsed **target tree**
|
|
157
|
+
directly against the Loro document, matching existing carriers to target children
|
|
158
|
+
by **content** (not by `NodeKey`). Unchanged blocks keep their `ContainerID`s — and
|
|
159
|
+
therefore their `NodeKey`s, their mounted decorator sub-apps, and any concurrent
|
|
160
|
+
peer edit into them; a text-changed block keeps its `LoroText` and diffs the
|
|
161
|
+
characters; only genuinely new / removed / moved blocks touch the ordering.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { reconcileTargetIntoLoro, targetFromEditorState } from '@llui/lexical-loro'
|
|
165
|
+
import { createHeadlessEditor } from '@lexical/headless'
|
|
166
|
+
import { $convertFromMarkdownString } from '@lexical/markdown'
|
|
167
|
+
|
|
168
|
+
// The CALLER owns the markdown → target-tree parse, with ITS OWN nodes and
|
|
169
|
+
// transformer set (custom nodes/transformers make the tree caller-specific).
|
|
170
|
+
const parser = createHeadlessEditor({ nodes: MY_NODES, onError })
|
|
171
|
+
parser.update(() => $convertFromMarkdownString(agentMarkdown, MY_TRANSFORMERS), {
|
|
172
|
+
discrete: true,
|
|
173
|
+
})
|
|
174
|
+
const target = targetFromEditorState(parser.getEditorState())
|
|
175
|
+
|
|
176
|
+
// The reusable CORE takes the parsed tree and writes Loro under 'agent-write'.
|
|
177
|
+
reconcileTargetIntoLoro(collab.doc, collab.root, target)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Guarantees.** Identical markdown ⇒ **zero ops, 100% survival**. A one-paragraph
|
|
181
|
+
edit ⇒ only that `LoroText` changes. Append / prepend / delete / move ⇒ every
|
|
182
|
+
surviving block keeps its identity, and a move is one `pos` write. The commit
|
|
183
|
+
carries the distinct origin `AGENT_WRITE_ORIGIN`, which `loroCollab` lists among
|
|
184
|
+
the local origins the **inbound** path applies — so an agent write on the shared
|
|
185
|
+
document **bounces into any live editor** bound to it, preserving `NodeKey`s and
|
|
186
|
+
decorator mounts on the way in. It deliberately does **not** consult the
|
|
187
|
+
`ContainerNodeMap`: content matching is the point, and the mapping self-heals on
|
|
188
|
+
the inbound bounce.
|
|
189
|
+
|
|
190
|
+
**Why the caller owns the markdown parse.** The reconciler is the reusable core;
|
|
191
|
+
the markdown → target parse is caller-specific (custom nodes and transformer set).
|
|
192
|
+
Keeping it in the caller is what lets this package depend on **neither
|
|
193
|
+
`@lexical/markdown` nor `@lexical/headless`** at runtime — they are dev-only here.
|
|
194
|
+
Project the caller's parsed editor state with `targetFromEditorState` (or
|
|
195
|
+
`projectTarget` inside a read); both use only `lexical`.
|
|
196
|
+
|
|
197
|
+
### The duplicate-block caveat (honest residual)
|
|
198
|
+
|
|
199
|
+
Content matching cannot fully disambiguate **byte-identical sibling blocks** —
|
|
200
|
+
they have no distinguishing content, and the agent path has no `NodeKey` identity
|
|
201
|
+
to fall back on. The exact-match pass applies a **content + position bias**: among
|
|
202
|
+
identical carriers, a target claims the one whose index is nearest its own. That
|
|
203
|
+
**fixes the common case** — changing the first of two identical blocks now leaves
|
|
204
|
+
that block's carrier free to absorb the change while the unchanged sibling keeps
|
|
205
|
+
its own carrier, so a concurrent edit to the other identical block no longer
|
|
206
|
+
collides.
|
|
207
|
+
|
|
208
|
+
It is **not a complete solution, and does not claim to be.** When the **count** of
|
|
209
|
+
an identical group changes (delete one of three identical paragraphs), content is
|
|
210
|
+
by definition insufficient to say which carrier the user meant to keep, and
|
|
211
|
+
position proximity is only a guess — the trailing carrier is dropped regardless of
|
|
212
|
+
intent. `NodeKey` identity, unavailable on the agent path, is the only complete
|
|
213
|
+
answer. `test/agent-write.test.ts` pins both the improvement and this residual.
|
|
214
|
+
|
|
215
|
+
## Scope
|
|
216
|
+
|
|
217
|
+
- **Document sync + peer-scoped undo.** No presence or remote cursors yet —
|
|
218
|
+
Loro's `EphemeralStore` makes those additive, later work.
|
|
219
|
+
- **Undo is CRDT-aware and LOCAL-ONLY** (`src/undo.ts`, exposed as
|
|
220
|
+
`LoroCollab.externalUndo`). It is built on Loro's `UndoManager`, which is
|
|
221
|
+
operation-based and bound to this peer's PeerID: undo reverts exactly the local
|
|
222
|
+
user's own last change and **never** a peer's concurrent edit. That is why a
|
|
223
|
+
host must let the editor turn `@lexical/history` off — `lexicalForeign` does so
|
|
224
|
+
automatically the moment `externalUndo` is passed, so the snapshot-based local
|
|
225
|
+
stack (which would rewind remote work for everyone) can never run alongside it.
|
|
226
|
+
A same-parent block move undoes to its previous fractional index; a text edit,
|
|
227
|
+
insert, delete and format change each undo to exactly their prior state.
|
|
228
|
+
`test/undo.test.ts` pins all of it, including the local-scope property.
|
|
229
|
+
`test/harden.test.ts` keeps a contrast test showing why the built-in snapshot
|
|
230
|
+
history — deliberately NOT used here — is not collaboration-safe.
|
|
231
|
+
|
|
232
|
+
- **Text-node `style`, `mode` and `detail` are not represented**; the run model
|
|
233
|
+
is `{ text, format }`.
|
|
234
|
+
|
|
235
|
+
## Triaging a suspected convergence bug
|
|
236
|
+
|
|
237
|
+
Exchange full snapshots between peers and compare `doc.toJSON()`. If the
|
|
238
|
+
**documents** differ, the problem is below this binding and not fixable here.
|
|
239
|
+
Only if the documents agree while the **editors** differ is it ours.
|
|
240
|
+
|
|
241
|
+
## Testing
|
|
242
|
+
|
|
243
|
+
`test/network.ts` is a multi-peer in-memory network with delay, reordering and
|
|
244
|
+
disconnect knobs; `test/convergence.test.ts` and `test/convergence-attack.test.ts`
|
|
245
|
+
drive the real binding through it, ending in randomized three-peer property
|
|
246
|
+
tests. Raising their round counts is how you hunt for new bugs — it is what found
|
|
247
|
+
every real defect fixed so far.
|
|
248
|
+
|
|
249
|
+
`test/constraints.test.ts` is different in kind: rather than testing that the
|
|
250
|
+
code honours the ordering rules, it demonstrates **what breaks without them**. A
|
|
251
|
+
failure there may be the cost of a deliberate tradeoff rather than a bug — read
|
|
252
|
+
the comment before "fixing" one.
|
|
253
|
+
|
|
254
|
+
## Peer dependencies
|
|
255
|
+
|
|
256
|
+
`lexical`, `@llui/lexical` and `loro-crdt` are peer dependencies — install them
|
|
257
|
+
in the host application so exactly one copy of each is deduped across the app and
|
|
258
|
+
this package.
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
import { type EditorState, type LexicalNode } from 'lexical';
|
|
56
|
+
import type { LoroDoc } from 'loro-crdt';
|
|
57
|
+
import { type ElementContainer, type PropValue } from './schema.js';
|
|
58
|
+
import { type TextRun } from './text.js';
|
|
59
|
+
/** A maximal run of adjacent text nodes — the schema's text unit (see `schema.ts`). */
|
|
60
|
+
export interface TargetText {
|
|
61
|
+
readonly kind: 'text';
|
|
62
|
+
readonly runs: readonly TextRun[];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A Lexical element (paragraph/heading/list/…) or a leaf mirrored as an element
|
|
66
|
+
* (`LineBreakNode`, `LLuiDecoratorNode`) whose payload lives entirely in `props`.
|
|
67
|
+
*/
|
|
68
|
+
export interface TargetElement {
|
|
69
|
+
readonly kind: 'element';
|
|
70
|
+
readonly type: string;
|
|
71
|
+
readonly props: Readonly<Record<string, PropValue>>;
|
|
72
|
+
readonly children: readonly TargetChild[];
|
|
73
|
+
}
|
|
74
|
+
/** One child of a target element: a text run or a nested element. */
|
|
75
|
+
export type TargetChild = TargetText | TargetElement;
|
|
76
|
+
/**
|
|
77
|
+
* Project one Lexical element (or element-mirrored leaf) to a {@link TargetElement}.
|
|
78
|
+
*
|
|
79
|
+
* MUST be called inside a Lexical read (`editorState.read(() => …)`), because it
|
|
80
|
+
* reads node content. Uses only `lexical` (a peer dependency) — never
|
|
81
|
+
* `@lexical/markdown`. See {@link targetFromEditorState} for the common wrapper.
|
|
82
|
+
*/
|
|
83
|
+
export declare function projectTarget(node: LexicalNode): TargetElement;
|
|
84
|
+
/**
|
|
85
|
+
* Project the root of an `EditorState` to a {@link TargetElement}, doing the read
|
|
86
|
+
* for you.
|
|
87
|
+
*
|
|
88
|
+
* The caller owns the markdown → editor-state parse (its own headless editor and
|
|
89
|
+
* `@lexical/markdown` transformer set); this projects the parsed tree into the
|
|
90
|
+
* plain, serializable shape {@link reconcileTargetIntoLoro} consumes. Uses only
|
|
91
|
+
* `lexical`.
|
|
92
|
+
*/
|
|
93
|
+
export declare function targetFromEditorState(state: EditorState): TargetElement;
|
|
94
|
+
/**
|
|
95
|
+
* Commit origin stamped on the single agent-write commit.
|
|
96
|
+
*
|
|
97
|
+
* Distinct from `to-loro.ts`'s `OUTBOUND_ORIGIN` so the inbound path can tell an
|
|
98
|
+
* agent write apart from an echo of its own outbound write: `binding.ts` lists
|
|
99
|
+
* this origin among the LOCAL batches the inbound path must still APPLY, which is
|
|
100
|
+
* what bounces an agent write into a live editor (preserving `NodeKey`s and
|
|
101
|
+
* decorator mounts).
|
|
102
|
+
*/
|
|
103
|
+
export declare const AGENT_WRITE_ORIGIN = "agent-write";
|
|
104
|
+
/**
|
|
105
|
+
* Reconcile a parsed target tree into an existing Loro document, preserving the
|
|
106
|
+
* `ContainerID`s of unchanged and text-edited blocks, and commit under `origin`.
|
|
107
|
+
*
|
|
108
|
+
* A SIBLING to `syncLexicalToLoro` — it writes Loro directly rather than mirroring
|
|
109
|
+
* a Lexical update, matches by CONTENT rather than by `NodeKey`, and does NOT
|
|
110
|
+
* consult the `ContainerNodeMap` (which self-heals on the inbound bounce).
|
|
111
|
+
*
|
|
112
|
+
* @param doc the shared document.
|
|
113
|
+
* @param root its root element container, as returned by `initDoc`.
|
|
114
|
+
* @param target the desired tree, from {@link targetFromEditorState} /
|
|
115
|
+
* {@link projectTarget} (the caller owns the markdown parse).
|
|
116
|
+
* @param origin the commit origin. Defaults to {@link AGENT_WRITE_ORIGIN}; keep
|
|
117
|
+
* it on the inbound target's applied-local-origins list, or a live
|
|
118
|
+
* editor bound to the same doc will not see the change.
|
|
119
|
+
* @returns the number of Loro write ops emitted. `0` means the target already
|
|
120
|
+
* matched the document — nothing committed, no peer sees an event.
|
|
121
|
+
*/
|
|
122
|
+
export declare function reconcileTargetIntoLoro(doc: LoroDoc, root: ElementContainer, target: TargetElement, origin?: string): number;
|
|
123
|
+
//# sourceMappingURL=agent-write.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-write.d.ts","sourceRoot":"","sources":["../src/agent-write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AAEH,OAAO,EAIL,KAAK,WAAW,EAEhB,KAAK,WAAW,EAEjB,MAAM,SAAS,CAAA;AAChB,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,WAAW,CAAA;AAGlD,OAAO,EAcL,KAAK,gBAAgB,EACrB,KAAK,SAAS,EACf,MAAM,aAAa,CAAA;AACpB,OAAO,EAQL,KAAK,OAAO,EACb,MAAM,WAAW,CAAA;AAMlB,uFAAuF;AACvF,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,SAAS,OAAO,EAAE,CAAA;CAClC;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAA;IACnD,QAAQ,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,CAAA;CAC1C;AAED,qEAAqE;AACrE,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,aAAa,CAAA;AA4CpD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,aAAa,CAwB9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,WAAW,GAAG,aAAa,CAQvE;AAkPD;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,gBAAgB,CAAA;AAE/C;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,aAAa,EACrB,MAAM,GAAE,MAA2B,GAClC,MAAM,CAKR"}
|