@datalayer/lexical-loro 1.0.3 → 1.0.5
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/README.md +2 -2
- package/lib/Editor.js +3 -2
- package/lib/appSettings.js +1 -1
- package/lib/collab/loro/Bindings.js +7 -7
- package/lib/collab/loro/Debug.js +35 -35
- package/lib/collab/loro/LexicalCollaborationContext.js +1 -1
- package/lib/collab/loro/LexicalCollaborationPlugin.d.ts +6 -1
- package/lib/collab/loro/LexicalCollaborationPlugin.js +41 -13
- package/lib/collab/loro/State.js +1 -1
- package/lib/collab/loro/components/LoroCollaborators.js +7 -19
- package/lib/collab/loro/components/index.js +1 -1
- package/lib/collab/loro/index.js +1 -1
- package/lib/collab/loro/integrators/BaseIntegrator.js +1 -1
- package/lib/collab/loro/integrators/CounterIntegrator.js +1 -1
- package/lib/collab/loro/integrators/ListIntegrator.js +4 -4
- package/lib/collab/loro/integrators/MapIntegrator.js +4 -4
- package/lib/collab/loro/integrators/TextIntegrator.js +2 -2
- package/lib/collab/loro/integrators/TreeIntegrator.d.ts +5 -0
- package/lib/collab/loro/integrators/TreeIntegrator.js +184 -142
- package/lib/collab/loro/nodes/NodeFactory.js +26 -47
- package/lib/collab/loro/nodes/NodesMapper.js +19 -7
- package/lib/collab/loro/propagators/DecoratorNodePropagator.js +1 -1
- package/lib/collab/loro/propagators/ElementNodePropagator.d.ts +1 -1
- package/lib/collab/loro/propagators/ElementNodePropagator.js +102 -35
- package/lib/collab/loro/propagators/LineBreakNodePropagator.js +1 -1
- package/lib/collab/loro/propagators/RootNodePropagator.js +1 -1
- package/lib/collab/loro/propagators/TextNodePropagator.js +55 -45
- package/lib/collab/loro/propagators/index.d.ts +1 -1
- package/lib/collab/loro/propagators/index.js +1 -1
- package/lib/collab/loro/provider/websocket.d.ts +1 -0
- package/lib/collab/loro/provider/websocket.js +47 -12
- package/lib/collab/loro/servers/index.js +1 -1
- package/lib/collab/loro/servers/ws/callback.js +1 -1
- package/lib/collab/loro/servers/ws/server.js +1 -1
- package/lib/collab/loro/servers/ws/utils.js +35 -35
- package/lib/collab/loro/sync/SyncCursors.js +42 -16
- package/lib/collab/loro/sync/SyncLexicalToLoro.js +76 -37
- package/lib/collab/loro/sync/SyncLoroToLexical.js +32 -26
- package/lib/collab/loro/types/LexicalNodeData.js +1 -1
- package/lib/collab/loro/useCollaboration.js +173 -28
- package/lib/collab/loro/utils/InitialContent.js +1 -1
- package/lib/collab/loro/utils/Invariant.d.ts +15 -0
- package/lib/collab/loro/utils/Invariant.js +24 -0
- package/lib/collab/loro/utils/LexicalToLoro.js +1 -1
- package/lib/collab/loro/utils/Utils.js +1 -1
- package/lib/collab/loro/wsProvider.js +21 -4
- package/lib/collab/utils/invariant.js +1 -1
- package/lib/collab/utils/simpleDiffWithCursor.js +1 -1
- package/lib/commenting/index.js +1 -1
- package/lib/demo.js +1 -1
- package/lib/hooks/useFlashMessage.js +1 -1
- package/lib/hooks/useReport.js +1 -1
- package/lib/index.js +1 -1
- package/lib/nodes/AutocompleteNode.js +1 -1
- package/lib/nodes/EmojiNode.js +1 -1
- package/lib/nodes/KeywordNode.js +1 -1
- package/lib/nodes/LayoutContainerNode.js +1 -1
- package/lib/nodes/LayoutItemNode.js +1 -1
- package/lib/nodes/MentionNode.js +1 -1
- package/lib/nodes/PlaygroundNodes.js +1 -1
- package/lib/nodes/SpecialTextNode.js +1 -1
- package/lib/plugins/AutocompletePlugin/index.js +1 -1
- package/lib/plugins/CodeActionMenuPlugin/components/CopyButton/index.js +1 -1
- package/lib/plugins/CodeActionMenuPlugin/components/PrettierButton/index.js +1 -1
- package/lib/plugins/CodeActionMenuPlugin/utils.js +1 -1
- package/lib/plugins/CodeHighlightPrismPlugin/index.js +1 -1
- package/lib/plugins/CodeHighlightShikiPlugin/index.js +1 -1
- package/lib/plugins/CollapsiblePlugin/CollapsibleContainerNode.js +1 -1
- package/lib/plugins/CollapsiblePlugin/CollapsibleContentNode.js +1 -1
- package/lib/plugins/CollapsiblePlugin/CollapsibleTitleNode.js +1 -1
- package/lib/plugins/CollapsiblePlugin/CollapsibleUtils.js +1 -1
- package/lib/plugins/CollapsiblePlugin/index.js +1 -1
- package/lib/plugins/CounterPlugin/index.js +1 -1
- package/lib/plugins/DateTimePlugin/index.js +1 -1
- package/lib/plugins/DebugPlugin/index.js +59 -3
- package/lib/plugins/DragDropPastePlugin/index.js +1 -1
- package/lib/plugins/EmojiPickerPlugin/index.js +1 -1
- package/lib/plugins/EmojisPlugin/index.js +1 -1
- package/lib/plugins/FigmaPlugin/index.js +1 -1
- package/lib/plugins/KeywordsPlugin/index.js +1 -1
- package/lib/plugins/LayoutPlugin/LayoutPlugin.js +1 -1
- package/lib/plugins/MarkdownTransformers/index.js +1 -1
- package/lib/plugins/MaxLengthPlugin/index.js +1 -1
- package/lib/plugins/PageBreakPlugin/index.js +1 -1
- package/lib/plugins/ShortcutsPlugin/index.js +1 -1
- package/lib/plugins/ShortcutsPlugin/shortcuts.js +1 -1
- package/lib/plugins/SpecialTextPlugin/index.js +1 -1
- package/lib/plugins/SpeechToTextPlugin/index.js +1 -1
- package/lib/plugins/StickyPlugin/index.js +1 -1
- package/lib/plugins/TabFocusPlugin/index.js +1 -1
- package/lib/plugins/ToolbarPlugin/fontSize.js +1 -1
- package/lib/plugins/ToolbarPlugin/utils.js +1 -1
- package/lib/plugins/TwitterPlugin/index.js +1 -1
- package/lib/plugins/TypingPerfPlugin/index.js +1 -1
- package/lib/plugins/YouTubePlugin/index.js +1 -1
- package/lib/server/validation.js +1 -1
- package/lib/setupEnv.js +1 -1
- package/lib/themes/CommentEditorTheme.js +1 -1
- package/lib/themes/PlaygroundEditorTheme.js +1 -1
- package/lib/themes/StickyEditorTheme.js +1 -1
- package/lib/tyes.dt.js +1 -1
- package/lib/utils/docSerialization.js +1 -1
- package/lib/utils/emoji-list.js +1 -1
- package/lib/utils/getDOMRangeRect.js +1 -1
- package/lib/utils/getSelectedNode.js +1 -1
- package/lib/utils/getThemeSelector.js +1 -1
- package/lib/utils/isMobileWidth.js +1 -1
- package/lib/utils/joinClasses.js +1 -1
- package/lib/utils/setFloatingElemPosition.js +1 -1
- package/lib/utils/setFloatingElemPositionForLinkEditor.js +1 -1
- package/lib/utils/swipe.js +1 -1
- package/lib/utils/url.js +1 -1
- package/package.json +4 -4
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Copyright (c)
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
3
|
* Distributed under the terms of the MIT License.
|
|
4
4
|
*/
|
|
5
|
-
import { $getRoot, $getNodeByKey, $isElementNode } from 'lexical';
|
|
5
|
+
import { $getRoot, $getNodeByKey, $isElementNode, $isDecoratorNode, } from 'lexical';
|
|
6
6
|
import { parseTreeID } from '../utils/Utils';
|
|
7
7
|
import { createLexicalNodeFromLoro } from '../nodes/NodeFactory';
|
|
8
|
+
import { invariant } from '../utils/Invariant';
|
|
8
9
|
/**
|
|
9
10
|
* Simplified tree diff integrater that trusts Loro's CRDT conflict resolution
|
|
10
11
|
* and applies operations directly without complex filtering or context classification
|
|
@@ -35,167 +36,208 @@ export class TreeIntegrator {
|
|
|
35
36
|
break;
|
|
36
37
|
}
|
|
37
38
|
}
|
|
39
|
+
// Loro often splits a node's placement into a parentless `create` followed
|
|
40
|
+
// by a `move` that supplies the real parent/index. Inline nodes (text,
|
|
41
|
+
// linebreak) can never live at the root — not even transiently — so fold
|
|
42
|
+
// the move's parent/index back into the create and drop the now-redundant
|
|
43
|
+
// move. This resolves the placement up front instead of skipping the node.
|
|
44
|
+
const moveByTarget = new Map();
|
|
45
|
+
for (const mv of moves) {
|
|
46
|
+
moveByTarget.set(String(mv.target), mv);
|
|
47
|
+
}
|
|
48
|
+
const foldedMoveTargets = new Set();
|
|
49
|
+
for (const cr of creates) {
|
|
50
|
+
if (cr.parent === undefined) {
|
|
51
|
+
const mv = moveByTarget.get(String(cr.target));
|
|
52
|
+
if (mv && mv.parent !== undefined) {
|
|
53
|
+
cr.parent = mv.parent;
|
|
54
|
+
cr.index = mv.index;
|
|
55
|
+
foldedMoveTargets.add(String(cr.target));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const effectiveMoves = moves.filter(mv => !foldedMoveTargets.has(String(mv.target)));
|
|
38
60
|
// Topologically sort create operations so parents are created before children.
|
|
39
61
|
// Without this, a TableCellNode may arrive before its parent TableRowNode,
|
|
40
62
|
// causing the cell to fall back to $getRoot() and appear on one flat line.
|
|
41
63
|
const sortedCreates = this.topologicalSortCreates(creates, binding);
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// Skip root node creation - root is integrated during initial setup
|
|
64
|
-
// But ensure the root mapping exists
|
|
65
|
-
// Only treat as root if it's actually a root-type node in Loro
|
|
66
|
-
if (nodeKey === "0") {
|
|
67
|
-
const treeNode = binding.tree.getNodeByID(operation.target);
|
|
68
|
-
const elementType = treeNode?.data.get('elementType');
|
|
69
|
-
if (elementType === 'root' || !elementType) {
|
|
70
|
-
const root = $getRoot();
|
|
71
|
-
binding.nodeMapper.setMapping(root.getKey(), operation.target);
|
|
72
|
-
return;
|
|
64
|
+
// Phase 1: deletes.
|
|
65
|
+
deletes.forEach(op => this.integrateDelete(op, binding, provider));
|
|
66
|
+
// Phase 2: creates (parent-first, with deferral).
|
|
67
|
+
//
|
|
68
|
+
// Topological sort orders parents before children *within this batch*, but
|
|
69
|
+
// a child may still reference a parent whose Lexical mapping is not yet
|
|
70
|
+
// established (deeply nested tables, list items inside quotes, Jupyter
|
|
71
|
+
// cells, …). Rather than dropping such a node onto the root — which throws
|
|
72
|
+
// for inline nodes like text/linebreak ("Only element or decorator nodes
|
|
73
|
+
// can be inserted to the root node") and corrupts the document — we defer
|
|
74
|
+
// it and retry until a full pass makes no further progress.
|
|
75
|
+
//
|
|
76
|
+
// Created Lexical nodes are cached so deferred retries reuse the same
|
|
77
|
+
// instance instead of importing (and registering) duplicate nodes.
|
|
78
|
+
const nodeCache = new Map();
|
|
79
|
+
let pending = sortedCreates;
|
|
80
|
+
while (pending.length > 0) {
|
|
81
|
+
const stillPending = [];
|
|
82
|
+
for (const op of pending) {
|
|
83
|
+
if (this.integrateCreate(op, binding, provider, nodeCache) === 'deferred') {
|
|
84
|
+
stillPending.push(op);
|
|
73
85
|
}
|
|
74
|
-
// If nodeKey is "0" but it's not actually a root element, continue with normal processing
|
|
75
86
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
// Create Lexical node from Loro data
|
|
89
|
-
const lexicalNode = createLexicalNodeFromLoro(operation.target, binding.tree, binding);
|
|
90
|
-
if (!lexicalNode) {
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
// Find parent node
|
|
94
|
-
let parentNode;
|
|
95
|
-
if (operation.parent) {
|
|
96
|
-
const parentKey = binding.nodeMapper.getLexicalKeyByLoroId(operation.parent);
|
|
97
|
-
const parentLexicalNode = parentKey ? $getNodeByKey(parentKey) : null;
|
|
98
|
-
if (parentLexicalNode && $isElementNode(parentLexicalNode)) {
|
|
99
|
-
parentNode = parentLexicalNode;
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
console.warn(`🌳 Parent NOT found for ${lexicalNode.getType()} target=${operation.target} parent=${operation.parent} parentKey=${parentKey} parentFound=${!!parentLexicalNode} isElement=${parentLexicalNode ? $isElementNode(parentLexicalNode) : 'N/A'}`);
|
|
103
|
-
// For text nodes, we MUST have a proper parent element
|
|
104
|
-
if (lexicalNode.getType() === 'text') {
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
parentNode = $getRoot();
|
|
108
|
-
}
|
|
87
|
+
if (stillPending.length === pending.length) {
|
|
88
|
+
// No progress in a full pass: every remaining node references a parent
|
|
89
|
+
// that never arrived in this batch. This is a real ordering/data bug.
|
|
90
|
+
// Surface it instead of dropping the nodes onto the root (or silently
|
|
91
|
+
// discarding inline nodes, which would lose user content).
|
|
92
|
+
invariant(false, 'TreeIntegrator: create operations reference parents missing from the batch', {
|
|
93
|
+
pending: stillPending.map(op => ({
|
|
94
|
+
target: String(op.target),
|
|
95
|
+
parent: op.parent ? String(op.parent) : null,
|
|
96
|
+
})),
|
|
97
|
+
});
|
|
109
98
|
}
|
|
110
|
-
|
|
111
|
-
|
|
99
|
+
pending = stillPending;
|
|
100
|
+
}
|
|
101
|
+
// Phase 3: moves (those not already folded into a create above).
|
|
102
|
+
effectiveMoves.forEach(op => this.integrateMove(op, binding, provider));
|
|
103
|
+
}
|
|
104
|
+
integrateCreate(operation, binding, provider, nodeCache) {
|
|
105
|
+
let { nodeKey } = parseTreeID(operation.target);
|
|
106
|
+
// Root node creation is integrated during initial setup. Only treat as
|
|
107
|
+
// root if it's actually a root-type node in Loro.
|
|
108
|
+
if (nodeKey === "0") {
|
|
109
|
+
const treeNode = binding.tree.getNodeByID(operation.target);
|
|
110
|
+
const elementType = treeNode?.data.get('elementType');
|
|
111
|
+
if (elementType === 'root' || !elementType) {
|
|
112
|
+
const root = $getRoot();
|
|
113
|
+
binding.nodeMapper.setMapping(root.getKey(), operation.target);
|
|
114
|
+
return 'created';
|
|
112
115
|
}
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
// nodeKey is "0" but not an actual root element — fall through.
|
|
117
|
+
}
|
|
118
|
+
// Node already exists with the same TreeID → idempotent, nothing to do.
|
|
119
|
+
const existingNode = $getNodeByKey(nodeKey);
|
|
120
|
+
if (existingNode) {
|
|
121
|
+
const existingTreeID = binding.nodeMapper.getTreeIDByLexicalKey(nodeKey);
|
|
122
|
+
if (existingTreeID === operation.target) {
|
|
123
|
+
return 'created';
|
|
116
124
|
}
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
// Different TreeID reuses this key — let Lexical assign a fresh one.
|
|
126
|
+
nodeKey = undefined;
|
|
127
|
+
}
|
|
128
|
+
// Create the Lexical node once and cache it, so deferred retries reuse
|
|
129
|
+
// the same instance instead of importing duplicate nodes.
|
|
130
|
+
const cacheKey = String(operation.target);
|
|
131
|
+
let lexicalNode = nodeCache.get(cacheKey);
|
|
132
|
+
if (lexicalNode === undefined) {
|
|
133
|
+
lexicalNode = createLexicalNodeFromLoro(operation.target, binding.tree, binding);
|
|
134
|
+
nodeCache.set(cacheKey, lexicalNode);
|
|
135
|
+
}
|
|
136
|
+
invariant(lexicalNode != null, 'integrateCreate: failed to materialise Lexical node from Loro data', { target: cacheKey });
|
|
137
|
+
if (operation.parent) {
|
|
138
|
+
const parentKey = binding.nodeMapper.getLexicalKeyByLoroId(operation.parent);
|
|
139
|
+
const parentLexicalNode = parentKey ? $getNodeByKey(parentKey) : null;
|
|
140
|
+
// Parent expected but not mapped yet → defer until it is created. This is
|
|
141
|
+
// legitimate ordering (a later pass/batch supplies the parent), not an
|
|
142
|
+
// error that hides a bug.
|
|
143
|
+
if (!(parentLexicalNode && $isElementNode(parentLexicalNode))) {
|
|
144
|
+
return 'deferred';
|
|
119
145
|
}
|
|
120
|
-
|
|
121
|
-
binding.nodeMapper.setMapping(lexicalNode.getKey(), operation.target);
|
|
146
|
+
this.insertChild(parentLexicalNode, lexicalNode, operation.index);
|
|
122
147
|
}
|
|
123
|
-
|
|
124
|
-
|
|
148
|
+
else {
|
|
149
|
+
// No parent → root. Only element/decorator nodes may live at the root; an
|
|
150
|
+
// inline node without a parent is a structural bug, not something to skip.
|
|
151
|
+
invariant(this.canBeRootChild(lexicalNode), 'integrateCreate: inline node has no parent (cannot live at root)', { target: cacheKey, nodeType: lexicalNode.getType() });
|
|
152
|
+
this.insertChild($getRoot(), lexicalNode, operation.index);
|
|
153
|
+
}
|
|
154
|
+
binding.nodeMapper.setMapping(lexicalNode.getKey(), operation.target);
|
|
155
|
+
return 'created';
|
|
156
|
+
}
|
|
157
|
+
/** Only element and decorator nodes may be inserted directly under the root. */
|
|
158
|
+
canBeRootChild(node) {
|
|
159
|
+
return $isElementNode(node) || $isDecoratorNode(node);
|
|
160
|
+
}
|
|
161
|
+
/** Insert a child into a parent element at an optional index. */
|
|
162
|
+
insertChild(parent, child, index) {
|
|
163
|
+
if (index !== undefined) {
|
|
164
|
+
parent.splice(index, 0, [child]);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
parent.append(child);
|
|
125
168
|
}
|
|
126
169
|
}
|
|
127
170
|
integrateMove(operation, binding, provider) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (parentNode && $isElementNode(parentNode)) {
|
|
145
|
-
newParent = parentNode;
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
148
|
-
// For text nodes, we can't move them to root - skip this move operation
|
|
149
|
-
// The node is already in the correct position from our create fix
|
|
150
|
-
if (nodeToMove.getType() === 'text') {
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
newParent = $getRoot();
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
// For text nodes without a parent, skip the move
|
|
158
|
-
if (nodeToMove.getType() === 'text') {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
newParent = $getRoot();
|
|
162
|
-
}
|
|
163
|
-
// Remove from current position and insert at new position
|
|
164
|
-
nodeToMove.remove();
|
|
165
|
-
if (operation.index !== undefined) {
|
|
166
|
-
newParent.splice(operation.index, 0, [nodeToMove]);
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
newParent.append(nodeToMove);
|
|
170
|
-
}
|
|
171
|
+
const lexicalKey = binding.nodeMapper.getLexicalKeyByLoroId(operation.target);
|
|
172
|
+
invariant(lexicalKey != null, 'integrateMove: no Lexical key for move target', { target: String(operation.target) });
|
|
173
|
+
const nodeToMove = $getNodeByKey(lexicalKey);
|
|
174
|
+
invariant(nodeToMove != null, 'integrateMove: node to move not found', { lexicalKey });
|
|
175
|
+
// Resolve the new parent.
|
|
176
|
+
let newParent;
|
|
177
|
+
if (operation.parent) {
|
|
178
|
+
const parentKey = binding.nodeMapper.getLexicalKeyByLoroId(operation.parent);
|
|
179
|
+
const parentNode = parentKey ? $getNodeByKey(parentKey) : null;
|
|
180
|
+
invariant(parentNode != null && $isElementNode(parentNode), 'integrateMove: move target parent is not a mapped element', { target: String(operation.target), parent: String(operation.parent) });
|
|
181
|
+
newParent = parentNode;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// No parent → root, only valid for element/decorator nodes.
|
|
185
|
+
invariant(this.canBeRootChild(nodeToMove), 'integrateMove: inline node cannot be moved to the root', { lexicalKey, nodeType: nodeToMove.getType() });
|
|
186
|
+
newParent = $getRoot();
|
|
171
187
|
}
|
|
172
|
-
|
|
173
|
-
|
|
188
|
+
// Remove from current position and insert at new position.
|
|
189
|
+
nodeToMove.remove();
|
|
190
|
+
if (operation.index !== undefined) {
|
|
191
|
+
newParent.splice(operation.index, 0, [nodeToMove]);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
newParent.append(nodeToMove);
|
|
174
195
|
}
|
|
175
196
|
}
|
|
176
197
|
integrateDelete(operation, binding, provider) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
// Remove from Lexical tree
|
|
189
|
-
nodeToDelete.remove();
|
|
190
|
-
// Clean up mapping only — do NOT call deleteMapping() here because
|
|
191
|
-
// the Loro tree has already processed this deletion from the remote
|
|
192
|
-
// peer. Calling tree.delete() again would throw "is deleted or does
|
|
193
|
-
// not exist".
|
|
198
|
+
const lexicalKey = binding.nodeMapper.getLexicalKeyByLoroId(operation.target);
|
|
199
|
+
// Parent-first delete batches can make child mappings stale before their
|
|
200
|
+
// own delete ops arrive. Missing mapping here is therefore benign.
|
|
201
|
+
if (!lexicalKey) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const nodeToDelete = $getNodeByKey(lexicalKey);
|
|
205
|
+
// Node may already be gone due to an ancestor deletion in this same batch.
|
|
206
|
+
if (!nodeToDelete) {
|
|
194
207
|
binding.nodeMapper.removeMappingForKey(lexicalKey);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// The root is never deletable in Lexical; a delete resolving to it means a
|
|
211
|
+
// mapping is wrong upstream — surface it rather than silently ignoring.
|
|
212
|
+
if (nodeToDelete === $getRoot()) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Clean descendant mappings first so later child-delete ops in the same
|
|
216
|
+
// batch become harmless no-ops instead of failing with stale mappings.
|
|
217
|
+
const descendantKeys = this.collectDescendantKeys(nodeToDelete);
|
|
218
|
+
for (const key of descendantKeys) {
|
|
219
|
+
binding.nodeMapper.removeMappingForKey(key);
|
|
195
220
|
}
|
|
196
|
-
|
|
197
|
-
|
|
221
|
+
// Remove from the Lexical tree.
|
|
222
|
+
nodeToDelete.remove();
|
|
223
|
+
// Clean up mapping only — the Loro tree already processed this deletion
|
|
224
|
+
// from the remote peer; calling tree.delete() again would throw.
|
|
225
|
+
binding.nodeMapper.removeMappingForKey(lexicalKey);
|
|
226
|
+
}
|
|
227
|
+
collectDescendantKeys(node) {
|
|
228
|
+
if (!$isElementNode(node)) {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
const keys = [];
|
|
232
|
+
const stack = [...node.getChildren()];
|
|
233
|
+
while (stack.length > 0) {
|
|
234
|
+
const current = stack.pop();
|
|
235
|
+
keys.push(current.getKey());
|
|
236
|
+
if ($isElementNode(current)) {
|
|
237
|
+
stack.push(...current.getChildren());
|
|
238
|
+
}
|
|
198
239
|
}
|
|
240
|
+
return keys;
|
|
199
241
|
}
|
|
200
242
|
/**
|
|
201
243
|
* Topologically sort create operations so that parent nodes are created
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Copyright (c)
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
3
|
* Distributed under the terms of the MIT License.
|
|
4
4
|
*/
|
|
5
|
+
import { invariant } from '../utils/Invariant';
|
|
5
6
|
/**
|
|
6
7
|
* Resolve lexical data from Loro, handling Loro container objects.
|
|
7
8
|
* When a JS object is stored in a LoroMap, Loro may return a LoroMap
|
|
@@ -25,10 +26,9 @@ function resolveLexicalData(raw) {
|
|
|
25
26
|
* static method required by Lexical's serialization contract.
|
|
26
27
|
*/
|
|
27
28
|
export function createLexicalNodeFromLoro(treeId, loroTree, binding, parentKey, nodeDataFromDiff) {
|
|
28
|
-
// Get node data from Loro tree
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
29
|
+
// Get node data from Loro tree. A create op for a tree node that does not
|
|
30
|
+
// exist is a real inconsistency.
|
|
31
|
+
invariant(loroTree.has(treeId), 'NodeFactory: TreeID not present in Loro tree', { treeId });
|
|
32
32
|
const treeNode = loroTree.getNodeByID(treeId);
|
|
33
33
|
// First try nodeData passed from TreeDiff integrator (has immediate lexical data)
|
|
34
34
|
let lexicalData = resolveLexicalData(nodeDataFromDiff?.lexical);
|
|
@@ -37,28 +37,19 @@ export function createLexicalNodeFromLoro(treeId, loroTree, binding, parentKey,
|
|
|
37
37
|
lexicalData = resolveLexicalData(treeNode?.data.get('lexical'));
|
|
38
38
|
}
|
|
39
39
|
if (!lexicalData || typeof lexicalData !== 'object') {
|
|
40
|
-
// Last resort:
|
|
40
|
+
// Last resort: reconstruct a minimal serialization from elementType metadata.
|
|
41
41
|
const fallbackType = nodeDataFromDiff?.elementType ||
|
|
42
42
|
treeNode?.data.get('elementType') ||
|
|
43
43
|
treeNode?.data.get('nodeType');
|
|
44
|
-
|
|
45
|
-
console.warn('🏭 NodeFactory: No lexical data or elementType for TreeID:', treeId);
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
44
|
+
invariant(typeof fallbackType === 'string' && fallbackType.length > 0, 'NodeFactory: no lexical data or elementType for TreeID', { treeId });
|
|
48
45
|
lexicalData = { type: fallbackType, version: 1 };
|
|
49
46
|
}
|
|
50
47
|
const nodeType = lexicalData.type || lexicalData.__type;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
// Get the registered node class from the editor
|
|
48
|
+
invariant(!!nodeType, 'NodeFactory: no type field in lexical data for TreeID', { treeId });
|
|
49
|
+
// Get the registered node class from the editor.
|
|
56
50
|
const registeredNodes = binding.editor._nodes;
|
|
57
51
|
const nodeInfo = registeredNodes.get(nodeType);
|
|
58
|
-
|
|
59
|
-
console.warn(`🏭 NodeFactory: Node type '${nodeType}' is not registered in the editor`);
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
52
|
+
invariant(nodeInfo != null, 'NodeFactory: node type is not registered in the editor', { treeId, nodeType });
|
|
62
53
|
// ---------- Generic creation via importJSON ----------
|
|
63
54
|
// Every Lexical node class must implement the static `importJSON` method.
|
|
64
55
|
// Using it guarantees that:
|
|
@@ -68,34 +59,22 @@ export function createLexicalNodeFromLoro(treeId, loroTree, binding, parentKey,
|
|
|
68
59
|
// • Base properties (format, indent, direction, style, mode, detail, …)
|
|
69
60
|
// are applied via the chained `updateFromJSON` call
|
|
70
61
|
// This removes the need for any node-type-specific branching.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
serializedData.version = 1;
|
|
79
|
-
}
|
|
80
|
-
// Provide an empty children array for element-type nodes whose children
|
|
81
|
-
// are managed as separate Loro tree nodes (importJSON itself does not
|
|
82
|
-
// recurse into children — that is handled by TreeIntegrator).
|
|
83
|
-
if (!('children' in serializedData)) {
|
|
84
|
-
serializedData.children = [];
|
|
85
|
-
}
|
|
86
|
-
const lexicalNode = nodeInfo.klass.importJSON(serializedData);
|
|
87
|
-
return lexicalNode;
|
|
62
|
+
const serializedData = { ...lexicalData };
|
|
63
|
+
// Ensure required serialization fields.
|
|
64
|
+
if (!serializedData.type) {
|
|
65
|
+
serializedData.type = nodeType;
|
|
66
|
+
}
|
|
67
|
+
if (serializedData.version === undefined) {
|
|
68
|
+
serializedData.version = 1;
|
|
88
69
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return lexicalNode;
|
|
95
|
-
}
|
|
96
|
-
catch (ctorError) {
|
|
97
|
-
console.warn(`🏭 NodeFactory: Constructor also failed for '${nodeType}':`, ctorError);
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
70
|
+
// Provide an empty children array for element-type nodes whose children are
|
|
71
|
+
// managed as separate Loro tree nodes (importJSON does not recurse into
|
|
72
|
+
// children — that is handled by TreeIntegrator).
|
|
73
|
+
if (!('children' in serializedData)) {
|
|
74
|
+
serializedData.children = [];
|
|
100
75
|
}
|
|
76
|
+
// Every registered Lexical node implements importJSON. If it throws, the
|
|
77
|
+
// serialized data is malformed — surface it instead of silently substituting
|
|
78
|
+
// a blank node via a constructor fallback (which hides the real problem).
|
|
79
|
+
return nodeInfo.klass.importJSON(serializedData);
|
|
101
80
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Copyright (c)
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
3
|
* Distributed under the terms of the MIT License.
|
|
4
4
|
*/
|
|
5
|
+
import { invariant } from '../utils/Invariant';
|
|
5
6
|
/**
|
|
6
7
|
* Bidirectional mapping between Lexical NodeKeys and Loro TreeIDs
|
|
7
8
|
*/
|
|
@@ -104,16 +105,27 @@ export class NodeMapper {
|
|
|
104
105
|
* Create a new Loro TreeNode and establish mapping
|
|
105
106
|
*/
|
|
106
107
|
createLoroNode(nodeKey, lexicalNode, parentTreeID, index) {
|
|
108
|
+
const insertionIndex = index;
|
|
109
|
+
// Loro requires the insertion index to be within [0, children.length]. An
|
|
110
|
+
// out-of-range index means the position was computed against stale/unsynced
|
|
111
|
+
// siblings; surface it instead of silently clamping (which would drop the
|
|
112
|
+
// node at the wrong position and hide the real ordering bug).
|
|
113
|
+
if (insertionIndex !== undefined) {
|
|
114
|
+
const parentNode = parentTreeID !== undefined ? this.tree.getNodeByID(parentTreeID) : null;
|
|
115
|
+
const childrenLength = parentNode
|
|
116
|
+
? (parentNode.children()?.length ?? 0)
|
|
117
|
+
: this.tree.roots().length;
|
|
118
|
+
invariant(insertionIndex >= 0 && insertionIndex <= childrenLength, 'createLoroNode: insertion index out of range', { nodeKey, parentTreeID, insertionIndex, childrenLength });
|
|
119
|
+
}
|
|
107
120
|
// Create the tree node first
|
|
108
|
-
const treeNode = this.tree.createNode(parentTreeID,
|
|
121
|
+
const treeNode = this.tree.createNode(parentTreeID, insertionIndex);
|
|
109
122
|
// Get the TreeID from the created node
|
|
110
123
|
const treeId = treeNode.id;
|
|
111
|
-
//
|
|
124
|
+
// A requested parent must actually become this node's parent. If it does
|
|
125
|
+
// not, the CRDT structure diverges from Lexical — surface it.
|
|
112
126
|
if (parentTreeID) {
|
|
113
127
|
const actualParent = treeNode.parent();
|
|
114
|
-
|
|
115
|
-
console.warn(`⚠️ Parent relationship not set correctly for ${nodeKey}: expected ${parentTreeID}, got ${actualParent?.id || 'None'}`);
|
|
116
|
-
}
|
|
128
|
+
invariant(actualParent != null && actualParent.id === parentTreeID, 'createLoroNode: parent relationship not established', { nodeKey, expected: parentTreeID, actual: actualParent?.id ?? null });
|
|
117
129
|
}
|
|
118
130
|
// Store basic metadata
|
|
119
131
|
treeNode.data.set('createdAt', Date.now());
|
|
@@ -232,7 +244,7 @@ export class NodeMapper {
|
|
|
232
244
|
// 2. Normal collaboration flow as nodes are created/updated
|
|
233
245
|
//
|
|
234
246
|
// This method is kept for compatibility but is now a no-op.
|
|
235
|
-
console.log('
|
|
247
|
+
console.log(' syncExistingNodes: Skipping sync - mappings established through TreeIntegrator');
|
|
236
248
|
}
|
|
237
249
|
}
|
|
238
250
|
// Global instance to be shared across the application
|
|
@@ -25,7 +25,7 @@ options?: ElementNodeMutatorOptions): TreeID;
|
|
|
25
25
|
/**
|
|
26
26
|
* Update ElementNode in Loro tree
|
|
27
27
|
*/
|
|
28
|
-
export declare function updateElementNodeInLoro(nodeKey: NodeKey, elementType?: string, parentId?: TreeID, index?: number, metadata?: Record<string, any>, lexicalNodeJSON?: any, // JSON object from exportJSON()
|
|
28
|
+
export declare function updateElementNodeInLoro(nodeKey: NodeKey, elementType?: string, parentId?: TreeID, index?: number, previousSiblingId?: TreeID, nextSiblingId?: TreeID, metadata?: Record<string, any>, lexicalNodeJSON?: any, // JSON object from exportJSON()
|
|
29
29
|
options?: ElementNodeMutatorOptions): void;
|
|
30
30
|
/**
|
|
31
31
|
* Delete ElementNode from Loro tree
|