@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.
Files changed (113) hide show
  1. package/README.md +2 -2
  2. package/lib/Editor.js +3 -2
  3. package/lib/appSettings.js +1 -1
  4. package/lib/collab/loro/Bindings.js +7 -7
  5. package/lib/collab/loro/Debug.js +35 -35
  6. package/lib/collab/loro/LexicalCollaborationContext.js +1 -1
  7. package/lib/collab/loro/LexicalCollaborationPlugin.d.ts +6 -1
  8. package/lib/collab/loro/LexicalCollaborationPlugin.js +41 -13
  9. package/lib/collab/loro/State.js +1 -1
  10. package/lib/collab/loro/components/LoroCollaborators.js +7 -19
  11. package/lib/collab/loro/components/index.js +1 -1
  12. package/lib/collab/loro/index.js +1 -1
  13. package/lib/collab/loro/integrators/BaseIntegrator.js +1 -1
  14. package/lib/collab/loro/integrators/CounterIntegrator.js +1 -1
  15. package/lib/collab/loro/integrators/ListIntegrator.js +4 -4
  16. package/lib/collab/loro/integrators/MapIntegrator.js +4 -4
  17. package/lib/collab/loro/integrators/TextIntegrator.js +2 -2
  18. package/lib/collab/loro/integrators/TreeIntegrator.d.ts +5 -0
  19. package/lib/collab/loro/integrators/TreeIntegrator.js +184 -142
  20. package/lib/collab/loro/nodes/NodeFactory.js +26 -47
  21. package/lib/collab/loro/nodes/NodesMapper.js +19 -7
  22. package/lib/collab/loro/propagators/DecoratorNodePropagator.js +1 -1
  23. package/lib/collab/loro/propagators/ElementNodePropagator.d.ts +1 -1
  24. package/lib/collab/loro/propagators/ElementNodePropagator.js +102 -35
  25. package/lib/collab/loro/propagators/LineBreakNodePropagator.js +1 -1
  26. package/lib/collab/loro/propagators/RootNodePropagator.js +1 -1
  27. package/lib/collab/loro/propagators/TextNodePropagator.js +55 -45
  28. package/lib/collab/loro/propagators/index.d.ts +1 -1
  29. package/lib/collab/loro/propagators/index.js +1 -1
  30. package/lib/collab/loro/provider/websocket.d.ts +1 -0
  31. package/lib/collab/loro/provider/websocket.js +47 -12
  32. package/lib/collab/loro/servers/index.js +1 -1
  33. package/lib/collab/loro/servers/ws/callback.js +1 -1
  34. package/lib/collab/loro/servers/ws/server.js +1 -1
  35. package/lib/collab/loro/servers/ws/utils.js +35 -35
  36. package/lib/collab/loro/sync/SyncCursors.js +42 -16
  37. package/lib/collab/loro/sync/SyncLexicalToLoro.js +76 -37
  38. package/lib/collab/loro/sync/SyncLoroToLexical.js +32 -26
  39. package/lib/collab/loro/types/LexicalNodeData.js +1 -1
  40. package/lib/collab/loro/useCollaboration.js +173 -28
  41. package/lib/collab/loro/utils/InitialContent.js +1 -1
  42. package/lib/collab/loro/utils/Invariant.d.ts +15 -0
  43. package/lib/collab/loro/utils/Invariant.js +24 -0
  44. package/lib/collab/loro/utils/LexicalToLoro.js +1 -1
  45. package/lib/collab/loro/utils/Utils.js +1 -1
  46. package/lib/collab/loro/wsProvider.js +21 -4
  47. package/lib/collab/utils/invariant.js +1 -1
  48. package/lib/collab/utils/simpleDiffWithCursor.js +1 -1
  49. package/lib/commenting/index.js +1 -1
  50. package/lib/demo.js +1 -1
  51. package/lib/hooks/useFlashMessage.js +1 -1
  52. package/lib/hooks/useReport.js +1 -1
  53. package/lib/index.js +1 -1
  54. package/lib/nodes/AutocompleteNode.js +1 -1
  55. package/lib/nodes/EmojiNode.js +1 -1
  56. package/lib/nodes/KeywordNode.js +1 -1
  57. package/lib/nodes/LayoutContainerNode.js +1 -1
  58. package/lib/nodes/LayoutItemNode.js +1 -1
  59. package/lib/nodes/MentionNode.js +1 -1
  60. package/lib/nodes/PlaygroundNodes.js +1 -1
  61. package/lib/nodes/SpecialTextNode.js +1 -1
  62. package/lib/plugins/AutocompletePlugin/index.js +1 -1
  63. package/lib/plugins/CodeActionMenuPlugin/components/CopyButton/index.js +1 -1
  64. package/lib/plugins/CodeActionMenuPlugin/components/PrettierButton/index.js +1 -1
  65. package/lib/plugins/CodeActionMenuPlugin/utils.js +1 -1
  66. package/lib/plugins/CodeHighlightPrismPlugin/index.js +1 -1
  67. package/lib/plugins/CodeHighlightShikiPlugin/index.js +1 -1
  68. package/lib/plugins/CollapsiblePlugin/CollapsibleContainerNode.js +1 -1
  69. package/lib/plugins/CollapsiblePlugin/CollapsibleContentNode.js +1 -1
  70. package/lib/plugins/CollapsiblePlugin/CollapsibleTitleNode.js +1 -1
  71. package/lib/plugins/CollapsiblePlugin/CollapsibleUtils.js +1 -1
  72. package/lib/plugins/CollapsiblePlugin/index.js +1 -1
  73. package/lib/plugins/CounterPlugin/index.js +1 -1
  74. package/lib/plugins/DateTimePlugin/index.js +1 -1
  75. package/lib/plugins/DebugPlugin/index.js +59 -3
  76. package/lib/plugins/DragDropPastePlugin/index.js +1 -1
  77. package/lib/plugins/EmojiPickerPlugin/index.js +1 -1
  78. package/lib/plugins/EmojisPlugin/index.js +1 -1
  79. package/lib/plugins/FigmaPlugin/index.js +1 -1
  80. package/lib/plugins/KeywordsPlugin/index.js +1 -1
  81. package/lib/plugins/LayoutPlugin/LayoutPlugin.js +1 -1
  82. package/lib/plugins/MarkdownTransformers/index.js +1 -1
  83. package/lib/plugins/MaxLengthPlugin/index.js +1 -1
  84. package/lib/plugins/PageBreakPlugin/index.js +1 -1
  85. package/lib/plugins/ShortcutsPlugin/index.js +1 -1
  86. package/lib/plugins/ShortcutsPlugin/shortcuts.js +1 -1
  87. package/lib/plugins/SpecialTextPlugin/index.js +1 -1
  88. package/lib/plugins/SpeechToTextPlugin/index.js +1 -1
  89. package/lib/plugins/StickyPlugin/index.js +1 -1
  90. package/lib/plugins/TabFocusPlugin/index.js +1 -1
  91. package/lib/plugins/ToolbarPlugin/fontSize.js +1 -1
  92. package/lib/plugins/ToolbarPlugin/utils.js +1 -1
  93. package/lib/plugins/TwitterPlugin/index.js +1 -1
  94. package/lib/plugins/TypingPerfPlugin/index.js +1 -1
  95. package/lib/plugins/YouTubePlugin/index.js +1 -1
  96. package/lib/server/validation.js +1 -1
  97. package/lib/setupEnv.js +1 -1
  98. package/lib/themes/CommentEditorTheme.js +1 -1
  99. package/lib/themes/PlaygroundEditorTheme.js +1 -1
  100. package/lib/themes/StickyEditorTheme.js +1 -1
  101. package/lib/tyes.dt.js +1 -1
  102. package/lib/utils/docSerialization.js +1 -1
  103. package/lib/utils/emoji-list.js +1 -1
  104. package/lib/utils/getDOMRangeRect.js +1 -1
  105. package/lib/utils/getSelectedNode.js +1 -1
  106. package/lib/utils/getThemeSelector.js +1 -1
  107. package/lib/utils/isMobileWidth.js +1 -1
  108. package/lib/utils/joinClasses.js +1 -1
  109. package/lib/utils/setFloatingElemPosition.js +1 -1
  110. package/lib/utils/setFloatingElemPositionForLinkEditor.js +1 -1
  111. package/lib/utils/swipe.js +1 -1
  112. package/lib/utils/url.js +1 -1
  113. package/package.json +4 -4
