@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
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound sync: the Loro mirror → a Lexical `EditorState`.
|
|
3
|
+
*
|
|
4
|
+
* ── The one thing this must not do ─────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* loro-prosemirror's inbound path replaces the whole document on every remote
|
|
7
|
+
* event (`sync-plugin.ts`). ProseMirror tolerates that. LEXICAL CANNOT: its
|
|
8
|
+
* `NodeKey` is a bare counter (`LexicalUtils.ts`: `'' + keyCounter++`), so a
|
|
9
|
+
* rebuild mints a fresh key for every node, which tears down all DOM, destroys
|
|
10
|
+
* the selection and IME composition, and — decisively for this stack — destroys
|
|
11
|
+
* and remounts EVERY `LLuiDecoratorNode`, because `packages/lexical/decorator.ts`
|
|
12
|
+
* disposes the mounted LLui sub-app on the 'destroyed' mutation.
|
|
13
|
+
*
|
|
14
|
+
* So this module is a PERSISTENT MIRROR: it resolves each remote change to an
|
|
15
|
+
* existing `NodeKey` through the registry in `mapping.ts` and MUTATES THAT NODE
|
|
16
|
+
* IN PLACE. Nodes nothing touched are never written, so their keys — and
|
|
17
|
+
* therefore their DOM, their mounts, and any selection inside them — survive by
|
|
18
|
+
* construction. That is the model `@lexical/yjs`'s `SyncEditorStates.ts` uses,
|
|
19
|
+
* and it is the only one that works here.
|
|
20
|
+
*
|
|
21
|
+
* ── Why it reconciles from STATE, not from the deltas ──────────────────────
|
|
22
|
+
*
|
|
23
|
+
* The events say WHERE the document changed; this module then reconciles those
|
|
24
|
+
* containers against Loro's CURRENT state rather than replaying each delta. Four
|
|
25
|
+
* reasons, all learned from the event shapes loro-crdt actually emits:
|
|
26
|
+
*
|
|
27
|
+
* 1. A batch that creates a subtree emits the parent's list insert AND a
|
|
28
|
+
* separate event for every descendant container. Replaying all of them
|
|
29
|
+
* double-applies; reconciling from state is idempotent, so the duplicate
|
|
30
|
+
* descriptions of one change collapse to one reconciliation.
|
|
31
|
+
* 2. A `LoroMovableList#move` arrives as an insert plus a delete carrying the
|
|
32
|
+
* SAME `ContainerID`. Reconciling by identity recognises that as a move —
|
|
33
|
+
* the whole point of the movable-list schema — where a delta replay would
|
|
34
|
+
* see a delete and rebuild the subtree.
|
|
35
|
+
* 3. A map deletion is reported as an `undefined` value in `MapDiff.updated`,
|
|
36
|
+
* which is indistinguishable from an absent key in most JS handling. Reading
|
|
37
|
+
* the map is unambiguous.
|
|
38
|
+
* 4. It is the mirror image of `to-loro.ts`, so both directions share one
|
|
39
|
+
* notion of "these two trees agree" (`text.ts`'s normalized runs) and cannot
|
|
40
|
+
* drift apart in their idea of what a difference is.
|
|
41
|
+
*
|
|
42
|
+
* The events are therefore used as a DIRTY SET, exactly as `to-loro.ts` uses
|
|
43
|
+
* Lexical's `dirtyElements`/`dirtyLeaves`: every element on the path from the
|
|
44
|
+
* root to a changed container is marked, and the walk descends only into marked
|
|
45
|
+
* children. A remote keystroke costs O(tree depth), not O(document).
|
|
46
|
+
*
|
|
47
|
+
* ── Selection ──────────────────────────────────────────────────────────────
|
|
48
|
+
*
|
|
49
|
+
* Untouched nodes keep their keys, so a caret outside the changed run needs no
|
|
50
|
+
* help at all. Only the case where a remote change lands INSIDE the exact text
|
|
51
|
+
* run holding the caret needs work: there the caret's offset is transformed
|
|
52
|
+
* through the same single-region diff used to update the run (a caret at or
|
|
53
|
+
* before the edit point does not move; one after it shifts by
|
|
54
|
+
* `insert.length - remove`; one inside a deleted span clamps to the edit point).
|
|
55
|
+
* That is deliberately narrow — reaching for cursors more broadly would mean
|
|
56
|
+
* writing selection on every remote event, which is its own source of jitter.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import {
|
|
60
|
+
$createTextNode,
|
|
61
|
+
$getRoot,
|
|
62
|
+
$getSelection,
|
|
63
|
+
$isElementNode,
|
|
64
|
+
$isRangeSelection,
|
|
65
|
+
$isTextNode,
|
|
66
|
+
$parseSerializedNode,
|
|
67
|
+
$setSelection,
|
|
68
|
+
COLLABORATION_TAG,
|
|
69
|
+
SKIP_SCROLL_INTO_VIEW_TAG,
|
|
70
|
+
type ElementNode,
|
|
71
|
+
type LexicalEditor,
|
|
72
|
+
type LexicalNode,
|
|
73
|
+
type NodeKey,
|
|
74
|
+
type PointType,
|
|
75
|
+
type SerializedLexicalNode,
|
|
76
|
+
type TextNode,
|
|
77
|
+
} from 'lexical'
|
|
78
|
+
import {
|
|
79
|
+
LoroMap,
|
|
80
|
+
type Container,
|
|
81
|
+
type ContainerID,
|
|
82
|
+
type LoroDoc,
|
|
83
|
+
type LoroEventBatch,
|
|
84
|
+
} from 'loro-crdt'
|
|
85
|
+
|
|
86
|
+
import { ContainerNodeMap } from './mapping.js'
|
|
87
|
+
import {
|
|
88
|
+
KEY_TYPE,
|
|
89
|
+
containerId,
|
|
90
|
+
containerIsLive,
|
|
91
|
+
elementProps,
|
|
92
|
+
elementType,
|
|
93
|
+
isTextContainer,
|
|
94
|
+
orderedChildren,
|
|
95
|
+
type ChildContainer,
|
|
96
|
+
type ElementContainer,
|
|
97
|
+
type PropValue,
|
|
98
|
+
} from './schema.js'
|
|
99
|
+
import { diffTextWithCursor, normalizeRuns, runsFromText, runsText, type TextRun } from './text.js'
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Public surface
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Tags stamped on every inbound writeback.
|
|
107
|
+
*
|
|
108
|
+
* `COLLABORATION_TAG` is echo layer (b): `to-loro.ts` skips updates carrying it,
|
|
109
|
+
* so our own writeback cannot bounce back into the shared document.
|
|
110
|
+
* `SKIP_SCROLL_INTO_VIEW_TAG` stops a peer's edit from yanking the local
|
|
111
|
+
* viewport.
|
|
112
|
+
*
|
|
113
|
+
* `PROGRAMMATIC_TAG` is deliberately ABSENT and must stay that way — echo layer
|
|
114
|
+
* (c). `packages/lexical/src/foreign.ts` treats that tag as "the host pushed new
|
|
115
|
+
* content: cancel pending outbound work and rebase", so a remote writeback
|
|
116
|
+
* carrying it would silently cancel the local user's in-flight debounced
|
|
117
|
+
* `onChange` and the host's persistence would go dark whenever a peer types.
|
|
118
|
+
*/
|
|
119
|
+
export const INBOUND_TAGS: readonly string[] = [COLLABORATION_TAG, SKIP_SCROLL_INTO_VIEW_TAG]
|
|
120
|
+
|
|
121
|
+
/** The Lexical side of the binding, plus the shared document it mirrors. */
|
|
122
|
+
export interface InboundTarget {
|
|
123
|
+
readonly doc: LoroDoc
|
|
124
|
+
/** The root element container, as returned by `initDoc`. */
|
|
125
|
+
readonly root: ElementContainer
|
|
126
|
+
/** The ContainerID ↔ NodeKey registry, owned by the binding and mutated here. */
|
|
127
|
+
readonly mapping: ContainerNodeMap
|
|
128
|
+
readonly editor: LexicalEditor
|
|
129
|
+
/**
|
|
130
|
+
* Commit origins whose LOCAL batches must still be applied.
|
|
131
|
+
*
|
|
132
|
+
* Echo layer (a) drops `by: 'local'` batches — they are this peer's own
|
|
133
|
+
* outbound writes coming back round. But not every local batch is an echo:
|
|
134
|
+
*
|
|
135
|
+
* - the CRDT-aware undo manager (`undo.ts`) produces LOCAL batches from
|
|
136
|
+
* `undo()`/`redo()` (`UNDO_ORIGINS`), and
|
|
137
|
+
* - the agent-write reconciler (`agent-write.ts`) writes the document directly
|
|
138
|
+
* under {@link import('./agent-write.js').AGENT_WRITE_ORIGIN},
|
|
139
|
+
*
|
|
140
|
+
* neither of which came FROM the editor, so both MUST be applied to bounce into
|
|
141
|
+
* it (preserving `NodeKey`s and decorator mounts). `binding.ts` passes those
|
|
142
|
+
* origins here. A local batch whose origin is on this list is applied; every
|
|
143
|
+
* other local batch is still dropped as an echo.
|
|
144
|
+
*/
|
|
145
|
+
readonly localOrigins?: readonly string[]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Apply one Loro event batch to the editor.
|
|
150
|
+
*
|
|
151
|
+
* @returns whether anything was applied. `false` means the batch was an echo of
|
|
152
|
+
* our own write, or described no change the editor could see.
|
|
153
|
+
*/
|
|
154
|
+
export function applyLoroToLexical(target: InboundTarget, batch: LoroEventBatch): boolean {
|
|
155
|
+
// ── Echo layer (a) ──────────────────────────────────────────────────────
|
|
156
|
+
// A local batch is this peer's own outbound write completing its commit. Our
|
|
157
|
+
// outbound listener already produced it FROM the editor; feeding it back would
|
|
158
|
+
// re-enter `editor.update` from inside a Lexical update listener.
|
|
159
|
+
if (batch.by === 'local') {
|
|
160
|
+
const origins = target.localOrigins ?? []
|
|
161
|
+
if (batch.origin === undefined || !origins.includes(batch.origin)) return false
|
|
162
|
+
}
|
|
163
|
+
if (batch.events.length === 0) return false
|
|
164
|
+
|
|
165
|
+
const dirty = collectDirtyElements(target.doc, containerId(target.root), batch.events)
|
|
166
|
+
return $applyReconciliation(target, dirty)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Reconcile the ENTIRE shared document into the editor, with no dirty gate.
|
|
171
|
+
*
|
|
172
|
+
* Used at boot by a peer adopting a document it has no event history for (see
|
|
173
|
+
* `seed.ts`), and as the fallback whenever an event's container ancestry cannot
|
|
174
|
+
* be resolved. Full-fidelity and identity-preserving: adopting a document the
|
|
175
|
+
* editor already matches writes nothing and churns no NodeKeys.
|
|
176
|
+
*/
|
|
177
|
+
export function adoptLoroDocument(target: InboundTarget): boolean {
|
|
178
|
+
return $applyReconciliation(target, null)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// The dirty set
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Every element container on the path from the root to a container this batch
|
|
187
|
+
* touched — the inbound analogue of Lexical's `dirtyElements`.
|
|
188
|
+
*
|
|
189
|
+
* The walk goes UP from each event target via `parent()`, so it needs no path
|
|
190
|
+
* arithmetic and cannot be confused by indices that shifted within the batch.
|
|
191
|
+
*
|
|
192
|
+
* ── The fail-safe, and why it must be exactly this one ─────────────────────
|
|
193
|
+
*
|
|
194
|
+
* The gate is only sound if the editor already agreed with the document before
|
|
195
|
+
* the batch: a child that is not marked is left ALONE, so a single event whose
|
|
196
|
+
* location we resolve too narrowly leaves that subtree permanently stale, and
|
|
197
|
+
* nothing later repairs it (no future batch will mention it either). A narrowed
|
|
198
|
+
* dirty set is therefore not a small error — it is silent, unrecoverable
|
|
199
|
+
* divergence.
|
|
200
|
+
*
|
|
201
|
+
* So anything less than a walk that terminates at the ROOT WE MIRROR returns
|
|
202
|
+
* `null`, which drops the gate and falls back to a full structural pass —
|
|
203
|
+
* correct regardless of provenance, at the cost of one O(document) walk. An
|
|
204
|
+
* earlier version skipped events whose target reported `isDeleted()` instead,
|
|
205
|
+
* and a randomized three-peer test caught it: a container one peer deletes and
|
|
206
|
+
* another concurrently MOVES is resurrected by the merge, but reports itself
|
|
207
|
+
* deleted to the peer that removed it, so its events were dropped and that
|
|
208
|
+
* peer's editor silently stopped tracking the run for the rest of the session.
|
|
209
|
+
*/
|
|
210
|
+
function collectDirtyElements(
|
|
211
|
+
doc: LoroDoc,
|
|
212
|
+
rootId: ContainerID,
|
|
213
|
+
events: LoroEventBatch['events'],
|
|
214
|
+
): Set<ContainerID> | null {
|
|
215
|
+
const dirty = new Set<ContainerID>()
|
|
216
|
+
for (const event of events) {
|
|
217
|
+
let cursor: Container | undefined = doc.getContainerById(event.target)
|
|
218
|
+
let reached = false
|
|
219
|
+
while (cursor !== undefined) {
|
|
220
|
+
// An element map is the only container carrying `type`; `props` maps and
|
|
221
|
+
// `children` lists are addressed through the element that owns them.
|
|
222
|
+
if (cursor instanceof LoroMap && cursor.get(KEY_TYPE) !== undefined) dirty.add(cursor.id)
|
|
223
|
+
if (cursor.id === rootId) reached = true
|
|
224
|
+
cursor = cursor.parent()
|
|
225
|
+
}
|
|
226
|
+
if (!reached) return null
|
|
227
|
+
}
|
|
228
|
+
return dirty
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// The update
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/** Everything the walk threads through itself. */
|
|
236
|
+
interface Context {
|
|
237
|
+
readonly doc: LoroDoc
|
|
238
|
+
readonly mapping: ContainerNodeMap
|
|
239
|
+
/** `null` means "descend everywhere" — see {@link collectDirtyElements}. */
|
|
240
|
+
readonly dirty: Set<ContainerID> | null
|
|
241
|
+
/** Text points captured before the walk, to be re-placed after it. */
|
|
242
|
+
readonly points: CapturedPoint[]
|
|
243
|
+
/** Set when any Lexical node was written. */
|
|
244
|
+
changed: boolean
|
|
245
|
+
/** Set when a node was removed, so the registry is swept once at the end. */
|
|
246
|
+
removed: boolean
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A selection endpoint that sat in a text run, in run-absolute UTF-16 units. */
|
|
250
|
+
interface CapturedPoint {
|
|
251
|
+
readonly point: PointType
|
|
252
|
+
/** The keys of the text nodes forming the run this point sits in. */
|
|
253
|
+
readonly runKeys: readonly NodeKey[]
|
|
254
|
+
/** Offset from the START of the run, not of the individual node. */
|
|
255
|
+
offset: number
|
|
256
|
+
/** Set once the run is rebuilt: where the point must land. */
|
|
257
|
+
resolved: { node: TextNode; offset: number } | null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function $applyReconciliation(target: InboundTarget, dirty: Set<ContainerID> | null): boolean {
|
|
261
|
+
const context: Context = {
|
|
262
|
+
doc: target.doc,
|
|
263
|
+
mapping: target.mapping,
|
|
264
|
+
dirty,
|
|
265
|
+
points: [],
|
|
266
|
+
changed: false,
|
|
267
|
+
removed: false,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
target.editor.update(
|
|
271
|
+
() => {
|
|
272
|
+
capturePoints(context)
|
|
273
|
+
const root = $getRoot()
|
|
274
|
+
context.mapping.link(containerId(target.root), root.getKey())
|
|
275
|
+
$applyProps(target.root, root, context)
|
|
276
|
+
$reconcileChildren(target.root, root, context)
|
|
277
|
+
$restorePoints(context)
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
tag: [...INBOUND_TAGS],
|
|
281
|
+
skipTransforms: true,
|
|
282
|
+
// REQUIRED. Lexical merges and splits adjacent TextNodes behind our back
|
|
283
|
+
// and reports it via `normalizedNodes`; without a synchronous flush the
|
|
284
|
+
// ContainerID ↔ NodeKey mapping drifts and the document corrupts.
|
|
285
|
+
discrete: true,
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if (context.removed) {
|
|
290
|
+
const nodeMap = target.editor.getEditorState()._nodeMap
|
|
291
|
+
target.mapping.sweep({
|
|
292
|
+
hasContainer: (id) => containerIsLive(target.doc, id),
|
|
293
|
+
hasNode: (key) => nodeMap.has(key),
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return context.changed
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Props
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Apply an element container's props to an existing node, in place.
|
|
306
|
+
*
|
|
307
|
+
* `updateFromJSON` is Lexical's own in-place applier and the exact inverse of
|
|
308
|
+
* the `exportJSON` the outbound direction writes, so this stays generic over
|
|
309
|
+
* custom node types instead of poking private `__`-prefixed fields the way
|
|
310
|
+
* `@lexical/yjs` does.
|
|
311
|
+
*
|
|
312
|
+
* @returns `false` when the node's type does NOT implement `updateFromJSON`
|
|
313
|
+
* faithfully — the props did not take, so the caller must replace the node. That
|
|
314
|
+
* is detected by re-reading `exportJSON`, never assumed, because a node whose
|
|
315
|
+
* props silently fail to apply is a permanently-diverged document.
|
|
316
|
+
*/
|
|
317
|
+
function $applyProps(container: ElementContainer, node: LexicalNode, context: Context): boolean {
|
|
318
|
+
const props = elementProps(container).toJSON() as Record<string, PropValue>
|
|
319
|
+
if (!propsDiffer(props, node)) return true
|
|
320
|
+
|
|
321
|
+
node.updateFromJSON(props as Record<string, never>)
|
|
322
|
+
context.changed = true
|
|
323
|
+
|
|
324
|
+
if (!propsDiffer(props, node)) return true
|
|
325
|
+
// Fall through to replacement, but say why: a node type reaching this point
|
|
326
|
+
// has an `exportJSON` its `updateFromJSON` cannot round-trip, which costs its
|
|
327
|
+
// subtree's NodeKeys (and any decorator mount in it) on every remote change.
|
|
328
|
+
console.error(
|
|
329
|
+
`lexical-loro: '${node.getType()}'.updateFromJSON() does not apply every property its ` +
|
|
330
|
+
'exportJSON() emits, so the node must be REPLACED on each remote change — which destroys ' +
|
|
331
|
+
'its NodeKey, its DOM, and any mounted decorator sub-app. Implement updateFromJSON.',
|
|
332
|
+
)
|
|
333
|
+
return false
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Whether any prop the shared document holds differs from the node's own.
|
|
338
|
+
*
|
|
339
|
+
* Reads through `getLatest()`, which is load-bearing rather than defensive:
|
|
340
|
+
* `updateFromJSON` writes through `getWritable()`, which CLONES the node, so the
|
|
341
|
+
* reference the caller is holding is stale the moment the write lands. Comparing
|
|
342
|
+
* against it would report the update as having failed and trigger a needless —
|
|
343
|
+
* and mount-destroying — node replacement.
|
|
344
|
+
*/
|
|
345
|
+
function propsDiffer(props: Record<string, PropValue>, node: LexicalNode): boolean {
|
|
346
|
+
const current = node.getLatest().exportJSON() as Record<string, unknown>
|
|
347
|
+
for (const [key, value] of Object.entries(props)) {
|
|
348
|
+
if (!jsonEqual(current[key], value)) return true
|
|
349
|
+
}
|
|
350
|
+
return false
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function jsonEqual(a: unknown, b: unknown): boolean {
|
|
354
|
+
if (a === b) return true
|
|
355
|
+
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
|
|
356
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
357
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
358
|
+
return a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]))
|
|
359
|
+
}
|
|
360
|
+
const left = a as Record<string, unknown>
|
|
361
|
+
const right = b as Record<string, unknown>
|
|
362
|
+
const keys = Object.keys(left)
|
|
363
|
+
if (keys.length !== Object.keys(right).length) return false
|
|
364
|
+
return keys.every((key) => key in right && jsonEqual(left[key], right[key]))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Children
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* A Lexical element's children, grouped the way the schema stores them: one
|
|
373
|
+
* entry per non-text child, one per MAXIMAL RUN of adjacent text nodes.
|
|
374
|
+
*
|
|
375
|
+
* The grouping must match `to-loro.ts`'s `describeChildren` exactly — a run is
|
|
376
|
+
* the shared unit of identity, and disagreeing about where runs begin would make
|
|
377
|
+
* the two directions match different containers to the same nodes.
|
|
378
|
+
*/
|
|
379
|
+
type Group = TextGroup | ElementGroup
|
|
380
|
+
|
|
381
|
+
interface TextGroup {
|
|
382
|
+
readonly kind: 'text'
|
|
383
|
+
readonly nodes: TextNode[]
|
|
384
|
+
/** The run's registry key: its FIRST node's `NodeKey`. */
|
|
385
|
+
readonly anchor: NodeKey
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
interface ElementGroup {
|
|
389
|
+
readonly kind: 'element'
|
|
390
|
+
readonly node: LexicalNode
|
|
391
|
+
readonly key: NodeKey
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function groupChildren(node: ElementNode): Group[] {
|
|
395
|
+
const out: Group[] = []
|
|
396
|
+
let run: TextNode[] = []
|
|
397
|
+
const flush = (): void => {
|
|
398
|
+
if (run.length === 0) return
|
|
399
|
+
out.push({ kind: 'text', nodes: run, anchor: run[0]!.getKey() })
|
|
400
|
+
run = []
|
|
401
|
+
}
|
|
402
|
+
for (const child of node.getChildren()) {
|
|
403
|
+
if ($isTextNode(child)) run.push(child)
|
|
404
|
+
else {
|
|
405
|
+
flush()
|
|
406
|
+
out.push({ kind: 'element', node: child, key: child.getKey() })
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
flush()
|
|
410
|
+
return out
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Reconcile a Lexical element's children against its container's child list.
|
|
415
|
+
*
|
|
416
|
+
* Matching is by IDENTITY through the registry: a container that already
|
|
417
|
+
* addresses a node reuses that node wherever it has moved to, which is what
|
|
418
|
+
* turns a remote reorder — one `pos` register write — into a Lexical reorder
|
|
419
|
+
* rather than a rebuild. Text runs additionally fall back to ordinal matching
|
|
420
|
+
* among the text
|
|
421
|
+
* children (mirroring the outbound direction), so a run whose anchor Lexical
|
|
422
|
+
* normalized away still finds its nodes instead of recreating them.
|
|
423
|
+
*/
|
|
424
|
+
function $reconcileChildren(
|
|
425
|
+
container: ElementContainer,
|
|
426
|
+
node: ElementNode,
|
|
427
|
+
context: Context,
|
|
428
|
+
): void {
|
|
429
|
+
const desired = liveChildren(container)
|
|
430
|
+
const groups = groupChildren(node)
|
|
431
|
+
const claimed = new Set<Group>()
|
|
432
|
+
|
|
433
|
+
const byKey = new Map<NodeKey, Group>()
|
|
434
|
+
for (const group of groups) {
|
|
435
|
+
byKey.set(group.kind === 'text' ? group.anchor : group.key, group)
|
|
436
|
+
}
|
|
437
|
+
const textGroups = groups.filter((group): group is TextGroup => group.kind === 'text')
|
|
438
|
+
let nextText = 0
|
|
439
|
+
|
|
440
|
+
const matched: (Group | undefined)[] = desired.map((child) => {
|
|
441
|
+
const key = context.mapping.nodeKey(containerId(child))
|
|
442
|
+
const found = key === undefined ? undefined : byKey.get(key)
|
|
443
|
+
if (found !== undefined && !claimed.has(found)) {
|
|
444
|
+
// A container whose node changed KIND (text ⇄ element) cannot be reused.
|
|
445
|
+
if (isTextContainer(child) === (found.kind === 'text')) {
|
|
446
|
+
claimed.add(found)
|
|
447
|
+
return found
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!isTextContainer(child)) return undefined
|
|
451
|
+
while (nextText < textGroups.length && claimed.has(textGroups[nextText]!)) nextText++
|
|
452
|
+
const ordinal = textGroups[nextText]
|
|
453
|
+
if (ordinal === undefined) return undefined
|
|
454
|
+
nextText++
|
|
455
|
+
claimed.add(ordinal)
|
|
456
|
+
return ordinal
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// 1. Build (or update in place) the node list this element should hold.
|
|
460
|
+
const ordered: LexicalNode[] = []
|
|
461
|
+
for (let i = 0; i < desired.length; i++) {
|
|
462
|
+
const child = desired[i]!
|
|
463
|
+
const group = matched[i]
|
|
464
|
+
if (isTextContainer(child)) {
|
|
465
|
+
const existing = group !== undefined && group.kind === 'text' ? group.nodes : []
|
|
466
|
+
const nodes = $reconcileTextRun(child, existing, context)
|
|
467
|
+
const id = containerId(child)
|
|
468
|
+
if (nodes.length === 0) context.mapping.unlinkContainer(id)
|
|
469
|
+
else context.mapping.link(id, nodes[0]!.getKey())
|
|
470
|
+
ordered.push(...nodes)
|
|
471
|
+
continue
|
|
472
|
+
}
|
|
473
|
+
const existing = group !== undefined && group.kind === 'element' ? group.node : undefined
|
|
474
|
+
ordered.push($reconcileElementChild(child, existing, context))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 2. Remove whatever is no longer wanted. `preserveEmptyParent` is TRUE:
|
|
478
|
+
// Lexical's default removes a parent that this leaves empty, which would
|
|
479
|
+
// delete a block the shared document still holds.
|
|
480
|
+
const keep = new Set(ordered.map((child) => child.getKey()))
|
|
481
|
+
for (const group of groups) {
|
|
482
|
+
const nodes = group.kind === 'text' ? group.nodes : [group.node]
|
|
483
|
+
for (const child of nodes) {
|
|
484
|
+
if (keep.has(child.getKey())) continue
|
|
485
|
+
const id = context.mapping.containerId(child.getKey())
|
|
486
|
+
if (id !== undefined) context.mapping.unlinkNode(child.getKey())
|
|
487
|
+
child.remove(true)
|
|
488
|
+
context.changed = true
|
|
489
|
+
context.removed = true
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 3. Place everything in order. Lexical's insert helpers MOVE an
|
|
494
|
+
// already-attached node without minting a new key, so a reorder here costs
|
|
495
|
+
// no identity — which is the whole point of the movable-list schema.
|
|
496
|
+
let previous: LexicalNode | null = null
|
|
497
|
+
for (const child of ordered) {
|
|
498
|
+
const parent = child.getParent()
|
|
499
|
+
const attached = parent !== null && parent.is(node)
|
|
500
|
+
const actual = attached ? child.getPreviousSibling() : undefined
|
|
501
|
+
const inPlace =
|
|
502
|
+
attached && ((actual === null && previous === null) || (actual?.is(previous) ?? false))
|
|
503
|
+
if (!inPlace) {
|
|
504
|
+
if (previous === null) {
|
|
505
|
+
const first = node.getFirstChild()
|
|
506
|
+
if (first === null) node.append(child)
|
|
507
|
+
else first.insertBefore(child, false)
|
|
508
|
+
} else {
|
|
509
|
+
previous.insertAfter(child, false)
|
|
510
|
+
}
|
|
511
|
+
context.changed = true
|
|
512
|
+
}
|
|
513
|
+
previous = child
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Reconcile one element child: update it in place when it is the same node, or
|
|
519
|
+
* build a fresh subtree when there is nothing reusable.
|
|
520
|
+
*/
|
|
521
|
+
function $reconcileElementChild(
|
|
522
|
+
container: ElementContainer,
|
|
523
|
+
existing: LexicalNode | undefined,
|
|
524
|
+
context: Context,
|
|
525
|
+
): LexicalNode {
|
|
526
|
+
const id = containerId(container)
|
|
527
|
+
if (existing !== undefined && existing.getType() === elementType(container)) {
|
|
528
|
+
// Not dirty ⇒ nothing under this container changed, so do not descend.
|
|
529
|
+
if (context.dirty !== null && !context.dirty.has(id)) {
|
|
530
|
+
context.mapping.link(id, existing.getKey())
|
|
531
|
+
return existing
|
|
532
|
+
}
|
|
533
|
+
if ($applyProps(container, existing, context)) {
|
|
534
|
+
context.mapping.link(id, existing.getKey())
|
|
535
|
+
if ($isElementNode(existing)) $reconcileChildren(container, existing, context)
|
|
536
|
+
return existing
|
|
537
|
+
}
|
|
538
|
+
// Props could not be applied in place; fall through and rebuild.
|
|
539
|
+
}
|
|
540
|
+
context.changed = true
|
|
541
|
+
return $buildNode(container, context)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Build a fresh Lexical subtree from a container the registry cannot resolve —
|
|
546
|
+
* a block a peer just created.
|
|
547
|
+
*
|
|
548
|
+
* Nodes are constructed through `$parseSerializedNode`, the inverse of the
|
|
549
|
+
* `exportJSON` the outbound direction wrote, so custom node types (including
|
|
550
|
+
* `LLuiDecoratorNode`) round-trip through their own `importJSON` with no
|
|
551
|
+
* special-casing here.
|
|
552
|
+
*/
|
|
553
|
+
function $buildNode(container: ElementContainer, context: Context): LexicalNode {
|
|
554
|
+
const serialized = {
|
|
555
|
+
...(elementProps(container).toJSON() as Record<string, unknown>),
|
|
556
|
+
type: elementType(container),
|
|
557
|
+
version: 1,
|
|
558
|
+
} as SerializedLexicalNode
|
|
559
|
+
const node = $parseSerializedNode(serialized)
|
|
560
|
+
context.mapping.link(containerId(container), node.getKey())
|
|
561
|
+
|
|
562
|
+
if ($isElementNode(node)) {
|
|
563
|
+
for (const child of liveChildren(container)) $appendChild(node, child, context)
|
|
564
|
+
}
|
|
565
|
+
return node
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* An element's children, in the order the shared document renders them.
|
|
570
|
+
*
|
|
571
|
+
* The ordering is `sort by (pos, uuid)` over the child carriers — see
|
|
572
|
+
* `order.ts`. This is the ONE place the inbound walk touches the ordering model
|
|
573
|
+
* at all: every carrier is dereferenced to the container the registry addresses
|
|
574
|
+
* it by (the `LoroText` for a run, the element map for an element), and from
|
|
575
|
+
* there this module is exactly as it was under the list schema.
|
|
576
|
+
*
|
|
577
|
+
* ── PROJECTION MUST DEPEND ONLY ON REPLICATED STATE ────────────────────────
|
|
578
|
+
*
|
|
579
|
+
* `orderedChildren` consults no `isDeleted()`, and nothing here may add one.
|
|
580
|
+
* Under the previous `LoroMovableList` schema an earlier version of this
|
|
581
|
+
* function filtered "tombstones" out, reasoning that a shared rule keeps both
|
|
582
|
+
* peers showing the same thing. It does not, and cannot: `isDeleted()` is
|
|
583
|
+
* PEER-LOCAL bookkeeping, not replicated state. The peer that issued a delete
|
|
584
|
+
* kept reporting `true` while every other peer reported `false`, for the same
|
|
585
|
+
* ContainerID in the same list, so it rendered one block fewer than everybody
|
|
586
|
+
* else — permanently, since no later batch ever mentioned the container again.
|
|
587
|
+
*
|
|
588
|
+
* The carrier schema removes the temptation rather than relying on the rule
|
|
589
|
+
* being remembered: a deleted carrier's key is absent from `keys()` on every
|
|
590
|
+
* peer, symmetrically, so the projection is a pure function of the document by
|
|
591
|
+
* construction.
|
|
592
|
+
*/
|
|
593
|
+
function liveChildren(container: ElementContainer): ChildContainer[] {
|
|
594
|
+
return orderedChildren(container).map((entry) => entry.container)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function $appendChild(parent: ElementNode, child: ChildContainer, context: Context): void {
|
|
598
|
+
if (isTextContainer(child)) {
|
|
599
|
+
const nodes = buildTextNodes(runsFromText(child))
|
|
600
|
+
if (nodes.length === 0) return
|
|
601
|
+
context.mapping.link(containerId(child), nodes[0]!.getKey())
|
|
602
|
+
for (const node of nodes) parent.append(node)
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
parent.append($buildNode(child, context))
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
// Text runs
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
function buildTextNodes(runs: readonly TextRun[]): TextNode[] {
|
|
613
|
+
return runs.map((run) => {
|
|
614
|
+
const node = $createTextNode(run.text)
|
|
615
|
+
if (run.format !== 0) node.setFormat(run.format)
|
|
616
|
+
return node
|
|
617
|
+
})
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Reconcile one text run in place, reusing the existing `TextNode`s.
|
|
622
|
+
*
|
|
623
|
+
* The FIRST node is the run's registry anchor, so keeping it is what keeps the
|
|
624
|
+
* container's address stable across the edit. Nodes are reused positionally and
|
|
625
|
+
* only written when their text or format actually differs, which is what keeps a
|
|
626
|
+
* caret in an unchanged part of the run untouched.
|
|
627
|
+
*
|
|
628
|
+
* An EMPTY container yields zero nodes rather than one empty `TextNode`: the
|
|
629
|
+
* outbound direction EMPTIES a run's last `LoroText` instead of deleting it (so
|
|
630
|
+
* a peer's concurrent insertion into that exact container survives), and an
|
|
631
|
+
* empty run must project as no text at all.
|
|
632
|
+
*/
|
|
633
|
+
function $reconcileTextRun(
|
|
634
|
+
text: import('loro-crdt').LoroText,
|
|
635
|
+
existing: readonly TextNode[],
|
|
636
|
+
context: Context,
|
|
637
|
+
): TextNode[] {
|
|
638
|
+
const target = runsFromText(text)
|
|
639
|
+
const current = normalizeRuns(
|
|
640
|
+
existing.map((node) => ({ text: node.getTextContent(), format: node.getFormat() })),
|
|
641
|
+
)
|
|
642
|
+
if (runsEqual(current, target)) return [...existing]
|
|
643
|
+
|
|
644
|
+
$transformPoints(current, target, existing, context)
|
|
645
|
+
|
|
646
|
+
const out: TextNode[] = []
|
|
647
|
+
for (let i = 0; i < target.length; i++) {
|
|
648
|
+
const run = target[i]!
|
|
649
|
+
const node = existing[i]
|
|
650
|
+
if (node === undefined) {
|
|
651
|
+
out.push(buildTextNodes([run])[0]!)
|
|
652
|
+
continue
|
|
653
|
+
}
|
|
654
|
+
if (node.getTextContent() !== run.text) node.setTextContent(run.text)
|
|
655
|
+
if (node.getFormat() !== run.format) node.setFormat(run.format)
|
|
656
|
+
out.push(node)
|
|
657
|
+
}
|
|
658
|
+
for (let i = target.length; i < existing.length; i++) {
|
|
659
|
+
const node = existing[i]!
|
|
660
|
+
context.mapping.unlinkNode(node.getKey())
|
|
661
|
+
node.remove(true)
|
|
662
|
+
context.removed = true
|
|
663
|
+
}
|
|
664
|
+
context.changed = true
|
|
665
|
+
|
|
666
|
+
$resolvePoints(target, out, context)
|
|
667
|
+
return out
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function runsEqual(a: readonly TextRun[], b: readonly TextRun[]): boolean {
|
|
671
|
+
return (
|
|
672
|
+
a.length === b.length &&
|
|
673
|
+
a.every((run, i) => run.text === b[i]!.text && run.format === b[i]!.format)
|
|
674
|
+
)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
// Selection
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Record where the caret sits, as a run-absolute offset.
|
|
683
|
+
*
|
|
684
|
+
* Only TEXT points are captured. An element point addresses a `NodeKey` this
|
|
685
|
+
* module preserves, so it survives on its own; a text point is the one thing a
|
|
686
|
+
* remote edit to the same run can invalidate.
|
|
687
|
+
*/
|
|
688
|
+
function capturePoints(context: Context): void {
|
|
689
|
+
const selection = $getSelection()
|
|
690
|
+
if (!$isRangeSelection(selection)) return
|
|
691
|
+
for (const point of [selection.anchor, selection.focus]) {
|
|
692
|
+
if (point.type !== 'text') continue
|
|
693
|
+
const node = point.getNode()
|
|
694
|
+
if (!$isTextNode(node)) continue
|
|
695
|
+
const run = runNodesFor(node)
|
|
696
|
+
let offset = 0
|
|
697
|
+
for (const sibling of run) {
|
|
698
|
+
if (sibling.is(node)) break
|
|
699
|
+
offset += sibling.getTextContentSize()
|
|
700
|
+
}
|
|
701
|
+
context.points.push({
|
|
702
|
+
point,
|
|
703
|
+
runKeys: run.map((n) => n.getKey()),
|
|
704
|
+
offset: offset + point.offset,
|
|
705
|
+
resolved: null,
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** The maximal run of adjacent text nodes containing `node`. */
|
|
711
|
+
function runNodesFor(node: TextNode): TextNode[] {
|
|
712
|
+
const run: TextNode[] = [node]
|
|
713
|
+
for (let prev = node.getPreviousSibling(); $isTextNode(prev); prev = prev.getPreviousSibling()) {
|
|
714
|
+
run.unshift(prev)
|
|
715
|
+
}
|
|
716
|
+
for (let next = node.getNextSibling(); $isTextNode(next); next = next.getNextSibling()) {
|
|
717
|
+
run.push(next)
|
|
718
|
+
}
|
|
719
|
+
return run
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Move a captured caret through the text change about to be applied.
|
|
724
|
+
*
|
|
725
|
+
* The change is described as ONE region (`diffTextWithCursor`, biased to the
|
|
726
|
+
* caret so a repeated-character edit is attributed where the user actually is):
|
|
727
|
+
* a caret at or before the region is unmoved, one after it shifts by
|
|
728
|
+
* `insert.length - remove`, and one inside a deleted span clamps to the region's
|
|
729
|
+
* start — which is where a user would expect to be left standing.
|
|
730
|
+
*/
|
|
731
|
+
function $transformPoints(
|
|
732
|
+
current: readonly TextRun[],
|
|
733
|
+
target: readonly TextRun[],
|
|
734
|
+
existing: readonly TextNode[],
|
|
735
|
+
context: Context,
|
|
736
|
+
): void {
|
|
737
|
+
const affected = context.points.filter((captured) =>
|
|
738
|
+
existing.some((node) => captured.runKeys.includes(node.getKey())),
|
|
739
|
+
)
|
|
740
|
+
if (affected.length === 0) return
|
|
741
|
+
|
|
742
|
+
const before = runsText(current)
|
|
743
|
+
const after = runsText(target)
|
|
744
|
+
for (const captured of affected) {
|
|
745
|
+
const diff = diffTextWithCursor(before, after, captured.offset)
|
|
746
|
+
if (captured.offset <= diff.index) continue
|
|
747
|
+
if (captured.offset <= diff.index + diff.remove) captured.offset = diff.index
|
|
748
|
+
else captured.offset += diff.insert.length - diff.remove
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Locate each affected caret inside the rebuilt run. */
|
|
753
|
+
function $resolvePoints(
|
|
754
|
+
target: readonly TextRun[],
|
|
755
|
+
nodes: readonly TextNode[],
|
|
756
|
+
context: Context,
|
|
757
|
+
): void {
|
|
758
|
+
for (const captured of context.points) {
|
|
759
|
+
if (!nodes.some((node) => captured.runKeys.includes(node.getKey()))) continue
|
|
760
|
+
if (nodes.length === 0) continue
|
|
761
|
+
let remaining = Math.max(0, Math.min(captured.offset, runsText(target).length))
|
|
762
|
+
let resolved: { node: TextNode; offset: number } | null = null
|
|
763
|
+
for (const node of nodes) {
|
|
764
|
+
const size = node.getTextContentSize()
|
|
765
|
+
if (remaining <= size) {
|
|
766
|
+
resolved = { node, offset: remaining }
|
|
767
|
+
break
|
|
768
|
+
}
|
|
769
|
+
remaining -= size
|
|
770
|
+
}
|
|
771
|
+
captured.resolved = resolved ?? {
|
|
772
|
+
node: nodes[nodes.length - 1]!,
|
|
773
|
+
offset: nodes[nodes.length - 1]!.getTextContentSize(),
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Re-place every caret whose run this pass rebuilt. */
|
|
779
|
+
function $restorePoints(context: Context): void {
|
|
780
|
+
for (const captured of context.points) {
|
|
781
|
+
const resolved = captured.resolved
|
|
782
|
+
if (resolved === null) continue
|
|
783
|
+
captured.point.set(resolved.node.getKey(), resolved.offset, 'text')
|
|
784
|
+
}
|
|
785
|
+
// Writing a point mutates the live selection in place; nothing else is needed,
|
|
786
|
+
// but re-setting it makes Lexical mark the selection dirty so the change is
|
|
787
|
+
// reconciled to the DOM.
|
|
788
|
+
const selection = $getSelection()
|
|
789
|
+
if (selection !== null && context.points.some((captured) => captured.resolved !== null)) {
|
|
790
|
+
$setSelection(selection.clone())
|
|
791
|
+
}
|
|
792
|
+
}
|