@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,9 +1,10 @@
|
|
|
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
5
|
import { $createParagraphNode, $isElementNode } from 'lexical';
|
|
6
6
|
import { getNodeMapper } from '../nodes/NodesMapper';
|
|
7
|
+
import { invariant } from '../utils/Invariant';
|
|
7
8
|
/**
|
|
8
9
|
* Create ElementNode in Loro tree
|
|
9
10
|
*/
|
|
@@ -28,7 +29,7 @@ options) {
|
|
|
28
29
|
/**
|
|
29
30
|
* Update ElementNode in Loro tree
|
|
30
31
|
*/
|
|
31
|
-
export function updateElementNodeInLoro(nodeKey, elementType, parentId, index, metadata, lexicalNodeJSON, // JSON object from exportJSON()
|
|
32
|
+
export function updateElementNodeInLoro(nodeKey, elementType, parentId, index, previousSiblingId, nextSiblingId, metadata, lexicalNodeJSON, // JSON object from exportJSON()
|
|
32
33
|
options) {
|
|
33
34
|
const mapper = getNodeMapper();
|
|
34
35
|
// Get the existing tree node using the mapper (don't pass lexicalNode to avoid context issues)
|
|
@@ -42,44 +43,87 @@ options) {
|
|
|
42
43
|
if (elementType !== undefined) {
|
|
43
44
|
treeNode.data.set('elementType', elementType);
|
|
44
45
|
}
|
|
45
|
-
// Move the node if
|
|
46
|
+
// Move the node only if its position in Loro actually differs from Lexical.
|
|
46
47
|
if (parentId !== undefined || index !== undefined) {
|
|
47
|
-
// Note: For moving, we need access to tree - this might need to be propagated differently
|
|
48
48
|
const { tree } = options;
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
// Debug: Check if the parent exists and its children count
|
|
55
|
-
const parentNode = parentId ? tree.getNodeByID(parentId) : null;
|
|
56
|
-
const parentChildCount = parentNode ? parentNode.children.length : tree.roots().length;
|
|
57
|
-
// Check if the node is already a child of the target parent
|
|
49
|
+
// A node can never be its own parent — that indicates a wrong parent
|
|
50
|
+
// mapping upstream, not something to silently skip.
|
|
51
|
+
invariant(!(parentId && treeNode.id === parentId), 'updateElementNodeInLoro: cycle move (node is its own parent)', { nodeKey, treeId: treeNode.id });
|
|
52
|
+
// Where does the node currently sit in the Loro tree?
|
|
58
53
|
const currentParent = treeNode.parent();
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
54
|
+
const currentParentId = currentParent ? currentParent.id : undefined;
|
|
55
|
+
const siblings = currentParent ? (currentParent.children() ?? []) : tree.roots();
|
|
56
|
+
const currentIndex = siblings.findIndex(child => child.id === treeNode.id);
|
|
57
|
+
const sameParent = currentParentId === parentId;
|
|
58
|
+
// Re-issuing a move on every content edit is unnecessary and races with
|
|
59
|
+
// sibling creation (the classic source of "index out of range"). Only move
|
|
60
|
+
// when the parent or index genuinely changed. This is a real no-op check,
|
|
61
|
+
// not a masked failure.
|
|
62
|
+
const needsMove = !sameParent || (index !== undefined && index !== currentIndex);
|
|
63
|
+
if (needsMove) {
|
|
64
|
+
const parentNode = parentId ? tree.getNodeByID(parentId) : null;
|
|
65
|
+
const parentChildCount = parentNode
|
|
66
|
+
? (parentNode.children()?.length ?? 0)
|
|
67
|
+
: tree.roots().length;
|
|
68
|
+
if (index !== undefined) {
|
|
69
|
+
// Loro move index bounds:
|
|
70
|
+
// same-parent reorder : [0, children.length - 1]
|
|
71
|
+
// cross-parent move : [0, children.length]
|
|
72
|
+
// An index outside this range means the position was computed against
|
|
73
|
+
// siblings that are not yet synced. Before failing, resolve index
|
|
74
|
+
// from mapped sibling anchors when available.
|
|
75
|
+
let targetIndex = index;
|
|
76
|
+
const maxIndex = sameParent
|
|
77
|
+
? Math.max(parentChildCount - 1, 0)
|
|
78
|
+
: parentChildCount;
|
|
79
|
+
if (targetIndex < 0 || targetIndex > maxIndex) {
|
|
80
|
+
const targetSiblings = parentNode
|
|
81
|
+
? (parentNode.children() ?? [])
|
|
82
|
+
: tree.roots();
|
|
83
|
+
if (previousSiblingId) {
|
|
84
|
+
const prevIndex = targetSiblings.findIndex(child => child.id === previousSiblingId);
|
|
85
|
+
if (prevIndex >= 0) {
|
|
86
|
+
targetIndex = prevIndex + 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if ((targetIndex < 0 || targetIndex > maxIndex) && nextSiblingId) {
|
|
90
|
+
const nextIndex = targetSiblings.findIndex(child => child.id === nextSiblingId);
|
|
91
|
+
if (nextIndex >= 0) {
|
|
92
|
+
targetIndex = nextIndex;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// A move can observe transiently sparse sibling mappings within one
|
|
96
|
+
// update batch (e.g. pressing Enter at the end of the document, where
|
|
97
|
+
// the sibling created in the same batch is not yet mapped). When the
|
|
98
|
+
// anchors cannot resolve the position, clamp into the valid Loro range
|
|
99
|
+
// instead of throwing: a hard failure rolls back the user's keystroke,
|
|
100
|
+
// whereas a bounded placement still converges via the CRDT and can be
|
|
101
|
+
// refined by subsequent updates.
|
|
102
|
+
if (targetIndex < 0 || targetIndex > maxIndex) {
|
|
103
|
+
const clamped = Math.min(Math.max(targetIndex, 0), maxIndex);
|
|
104
|
+
console.warn(`[loro-collab] updateElementNodeInLoro: clamping out-of-range move index`, {
|
|
105
|
+
nodeKey,
|
|
106
|
+
treeId: treeNode.id,
|
|
107
|
+
index,
|
|
108
|
+
targetIndex,
|
|
109
|
+
clamped,
|
|
110
|
+
maxIndex,
|
|
111
|
+
sameParent,
|
|
112
|
+
previousSiblingId,
|
|
113
|
+
nextSiblingId,
|
|
114
|
+
});
|
|
115
|
+
targetIndex = clamped;
|
|
116
|
+
}
|
|
66
117
|
}
|
|
118
|
+
tree.move(treeNode.id, parentId, targetIndex);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
tree.move(treeNode.id, parentId, parentChildCount);
|
|
67
122
|
}
|
|
68
|
-
tree.move(treeNode.id, parentId, adjustedIndex);
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
// No specific index, append to end
|
|
72
|
-
tree.move(treeNode.id, parentId, parentChildCount);
|
|
73
123
|
}
|
|
74
124
|
}
|
|
75
|
-
// The exported Lexical node data is already propagated by the mapper
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
treeNode.data.set('lastUpdated', Date.now());
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
console.warn(`🔄 ElementNode ${treeNode.id} container deleted during timestamp update (normal during operations):`, error.message);
|
|
82
|
-
}
|
|
125
|
+
// The exported Lexical node data is already propagated by the mapper.
|
|
126
|
+
treeNode.data.set('lastUpdated', Date.now());
|
|
83
127
|
}
|
|
84
128
|
/**
|
|
85
129
|
* Delete ElementNode from Loro tree
|
|
@@ -263,7 +307,7 @@ export function propagateElementNode(update, mutation, nodeKey, options) {
|
|
|
263
307
|
// because SyncLexicalToLoro sorts element mutations by depth.
|
|
264
308
|
// But as a safety net, log a warning.
|
|
265
309
|
if (parent && !parentId) {
|
|
266
|
-
console.warn(
|
|
310
|
+
console.warn(` ElementNodePropagator: parent mapping missing for ${elementType} nodeKey=${nodeKey}, parentKey=${parent.getKey()}`);
|
|
267
311
|
}
|
|
268
312
|
// Collect metadata (format, style, direction, etc.)
|
|
269
313
|
if (typeof currentNode.getFormat === 'function') {
|
|
@@ -288,6 +332,8 @@ export function propagateElementNode(update, mutation, nodeKey, options) {
|
|
|
288
332
|
if (currentNode && $isElementNode(currentNode)) {
|
|
289
333
|
// Use editorState.read() to safely access node methods
|
|
290
334
|
let parent, parentId, index;
|
|
335
|
+
let previousSiblingId;
|
|
336
|
+
let nextSiblingId;
|
|
291
337
|
let elementType;
|
|
292
338
|
const metadata = {};
|
|
293
339
|
// Export node data and collect metadata within editor context
|
|
@@ -299,6 +345,27 @@ export function propagateElementNode(update, mutation, nodeKey, options) {
|
|
|
299
345
|
const mapper = getNodeMapper();
|
|
300
346
|
parentId = parent ? mapper.getTreeIDByLexicalKey(parent.getKey()) : undefined;
|
|
301
347
|
index = currentNode.getIndexWithinParent();
|
|
348
|
+
// Resolve nearest mapped sibling anchors to stabilize target index
|
|
349
|
+
// when Lexical index includes siblings not yet represented in Loro.
|
|
350
|
+
if (parent) {
|
|
351
|
+
const siblings = parent.getChildren();
|
|
352
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
353
|
+
const candidate = siblings[i];
|
|
354
|
+
const candidateTreeId = mapper.getTreeIDByLexicalKey(candidate.getKey());
|
|
355
|
+
if (candidateTreeId && tree.has(candidateTreeId)) {
|
|
356
|
+
previousSiblingId = candidateTreeId;
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (let i = index + 1; i < siblings.length; i++) {
|
|
361
|
+
const candidate = siblings[i];
|
|
362
|
+
const candidateTreeId = mapper.getTreeIDByLexicalKey(candidate.getKey());
|
|
363
|
+
if (candidateTreeId && tree.has(candidateTreeId)) {
|
|
364
|
+
nextSiblingId = candidateTreeId;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
302
369
|
// Get element type and metadata
|
|
303
370
|
elementType = currentNode.getType();
|
|
304
371
|
if (typeof currentNode.getFormat === 'function') {
|
|
@@ -314,7 +381,7 @@ export function propagateElementNode(update, mutation, nodeKey, options) {
|
|
|
314
381
|
lexicalNodeJSON = currentNode.exportJSON();
|
|
315
382
|
});
|
|
316
383
|
// Update the node in Loro after safely reading from editor state
|
|
317
|
-
updateElementNodeInLoro(nodeKey, elementType, parentId, index, metadata, lexicalNodeJSON, options);
|
|
384
|
+
updateElementNodeInLoro(nodeKey, elementType, parentId, index, previousSiblingId, nextSiblingId, metadata, lexicalNodeJSON, options);
|
|
318
385
|
}
|
|
319
386
|
break;
|
|
320
387
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
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
5
|
import { $createTextNode, $isTextNode } from 'lexical';
|
|
6
6
|
import { getNodeMapper } from '../nodes/NodesMapper';
|
|
7
7
|
import { $diffTextContentAndApplyDelta } from '../utils/Utils';
|
|
8
|
+
import { invariant } from '../utils/Invariant';
|
|
8
9
|
/**
|
|
9
10
|
* Create TextNode in Loro tree
|
|
10
11
|
*/
|
|
11
12
|
export function createTextNodeInLoro(nodeKey, textContent, format, mode, parentId, index, lexicalNodeJSON, // JSON object from exportJSON()
|
|
12
13
|
options) {
|
|
13
14
|
const mapper = getNodeMapper();
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
15
|
+
// A text node is always a leaf with a parent element. A missing parent means
|
|
16
|
+
// the parent element was not synced first — surface it instead of creating an
|
|
17
|
+
// orphaned (unrenderable) node.
|
|
18
|
+
invariant(parentId !== undefined, 'createTextNodeInLoro: text node has no parent in Loro tree', { nodeKey });
|
|
19
19
|
// Use mapper to get or create the tree node
|
|
20
20
|
// Note: We can't pass lexicalNode directly due to context issues, but parentId should be sufficient
|
|
21
21
|
const treeNode = mapper.getLoroNodeByLexicalKey(nodeKey, undefined, // don't pass lexicalNode to avoid context issues
|
|
@@ -49,44 +49,30 @@ options) {
|
|
|
49
49
|
export function updateTextNodeInLoro(nodeKey, newTextContent, format, mode, lexicalNodeJSON, // JSON object from exportJSON()
|
|
50
50
|
options) {
|
|
51
51
|
const mapper = getNodeMapper();
|
|
52
|
-
//
|
|
53
|
-
|
|
52
|
+
// Updates must target an existing mapped node. Creating on update can
|
|
53
|
+
// accidentally create inline nodes at root when parent info is unavailable.
|
|
54
|
+
const treeID = mapper.getTreeIDByLexicalKey(nodeKey);
|
|
55
|
+
if (!treeID || !options?.tree.has(treeID)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const treeNode = options.tree.getNodeByID(treeID);
|
|
54
59
|
if (!treeNode) {
|
|
55
|
-
console.warn(`📝 TextNode ${nodeKey} not found in Loro, skipping update`);
|
|
56
60
|
return;
|
|
57
61
|
}
|
|
58
|
-
//
|
|
59
|
-
// the container can be deleted between operations
|
|
60
|
-
// Store complete lexical node data as clean JSON if provided
|
|
62
|
+
// Store complete lexical node data as clean JSON if provided.
|
|
61
63
|
if (lexicalNodeJSON) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const { key, __key, lexicalKey, children, ...cleanedData } = lexicalNodeJSON;
|
|
66
|
-
treeNode.data.set('lexical', cleanedData);
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
const { children, ...cleanedData } = lexicalNodeJSON;
|
|
70
|
-
treeNode.data.set('lexical', cleanedData);
|
|
71
|
-
}
|
|
64
|
+
if ('key' in lexicalNodeJSON || '__key' in lexicalNodeJSON || 'lexicalKey' in lexicalNodeJSON) {
|
|
65
|
+
const { key, __key, lexicalKey, children, ...cleanedData } = lexicalNodeJSON;
|
|
66
|
+
treeNode.data.set('lexical', cleanedData);
|
|
72
67
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return;
|
|
68
|
+
else {
|
|
69
|
+
const { children, ...cleanedData } = lexicalNodeJSON;
|
|
70
|
+
treeNode.data.set('lexical', cleanedData);
|
|
77
71
|
}
|
|
78
72
|
}
|
|
79
|
-
// Update only essential metadata
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
treeNode.data.set('updatedAt', Date.now());
|
|
83
|
-
}
|
|
84
|
-
catch (error) {
|
|
85
|
-
console.warn(`📝 TextNode ${nodeKey} container deleted during metadata update (normal during text operations):`, error.message);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
// The exported Lexical node data is already propagated by the mapper
|
|
89
|
-
// No additional JSON export needed since mapper propagates exportJSON automatically
|
|
73
|
+
// Update only essential metadata.
|
|
74
|
+
treeNode.data.set('elementType', 'text');
|
|
75
|
+
treeNode.data.set('updatedAt', Date.now());
|
|
90
76
|
}
|
|
91
77
|
/**
|
|
92
78
|
* Delete TextNode from Loro tree
|
|
@@ -241,7 +227,7 @@ export function applyTextFormatInLoro(nodeKey, formatType, apply, options) {
|
|
|
241
227
|
treeNode.data.get('format');
|
|
242
228
|
}
|
|
243
229
|
catch (error) {
|
|
244
|
-
console.warn(
|
|
230
|
+
console.warn(` TextNode ${nodeKey} container deleted during format check (normal during text operations):`, error.message);
|
|
245
231
|
return;
|
|
246
232
|
}
|
|
247
233
|
let currentFormat = treeNode.data.get('format') || 0;
|
|
@@ -259,7 +245,7 @@ export function applyTextFormatInLoro(nodeKey, formatType, apply, options) {
|
|
|
259
245
|
treeNode.data.set('lastUpdated', Date.now());
|
|
260
246
|
}
|
|
261
247
|
catch (error) {
|
|
262
|
-
console.warn(
|
|
248
|
+
console.warn(` TextNode ${nodeKey} container deleted during format update (normal during text operations):`, error.message);
|
|
263
249
|
return;
|
|
264
250
|
}
|
|
265
251
|
}
|
|
@@ -289,7 +275,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
|
|
|
289
275
|
}
|
|
290
276
|
}
|
|
291
277
|
catch (error) {
|
|
292
|
-
console.warn(
|
|
278
|
+
console.warn(` Failed to get parent for existing TreeID ${existingTreeID}:`, error);
|
|
293
279
|
}
|
|
294
280
|
return { currentParentId, expectedParentId };
|
|
295
281
|
});
|
|
@@ -306,7 +292,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
|
|
|
306
292
|
}
|
|
307
293
|
}
|
|
308
294
|
catch (error) {
|
|
309
|
-
console.warn(
|
|
295
|
+
console.warn(` Could not get TreeNode for ${existingTreeID} during creation:`, error);
|
|
310
296
|
}
|
|
311
297
|
// Clear the old mapping first to avoid conflicts
|
|
312
298
|
mapper.deleteMapping(nodeKey);
|
|
@@ -317,7 +303,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
|
|
|
317
303
|
}
|
|
318
304
|
}
|
|
319
305
|
catch (error) {
|
|
320
|
-
console.warn(
|
|
306
|
+
console.warn(` Failed to delete TreeNode ${actualTreeID} during creation:`, error);
|
|
321
307
|
}
|
|
322
308
|
}
|
|
323
309
|
}
|
|
@@ -366,7 +352,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
|
|
|
366
352
|
}
|
|
367
353
|
}
|
|
368
354
|
catch (error) {
|
|
369
|
-
console.warn(
|
|
355
|
+
console.warn(` Failed to get parent for existing TreeID ${existingTreeID} during update:`, error);
|
|
370
356
|
}
|
|
371
357
|
return { currentParentId, expectedParentId, parent };
|
|
372
358
|
});
|
|
@@ -380,7 +366,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
|
|
|
380
366
|
}
|
|
381
367
|
}
|
|
382
368
|
catch (error) {
|
|
383
|
-
console.warn(
|
|
369
|
+
console.warn(` Could not get TreeNode for ${existingTreeID}:`, error);
|
|
384
370
|
}
|
|
385
371
|
// Clear the old mapping first to avoid conflicts
|
|
386
372
|
mapper.deleteMapping(nodeKey);
|
|
@@ -391,7 +377,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
|
|
|
391
377
|
}
|
|
392
378
|
}
|
|
393
379
|
catch (error) {
|
|
394
|
-
console.warn(
|
|
380
|
+
console.warn(` Failed to delete TreeNode ${actualTreeID} during update:`, error);
|
|
395
381
|
}
|
|
396
382
|
// Now recreate the TextNode with the new parent (treat as "created")
|
|
397
383
|
const { parentId, index, textContent, format, mode, lexicalNodeJSON } = update.editorState.read(() => {
|
|
@@ -413,6 +399,30 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
|
|
|
413
399
|
return; // Exit early since we recreated the node
|
|
414
400
|
}
|
|
415
401
|
}
|
|
402
|
+
// If a text node emits only an "updated" mutation before we have a
|
|
403
|
+
// mapping, treat it as a create so parent/index are explicit.
|
|
404
|
+
if (!existingTreeID) {
|
|
405
|
+
const { parent, parentId, index, textContent, format, mode, lexicalNodeJSON } = update.editorState.read(() => {
|
|
406
|
+
const parent = currentNode.getParent();
|
|
407
|
+
const parentId = parent ? mapper.getTreeIDByLexicalKey(parent.getKey()) : undefined;
|
|
408
|
+
const index = currentNode.getIndexWithinParent();
|
|
409
|
+
const textContent = currentNode.getTextContent();
|
|
410
|
+
const format = currentNode.getFormat();
|
|
411
|
+
const mode = currentNode.getMode();
|
|
412
|
+
let lexicalNodeJSON = undefined;
|
|
413
|
+
try {
|
|
414
|
+
lexicalNodeJSON = currentNode.exportJSON();
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
console.warn('Failed to export node JSON in propagateTextNode updated(create fallback):', error);
|
|
418
|
+
}
|
|
419
|
+
return { parent, parentId, index, textContent, format, mode, lexicalNodeJSON };
|
|
420
|
+
});
|
|
421
|
+
if (parentId !== undefined) {
|
|
422
|
+
createTextNodeInLoro(nodeKey, textContent, format, mode, parentId, index, lexicalNodeJSON, options);
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
416
426
|
// Normal update case - parent hasn't changed
|
|
417
427
|
const { textContent, format, mode, lexicalNodeJSON } = update.editorState.read(() => {
|
|
418
428
|
const textContent = currentNode.getTextContent();
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { ElementNode, NodeKey } from 'lexical';
|
|
8
8
|
import { Binding } from '../Bindings';
|
|
9
|
-
import { LoroTree } from 'loro-crdt
|
|
9
|
+
import type { LoroTree } from 'loro-crdt';
|
|
10
10
|
export * from './RootNodePropagator';
|
|
11
11
|
export * from './LineBreakNodePropagator';
|
|
12
12
|
export * from './ElementNodePropagator';
|
|
@@ -1,5 +1,5 @@
|
|
|
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
5
|
import { EphemeralStore } from 'loro-crdt';
|
|
@@ -28,7 +28,7 @@ class AwarenessAdapter {
|
|
|
28
28
|
this.ephemeralStore = ephemeralStore;
|
|
29
29
|
// Use the same client ID as the binding for consistency
|
|
30
30
|
this.localClientId = doc ? generateClientID(doc) : generateRandomClientID();
|
|
31
|
-
console.log('
|
|
31
|
+
console.log(' AwarenessAdapter created:', {
|
|
32
32
|
localClientId: this.localClientId,
|
|
33
33
|
docPeerId: doc ? doc.peerId : 'no-doc',
|
|
34
34
|
existingStatesCount: Object.keys(ephemeralStore.getAllStates()).length
|
|
@@ -256,6 +256,7 @@ messageHandlers[messageUpdate] = (provider, message, emitSynced) => {
|
|
|
256
256
|
// The key point is that it's NOT our local peerId
|
|
257
257
|
const importStatus = provider.doc.import(updateBytes);
|
|
258
258
|
const afterVersion = provider.doc.version();
|
|
259
|
+
console.log('[SEED-DEBUG] messageUpdate: imported', updateBytes.length, 'bytes; importStatus=', JSON.stringify(importStatus));
|
|
259
260
|
// Update our last exported version to include the remote changes
|
|
260
261
|
// This ensures we don't re-export remote changes
|
|
261
262
|
provider._lastExportedVersion = afterVersion;
|
|
@@ -265,7 +266,7 @@ messageHandlers[messageUpdate] = (provider, message, emitSynced) => {
|
|
|
265
266
|
return null; // No response needed
|
|
266
267
|
}
|
|
267
268
|
catch (error) {
|
|
268
|
-
console.warn(
|
|
269
|
+
console.warn(` [LORO-UPDATE-ERROR] Failed to apply Loro update:`, error);
|
|
269
270
|
return null;
|
|
270
271
|
}
|
|
271
272
|
};
|
|
@@ -432,7 +433,7 @@ const setupWS = (provider) => {
|
|
|
432
433
|
provider.emit('status', [{
|
|
433
434
|
status: 'connected'
|
|
434
435
|
}]);
|
|
435
|
-
console.log('
|
|
436
|
+
console.log(' WebSocket connection established, requesting initial data');
|
|
436
437
|
// Since we're in onopen, we know the WebSocket is ready
|
|
437
438
|
// Use sendMessage directly to avoid any race conditions
|
|
438
439
|
// Only request snapshot if we haven't already loaded it
|
|
@@ -445,12 +446,12 @@ const setupWS = (provider) => {
|
|
|
445
446
|
docId: provider.docId,
|
|
446
447
|
clientId: clientId
|
|
447
448
|
};
|
|
448
|
-
console.log(
|
|
449
|
-
console.log(
|
|
449
|
+
console.log(` Requesting initial snapshot from server (ID: ${requestId}, clientId: ${clientId}):`, snapshotRequest);
|
|
450
|
+
console.log(` Provider instance ID: ${provider.wsServerUrl}/${provider.docId}, snapshotLoaded: ${provider.snapshotLoaded}`);
|
|
450
451
|
sendMessage(ws, snapshotRequest);
|
|
451
452
|
}
|
|
452
453
|
else {
|
|
453
|
-
console.log('
|
|
454
|
+
console.log(' Snapshot already loaded, skipping request');
|
|
454
455
|
}
|
|
455
456
|
// Then request initial ephemeral state from server
|
|
456
457
|
const clientId = generateClientID(provider.doc).toString();
|
|
@@ -482,6 +483,22 @@ const setupWS = (provider) => {
|
|
|
482
483
|
});
|
|
483
484
|
}
|
|
484
485
|
}
|
|
486
|
+
// Flush any local document updates that were buffered while the socket
|
|
487
|
+
// was not ready to send (e.g. the initial-content bootstrap ran before
|
|
488
|
+
// the connection opened). Without this, seeded content committed before
|
|
489
|
+
// the socket opened would never reach the server or other collaborators.
|
|
490
|
+
if (provider._pendingUpdates.length > 0) {
|
|
491
|
+
const pending = provider._pendingUpdates;
|
|
492
|
+
provider._pendingUpdates = [];
|
|
493
|
+
console.log('[SEED-DEBUG] ws.onopen: flushing', pending.length, 'buffered local updates');
|
|
494
|
+
for (const pendingUpdate of pending) {
|
|
495
|
+
sendMessage(ws, {
|
|
496
|
+
type: 'update',
|
|
497
|
+
update: Array.from(pendingUpdate),
|
|
498
|
+
docId: provider.docId
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
485
502
|
};
|
|
486
503
|
provider.emit('status', [{
|
|
487
504
|
status: 'connecting'
|
|
@@ -497,13 +514,13 @@ const broadcastMessage = (provider, message) => {
|
|
|
497
514
|
sendMessage(ws, message);
|
|
498
515
|
}
|
|
499
516
|
else {
|
|
500
|
-
console.log('
|
|
517
|
+
console.log(' [BROADCAST] WebSocket not ready for sending');
|
|
501
518
|
}
|
|
502
519
|
if (provider.bcconnected) {
|
|
503
520
|
bc.publish(provider.bcChannel, JSON.stringify(message), provider);
|
|
504
521
|
}
|
|
505
522
|
else {
|
|
506
|
-
console.log('
|
|
523
|
+
console.log(' [BROADCAST] BroadcastChannel not connected');
|
|
507
524
|
}
|
|
508
525
|
};
|
|
509
526
|
/**
|
|
@@ -541,6 +558,7 @@ export class WebsocketProvider extends ObservableV2 {
|
|
|
541
558
|
_exitIntegrator = null;
|
|
542
559
|
_bcSubscriber = null;
|
|
543
560
|
_lastExportedVersion = null; // Track last exported version for incremental updates
|
|
561
|
+
_pendingUpdates = []; // Local updates buffered while the socket is not ready to send
|
|
544
562
|
/**
|
|
545
563
|
* @param {string} wsServerUrl
|
|
546
564
|
* @param {string} docId
|
|
@@ -587,7 +605,7 @@ export class WebsocketProvider extends ObservableV2 {
|
|
|
587
605
|
console.log('🆕 Created new global EphemeralStore');
|
|
588
606
|
}
|
|
589
607
|
else {
|
|
590
|
-
console.log('
|
|
608
|
+
console.log(' Reusing existing global EphemeralStore - cleaning up stale user states');
|
|
591
609
|
// Clean up all existing user states when reusing store to prevent accumulation
|
|
592
610
|
const allStates = WebsocketProvider.globalEphemeralStore.getAllStates();
|
|
593
611
|
Object.keys(allStates).forEach(key => {
|
|
@@ -595,7 +613,7 @@ export class WebsocketProvider extends ObservableV2 {
|
|
|
595
613
|
const clientId = parseInt(key, 10);
|
|
596
614
|
if (!isNaN(clientId)) {
|
|
597
615
|
WebsocketProvider.globalEphemeralStore.delete(key);
|
|
598
|
-
console.log('
|
|
616
|
+
console.log(' Cleaned up stale user state:', key);
|
|
599
617
|
}
|
|
600
618
|
});
|
|
601
619
|
}
|
|
@@ -673,7 +691,24 @@ export class WebsocketProvider extends ObservableV2 {
|
|
|
673
691
|
update: Array.from(update),
|
|
674
692
|
docId: this.docId
|
|
675
693
|
};
|
|
676
|
-
|
|
694
|
+
const ws = this.ws;
|
|
695
|
+
if (this.wsconnected && ws && ws.readyState === ws.OPEN) {
|
|
696
|
+
console.log('[SEED-DEBUG] _updateHandler: sending local update over WS', update.length, 'bytes');
|
|
697
|
+
sendMessage(ws, updateMessage);
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
// The socket is not ready yet — this happens when the initial-content
|
|
701
|
+
// bootstrap commits before the connection opens. Loro delivers each
|
|
702
|
+
// local update to subscribeLocalUpdates exactly once, so dropping it
|
|
703
|
+
// here would lose the seeded content permanently (remote peers would
|
|
704
|
+
// never receive it). Buffer it and flush on open instead.
|
|
705
|
+
console.log('[SEED-DEBUG] _updateHandler: WS not ready, buffering local update', update.length, 'bytes (pending now', this._pendingUpdates.length + 1, ')');
|
|
706
|
+
this._pendingUpdates.push(update);
|
|
707
|
+
}
|
|
708
|
+
// Mirror to other same-origin tabs when the BroadcastChannel is available.
|
|
709
|
+
if (this.bcconnected) {
|
|
710
|
+
bc.publish(this.bcChannel, JSON.stringify(updateMessage), this);
|
|
711
|
+
}
|
|
677
712
|
};
|
|
678
713
|
// Document update integrater - called when Loro emits document change events
|
|
679
714
|
/**
|