@@ -1,10 +1,11 @@
1
1
  /*
2
- * Copyright (c) 2023-2025 Datalayer, Inc.
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
- // Execute: deletes → creates (parent-first) → moves
43
- const operations = [...deletes, ...sortedCreates, ...moves];
44
- operations.forEach(operation => {
45
- switch (operation.action) {
46
- case 'create':
47
- this.integrateCreate(operation, binding, provider);
48
- break;
49
- case 'move':
50
- this.integrateMove(operation, binding, provider);
51
- break;
52
- case 'delete':
53
- this.integrateDelete(operation, binding, provider);
54
- break;
55
- default:
56
- console.warn(`🌳 Unknown tree operation: ${operation.action}`);
57
- }
58
- });
59
- }
60
- integrateCreate(operation, binding, provider) {
61
- try {
62
- let { nodeKey } = parseTreeID(operation.target);
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
- // Check if node already exists and if it's the same TreeID
77
- const existingNode = $getNodeByKey(nodeKey);
78
- if (existingNode) {
79
- const existingTreeID = binding.nodeMapper.getTreeIDByLexicalKey(nodeKey);
80
- if (existingTreeID === operation.target) {
81
- return;
82
- }
83
- else {
84
- // Don't reuse existing keys for different TreeIDs - let Lexical generate a fresh one
85
- nodeKey = undefined; // Let createLexicalNodeFromLoro generate a fresh key
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
- else {
111
- parentNode = $getRoot();
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
- // Normal insertion
114
- if (operation.index !== undefined) {
115
- parentNode.splice(operation.index, 0, [lexicalNode]);
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
- else {
118
- parentNode.append(lexicalNode);
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
- // Set up mapping
121
- binding.nodeMapper.setMapping(lexicalNode.getKey(), operation.target);
146
+ this.insertChild(parentLexicalNode, lexicalNode, operation.index);
122
147
  }
123
- catch (error) {
124
- console.warn(`🌳 Error creating node for ${operation.target}:`, error);
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
- try {
129
- const lexicalKey = binding.nodeMapper.getLexicalKeyByLoroId(operation.target);
130
- if (!lexicalKey) {
131
- console.warn(`🌳 No Lexical key found for move target ${operation.target}`);
132
- return;
133
- }
134
- const nodeToMove = $getNodeByKey(lexicalKey);
135
- if (!nodeToMove) {
136
- console.warn(`🌳 Node to move not found: ${lexicalKey}`);
137
- return;
138
- }
139
- // Find new parent
140
- let newParent;
141
- if (operation.parent) {
142
- const parentKey = binding.nodeMapper.getLexicalKeyByLoroId(operation.parent);
143
- const parentNode = parentKey ? $getNodeByKey(parentKey) : null;
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
- catch (error) {
173
- console.warn(`🌳 Error moving node ${operation.target}:`, error);
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
- try {
178
- const lexicalKey = binding.nodeMapper.getLexicalKeyByLoroId(operation.target);
179
- if (!lexicalKey) {
180
- console.warn(`🌳 No Lexical key found for delete target ${operation.target}`);
181
- return;
182
- }
183
- const nodeToDelete = $getNodeByKey(lexicalKey);
184
- if (!nodeToDelete) {
185
- console.warn(`🌳 Node to delete not found: ${lexicalKey}`);
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
- catch (error) {
197
- console.warn(`🌳 Error deleting node ${operation.target}:`, error);
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) 2023-2025 Datalayer, Inc.
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
- if (!loroTree.has(treeId)) {
30
- return null;
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: construct a minimal serialization from elementType metadata
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
- if (!fallbackType || typeof fallbackType !== 'string') {
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
- if (!nodeType) {
52
- console.warn('🏭 NodeFactory: No type field in lexical data for TreeID:', treeId);
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
- if (!nodeInfo) {
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
- try {
72
- const serializedData = { ...lexicalData };
73
- // Ensure required serialization fields
74
- if (!serializedData.type) {
75
- serializedData.type = nodeType;
76
- }
77
- if (serializedData.version === undefined) {
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
- catch (importError) {
90
- console.warn(`🏭 NodeFactory: importJSON failed for '${nodeType}', trying constructor fallback:`, importError);
91
- // Fallback: try no-arg constructor (works for simple nodes)
92
- try {
93
- const lexicalNode = new nodeInfo.klass();
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) 2023-2025 Datalayer, Inc.
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, index);
121
+ const treeNode = this.tree.createNode(parentTreeID, insertionIndex);
109
122
  // Get the TreeID from the created node
110
123
  const treeId = treeNode.id;
111
- // Debug logging for parent relationship issues
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
- if (!actualParent || actualParent.id !== parentTreeID) {
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('📍 syncExistingNodes: Skipping sync - mappings established through TreeIntegrator');
247
+ console.log(' syncExistingNodes: Skipping sync - mappings established through TreeIntegrator');
236
248
  }
237
249
  }
238
250
  // Global instance to be shared across the application
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright (c) 2023-2025 Datalayer, Inc.
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the MIT License.
4
4
  */
5
5
  import { DecoratorNode, $isDecoratorNode } from 'lexical';
@@ -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