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