@datalayer/lexical-loro 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +551 -0
- package/lib/LoroCollaborativePlugin.d.ts +14 -0
- package/lib/LoroCollaborativePlugin.js +2421 -0
- package/package.json +73 -0
|
@@ -0,0 +1,2421 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useCallback, useState } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { $createParagraphNode, $getRoot, $getSelection, $isRangeSelection, $getNodeByKey, $isTextNode, $isElementNode, $isLineBreakNode, $createTextNode, createState, $getState, $setState } from 'lexical';
|
|
5
|
+
import { createDOMRange, createRectsFromDOMRange } from '@lexical/selection';
|
|
6
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
7
|
+
import { LoroDoc, LoroText, Cursor, EphemeralStore } from 'loro-crdt';
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// STABLE NODE UUID SYSTEM using Lexical NodeState
|
|
10
|
+
// ============================================================================
|
|
11
|
+
/**
|
|
12
|
+
* NodeState configuration for storing stable UUIDs in Lexical nodes.
|
|
13
|
+
* This replaces the unstable NodeKey system for cursor positioning.
|
|
14
|
+
*
|
|
15
|
+
* Based on Lexical NodeState documentation:
|
|
16
|
+
* https://lexical.dev/docs/concepts/node-state
|
|
17
|
+
*/
|
|
18
|
+
const stableNodeIdState = createState('stable-node-id', {
|
|
19
|
+
parse: (v) => typeof v === 'string' ? v : undefined,
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Generate a stable UUID for nodes
|
|
23
|
+
*/
|
|
24
|
+
function generateStableNodeId() {
|
|
25
|
+
return `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Get or create a stable UUID for a Lexical node using NodeState
|
|
29
|
+
*/
|
|
30
|
+
function $getStableNodeId(node) {
|
|
31
|
+
let stableId = $getState(node, stableNodeIdState);
|
|
32
|
+
if (!stableId) {
|
|
33
|
+
stableId = generateStableNodeId();
|
|
34
|
+
$setState(node, stableNodeIdState, stableId);
|
|
35
|
+
}
|
|
36
|
+
return stableId;
|
|
37
|
+
}
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// STABLE CURSOR POSITION FUNCTIONS - UUID Based (No Performance Issues)
|
|
40
|
+
// ============================================================================
|
|
41
|
+
/**
|
|
42
|
+
* Create stable position data from Lexical selection point using UUID
|
|
43
|
+
* This replaces NodeKey-based approach with stable UUIDs
|
|
44
|
+
* Must be called within editor.getEditorState().read() or editor.update()
|
|
45
|
+
*/
|
|
46
|
+
function $createStablePositionFromPoint(point) {
|
|
47
|
+
const node = $getNodeByKey(point.key);
|
|
48
|
+
if (!node) {
|
|
49
|
+
console.warn('❌ Node not found for key:', point.key);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
// Get or create stable UUID for this node
|
|
53
|
+
const stableNodeId = $getStableNodeId(node);
|
|
54
|
+
return {
|
|
55
|
+
stableNodeId,
|
|
56
|
+
offset: point.offset,
|
|
57
|
+
type: $isTextNode(node) ? 'text' : 'element'
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Find a node by its stable UUID (traverses the document tree)
|
|
62
|
+
* This is the reverse operation - finding node by stable ID
|
|
63
|
+
*/
|
|
64
|
+
function $findNodeByStableId(stableNodeId) {
|
|
65
|
+
const root = $getRoot();
|
|
66
|
+
// Traverse the document tree to find node with matching stable ID
|
|
67
|
+
function traverse(node) {
|
|
68
|
+
// Check if this node has the stable ID we're looking for
|
|
69
|
+
const nodeStableId = $getState(node, stableNodeIdState);
|
|
70
|
+
if (nodeStableId === stableNodeId) {
|
|
71
|
+
return node;
|
|
72
|
+
}
|
|
73
|
+
// If this is an element node, traverse its children
|
|
74
|
+
if ($isElementNode(node)) {
|
|
75
|
+
const children = node.getChildren();
|
|
76
|
+
for (const child of children) {
|
|
77
|
+
const found = traverse(child);
|
|
78
|
+
if (found)
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return traverse(root);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Convert stable position back to NodeKey and offset for Lexical operations
|
|
88
|
+
* This allows compatibility with existing cursor positioning code
|
|
89
|
+
*/
|
|
90
|
+
function $resolveStablePosition(stablePos) {
|
|
91
|
+
const node = $findNodeByStableId(stablePos.stableNodeId);
|
|
92
|
+
if (!node) {
|
|
93
|
+
console.warn('❌ Could not find node for stable ID:', stablePos.stableNodeId, '- using document end fallback');
|
|
94
|
+
// ROBUST FALLBACK: When stable UUID can't be resolved (node doesn't exist yet),
|
|
95
|
+
// position cursor at end of document instead of failing
|
|
96
|
+
const root = $getRoot();
|
|
97
|
+
const children = root.getChildren();
|
|
98
|
+
// Find the last text node in the document
|
|
99
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
100
|
+
const child = children[i];
|
|
101
|
+
if ($isElementNode(child)) {
|
|
102
|
+
const textChildren = child.getChildren().filter($isTextNode);
|
|
103
|
+
if (textChildren.length > 0) {
|
|
104
|
+
const lastText = textChildren[textChildren.length - 1];
|
|
105
|
+
console.log('✅ Fallback: Using end of last text node:', {
|
|
106
|
+
nodeKey: lastText.getKey(),
|
|
107
|
+
textLength: lastText.getTextContentSize(),
|
|
108
|
+
stableIdThatFailed: stablePos.stableNodeId
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
key: lastText.getKey(),
|
|
112
|
+
offset: lastText.getTextContentSize()
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// If no text nodes found, use root
|
|
118
|
+
console.log('✅ Fallback: Using root node (no text nodes found)');
|
|
119
|
+
return {
|
|
120
|
+
key: root.getKey(),
|
|
121
|
+
offset: 0
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
key: node.getKey(),
|
|
126
|
+
offset: stablePos.offset
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Ensure all nodes in the document have stable UUIDs
|
|
131
|
+
* This should be called after document updates to maintain stability
|
|
132
|
+
*/
|
|
133
|
+
function $ensureAllNodesHaveStableIds() {
|
|
134
|
+
const root = $getRoot();
|
|
135
|
+
function traverse(node) {
|
|
136
|
+
// Ensure this node has a stable ID
|
|
137
|
+
$getStableNodeId(node);
|
|
138
|
+
// If this is an element node, traverse its children
|
|
139
|
+
if ($isElementNode(node)) {
|
|
140
|
+
const children = node.getChildren();
|
|
141
|
+
for (const child of children) {
|
|
142
|
+
traverse(child);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
traverse(root);
|
|
147
|
+
} /**
|
|
148
|
+
* LoroCollaborativePlugin - Enhanced Cursor Management
|
|
149
|
+
*
|
|
150
|
+
* IMPROVEMENTS IMPLEMENTED based on Loro Cursor documentation and YJS SyncCursors patterns:
|
|
151
|
+
*
|
|
152
|
+
* 1. Enhanced CursorAwareness class with Loro document reference
|
|
153
|
+
* - Added loroDoc parameter for proper cursor operations
|
|
154
|
+
* - Provides framework for stable cursor positioning
|
|
155
|
+
*
|
|
156
|
+
* 2. Added createCursorFromLexicalPoint method
|
|
157
|
+
* - Inspired by YJS SyncCursors createRelativePosition pattern
|
|
158
|
+
* - Creates stable Loro cursors from Lexical selection points
|
|
159
|
+
* - Replaces approximation with proper cursor positioning
|
|
160
|
+
*
|
|
161
|
+
* 3. Added getStableCursorPosition method
|
|
162
|
+
* - Inspired by YJS SyncCursors createAbsolutePosition pattern
|
|
163
|
+
* - Converts Loro cursors back to stable positions
|
|
164
|
+
* - Provides better positioning than current approximations
|
|
165
|
+
*
|
|
166
|
+
* 4. Enhanced cursor side information support
|
|
167
|
+
* - Added anchorSide and focusSide to stable cursor data
|
|
168
|
+
* - Follows Loro Cursor documentation patterns for precise positioning
|
|
169
|
+
* - Equivalent to YJS RelativePosition side information
|
|
170
|
+
*
|
|
171
|
+
* 5. Improved cursor creation with framework for better methods
|
|
172
|
+
* - Added TODO comments showing enhanced cursor creation approach
|
|
173
|
+
* - Framework ready for using createCursorFromLexicalPoint
|
|
174
|
+
* - Maintains backward compatibility while providing upgrade path
|
|
175
|
+
*
|
|
176
|
+
* 6. Enhanced remote cursor processing
|
|
177
|
+
* - Added support for cursor side information in stable cursor data
|
|
178
|
+
* - Provides framework for direct Loro cursor conversion
|
|
179
|
+
* - Better handling of cursor position stability across edits
|
|
180
|
+
*
|
|
181
|
+
* TECHNICAL APPROACH:
|
|
182
|
+
* - Loro Cursor type is equivalent to YJS RelativePosition (as documented)
|
|
183
|
+
* - Stable positions survive document edits (like YJS RelativePosition)
|
|
184
|
+
* - Cursor side information provides precise positioning
|
|
185
|
+
* - Framework supports proper createRelativePosition/createAbsolutePosition patterns
|
|
186
|
+
*
|
|
187
|
+
* NEXT STEPS for full implementation:
|
|
188
|
+
* - Implement calculateGlobalPosition method with proper document traversal
|
|
189
|
+
* - Add convertGlobalPositionToLexical helper function
|
|
190
|
+
* - Enable the enhanced cursor creation methods by uncommenting TODO sections
|
|
191
|
+
* - Complete the direct Loro cursor conversion path
|
|
192
|
+
*/
|
|
193
|
+
const CursorComponent = ({ peerId, position, color, name, isCurrentUser, selection }) => {
|
|
194
|
+
const displayName = `${name} (peer:${peerId})`;
|
|
195
|
+
return (_jsxs(_Fragment, { children: [selection && selection.rects.map((rect, index) => (_jsx("span", { style: {
|
|
196
|
+
position: 'fixed',
|
|
197
|
+
top: `${rect.top}px`,
|
|
198
|
+
left: `${rect.left}px`,
|
|
199
|
+
width: `${rect.width}px`,
|
|
200
|
+
height: `${rect.height}px`,
|
|
201
|
+
backgroundColor: color,
|
|
202
|
+
opacity: 0.2,
|
|
203
|
+
pointerEvents: 'none',
|
|
204
|
+
zIndex: 1, // Behind cursor
|
|
205
|
+
} }, `selection-${peerId}-${index}`))), _jsxs("span", { style: {
|
|
206
|
+
position: 'fixed',
|
|
207
|
+
top: `${position.top}px`,
|
|
208
|
+
left: `${position.left}px`,
|
|
209
|
+
height: '20px', // Standard text line height
|
|
210
|
+
width: '0px',
|
|
211
|
+
pointerEvents: 'none',
|
|
212
|
+
zIndex: 5,
|
|
213
|
+
opacity: isCurrentUser ? 0.6 : 1.0,
|
|
214
|
+
}, children: [_jsx("span", { style: {
|
|
215
|
+
position: 'absolute',
|
|
216
|
+
left: '0',
|
|
217
|
+
top: '0',
|
|
218
|
+
backgroundColor: color,
|
|
219
|
+
opacity: 0.3,
|
|
220
|
+
height: '20px',
|
|
221
|
+
width: '2px',
|
|
222
|
+
pointerEvents: 'none',
|
|
223
|
+
zIndex: 5,
|
|
224
|
+
} }), _jsx("span", { style: {
|
|
225
|
+
position: 'absolute',
|
|
226
|
+
top: '0',
|
|
227
|
+
bottom: '0',
|
|
228
|
+
right: '-1px',
|
|
229
|
+
width: '1px',
|
|
230
|
+
backgroundColor: color,
|
|
231
|
+
zIndex: 10,
|
|
232
|
+
pointerEvents: 'none',
|
|
233
|
+
}, children: _jsx("span", { style: {
|
|
234
|
+
position: 'absolute',
|
|
235
|
+
left: '-2px',
|
|
236
|
+
top: '-16px',
|
|
237
|
+
backgroundColor: color,
|
|
238
|
+
color: '#fff',
|
|
239
|
+
lineHeight: '12px',
|
|
240
|
+
fontSize: '12px',
|
|
241
|
+
padding: '2px',
|
|
242
|
+
fontFamily: 'Arial',
|
|
243
|
+
fontWeight: 'bold',
|
|
244
|
+
whiteSpace: 'nowrap',
|
|
245
|
+
borderRadius: '2px',
|
|
246
|
+
maxWidth: '200px',
|
|
247
|
+
overflow: 'hidden',
|
|
248
|
+
textOverflow: 'ellipsis',
|
|
249
|
+
}, children: displayName }) })] })] }));
|
|
250
|
+
};
|
|
251
|
+
const CursorsContainer = ({ remoteCursors, getPositionFromLexicalPosition, clientId, editor }) => {
|
|
252
|
+
const [portalContainer, setPortalContainer] = useState(null);
|
|
253
|
+
// Keep last known good positions to avoid snapping to x=0 when mapping fails
|
|
254
|
+
const lastCursorStateRef = useRef({});
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
// Create or get the cursor overlay container
|
|
257
|
+
let container = document.getElementById('loro-cursor-overlay');
|
|
258
|
+
if (!container) {
|
|
259
|
+
container = document.createElement('div');
|
|
260
|
+
container.id = 'loro-cursor-overlay';
|
|
261
|
+
container.style.cssText = `
|
|
262
|
+
position: fixed;
|
|
263
|
+
top: 0;
|
|
264
|
+
left: 0;
|
|
265
|
+
width: 100vw;
|
|
266
|
+
height: 100vh;
|
|
267
|
+
pointer-events: none;
|
|
268
|
+
z-index: 999999;
|
|
269
|
+
overflow: visible;
|
|
270
|
+
`;
|
|
271
|
+
document.body.appendChild(container);
|
|
272
|
+
console.log('🎭 Created React portal cursor overlay container');
|
|
273
|
+
}
|
|
274
|
+
setPortalContainer(container);
|
|
275
|
+
return () => {
|
|
276
|
+
// Clean up container on unmount
|
|
277
|
+
const existingContainer = document.getElementById('loro-cursor-overlay');
|
|
278
|
+
if (existingContainer && existingContainer.parentNode) {
|
|
279
|
+
existingContainer.parentNode.removeChild(existingContainer);
|
|
280
|
+
console.log('🧹 Cleaned up cursor overlay container');
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
}, []);
|
|
284
|
+
if (!portalContainer) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
console.log('🎯 Rendering cursors via React portal:', {
|
|
288
|
+
remoteCursorsCount: Object.keys(remoteCursors).length,
|
|
289
|
+
clientId
|
|
290
|
+
});
|
|
291
|
+
const cursors = Object.values(remoteCursors)
|
|
292
|
+
.map(remoteCursor => {
|
|
293
|
+
const { peerId, anchor, focus, user } = remoteCursor;
|
|
294
|
+
if (!anchor) {
|
|
295
|
+
console.log('⚠️ No anchor for peer:', peerId);
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
// Get cursor position using standard positioning
|
|
300
|
+
let position = getPositionFromLexicalPosition(anchor.key, anchor.offset);
|
|
301
|
+
const lastState = lastCursorStateRef.current[peerId];
|
|
302
|
+
// Basic position validation
|
|
303
|
+
const isPositionValid = (pos) => {
|
|
304
|
+
if (!pos)
|
|
305
|
+
return false;
|
|
306
|
+
// Check for NaN values
|
|
307
|
+
if (isNaN(pos.top) || isNaN(pos.left))
|
|
308
|
+
return false;
|
|
309
|
+
// Check for negative positions (usually indicates positioning error)
|
|
310
|
+
if (pos.top < 0 || pos.left < 0)
|
|
311
|
+
return false;
|
|
312
|
+
// Check for unreasonably large positions (likely positioning error)
|
|
313
|
+
if (pos.top > window.innerHeight * 3 || pos.left > window.innerWidth * 3)
|
|
314
|
+
return false;
|
|
315
|
+
return true;
|
|
316
|
+
};
|
|
317
|
+
// If position seems invalid, try to recalculate
|
|
318
|
+
if (!isPositionValid(position)) {
|
|
319
|
+
console.log('⚠️ Position validation failed, recalculating...', position);
|
|
320
|
+
// Try again to get position
|
|
321
|
+
position = getPositionFromLexicalPosition(anchor.key, anchor.offset);
|
|
322
|
+
console.log('🔄 Recalculated position:', position);
|
|
323
|
+
}
|
|
324
|
+
// Heuristic: if mapping still invalid, or we detect a suspicious jump to line start,
|
|
325
|
+
// keep the last known good position to avoid snapping to x=0.
|
|
326
|
+
const looksLikeLineStartFallback = () => {
|
|
327
|
+
if (!position || !lastState)
|
|
328
|
+
return false;
|
|
329
|
+
// Consider a suspicious leftward jump on the same line while offset increased or stayed
|
|
330
|
+
const leftwardJump = position.left < (lastState.position.left - 20); // >20px jump left
|
|
331
|
+
const roughlySameLine = Math.abs(position.top - lastState.position.top) < 30; // within same line height
|
|
332
|
+
const offsetDidNotDecrease = anchor.offset >= (lastState.offset || 0);
|
|
333
|
+
return leftwardJump && roughlySameLine && offsetDidNotDecrease;
|
|
334
|
+
};
|
|
335
|
+
if (!isPositionValid(position) || looksLikeLineStartFallback()) {
|
|
336
|
+
if (!isPositionValid(position)) {
|
|
337
|
+
console.log('⚠️ Final position invalid; using last known good position for peer:', peerId, { last: lastState?.position });
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
console.log('⚠️ Suspicious leftward jump detected; keeping last position for peer:', peerId, {
|
|
341
|
+
current: position,
|
|
342
|
+
last: lastState?.position,
|
|
343
|
+
anchorOffset: anchor.offset,
|
|
344
|
+
lastOffset: lastState?.offset
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (lastState && isPositionValid(lastState.position)) {
|
|
348
|
+
position = lastState.position;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (!isPositionValid(position)) {
|
|
352
|
+
console.log('⚠️ No valid position available for peer after fallback:', peerId, position);
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
// Position is now guaranteed to be valid due to isPositionValid check above
|
|
356
|
+
const color = user?.color || '#007acc';
|
|
357
|
+
const displayName = user?.name || peerId.slice(-8);
|
|
358
|
+
const isCurrentUser = peerId === clientId;
|
|
359
|
+
// Calculate selection rectangles if there's a focus position different from anchor
|
|
360
|
+
let selection;
|
|
361
|
+
if (focus && (focus.key !== anchor.key || focus.offset !== anchor.offset)) {
|
|
362
|
+
// There's a selection, calculate the selection rectangles
|
|
363
|
+
console.log('� Calculating selection for peer:', peerId, { anchor, focus });
|
|
364
|
+
try {
|
|
365
|
+
// Use the provided editor instance to create a range from anchor to focus
|
|
366
|
+
if (editor) {
|
|
367
|
+
const rects = editor.getEditorState().read(() => {
|
|
368
|
+
const anchorNode = $getNodeByKey(anchor.key);
|
|
369
|
+
const focusNode = $getNodeByKey(focus.key);
|
|
370
|
+
if (!anchorNode || !focusNode) {
|
|
371
|
+
console.log('⚠️ Selection nodes not found:', { anchorNode: !!anchorNode, focusNode: !!focusNode });
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
// Create a DOM range from anchor to focus
|
|
376
|
+
const range = createDOMRange(editor, anchorNode, anchor.offset, focusNode, focus.offset);
|
|
377
|
+
if (range) {
|
|
378
|
+
const rectList = createRectsFromDOMRange(editor, range);
|
|
379
|
+
console.log('📐 Selection rects calculated:', rectList.length);
|
|
380
|
+
return rectList.map(rect => ({
|
|
381
|
+
top: rect.top,
|
|
382
|
+
left: rect.left,
|
|
383
|
+
width: rect.width,
|
|
384
|
+
height: rect.height
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch (rangeError) {
|
|
389
|
+
console.warn('Error creating selection range:', rangeError);
|
|
390
|
+
}
|
|
391
|
+
return [];
|
|
392
|
+
});
|
|
393
|
+
if (rects.length > 0) {
|
|
394
|
+
selection = { rects };
|
|
395
|
+
console.log('✅ Selection calculated successfully for peer:', peerId, selection);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
catch (selectionError) {
|
|
400
|
+
console.warn('Error calculating selection for peer:', peerId, selectionError);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
console.log('�🟢 Rendering cursor for peer:', peerId, {
|
|
404
|
+
position,
|
|
405
|
+
color,
|
|
406
|
+
displayName,
|
|
407
|
+
isCurrentUser,
|
|
408
|
+
hasSelection: !!selection
|
|
409
|
+
});
|
|
410
|
+
// Store last known good position and offset for future fallbacks
|
|
411
|
+
lastCursorStateRef.current[peerId] = {
|
|
412
|
+
position: { top: position.top, left: position.left },
|
|
413
|
+
offset: anchor.offset
|
|
414
|
+
};
|
|
415
|
+
return (_jsx(CursorComponent, { peerId: peerId, position: {
|
|
416
|
+
top: Math.max(position.top, 20),
|
|
417
|
+
left: Math.max(position.left, 20)
|
|
418
|
+
}, color: color, name: displayName, isCurrentUser: isCurrentUser, selection: selection }, peerId));
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
console.warn('Error creating cursor for peer:', peerId, error);
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
.filter(Boolean);
|
|
426
|
+
return createPortal(_jsx(_Fragment, { children: cursors }), portalContainer);
|
|
427
|
+
};
|
|
428
|
+
class CursorAwareness {
|
|
429
|
+
ephemeralStore;
|
|
430
|
+
peerId;
|
|
431
|
+
listeners = [];
|
|
432
|
+
loroDoc; // Add reference to Loro document for proper cursor operations
|
|
433
|
+
constructor(peer, loroDoc, timeout = 300_000) {
|
|
434
|
+
this.ephemeralStore = new EphemeralStore(timeout);
|
|
435
|
+
this.peerId = peer.toString();
|
|
436
|
+
this.loroDoc = loroDoc; // Store document reference for stable cursor operations
|
|
437
|
+
// Subscribe to EphemeralStore events with proper event handling
|
|
438
|
+
this.ephemeralStore.subscribe((event) => {
|
|
439
|
+
console.log('🔔 EphemeralStore event received:', {
|
|
440
|
+
by: event.by,
|
|
441
|
+
added: event.added,
|
|
442
|
+
updated: event.updated,
|
|
443
|
+
removed: event.removed
|
|
444
|
+
});
|
|
445
|
+
// Notify all listeners about changes with event details
|
|
446
|
+
this.notifyListeners(event);
|
|
447
|
+
return true; // Continue subscription
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
getAll() {
|
|
451
|
+
const ans = {};
|
|
452
|
+
const allStates = this.ephemeralStore.getAllStates();
|
|
453
|
+
for (const [peer, state] of Object.entries(allStates)) {
|
|
454
|
+
const stateData = state;
|
|
455
|
+
try {
|
|
456
|
+
const decodedAnchor = stateData.anchor ? Cursor.decode(stateData.anchor) : undefined;
|
|
457
|
+
const decodedFocus = stateData.focus ? Cursor.decode(stateData.focus) : undefined;
|
|
458
|
+
ans[peer] = {
|
|
459
|
+
anchor: decodedAnchor,
|
|
460
|
+
focus: decodedFocus,
|
|
461
|
+
user: stateData.user ? stateData.user : undefined,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
console.warn('Error decoding cursor for peer', peer, error);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return ans;
|
|
469
|
+
}
|
|
470
|
+
setLocal(state) {
|
|
471
|
+
this.ephemeralStore.set(this.peerId, {
|
|
472
|
+
anchor: state.anchor?.encode() || null,
|
|
473
|
+
focus: state.focus?.encode() || null,
|
|
474
|
+
user: state.user || null,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
getLocal() {
|
|
478
|
+
const state = this.ephemeralStore.get(this.peerId);
|
|
479
|
+
if (!state) {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
const stateData = state;
|
|
483
|
+
try {
|
|
484
|
+
return {
|
|
485
|
+
anchor: stateData.anchor && Cursor.decode(stateData.anchor),
|
|
486
|
+
focus: stateData.focus && Cursor.decode(stateData.focus),
|
|
487
|
+
user: stateData.user,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
console.warn('Error decoding local cursor:', error);
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
getLocalState() {
|
|
496
|
+
const state = this.ephemeralStore.get(this.peerId);
|
|
497
|
+
if (!state)
|
|
498
|
+
return null;
|
|
499
|
+
const stateData = state;
|
|
500
|
+
return {
|
|
501
|
+
anchor: stateData.anchor || null,
|
|
502
|
+
focus: stateData.focus || null,
|
|
503
|
+
user: stateData.user || null,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
setRemoteState(peerId, state) {
|
|
507
|
+
console.log('Setting remote state for peer:', peerId, state);
|
|
508
|
+
try {
|
|
509
|
+
if (state === null || (state.anchor === null && state.focus === null)) {
|
|
510
|
+
this.ephemeralStore.delete(peerId.toString());
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// Store the raw state in EphemeralStore
|
|
514
|
+
this.ephemeralStore.set(peerId.toString(), {
|
|
515
|
+
anchor: state.anchor,
|
|
516
|
+
focus: state.focus,
|
|
517
|
+
user: state.user
|
|
518
|
+
});
|
|
519
|
+
// Validate and decode cursor data safely for callback
|
|
520
|
+
let anchor;
|
|
521
|
+
let focus;
|
|
522
|
+
if (state.anchor && state.anchor.length > 0) {
|
|
523
|
+
try {
|
|
524
|
+
anchor = Cursor.decode(state.anchor);
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
console.warn('Failed to decode anchor cursor:', error);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (state.focus && state.focus.length > 0) {
|
|
531
|
+
try {
|
|
532
|
+
focus = Cursor.decode(state.focus);
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
console.warn('Failed to decode focus cursor:', error);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (anchor || focus) {
|
|
539
|
+
// The awareness callback will handle the cursor conversion
|
|
540
|
+
// Just trigger a notification that this peer's cursor has changed
|
|
541
|
+
setTimeout(() => {
|
|
542
|
+
// Force the awareness callback to run by notifying listeners
|
|
543
|
+
this.notifyListeners();
|
|
544
|
+
}, 0);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
console.error('Error processing remote state:', error);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// Add methods for compatibility with existing code
|
|
552
|
+
addListener(callback) {
|
|
553
|
+
this.listeners.push(callback);
|
|
554
|
+
}
|
|
555
|
+
removeListener(callback) {
|
|
556
|
+
const index = this.listeners.indexOf(callback);
|
|
557
|
+
if (index > -1) {
|
|
558
|
+
this.listeners.splice(index, 1);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
notifyListeners(event) {
|
|
562
|
+
const states = new Map();
|
|
563
|
+
const allStates = this.ephemeralStore.getAllStates();
|
|
564
|
+
for (const [peer, state] of Object.entries(allStates)) {
|
|
565
|
+
states.set(peer, state);
|
|
566
|
+
}
|
|
567
|
+
this.listeners.forEach(listener => listener(states, event));
|
|
568
|
+
}
|
|
569
|
+
// Get encoded data for network transmission
|
|
570
|
+
encode() {
|
|
571
|
+
return this.ephemeralStore.encodeAll();
|
|
572
|
+
}
|
|
573
|
+
// Apply received encoded data
|
|
574
|
+
apply(data) {
|
|
575
|
+
this.ephemeralStore.apply(data);
|
|
576
|
+
// Trigger listeners after applying external data
|
|
577
|
+
this.notifyListeners();
|
|
578
|
+
}
|
|
579
|
+
setRemoteCursorCallback(callback) {
|
|
580
|
+
this._onRemoteCursorUpdate = callback;
|
|
581
|
+
}
|
|
582
|
+
// Simplified cursor creation from Lexical point (inspired by YJS createRelativePosition)
|
|
583
|
+
// Loro Cursor = container ID + character ID, much simpler than YJS RelativePosition
|
|
584
|
+
createLoroPosition(nodeKey, offset, textContainer) {
|
|
585
|
+
try {
|
|
586
|
+
if (!this.loroDoc || !textContainer) {
|
|
587
|
+
console.warn('❌ No Loro document or text container available');
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
// SIMPLIFIED APPROACH: For Loro, we just need the global text position
|
|
591
|
+
// Loro will handle the container ID + character ID mapping internally
|
|
592
|
+
const globalPosition = this.calculateSimpleGlobalPosition(nodeKey, offset);
|
|
593
|
+
// Let Loro create the cursor with its internal container+character structure
|
|
594
|
+
const cursor = textContainer.getCursor(globalPosition);
|
|
595
|
+
console.log('🎯 Created Loro cursor:', {
|
|
596
|
+
nodeKey,
|
|
597
|
+
offset,
|
|
598
|
+
globalPosition,
|
|
599
|
+
cursorCreated: !!cursor
|
|
600
|
+
});
|
|
601
|
+
return cursor || null;
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
console.warn('❌ Failed to create Loro position:', error);
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Simplified position calculation (much simpler than YJS approach)
|
|
609
|
+
calculateSimpleGlobalPosition(nodeKey, offset) {
|
|
610
|
+
// For Loro, we don't need complex CollabNode mapping like YJS
|
|
611
|
+
// Just calculate the simple global text position
|
|
612
|
+
// This is much simpler because Loro handles container+character mapping internally
|
|
613
|
+
// TODO: Implement simple document traversal
|
|
614
|
+
// For now, return a basic position - this would be implemented with:
|
|
615
|
+
// 1. Find the text node in the document
|
|
616
|
+
// 2. Calculate its start position
|
|
617
|
+
// 3. Add the offset within that node
|
|
618
|
+
console.log('🔄 Calculating simple position for Loro cursor:', { nodeKey, offset });
|
|
619
|
+
return 0; // Placeholder for simplified implementation
|
|
620
|
+
}
|
|
621
|
+
// Debug method to access raw ephemeral store data
|
|
622
|
+
getRawStates() {
|
|
623
|
+
return this.ephemeralStore.getAllStates();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
export function LoroCollaborativePlugin({ websocketUrl, docId, onConnectionChange, onDisconnectReady, onPeerIdChange, onAwarenessChange }) {
|
|
627
|
+
const [editor] = useLexicalComposerContext();
|
|
628
|
+
const wsRef = useRef(null);
|
|
629
|
+
const loroDocRef = useRef(new LoroDoc());
|
|
630
|
+
const loroTextRef = useRef(null);
|
|
631
|
+
const isLocalChange = useRef(false);
|
|
632
|
+
const hasReceivedInitialSnapshot = useRef(false);
|
|
633
|
+
// Cursor awareness system
|
|
634
|
+
const awarenessRef = useRef(null);
|
|
635
|
+
const [remoteCursors, setRemoteCursors] = useState({});
|
|
636
|
+
const [clientId, setClientId] = useState('');
|
|
637
|
+
const [clientColor, setClientColor] = useState('');
|
|
638
|
+
const peerIdRef = useRef(''); // Changed from numericPeerIdRef to handle string IDs
|
|
639
|
+
// Version vector state for optimized updates
|
|
640
|
+
const [lastSentVersionVector, setLastSentVersionVector] = useState(null);
|
|
641
|
+
const isConnectingRef = useRef(false);
|
|
642
|
+
const [forceUpdate, setForceUpdate] = useState(0); // Force cursor re-render
|
|
643
|
+
const cursorTimestamps = useRef({});
|
|
644
|
+
const updateLoroFromLexical = useCallback((editorState) => {
|
|
645
|
+
if (!loroTextRef.current)
|
|
646
|
+
return;
|
|
647
|
+
let editorStateJson = '';
|
|
648
|
+
editorState.read(() => {
|
|
649
|
+
// Store the raw Lexical EditorState JSON instead of HTML
|
|
650
|
+
const serialized = editorState.toJSON();
|
|
651
|
+
editorStateJson = JSON.stringify(serialized);
|
|
652
|
+
});
|
|
653
|
+
const currentLoroText = loroTextRef.current.toString();
|
|
654
|
+
if (currentLoroText === editorStateJson)
|
|
655
|
+
return;
|
|
656
|
+
// Mark this as a local change
|
|
657
|
+
isLocalChange.current = true;
|
|
658
|
+
// FIXED: Use incremental text operations instead of wholesale replacement
|
|
659
|
+
// This prevents massive changes that can cause connection issues
|
|
660
|
+
try {
|
|
661
|
+
// Calculate the difference between current and new content
|
|
662
|
+
const oldContent = currentLoroText;
|
|
663
|
+
const newContent = editorStateJson;
|
|
664
|
+
// Find common prefix and suffix to minimize changes
|
|
665
|
+
let prefixEnd = 0;
|
|
666
|
+
const minLength = Math.min(oldContent.length, newContent.length);
|
|
667
|
+
// Find common prefix
|
|
668
|
+
while (prefixEnd < minLength && oldContent[prefixEnd] === newContent[prefixEnd]) {
|
|
669
|
+
prefixEnd++;
|
|
670
|
+
}
|
|
671
|
+
// Find common suffix
|
|
672
|
+
let suffixStart = oldContent.length;
|
|
673
|
+
let newSuffixStart = newContent.length;
|
|
674
|
+
while (suffixStart > prefixEnd && newSuffixStart > prefixEnd &&
|
|
675
|
+
oldContent[suffixStart - 1] === newContent[newSuffixStart - 1]) {
|
|
676
|
+
suffixStart--;
|
|
677
|
+
newSuffixStart--;
|
|
678
|
+
}
|
|
679
|
+
// Apply incremental changes
|
|
680
|
+
if (prefixEnd < suffixStart) {
|
|
681
|
+
// Delete the changed portion
|
|
682
|
+
const deleteLength = suffixStart - prefixEnd;
|
|
683
|
+
if (deleteLength > 0) {
|
|
684
|
+
loroTextRef.current.delete(prefixEnd, deleteLength);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (prefixEnd < newSuffixStart) {
|
|
688
|
+
// Insert the new content
|
|
689
|
+
const insertText = newContent.substring(prefixEnd, newSuffixStart);
|
|
690
|
+
if (insertText.length > 0) {
|
|
691
|
+
loroTextRef.current.insert(prefixEnd, insertText);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
console.warn('Error with incremental update, falling back to full replacement:', error);
|
|
697
|
+
// Fallback to full replacement if incremental update fails
|
|
698
|
+
loroTextRef.current.delete(0, currentLoroText.length);
|
|
699
|
+
loroTextRef.current.insert(0, editorStateJson);
|
|
700
|
+
}
|
|
701
|
+
// Send update to WebSocket server
|
|
702
|
+
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
703
|
+
// Use the new export method with version vector optimization
|
|
704
|
+
const currentVersion = loroDocRef.current.version();
|
|
705
|
+
const update = loroDocRef.current.export({
|
|
706
|
+
mode: "update",
|
|
707
|
+
from: lastSentVersionVector || undefined
|
|
708
|
+
});
|
|
709
|
+
// Update the last sent version vector
|
|
710
|
+
setLastSentVersionVector(currentVersion);
|
|
711
|
+
wsRef.current.send(JSON.stringify({
|
|
712
|
+
type: 'loro-update',
|
|
713
|
+
update: Array.from(update),
|
|
714
|
+
docId: docId
|
|
715
|
+
}));
|
|
716
|
+
// Also send a snapshot occasionally to keep server state updated
|
|
717
|
+
if (Math.random() < 0.1) { // 10% chance to send snapshot
|
|
718
|
+
const snapshot = loroDocRef.current.export({ mode: "snapshot" });
|
|
719
|
+
wsRef.current.send(JSON.stringify({
|
|
720
|
+
type: 'snapshot',
|
|
721
|
+
snapshot: Array.from(snapshot),
|
|
722
|
+
docId: docId
|
|
723
|
+
}));
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// Reset the flag after a delay to prevent infinite loops
|
|
727
|
+
setTimeout(() => {
|
|
728
|
+
isLocalChange.current = false;
|
|
729
|
+
}, 50);
|
|
730
|
+
}, [docId, lastSentVersionVector, setLastSentVersionVector]);
|
|
731
|
+
const updateLexicalFromLoro = useCallback((editor, incoming) => {
|
|
732
|
+
if (isLocalChange.current)
|
|
733
|
+
return; // Don't update if this is a local change
|
|
734
|
+
isLocalChange.current = true;
|
|
735
|
+
let applied = false;
|
|
736
|
+
editor.update(() => {
|
|
737
|
+
const root = $getRoot();
|
|
738
|
+
// Avoid unnecessary updates when the incoming JSON exactly matches current state
|
|
739
|
+
try {
|
|
740
|
+
const currentStateJson = JSON.stringify(editor.getEditorState().toJSON());
|
|
741
|
+
if (incoming === currentStateJson) {
|
|
742
|
+
isLocalChange.current = false;
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
// ignore JSON stringify/compare failure; not critical for update gating
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
if (incoming && incoming.trim().length > 0) {
|
|
751
|
+
// Try to parse as Lexical EditorState JSON first
|
|
752
|
+
try {
|
|
753
|
+
const parsed = JSON.parse(incoming);
|
|
754
|
+
// Support both raw EditorState and wrapper { editorState: { ... } }
|
|
755
|
+
const stateLike = (parsed && typeof parsed === 'object' && parsed.editorState)
|
|
756
|
+
? parsed.editorState
|
|
757
|
+
: parsed;
|
|
758
|
+
if (stateLike && typeof stateLike === 'object' && stateLike.root && stateLike.root.type === 'root') {
|
|
759
|
+
const newEditorState = editor.parseEditorState(stateLike);
|
|
760
|
+
editor.setEditorState(newEditorState);
|
|
761
|
+
applied = true;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
// Not JSON; will treat as plain text below
|
|
766
|
+
}
|
|
767
|
+
if (!applied) {
|
|
768
|
+
// Treat incoming as plain text (e.g., from Python server)
|
|
769
|
+
root.clear();
|
|
770
|
+
const lines = incoming.split(/\r?\n/);
|
|
771
|
+
if (lines.length === 0) {
|
|
772
|
+
const p = $createParagraphNode();
|
|
773
|
+
root.append(p);
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
for (const line of lines) {
|
|
777
|
+
const p = $createParagraphNode();
|
|
778
|
+
if (line.length > 0) {
|
|
779
|
+
p.append($createTextNode(line));
|
|
780
|
+
}
|
|
781
|
+
root.append(p);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
applied = true;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
// Empty content -> ensure there's one empty paragraph
|
|
789
|
+
root.clear();
|
|
790
|
+
const paragraph = $createParagraphNode();
|
|
791
|
+
root.append(paragraph);
|
|
792
|
+
applied = true;
|
|
793
|
+
}
|
|
794
|
+
// Defer UUID assignment to a follow-up update to avoid frozen node map mutations
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
console.error('Error applying incoming content to Lexical editor:', error);
|
|
798
|
+
// Fallback: create a single empty paragraph
|
|
799
|
+
root.clear();
|
|
800
|
+
const paragraph = $createParagraphNode();
|
|
801
|
+
root.append(paragraph);
|
|
802
|
+
}
|
|
803
|
+
}, { tag: 'collaboration' });
|
|
804
|
+
if (applied) {
|
|
805
|
+
// Ensure the previous update is committed before assigning UUIDs
|
|
806
|
+
setTimeout(() => {
|
|
807
|
+
editor.update(() => {
|
|
808
|
+
try {
|
|
809
|
+
$ensureAllNodesHaveStableIds();
|
|
810
|
+
console.log('🆔 Assigned stable UUIDs after applying incoming content');
|
|
811
|
+
}
|
|
812
|
+
catch (e) {
|
|
813
|
+
console.warn('⚠️ Failed to assign stable UUIDs in deferred update:', e);
|
|
814
|
+
}
|
|
815
|
+
}, { tag: 'uuid-assignment' });
|
|
816
|
+
}, 0);
|
|
817
|
+
}
|
|
818
|
+
// Reset the flag after a short delay
|
|
819
|
+
setTimeout(() => {
|
|
820
|
+
isLocalChange.current = false;
|
|
821
|
+
}, 50);
|
|
822
|
+
}, []);
|
|
823
|
+
// Send cursor position using Awareness
|
|
824
|
+
const updateCursorAwareness = useCallback(() => {
|
|
825
|
+
if (!awarenessRef.current || !loroTextRef.current)
|
|
826
|
+
return;
|
|
827
|
+
editor.getEditorState().read(() => {
|
|
828
|
+
const selection = $getSelection();
|
|
829
|
+
if ($isRangeSelection(selection)) {
|
|
830
|
+
try {
|
|
831
|
+
// =================================================================
|
|
832
|
+
// NEW STABLE UUID APPROACH - Replace unstable NodeKeys
|
|
833
|
+
// =================================================================
|
|
834
|
+
// Create stable positions using UUIDs instead of NodeKeys
|
|
835
|
+
const anchorStablePos = $createStablePositionFromPoint({
|
|
836
|
+
key: selection.anchor.key,
|
|
837
|
+
offset: selection.anchor.offset
|
|
838
|
+
});
|
|
839
|
+
const focusStablePos = $createStablePositionFromPoint({
|
|
840
|
+
key: selection.focus.key,
|
|
841
|
+
offset: selection.focus.offset
|
|
842
|
+
});
|
|
843
|
+
if (!anchorStablePos || !focusStablePos) {
|
|
844
|
+
console.warn('❌ Failed to create stable positions');
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
console.log('🎯 Created stable UUID-based positions:', {
|
|
848
|
+
anchor: anchorStablePos,
|
|
849
|
+
focus: focusStablePos
|
|
850
|
+
});
|
|
851
|
+
// LEGACY APPROACH for Loro cursor creation (still needed for now)
|
|
852
|
+
// Create Loro cursors using the resolved NodeKeys
|
|
853
|
+
const anchorKey = selection.anchor.key;
|
|
854
|
+
const anchorOffset = selection.anchor.offset;
|
|
855
|
+
const focusKey = selection.focus.key;
|
|
856
|
+
const focusOffset = selection.focus.offset;
|
|
857
|
+
const anchor = awarenessRef.current.createLoroPosition(anchorKey, anchorOffset, loroTextRef.current);
|
|
858
|
+
const focus = awarenessRef.current.createLoroPosition(focusKey, focusOffset, loroTextRef.current);
|
|
859
|
+
if (!anchor || !focus) {
|
|
860
|
+
console.warn('❌ Failed to create Loro cursors');
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
console.log('🎯 Created Loro cursors with stable position data:', {
|
|
864
|
+
anchorStableId: anchorStablePos.stableNodeId,
|
|
865
|
+
focusStableId: focusStablePos.stableNodeId,
|
|
866
|
+
anchorCreated: !!anchor,
|
|
867
|
+
focusCreated: !!focus
|
|
868
|
+
});
|
|
869
|
+
// Extract meaningful part from client ID
|
|
870
|
+
const extractedId = clientId.includes('_') ?
|
|
871
|
+
clientId.split('_').find(part => /^\d{13}$/.test(part)) || clientId.slice(-8) :
|
|
872
|
+
clientId.slice(-8);
|
|
873
|
+
// ENHANCED: Store stable UUID-based cursor data instead of NodeKeys
|
|
874
|
+
const userWithCursorData = {
|
|
875
|
+
name: extractedId,
|
|
876
|
+
color: clientColor || '#007acc',
|
|
877
|
+
// NEW: Use stable UUIDs that survive document edits
|
|
878
|
+
stableCursor: {
|
|
879
|
+
// Store stable UUIDs instead of unstable NodeKeys
|
|
880
|
+
anchorStableId: anchorStablePos.stableNodeId,
|
|
881
|
+
anchorOffset: anchorStablePos.offset,
|
|
882
|
+
anchorType: anchorStablePos.type,
|
|
883
|
+
focusStableId: focusStablePos.stableNodeId,
|
|
884
|
+
focusOffset: focusStablePos.offset,
|
|
885
|
+
focusType: focusStablePos.type,
|
|
886
|
+
timestamp: Date.now()
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
awarenessRef.current.setLocal({
|
|
890
|
+
anchor,
|
|
891
|
+
focus,
|
|
892
|
+
user: userWithCursorData
|
|
893
|
+
});
|
|
894
|
+
console.log('🎯 Set awareness with stable cursor data:', { userWithCursorData, clientId });
|
|
895
|
+
// Send ephemeral update to other clients via WebSocket
|
|
896
|
+
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && awarenessRef.current) {
|
|
897
|
+
const ephemeralData = awarenessRef.current.encode();
|
|
898
|
+
const hexData = Array.from(ephemeralData).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
899
|
+
wsRef.current.send(JSON.stringify({
|
|
900
|
+
type: 'ephemeral-update',
|
|
901
|
+
docId: docId,
|
|
902
|
+
data: hexData // Convert to hex string
|
|
903
|
+
}));
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
console.warn('Error creating cursor:', error);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
}, [editor, clientId, clientColor, docId]);
|
|
912
|
+
useEffect(() => {
|
|
913
|
+
// Initialize Loro document and text object
|
|
914
|
+
loroTextRef.current = loroDocRef.current.getText(docId);
|
|
915
|
+
// Only initialize awareness if it doesn't exist yet
|
|
916
|
+
if (!awarenessRef.current) {
|
|
917
|
+
// Initialize cursor awareness with a temporary numeric ID
|
|
918
|
+
// We'll update this with the actual client ID when we receive the welcome message
|
|
919
|
+
const tempNumericId = Date.now(); // Temporary ID until we get the real client ID
|
|
920
|
+
peerIdRef.current = tempNumericId.toString();
|
|
921
|
+
awarenessRef.current = new CursorAwareness(tempNumericId.toString(), loroDocRef.current);
|
|
922
|
+
console.log('🎯 Initializing awareness with temporary numeric ID:', tempNumericId, '(will be updated with client ID)');
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
console.log('🎯 Awareness already exists, skipping initialization');
|
|
926
|
+
}
|
|
927
|
+
// Subscribe to awareness changes with event-aware callback
|
|
928
|
+
const awarenessCallback = (_states, event) => {
|
|
929
|
+
console.log('🚨 AWARENESS CALLBACK TRIGGERED!', {
|
|
930
|
+
event: event ? {
|
|
931
|
+
by: event.by,
|
|
932
|
+
added: event.added,
|
|
933
|
+
updated: event.updated,
|
|
934
|
+
removed: event.removed
|
|
935
|
+
} : 'no event',
|
|
936
|
+
statesSize: _states?.size,
|
|
937
|
+
timestamp: Date.now()
|
|
938
|
+
});
|
|
939
|
+
if (awarenessRef.current) {
|
|
940
|
+
const allCursors = awarenessRef.current.getAll();
|
|
941
|
+
const remoteCursorsData = {};
|
|
942
|
+
const currentPeerId = peerIdRef.current || clientId;
|
|
943
|
+
console.log('👁️ Awareness callback - all cursors:', allCursors);
|
|
944
|
+
console.log('👁️ Current peer ID:', currentPeerId);
|
|
945
|
+
console.log('👁️ All cursor peer IDs:', Object.keys(allCursors));
|
|
946
|
+
// Debug: Check raw ephemeral store data
|
|
947
|
+
const rawStates = awarenessRef.current.getRawStates();
|
|
948
|
+
console.log('👁️ Raw ephemeral store states:', rawStates);
|
|
949
|
+
console.log('👁️ Awareness callback triggered:', {
|
|
950
|
+
event: event ? {
|
|
951
|
+
by: event.by,
|
|
952
|
+
added: event.added,
|
|
953
|
+
updated: event.updated,
|
|
954
|
+
removed: event.removed
|
|
955
|
+
} : 'no event',
|
|
956
|
+
allCursorsKeys: Object.keys(allCursors),
|
|
957
|
+
allCursorsDetail: allCursors,
|
|
958
|
+
currentPeerId: currentPeerId,
|
|
959
|
+
clientId: clientId
|
|
960
|
+
});
|
|
961
|
+
// CRITICAL DEBUG: Check if we have remote cursors before processing
|
|
962
|
+
const remoteCursorsBefore = Object.keys(allCursors).filter(peerId => peerId !== currentPeerId);
|
|
963
|
+
console.log('🔍 Remote cursors BEFORE processing:', remoteCursorsBefore);
|
|
964
|
+
console.log('🔍 ALL CURSORS DATA:', allCursors);
|
|
965
|
+
console.log('🔍 TOTAL CURSORS COUNT:', Object.keys(allCursors).length);
|
|
966
|
+
// Use event information to optimize cursor processing
|
|
967
|
+
let peersToProcess = [];
|
|
968
|
+
if (event) {
|
|
969
|
+
console.log('🔍 DETAILED EVENT ANALYSIS:', {
|
|
970
|
+
eventBy: event.by,
|
|
971
|
+
isImportEvent: event.by === 'import',
|
|
972
|
+
isImportEventCaseInsensitive: event.by?.toLowerCase() === 'import',
|
|
973
|
+
removedCount: event.removed?.length || 0,
|
|
974
|
+
removedPeers: event.removed || [],
|
|
975
|
+
addedCount: event.added?.length || 0,
|
|
976
|
+
updatedCount: event.updated?.length || 0
|
|
977
|
+
});
|
|
978
|
+
// Check if this is a local event (our own cursor update)
|
|
979
|
+
const isLocalEvent = event.by === 'local' || event.by === currentPeerId;
|
|
980
|
+
const isImportEvent = event.by === 'import' || event.by?.toLowerCase() === 'import';
|
|
981
|
+
if (isLocalEvent) {
|
|
982
|
+
console.log('👁️ Local event detected - processing all cursors to ensure remote cursors remain visible');
|
|
983
|
+
// For local events, process all cursors to maintain remote cursor visibility
|
|
984
|
+
peersToProcess = Object.keys(allCursors);
|
|
985
|
+
}
|
|
986
|
+
else if (isImportEvent) {
|
|
987
|
+
console.log('👁️ Import event detected - processing all current cursors to maintain visibility');
|
|
988
|
+
// For import events, process all current cursors to maintain remote cursor visibility
|
|
989
|
+
// Import events often have misleading added/updated arrays
|
|
990
|
+
peersToProcess = Object.keys(allCursors);
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
console.log('👁️ Remote event detected - processing only changed peers');
|
|
994
|
+
// For other remote events, process only the peers that changed
|
|
995
|
+
peersToProcess = [...event.added, ...event.updated];
|
|
996
|
+
}
|
|
997
|
+
console.log('👁️ Event-driven processing - peers to process:', peersToProcess);
|
|
998
|
+
// CRITICAL FIX: Be much more conservative about removals
|
|
999
|
+
// Only remove cursors if they're not in the current allCursors AND
|
|
1000
|
+
// this is not an "import" event (which often has false removals)
|
|
1001
|
+
if (event.removed && event.removed.length > 0) {
|
|
1002
|
+
console.log('🔍 REMOVAL EVENT ANALYSIS:', {
|
|
1003
|
+
eventBy: event.by,
|
|
1004
|
+
isImport: event.by?.toLowerCase() === 'import',
|
|
1005
|
+
isImportLowercase: event.by?.toLowerCase() === 'import',
|
|
1006
|
+
removedPeers: event.removed,
|
|
1007
|
+
shouldIgnore: event.by?.toLowerCase() === 'import'
|
|
1008
|
+
});
|
|
1009
|
+
if (event.by?.toLowerCase() === 'import') {
|
|
1010
|
+
console.log('👁️ 🚫 IGNORING import-based removal events (often false positives):', event.removed);
|
|
1011
|
+
// Don't process removals for import events - they're usually false positives
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
console.log('👁️ Processing potential removals for peers (non-import event):', event.removed);
|
|
1015
|
+
const currentAllCursors = awarenessRef.current.getAll();
|
|
1016
|
+
const currentPeerIds = Object.keys(currentAllCursors);
|
|
1017
|
+
console.log('🔍 REMOVAL VALIDATION:', {
|
|
1018
|
+
removedPeers: event.removed,
|
|
1019
|
+
currentPeerIds: currentPeerIds,
|
|
1020
|
+
peerStillExists: event.removed.map(peerId => ({
|
|
1021
|
+
peerId,
|
|
1022
|
+
stillExists: currentPeerIds.includes(peerId)
|
|
1023
|
+
}))
|
|
1024
|
+
});
|
|
1025
|
+
event.removed.forEach(peerId => {
|
|
1026
|
+
// Only remove if the peer is truly no longer in the awareness state
|
|
1027
|
+
if (!currentPeerIds.includes(peerId)) {
|
|
1028
|
+
console.log('👁️ ✅ Confirmed removal - peer not in current state:', peerId);
|
|
1029
|
+
setRemoteCursors(prev => {
|
|
1030
|
+
const updated = { ...prev };
|
|
1031
|
+
delete updated[peerId];
|
|
1032
|
+
console.log('👁️ Removed peer from remote cursors:', peerId);
|
|
1033
|
+
return updated;
|
|
1034
|
+
});
|
|
1035
|
+
// Clear cursor timestamps
|
|
1036
|
+
delete cursorTimestamps.current[peerId];
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
console.log('👁️ ❌ Ignoring removal - peer still in current state:', peerId);
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
// Don't force reprocessing of all peers, just continue with the event-driven processing
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
// No event info, process all cursors
|
|
1048
|
+
peersToProcess = Object.keys(allCursors);
|
|
1049
|
+
console.log('👁️ Full processing - all peers:', peersToProcess);
|
|
1050
|
+
}
|
|
1051
|
+
// Process the relevant peers
|
|
1052
|
+
console.log('🔍 PEER PROCESSING START:', {
|
|
1053
|
+
peersToProcess,
|
|
1054
|
+
totalPeersInAllCursors: Object.keys(allCursors).length,
|
|
1055
|
+
currentPeerId
|
|
1056
|
+
});
|
|
1057
|
+
peersToProcess.forEach(peerId => {
|
|
1058
|
+
const cursorData = allCursors[peerId];
|
|
1059
|
+
console.log('🔍 Processing peer:', peerId, {
|
|
1060
|
+
hasData: !!cursorData,
|
|
1061
|
+
isCurrentUser: peerId === currentPeerId,
|
|
1062
|
+
cursorData: cursorData ? {
|
|
1063
|
+
hasAnchor: !!cursorData.anchor,
|
|
1064
|
+
hasFocus: !!cursorData.focus,
|
|
1065
|
+
hasUser: !!cursorData.user
|
|
1066
|
+
} : 'NO DATA'
|
|
1067
|
+
});
|
|
1068
|
+
if (!cursorData) {
|
|
1069
|
+
console.log('⚠️ No cursor data for peer:', peerId);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
// Only exclude our own cursor (using current peer ID)
|
|
1073
|
+
if (peerId !== currentPeerId) {
|
|
1074
|
+
console.log('👁️ Processing remote cursor for peer:', peerId, {
|
|
1075
|
+
hasAnchor: !!cursorData.anchor,
|
|
1076
|
+
hasFocus: !!cursorData.focus,
|
|
1077
|
+
hasUser: !!cursorData.user,
|
|
1078
|
+
hasStableCursor: !!cursorData.user?.stableCursor,
|
|
1079
|
+
user: cursorData.user
|
|
1080
|
+
});
|
|
1081
|
+
let anchorPos;
|
|
1082
|
+
let focusPos;
|
|
1083
|
+
// Check if we have stable cursor data in user metadata (preferred)
|
|
1084
|
+
const stableCursor = cursorData.user?.stableCursor;
|
|
1085
|
+
// =================================================================
|
|
1086
|
+
// NEW STABLE UUID RESOLUTION - Replace NodeKey validation
|
|
1087
|
+
// =================================================================
|
|
1088
|
+
if (stableCursor && stableCursor.anchorStableId && stableCursor.focusStableId) {
|
|
1089
|
+
console.log('👁️ Using NEW stable UUID-based cursor data:', stableCursor);
|
|
1090
|
+
// Use stable UUIDs to resolve positions
|
|
1091
|
+
const anchorResolved = editor.getEditorState().read(() => {
|
|
1092
|
+
return $resolveStablePosition({
|
|
1093
|
+
stableNodeId: stableCursor.anchorStableId,
|
|
1094
|
+
offset: stableCursor.anchorOffset,
|
|
1095
|
+
type: stableCursor.anchorType || 'text'
|
|
1096
|
+
});
|
|
1097
|
+
});
|
|
1098
|
+
const focusResolved = editor.getEditorState().read(() => {
|
|
1099
|
+
return $resolveStablePosition({
|
|
1100
|
+
stableNodeId: stableCursor.focusStableId,
|
|
1101
|
+
offset: stableCursor.focusOffset,
|
|
1102
|
+
type: stableCursor.focusType || 'text'
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
if (anchorResolved && focusResolved) {
|
|
1106
|
+
console.log('✅ Successfully resolved stable UUID positions:', {
|
|
1107
|
+
anchorStableId: stableCursor.anchorStableId,
|
|
1108
|
+
focusStableId: stableCursor.focusStableId,
|
|
1109
|
+
anchorNodeKey: anchorResolved.key,
|
|
1110
|
+
focusNodeKey: focusResolved.key
|
|
1111
|
+
});
|
|
1112
|
+
anchorPos = {
|
|
1113
|
+
key: anchorResolved.key,
|
|
1114
|
+
offset: anchorResolved.offset,
|
|
1115
|
+
type: 'text'
|
|
1116
|
+
};
|
|
1117
|
+
focusPos = {
|
|
1118
|
+
key: focusResolved.key,
|
|
1119
|
+
offset: focusResolved.offset,
|
|
1120
|
+
type: 'text'
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
else {
|
|
1124
|
+
console.log('🔄 STABLE UUID RESOLUTION FAILED - positions will use document fallback:', {
|
|
1125
|
+
anchorStableId: stableCursor.anchorStableId,
|
|
1126
|
+
focusStableId: stableCursor.focusStableId,
|
|
1127
|
+
anchorResolved: !!anchorResolved,
|
|
1128
|
+
focusResolved: !!focusResolved,
|
|
1129
|
+
note: 'Fallback positioning will be used - this prevents (0,0) cursor jumps'
|
|
1130
|
+
});
|
|
1131
|
+
// anchorPos and focusPos will remain undefined, triggering legacy fallback
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// FALLBACK: Legacy NodeKey-based approach (for backwards compatibility)
|
|
1135
|
+
else if (stableCursor && stableCursor.anchorKey && typeof stableCursor.anchorOffset === 'number') {
|
|
1136
|
+
console.log('👁️ Fallback to legacy NodeKey-based cursor data:', stableCursor);
|
|
1137
|
+
// ENHANCEMENT: Use cursor side information for better positioning
|
|
1138
|
+
// The stableCursor now includes anchorSide and focusSide following Loro Cursor patterns
|
|
1139
|
+
const hasPositioningSides = stableCursor.anchorSide && stableCursor.focusSide;
|
|
1140
|
+
if (hasPositioningSides) {
|
|
1141
|
+
console.log('🎯 Enhanced positioning with cursor side information:', {
|
|
1142
|
+
anchorSide: stableCursor.anchorSide,
|
|
1143
|
+
focusSide: stableCursor.focusSide
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
// Validate that the node keys still exist in the current editor state
|
|
1147
|
+
const validAnchor = editor.getEditorState().read(() => {
|
|
1148
|
+
const anchorNode = $getNodeByKey(stableCursor.anchorKey);
|
|
1149
|
+
const isValid = !!anchorNode;
|
|
1150
|
+
console.log('🔍 Anchor node validation:', {
|
|
1151
|
+
key: stableCursor.anchorKey,
|
|
1152
|
+
found: isValid,
|
|
1153
|
+
nodeType: anchorNode?.getType?.() || 'null'
|
|
1154
|
+
});
|
|
1155
|
+
return isValid;
|
|
1156
|
+
});
|
|
1157
|
+
const validFocus = editor.getEditorState().read(() => {
|
|
1158
|
+
const focusNode = $getNodeByKey(stableCursor.focusKey);
|
|
1159
|
+
const isValid = !!focusNode;
|
|
1160
|
+
console.log('🔍 Focus node validation:', {
|
|
1161
|
+
key: stableCursor.focusKey,
|
|
1162
|
+
found: isValid,
|
|
1163
|
+
nodeType: focusNode?.getType?.() || 'null'
|
|
1164
|
+
});
|
|
1165
|
+
return isValid;
|
|
1166
|
+
});
|
|
1167
|
+
if (validAnchor && validFocus) {
|
|
1168
|
+
console.log('✅ Using stable cursor data - nodes are valid');
|
|
1169
|
+
anchorPos = {
|
|
1170
|
+
key: stableCursor.anchorKey,
|
|
1171
|
+
offset: stableCursor.anchorOffset,
|
|
1172
|
+
type: 'text'
|
|
1173
|
+
};
|
|
1174
|
+
focusPos = {
|
|
1175
|
+
key: stableCursor.focusKey,
|
|
1176
|
+
offset: stableCursor.focusOffset,
|
|
1177
|
+
type: 'text'
|
|
1178
|
+
};
|
|
1179
|
+
console.log('👁️ Successfully used stable cursor data:', { anchorPos, focusPos });
|
|
1180
|
+
}
|
|
1181
|
+
else {
|
|
1182
|
+
console.log('👁️ Node keys invalid, using line-aware stable fallback');
|
|
1183
|
+
// LINE-AWARE MINIMAL FALLBACK: Try to preserve which line the cursor was on
|
|
1184
|
+
const lineAwarePosition = editor.getEditorState().read(() => {
|
|
1185
|
+
const root = $getRoot();
|
|
1186
|
+
const children = root.getChildren();
|
|
1187
|
+
// Build a simple map of text nodes (representing lines/paragraphs)
|
|
1188
|
+
const textNodesList = [];
|
|
1189
|
+
let lineIndex = 0;
|
|
1190
|
+
for (const child of children) {
|
|
1191
|
+
if ($isElementNode(child)) {
|
|
1192
|
+
const textChildren = child.getChildren().filter($isTextNode);
|
|
1193
|
+
for (const textNode of textChildren) {
|
|
1194
|
+
textNodesList.push({ node: textNode, lineIndex });
|
|
1195
|
+
}
|
|
1196
|
+
// Each element (paragraph/div) represents a new line
|
|
1197
|
+
if (textChildren.length > 0) {
|
|
1198
|
+
lineIndex++;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
console.log('👁️ Document structure for line-aware fallback:', {
|
|
1203
|
+
totalLines: lineIndex,
|
|
1204
|
+
totalTextNodes: textNodesList.length,
|
|
1205
|
+
originalOffset: stableCursor.anchorOffset
|
|
1206
|
+
});
|
|
1207
|
+
if (textNodesList.length === 0) {
|
|
1208
|
+
// No text nodes, use root
|
|
1209
|
+
return {
|
|
1210
|
+
key: root.getKey(),
|
|
1211
|
+
offset: 0,
|
|
1212
|
+
type: 'text'
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
// SMART ESTIMATION: Use the original offset to guess which line
|
|
1216
|
+
const originalOffset = stableCursor.anchorOffset;
|
|
1217
|
+
let targetLineIndex = 0;
|
|
1218
|
+
if (textNodesList.length > 1) {
|
|
1219
|
+
// Multiple lines available - estimate which line based on offset
|
|
1220
|
+
if (originalOffset <= 10) {
|
|
1221
|
+
targetLineIndex = 0; // Small offset = first line
|
|
1222
|
+
}
|
|
1223
|
+
else if (originalOffset <= 30) {
|
|
1224
|
+
targetLineIndex = Math.min(1, textNodesList.length - 1); // Medium offset = second line
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
// Large offset = later line (proportional)
|
|
1228
|
+
targetLineIndex = Math.min(Math.floor(originalOffset / 25), // Assume ~25 chars per line average
|
|
1229
|
+
textNodesList.length - 1);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
// Find text node for the target line
|
|
1233
|
+
const targetTextNodeInfo = textNodesList.find(info => info.lineIndex === targetLineIndex) || textNodesList[0];
|
|
1234
|
+
const targetTextNode = targetTextNodeInfo.node;
|
|
1235
|
+
// Use a small, safe offset within that line
|
|
1236
|
+
const safeOffset = Math.min(1, targetTextNode.getTextContentSize());
|
|
1237
|
+
console.log('👁️ Line-aware positioning:', {
|
|
1238
|
+
originalOffset,
|
|
1239
|
+
estimatedLine: targetLineIndex,
|
|
1240
|
+
selectedLine: targetTextNodeInfo.lineIndex,
|
|
1241
|
+
nodeKey: targetTextNode.getKey(),
|
|
1242
|
+
safeOffset,
|
|
1243
|
+
nodeText: targetTextNode.getTextContent().substring(0, 15)
|
|
1244
|
+
});
|
|
1245
|
+
return {
|
|
1246
|
+
key: targetTextNode.getKey(),
|
|
1247
|
+
offset: safeOffset,
|
|
1248
|
+
type: 'text'
|
|
1249
|
+
};
|
|
1250
|
+
});
|
|
1251
|
+
anchorPos = lineAwarePosition;
|
|
1252
|
+
focusPos = lineAwarePosition;
|
|
1253
|
+
console.log('👁️ Applied line-aware stable fallback:', { anchorPos, focusPos });
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
console.log('👁️ No stable cursor data available, creating smart fallback positions');
|
|
1258
|
+
// Instead of trying LORO cursor conversion (which we skip), create immediate fallback
|
|
1259
|
+
const smartFallbackPosition = editor.getEditorState().read(() => {
|
|
1260
|
+
const root = $getRoot();
|
|
1261
|
+
const children = root.getChildren();
|
|
1262
|
+
// Find the first available text node
|
|
1263
|
+
for (const child of children) {
|
|
1264
|
+
if ($isElementNode(child)) {
|
|
1265
|
+
const grandChildren = child.getChildren();
|
|
1266
|
+
for (const grandChild of grandChildren) {
|
|
1267
|
+
if ($isTextNode(grandChild)) {
|
|
1268
|
+
console.log('👁️ Using first available text node for cursor:', {
|
|
1269
|
+
nodeKey: grandChild.getKey(),
|
|
1270
|
+
textContent: grandChild.getTextContent().substring(0, 30)
|
|
1271
|
+
});
|
|
1272
|
+
return {
|
|
1273
|
+
key: grandChild.getKey(),
|
|
1274
|
+
offset: Math.min(5, grandChild.getTextContent().length), // Small offset from start
|
|
1275
|
+
type: 'text'
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
// Fallback to root if no text nodes found
|
|
1282
|
+
console.log('👁️ No text nodes found, using root as fallback');
|
|
1283
|
+
return {
|
|
1284
|
+
key: root.getKey(),
|
|
1285
|
+
offset: 0,
|
|
1286
|
+
type: 'text'
|
|
1287
|
+
};
|
|
1288
|
+
});
|
|
1289
|
+
anchorPos = smartFallbackPosition;
|
|
1290
|
+
focusPos = smartFallbackPosition;
|
|
1291
|
+
console.log('👁️ Applied smart fallback for no stable cursor data:', { anchorPos, focusPos });
|
|
1292
|
+
}
|
|
1293
|
+
// ENHANCEMENT: Direct Loro cursor conversion path
|
|
1294
|
+
// When stable cursor data is not available, we could use the improved
|
|
1295
|
+
// CursorAwareness methods to convert Loro cursors to Lexical positions:
|
|
1296
|
+
//
|
|
1297
|
+
// if (cursorData.anchor && awarenessRef.current) {
|
|
1298
|
+
// const stableAnchorPos = awarenessRef.current.getStableCursorPosition(cursorData.anchor);
|
|
1299
|
+
// if (stableAnchorPos !== null) {
|
|
1300
|
+
// // Convert stable position to Lexical node position using document traversal
|
|
1301
|
+
// anchorPos = convertGlobalPositionToLexical(stableAnchorPos);
|
|
1302
|
+
// }
|
|
1303
|
+
// }
|
|
1304
|
+
//
|
|
1305
|
+
// This would provide better cursor positioning than approximations
|
|
1306
|
+
console.log('👁️ Note: Enhanced Loro cursor conversion framework available for implementation');
|
|
1307
|
+
console.log('👁️ Converted positions for peer:', peerId, {
|
|
1308
|
+
anchorPos,
|
|
1309
|
+
focusPos
|
|
1310
|
+
});
|
|
1311
|
+
// CRITICAL: Ensure we always have valid anchor and focus positions
|
|
1312
|
+
if (!anchorPos || !focusPos) {
|
|
1313
|
+
console.log('🚨 Missing anchor or focus position, creating smart fallback for peer:', peerId);
|
|
1314
|
+
// Try to use the stored stable cursor as reference for finding a similar position
|
|
1315
|
+
let referencePosition = null;
|
|
1316
|
+
if (stableCursor && stableCursor.anchorKey && typeof stableCursor.anchorOffset === 'number') {
|
|
1317
|
+
referencePosition = {
|
|
1318
|
+
anchorKey: stableCursor.anchorKey,
|
|
1319
|
+
anchorOffset: stableCursor.anchorOffset
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
const smartPosition = editor.getEditorState().read(() => {
|
|
1323
|
+
const root = $getRoot();
|
|
1324
|
+
// If we have a reference position, calculate the global document position
|
|
1325
|
+
// and try to find a position that maintains the same relative location
|
|
1326
|
+
if (referencePosition) {
|
|
1327
|
+
console.log('🔄 Using reference position for smart fallback:', referencePosition);
|
|
1328
|
+
// First, try to find the exact same node (it might still exist)
|
|
1329
|
+
let targetNode = null;
|
|
1330
|
+
const findExactNode = (node) => {
|
|
1331
|
+
if (node.getKey() === referencePosition.anchorKey) {
|
|
1332
|
+
targetNode = node;
|
|
1333
|
+
return true;
|
|
1334
|
+
}
|
|
1335
|
+
if ($isElementNode(node)) {
|
|
1336
|
+
const nodeChildren = node.getChildren();
|
|
1337
|
+
for (const child of nodeChildren) {
|
|
1338
|
+
if (findExactNode(child)) {
|
|
1339
|
+
return true;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
return false;
|
|
1344
|
+
};
|
|
1345
|
+
findExactNode(root);
|
|
1346
|
+
if (targetNode && $isTextNode(targetNode)) {
|
|
1347
|
+
const textNode = targetNode;
|
|
1348
|
+
const textLength = textNode.getTextContent().length;
|
|
1349
|
+
const safeOffset = Math.min(referencePosition.anchorOffset, textLength);
|
|
1350
|
+
console.log('🔄 Found exact node still exists:', {
|
|
1351
|
+
nodeKey: textNode.getKey(),
|
|
1352
|
+
offset: safeOffset
|
|
1353
|
+
});
|
|
1354
|
+
return {
|
|
1355
|
+
key: textNode.getKey(),
|
|
1356
|
+
offset: safeOffset,
|
|
1357
|
+
type: 'text'
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
// If exact node not found, we need to calculate the global position
|
|
1361
|
+
// that this cursor was at and find the equivalent position in the new tree
|
|
1362
|
+
console.log('🔄 Exact node not found, calculating global position equivalent');
|
|
1363
|
+
// Instead of guessing, let's calculate where this cursor should be
|
|
1364
|
+
// based on the current document structure
|
|
1365
|
+
const fullDocumentText = root.getTextContent();
|
|
1366
|
+
console.log('🔄 Full document text for position calculation:', {
|
|
1367
|
+
text: JSON.stringify(fullDocumentText),
|
|
1368
|
+
length: fullDocumentText.length
|
|
1369
|
+
});
|
|
1370
|
+
// Calculate a reasonable position: try to maintain the same relative position
|
|
1371
|
+
// For a simple approach, let's use the offset as a ratio of the original node length
|
|
1372
|
+
// and apply that ratio to a reasonable position in the current document
|
|
1373
|
+
// Find a good text node to place the cursor in
|
|
1374
|
+
let bestFallbackNode = null;
|
|
1375
|
+
let bestFallbackOffset = 0;
|
|
1376
|
+
const findBestFallbackPosition = (node) => {
|
|
1377
|
+
if ($isTextNode(node)) {
|
|
1378
|
+
const textContent = node.getTextContent();
|
|
1379
|
+
const textLength = textContent.length;
|
|
1380
|
+
if (textLength > 0 && !bestFallbackNode) {
|
|
1381
|
+
bestFallbackNode = node;
|
|
1382
|
+
// Use a reasonable offset: if original offset was small, use small offset
|
|
1383
|
+
// if original offset was large relative to typical text, use larger offset
|
|
1384
|
+
const originalOffset = referencePosition.anchorOffset;
|
|
1385
|
+
if (originalOffset <= 5) {
|
|
1386
|
+
// Original was near start, place near start
|
|
1387
|
+
bestFallbackOffset = Math.min(originalOffset, textLength);
|
|
1388
|
+
}
|
|
1389
|
+
else {
|
|
1390
|
+
// Original was further in, place proportionally
|
|
1391
|
+
bestFallbackOffset = Math.min(Math.floor(textLength * 0.3), textLength);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
else if ($isElementNode(node)) {
|
|
1396
|
+
const nodeChildren = node.getChildren();
|
|
1397
|
+
for (const child of nodeChildren) {
|
|
1398
|
+
findBestFallbackPosition(child);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
findBestFallbackPosition(root);
|
|
1403
|
+
if (bestFallbackNode && $isTextNode(bestFallbackNode)) {
|
|
1404
|
+
const textNode = bestFallbackNode;
|
|
1405
|
+
const textLength = textNode.getTextContent().length;
|
|
1406
|
+
const safeOffset = Math.min(bestFallbackOffset, textLength);
|
|
1407
|
+
console.log('🔄 Found proportional fallback position:', {
|
|
1408
|
+
nodeKey: textNode.getKey(),
|
|
1409
|
+
offset: safeOffset,
|
|
1410
|
+
originalOffset: referencePosition.anchorOffset,
|
|
1411
|
+
nodeLength: textLength
|
|
1412
|
+
});
|
|
1413
|
+
return {
|
|
1414
|
+
key: textNode.getKey(),
|
|
1415
|
+
offset: safeOffset,
|
|
1416
|
+
type: 'text'
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
// If no reference position or position calculation failed,
|
|
1421
|
+
// fallback to a reasonable default position (not beginning of document)
|
|
1422
|
+
console.log('🔄 No reference position or calculation failed, using safe fallback');
|
|
1423
|
+
// Find the first text node with some content
|
|
1424
|
+
const findFirstTextNode = (node) => {
|
|
1425
|
+
if ($isTextNode(node) && node.getTextContent().length > 0) {
|
|
1426
|
+
return node;
|
|
1427
|
+
}
|
|
1428
|
+
if ($isElementNode(node)) {
|
|
1429
|
+
const nodeChildren = node.getChildren();
|
|
1430
|
+
for (const child of nodeChildren) {
|
|
1431
|
+
const result = findFirstTextNode(child);
|
|
1432
|
+
if (result)
|
|
1433
|
+
return result;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
return null;
|
|
1437
|
+
};
|
|
1438
|
+
const firstTextNode = findFirstTextNode(root);
|
|
1439
|
+
if (firstTextNode && $isTextNode(firstTextNode)) {
|
|
1440
|
+
const textNode = firstTextNode;
|
|
1441
|
+
// Place cursor at a reasonable position, not at the very beginning
|
|
1442
|
+
const textLength = textNode.getTextContent().length;
|
|
1443
|
+
const offset = Math.min(1, textLength); // Position 1 or end if shorter
|
|
1444
|
+
console.log('🔄 Using reasonable position in first text node as fallback:', {
|
|
1445
|
+
nodeKey: textNode.getKey(),
|
|
1446
|
+
offset: offset,
|
|
1447
|
+
textLength: textLength
|
|
1448
|
+
});
|
|
1449
|
+
return {
|
|
1450
|
+
key: textNode.getKey(),
|
|
1451
|
+
offset: offset,
|
|
1452
|
+
type: 'text'
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
// Ultimate fallback to root
|
|
1456
|
+
console.log('🔄 Ultimate emergency fallback to root');
|
|
1457
|
+
return {
|
|
1458
|
+
key: root.getKey(),
|
|
1459
|
+
offset: 0,
|
|
1460
|
+
type: 'text'
|
|
1461
|
+
};
|
|
1462
|
+
});
|
|
1463
|
+
anchorPos = anchorPos || smartPosition;
|
|
1464
|
+
focusPos = focusPos || smartPosition;
|
|
1465
|
+
console.log('🔄 Applied smart fallback positions:', { anchorPos, focusPos });
|
|
1466
|
+
}
|
|
1467
|
+
remoteCursorsData[peerId] = {
|
|
1468
|
+
peerId: peerId,
|
|
1469
|
+
anchor: anchorPos,
|
|
1470
|
+
focus: focusPos,
|
|
1471
|
+
user: cursorData.user
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
console.log('👁️ Skipping own cursor for peer:', peerId);
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
console.log('🔍 PEER PROCESSING END:', {
|
|
1479
|
+
remoteCursorsDataKeys: Object.keys(remoteCursorsData),
|
|
1480
|
+
remoteCursorsDataCount: Object.keys(remoteCursorsData).length,
|
|
1481
|
+
originalAllCursorsKeys: Object.keys(allCursors),
|
|
1482
|
+
currentPeerId,
|
|
1483
|
+
peersProcessed: peersToProcess
|
|
1484
|
+
});
|
|
1485
|
+
console.log('🎯 Setting remote cursors:', remoteCursorsData);
|
|
1486
|
+
console.log('🔢 Remote cursors count after processing:', Object.keys(remoteCursorsData).length);
|
|
1487
|
+
if (Object.keys(remoteCursorsData).length === 0) {
|
|
1488
|
+
console.log('💡 No remote cursors to display. Open another browser tab to see collaborative cursors!');
|
|
1489
|
+
}
|
|
1490
|
+
// Update cursor timestamps for activity tracking
|
|
1491
|
+
const now = Date.now();
|
|
1492
|
+
Object.keys(remoteCursorsData).forEach(peerId => {
|
|
1493
|
+
cursorTimestamps.current[peerId] = now;
|
|
1494
|
+
});
|
|
1495
|
+
setRemoteCursors(remoteCursorsData);
|
|
1496
|
+
// Call awareness change callback for UI display (include ALL users, including self)
|
|
1497
|
+
if (stableOnAwarenessChange.current) {
|
|
1498
|
+
const awarenessData = Object.keys(allCursors).map(peerId => {
|
|
1499
|
+
// Extract meaningful part from peer ID
|
|
1500
|
+
const extractedId = peerId.includes('_') ?
|
|
1501
|
+
peerId.split('_').find(part => /^\d{13}$/.test(part)) || peerId.slice(-8) :
|
|
1502
|
+
peerId.slice(-8);
|
|
1503
|
+
const isCurrentUser = peerId === currentPeerId;
|
|
1504
|
+
return {
|
|
1505
|
+
peerId: peerId,
|
|
1506
|
+
userName: allCursors[peerId]?.user?.name || extractedId,
|
|
1507
|
+
isCurrentUser: isCurrentUser
|
|
1508
|
+
};
|
|
1509
|
+
});
|
|
1510
|
+
stableOnAwarenessChange.current(awarenessData);
|
|
1511
|
+
}
|
|
1512
|
+
// Force cursor re-render when remote cursors change
|
|
1513
|
+
setForceUpdate(prev => prev + 1);
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
// Only add the listener if this is a new awareness instance
|
|
1517
|
+
const currentAwareness = awarenessRef.current;
|
|
1518
|
+
if (currentAwareness) {
|
|
1519
|
+
// Remove any existing listeners first to prevent duplicates
|
|
1520
|
+
currentAwareness.removeListener(awarenessCallback);
|
|
1521
|
+
// Add the new listener
|
|
1522
|
+
currentAwareness.addListener(awarenessCallback);
|
|
1523
|
+
console.log('🎯 Added awareness callback listener');
|
|
1524
|
+
}
|
|
1525
|
+
// Set up the remote cursor callback
|
|
1526
|
+
awarenessRef.current.setRemoteCursorCallback((peerId, cursor) => {
|
|
1527
|
+
console.log('🎯 Remote cursor callback triggered:', peerId, cursor);
|
|
1528
|
+
setRemoteCursors(prev => {
|
|
1529
|
+
const updated = {
|
|
1530
|
+
...prev,
|
|
1531
|
+
[peerId]: cursor
|
|
1532
|
+
};
|
|
1533
|
+
console.log('🎯 Updated remote cursors state:', updated);
|
|
1534
|
+
// Force cursor re-render
|
|
1535
|
+
setForceUpdate(updateVal => updateVal + 1);
|
|
1536
|
+
return updated;
|
|
1537
|
+
});
|
|
1538
|
+
});
|
|
1539
|
+
// Subscribe to Loro document changes
|
|
1540
|
+
const unsubscribe = loroDocRef.current.subscribe(() => {
|
|
1541
|
+
if (!isLocalChange.current) {
|
|
1542
|
+
// This is a remote change, update Lexical editor
|
|
1543
|
+
const currentText = loroTextRef.current?.toString() || '';
|
|
1544
|
+
updateLexicalFromLoro(editor, currentText);
|
|
1545
|
+
}
|
|
1546
|
+
// Force cursor re-render when document changes (content affects cursor positioning)
|
|
1547
|
+
setForceUpdate(prev => prev + 1);
|
|
1548
|
+
});
|
|
1549
|
+
// Subscribe to Lexical editor changes with debouncing
|
|
1550
|
+
let updateTimeout = null;
|
|
1551
|
+
const removeEditorListener = editor.registerUpdateListener(({ editorState, tags }) => {
|
|
1552
|
+
// Skip if this is a local change from our plugin
|
|
1553
|
+
if (isLocalChange.current || tags.has('collaboration'))
|
|
1554
|
+
return;
|
|
1555
|
+
// =================================================================
|
|
1556
|
+
// CRITICAL: Assign stable UUIDs to new nodes on local changes
|
|
1557
|
+
// =================================================================
|
|
1558
|
+
editor.update(() => {
|
|
1559
|
+
$ensureAllNodesHaveStableIds();
|
|
1560
|
+
}, { tag: 'uuid-assignment' });
|
|
1561
|
+
// Clear previous timeout
|
|
1562
|
+
if (updateTimeout) {
|
|
1563
|
+
clearTimeout(updateTimeout);
|
|
1564
|
+
}
|
|
1565
|
+
// Debounce updates to prevent rapid firing
|
|
1566
|
+
updateTimeout = setTimeout(() => {
|
|
1567
|
+
if (!isLocalChange.current) {
|
|
1568
|
+
updateLoroFromLexical(editorState);
|
|
1569
|
+
}
|
|
1570
|
+
}, 25); // 25ms debounce for better responsiveness
|
|
1571
|
+
});
|
|
1572
|
+
return () => {
|
|
1573
|
+
if (updateTimeout) {
|
|
1574
|
+
clearTimeout(updateTimeout);
|
|
1575
|
+
}
|
|
1576
|
+
if (awarenessRef.current) {
|
|
1577
|
+
awarenessRef.current.removeListener(awarenessCallback);
|
|
1578
|
+
console.log('🎯 Removed awareness callback listener');
|
|
1579
|
+
}
|
|
1580
|
+
unsubscribe();
|
|
1581
|
+
removeEditorListener();
|
|
1582
|
+
};
|
|
1583
|
+
}, [editor, docId, updateLoroFromLexical, updateLexicalFromLoro, clientId]);
|
|
1584
|
+
// Connection retry state
|
|
1585
|
+
const retryTimeoutRef = useRef(null);
|
|
1586
|
+
const retryCountRef = useRef(0);
|
|
1587
|
+
const maxRetries = 5;
|
|
1588
|
+
// Create stable refs for callbacks to avoid dependency issues
|
|
1589
|
+
const stableOnAwarenessChange = useRef(onAwarenessChange);
|
|
1590
|
+
stableOnAwarenessChange.current = onAwarenessChange;
|
|
1591
|
+
// WebSocket connection management with stable dependencies
|
|
1592
|
+
const stableOnConnectionChange = useRef(onConnectionChange);
|
|
1593
|
+
const stableOnDisconnectReady = useRef(onDisconnectReady);
|
|
1594
|
+
// Update refs when props change without triggering effect
|
|
1595
|
+
useEffect(() => {
|
|
1596
|
+
stableOnConnectionChange.current = onConnectionChange;
|
|
1597
|
+
stableOnDisconnectReady.current = onDisconnectReady;
|
|
1598
|
+
});
|
|
1599
|
+
useEffect(() => {
|
|
1600
|
+
// Close any existing connection before creating a new one
|
|
1601
|
+
if (wsRef.current) {
|
|
1602
|
+
wsRef.current.close();
|
|
1603
|
+
wsRef.current = null;
|
|
1604
|
+
}
|
|
1605
|
+
const connectWebSocket = () => {
|
|
1606
|
+
// Prevent multiple connections
|
|
1607
|
+
if (isConnectingRef.current || (wsRef.current && wsRef.current.readyState === WebSocket.OPEN)) {
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
try {
|
|
1611
|
+
isConnectingRef.current = true;
|
|
1612
|
+
const ws = new WebSocket(websocketUrl);
|
|
1613
|
+
wsRef.current = ws;
|
|
1614
|
+
// Wrap send to log all outgoing messages with a clear, visible marker
|
|
1615
|
+
try {
|
|
1616
|
+
const originalSend = ws.send.bind(ws);
|
|
1617
|
+
ws.send = (data) => {
|
|
1618
|
+
try {
|
|
1619
|
+
if (typeof data === 'string') {
|
|
1620
|
+
const len = data.length;
|
|
1621
|
+
let parsed = null;
|
|
1622
|
+
try {
|
|
1623
|
+
parsed = JSON.parse(data);
|
|
1624
|
+
}
|
|
1625
|
+
catch { /* ignore parse errors for preview */ }
|
|
1626
|
+
const preview = data.slice(0, 300) + (len > 300 ? '…' : '');
|
|
1627
|
+
console.log('🛰️📤 WS SEND →', {
|
|
1628
|
+
type: parsed?.type,
|
|
1629
|
+
docId: parsed?.docId,
|
|
1630
|
+
length: len,
|
|
1631
|
+
keys: parsed ? Object.keys(parsed) : ['<unparsed>'],
|
|
1632
|
+
preview
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
else {
|
|
1636
|
+
console.log('🛰️📤 WS SEND → (non-string payload)', { kind: typeof data });
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
catch (logErr) {
|
|
1640
|
+
console.warn('WS send log failed:', logErr);
|
|
1641
|
+
}
|
|
1642
|
+
return originalSend(data);
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
catch (wrapErr) {
|
|
1646
|
+
console.warn('Failed to wrap WebSocket.send for logging:', wrapErr);
|
|
1647
|
+
}
|
|
1648
|
+
ws.onopen = () => {
|
|
1649
|
+
isConnectingRef.current = false;
|
|
1650
|
+
retryCountRef.current = 0; // Reset retry count on successful connection
|
|
1651
|
+
console.log('🔗 Lexical editor connected to WebSocket server');
|
|
1652
|
+
stableOnConnectionChange.current?.(true);
|
|
1653
|
+
// Initialize version vector for optimized updates
|
|
1654
|
+
setLastSentVersionVector(loroDocRef.current.version());
|
|
1655
|
+
// Provide disconnect function to parent component
|
|
1656
|
+
const disconnectFn = () => {
|
|
1657
|
+
if (wsRef.current) {
|
|
1658
|
+
wsRef.current.close();
|
|
1659
|
+
stableOnConnectionChange.current?.(false);
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
stableOnDisconnectReady.current?.(disconnectFn);
|
|
1663
|
+
};
|
|
1664
|
+
ws.onmessage = (event) => {
|
|
1665
|
+
try {
|
|
1666
|
+
const data = JSON.parse(event.data);
|
|
1667
|
+
// Prominent log for ALL incoming messages with safe preview
|
|
1668
|
+
const preview = typeof event.data === 'string' ? event.data.slice(0, 300) + (event.data.length > 300 ? '…' : '') : '';
|
|
1669
|
+
console.log('🛰️📥 WS RECV ←', {
|
|
1670
|
+
type: data.type,
|
|
1671
|
+
docId: data.docId,
|
|
1672
|
+
hasData: !!data.data,
|
|
1673
|
+
hasEvent: !!data.event,
|
|
1674
|
+
clientId: data.clientId,
|
|
1675
|
+
length: typeof event.data === 'string' ? event.data.length : undefined,
|
|
1676
|
+
preview
|
|
1677
|
+
});
|
|
1678
|
+
if (data.type === 'loro-update' && data.docId === docId) {
|
|
1679
|
+
// Apply remote update to local document
|
|
1680
|
+
const update = new Uint8Array(data.update);
|
|
1681
|
+
loroDocRef.current.import(update);
|
|
1682
|
+
}
|
|
1683
|
+
else if (data.type === 'initial-snapshot' && data.docId === docId) {
|
|
1684
|
+
// Apply initial snapshot from server and immediately sync to Lexical
|
|
1685
|
+
const snapshot = new Uint8Array(data.snapshot);
|
|
1686
|
+
loroDocRef.current.import(snapshot);
|
|
1687
|
+
hasReceivedInitialSnapshot.current = true;
|
|
1688
|
+
console.log('📄 Lexical editor received and applied initial snapshot');
|
|
1689
|
+
// Immediately reflect the current Loro text into the editor after import
|
|
1690
|
+
try {
|
|
1691
|
+
const currentText = loroDocRef.current.getText(docId).toString();
|
|
1692
|
+
updateLexicalFromLoro(editor, currentText);
|
|
1693
|
+
}
|
|
1694
|
+
catch (e) {
|
|
1695
|
+
console.warn('⚠️ Could not immediately reflect snapshot to editor:', e);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
else if (data.type === 'ephemeral-update' || data.type === 'ephemeral-event') {
|
|
1699
|
+
// Handle ephemeral updates from other clients using EphemeralStore
|
|
1700
|
+
if (data.docId === docId && data.data) {
|
|
1701
|
+
try {
|
|
1702
|
+
console.log('📡 Received ephemeral update:', {
|
|
1703
|
+
type: data.type,
|
|
1704
|
+
event: data.event || 'legacy',
|
|
1705
|
+
hasEventInfo: !!data.event,
|
|
1706
|
+
eventDetails: data.event
|
|
1707
|
+
});
|
|
1708
|
+
// Convert hex string back to Uint8Array
|
|
1709
|
+
const ephemeralBytes = new Uint8Array(data.data.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []);
|
|
1710
|
+
if (awarenessRef.current && ephemeralBytes.length > 0) {
|
|
1711
|
+
console.log('🎯 About to apply ephemeral data, current state before apply:');
|
|
1712
|
+
console.log('🎯 Current awareness data before apply:', awarenessRef.current.getAll());
|
|
1713
|
+
// Apply the ephemeral data to our local store
|
|
1714
|
+
// This will now automatically trigger the awareness callback
|
|
1715
|
+
awarenessRef.current.apply(ephemeralBytes);
|
|
1716
|
+
console.log('🎯 Current awareness data after apply:', awarenessRef.current.getAll());
|
|
1717
|
+
// Process ephemeral event - the awareness callback handles cursor updates
|
|
1718
|
+
console.log('🎯 Processing ephemeral event with details:', {
|
|
1719
|
+
by: data.event?.by,
|
|
1720
|
+
added: data.event?.added,
|
|
1721
|
+
updated: data.event?.updated,
|
|
1722
|
+
removed: data.event?.removed
|
|
1723
|
+
});
|
|
1724
|
+
// CRITICAL FIX: Don't immediately remove cursors on ephemeral events
|
|
1725
|
+
// The typing action often triggers false "removal" events
|
|
1726
|
+
// Let the awareness callback handle cursor state properly
|
|
1727
|
+
if (data.event?.removed && data.event.removed.length > 0) {
|
|
1728
|
+
console.log('�️ Note: Ephemeral event indicates removals:', data.event.removed, '(will be validated by awareness callback)');
|
|
1729
|
+
// Don't immediately remove cursors - let the awareness callback validate
|
|
1730
|
+
}
|
|
1731
|
+
console.log('👁️ Applied ephemeral update from remote clients');
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
catch (error) {
|
|
1735
|
+
console.warn('Error applying ephemeral update:', error);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
else if (data.type === 'welcome') {
|
|
1740
|
+
console.log('👋 Lexical editor welcome message received', {
|
|
1741
|
+
clientId: data.clientId,
|
|
1742
|
+
color: data.color
|
|
1743
|
+
});
|
|
1744
|
+
// Set client ID and color for cursor tracking
|
|
1745
|
+
setClientId(data.clientId || '');
|
|
1746
|
+
setClientColor(data.color || '');
|
|
1747
|
+
// Update the numeric peer ID to use the client ID for consistency
|
|
1748
|
+
if (data.clientId && awarenessRef.current) {
|
|
1749
|
+
// Store the client ID as the peer ID
|
|
1750
|
+
peerIdRef.current = data.clientId;
|
|
1751
|
+
// Create a new CursorAwareness instance with the client ID as peer ID
|
|
1752
|
+
awarenessRef.current = new CursorAwareness(data.clientId, loroDocRef.current);
|
|
1753
|
+
console.log('🎯 Updated awareness to use client ID as peer ID:', data.clientId);
|
|
1754
|
+
// Extract meaningful part from client ID
|
|
1755
|
+
const extractedId = data.clientId.includes('_') ?
|
|
1756
|
+
data.clientId.split('_').find(part => /^\d{13}$/.test(part)) || data.clientId.slice(-8) :
|
|
1757
|
+
data.clientId.slice(-8);
|
|
1758
|
+
// We'll re-add the awareness callback in the main useEffect
|
|
1759
|
+
// Update awareness with client info using the client ID
|
|
1760
|
+
awarenessRef.current.setLocal({
|
|
1761
|
+
user: { name: extractedId, color: data.color || '#007acc' }
|
|
1762
|
+
});
|
|
1763
|
+
console.log('🎯 Updated awareness with WebSocket client ID user data:', { name: extractedId, color: data.color || '#007acc', clientId: data.clientId });
|
|
1764
|
+
} // Notify parent component of the peerId
|
|
1765
|
+
if (onPeerIdChange && data.clientId) {
|
|
1766
|
+
onPeerIdChange(data.clientId);
|
|
1767
|
+
}
|
|
1768
|
+
// Request current snapshot from server after a small delay
|
|
1769
|
+
setTimeout(() => {
|
|
1770
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1771
|
+
ws.send(JSON.stringify({
|
|
1772
|
+
type: 'request-snapshot',
|
|
1773
|
+
docId: docId
|
|
1774
|
+
}));
|
|
1775
|
+
console.log('📞 Lexical editor requested current snapshot from server');
|
|
1776
|
+
}
|
|
1777
|
+
}, 150); // Slightly different delay than text editor
|
|
1778
|
+
}
|
|
1779
|
+
else if (data.type === 'snapshot-request' && data.docId === docId) {
|
|
1780
|
+
// Another client is requesting a snapshot, send ours if we have content
|
|
1781
|
+
editor.getEditorState().read(() => {
|
|
1782
|
+
const currentText = $getRoot().getTextContent();
|
|
1783
|
+
if (currentText.length > 0) {
|
|
1784
|
+
const snapshot = loroDocRef.current.export({ mode: "snapshot" });
|
|
1785
|
+
ws.send(JSON.stringify({
|
|
1786
|
+
type: 'snapshot',
|
|
1787
|
+
snapshot: Array.from(snapshot),
|
|
1788
|
+
docId: docId
|
|
1789
|
+
}));
|
|
1790
|
+
console.log('📄 Lexical editor sent snapshot in response to request');
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
else if (data.type === 'client-disconnect') {
|
|
1795
|
+
// Handle explicit client disconnect notifications
|
|
1796
|
+
console.log('📢 Received client disconnect notification:', data);
|
|
1797
|
+
const disconnectedClientId = data.clientId;
|
|
1798
|
+
if (disconnectedClientId && awarenessRef.current) {
|
|
1799
|
+
console.log('🧹 Forcing cleanup of disconnected client:', disconnectedClientId);
|
|
1800
|
+
// Remove from remote cursors immediately
|
|
1801
|
+
setRemoteCursors(prev => {
|
|
1802
|
+
const updated = { ...prev };
|
|
1803
|
+
console.log('🧹 Current remote cursors before cleanup:', prev);
|
|
1804
|
+
delete updated[disconnectedClientId];
|
|
1805
|
+
console.log('🧹 Removed disconnected client from remote cursors, new state:', updated);
|
|
1806
|
+
return updated;
|
|
1807
|
+
});
|
|
1808
|
+
// Clear from timestamps
|
|
1809
|
+
delete cursorTimestamps.current[disconnectedClientId];
|
|
1810
|
+
// Force awareness refresh
|
|
1811
|
+
setForceUpdate(prev => prev + 1);
|
|
1812
|
+
console.log('🧹 Completed immediate cleanup for disconnected client');
|
|
1813
|
+
}
|
|
1814
|
+
else {
|
|
1815
|
+
console.warn('🧹 Cannot cleanup - missing client ID or awareness ref');
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
catch (err) {
|
|
1820
|
+
console.error('Error processing WebSocket message in Lexical plugin:', err);
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
ws.onclose = () => {
|
|
1824
|
+
isConnectingRef.current = false;
|
|
1825
|
+
console.log('📴 Lexical editor disconnected from WebSocket server');
|
|
1826
|
+
stableOnConnectionChange.current?.(false);
|
|
1827
|
+
// Clear any existing retry timeout
|
|
1828
|
+
if (retryTimeoutRef.current) {
|
|
1829
|
+
clearTimeout(retryTimeoutRef.current);
|
|
1830
|
+
}
|
|
1831
|
+
// Only retry if we haven't exceeded max retries
|
|
1832
|
+
if (retryCountRef.current < maxRetries) {
|
|
1833
|
+
const retryDelay = Math.min(1000 * Math.pow(2, retryCountRef.current), 10000); // Exponential backoff, max 10s
|
|
1834
|
+
retryCountRef.current++;
|
|
1835
|
+
console.log(`🔄 Retrying connection in ${retryDelay}ms (attempt ${retryCountRef.current}/${maxRetries})`);
|
|
1836
|
+
retryTimeoutRef.current = setTimeout(connectWebSocket, retryDelay);
|
|
1837
|
+
}
|
|
1838
|
+
else {
|
|
1839
|
+
console.log('❌ Max connection retries exceeded, giving up');
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
ws.onerror = (err) => {
|
|
1843
|
+
isConnectingRef.current = false;
|
|
1844
|
+
console.error('WebSocket error in Lexical plugin:', err);
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
catch (err) {
|
|
1848
|
+
isConnectingRef.current = false;
|
|
1849
|
+
console.error('Failed to connect to WebSocket server in Lexical plugin:', err);
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
connectWebSocket();
|
|
1853
|
+
return () => {
|
|
1854
|
+
// Clear retry timeout
|
|
1855
|
+
if (retryTimeoutRef.current) {
|
|
1856
|
+
clearTimeout(retryTimeoutRef.current);
|
|
1857
|
+
}
|
|
1858
|
+
if (wsRef.current) {
|
|
1859
|
+
wsRef.current.close();
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
}, [websocketUrl, docId, editor, onPeerIdChange, updateLexicalFromLoro]); // Include all dependencies
|
|
1863
|
+
// Cleanup stale cursors periodically
|
|
1864
|
+
useEffect(() => {
|
|
1865
|
+
const cleanupInterval = setInterval(() => {
|
|
1866
|
+
const now = Date.now();
|
|
1867
|
+
const staleThreshold = 10000; // 10 seconds
|
|
1868
|
+
setRemoteCursors(prev => {
|
|
1869
|
+
const updated = { ...prev };
|
|
1870
|
+
let hasChanges = false;
|
|
1871
|
+
Object.keys(updated).forEach(peerId => {
|
|
1872
|
+
const lastSeen = cursorTimestamps.current[peerId] || 0;
|
|
1873
|
+
if (now - lastSeen > staleThreshold) {
|
|
1874
|
+
console.log('🧹 Removing stale cursor for peer:', peerId, 'last seen:', now - lastSeen, 'ms ago');
|
|
1875
|
+
delete updated[peerId];
|
|
1876
|
+
delete cursorTimestamps.current[peerId];
|
|
1877
|
+
hasChanges = true;
|
|
1878
|
+
}
|
|
1879
|
+
});
|
|
1880
|
+
return hasChanges ? updated : prev;
|
|
1881
|
+
});
|
|
1882
|
+
}, 2000); // Check every 2 seconds
|
|
1883
|
+
return () => clearInterval(cleanupInterval);
|
|
1884
|
+
}, []);
|
|
1885
|
+
// Track selection changes for collaborative cursors using Awareness
|
|
1886
|
+
useEffect(() => {
|
|
1887
|
+
// Listen to both content changes AND selection changes
|
|
1888
|
+
const removeUpdateListener = editor.registerUpdateListener(({ editorState }) => {
|
|
1889
|
+
// Always update cursor awareness on any state change (content or selection)
|
|
1890
|
+
editorState.read(() => {
|
|
1891
|
+
updateCursorAwareness();
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
// Add DOM event listeners to track cursor movements
|
|
1895
|
+
const editorElement = editor.getElementByKey('root');
|
|
1896
|
+
const editorContainer = editorElement?.closest('[contenteditable]');
|
|
1897
|
+
if (editorContainer) {
|
|
1898
|
+
// Listen for mouse clicks that change cursor position
|
|
1899
|
+
const handleClick = () => {
|
|
1900
|
+
// Small delay to ensure selection has updated
|
|
1901
|
+
setTimeout(() => {
|
|
1902
|
+
updateCursorAwareness();
|
|
1903
|
+
}, 10);
|
|
1904
|
+
};
|
|
1905
|
+
// Listen for keyboard events that change cursor position
|
|
1906
|
+
const handleKeyboard = (event) => {
|
|
1907
|
+
// Check for cursor movement keys OR typing keys
|
|
1908
|
+
const cursorKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'];
|
|
1909
|
+
const isTyping = event.key.length === 1 || event.key === 'Backspace' || event.key === 'Delete';
|
|
1910
|
+
if (cursorKeys.includes(event.key) || isTyping) {
|
|
1911
|
+
// Small delay to ensure selection has updated
|
|
1912
|
+
setTimeout(() => {
|
|
1913
|
+
updateCursorAwareness();
|
|
1914
|
+
}, 10);
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
// Listen for global selection changes
|
|
1918
|
+
const handleSelectionChange = () => {
|
|
1919
|
+
// Check if the current selection is within our editor
|
|
1920
|
+
const selection = window.getSelection();
|
|
1921
|
+
if (selection && selection.rangeCount > 0) {
|
|
1922
|
+
const range = selection.getRangeAt(0);
|
|
1923
|
+
if (editorContainer.contains(range.commonAncestorContainer)) {
|
|
1924
|
+
updateCursorAwareness();
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
};
|
|
1928
|
+
editorContainer.addEventListener('click', handleClick);
|
|
1929
|
+
editorContainer.addEventListener('keyup', handleKeyboard);
|
|
1930
|
+
document.addEventListener('selectionchange', handleSelectionChange);
|
|
1931
|
+
// Periodic cursor refresh to keep cursor alive in EphemeralStore
|
|
1932
|
+
// Send cursor update every 60 seconds to prevent timeout
|
|
1933
|
+
const cursorRefreshInterval = setInterval(() => {
|
|
1934
|
+
updateCursorAwareness();
|
|
1935
|
+
}, 60_000); // Every 60 seconds
|
|
1936
|
+
return () => {
|
|
1937
|
+
clearInterval(cursorRefreshInterval);
|
|
1938
|
+
removeUpdateListener();
|
|
1939
|
+
editorContainer.removeEventListener('click', handleClick);
|
|
1940
|
+
editorContainer.removeEventListener('keyup', handleKeyboard);
|
|
1941
|
+
document.removeEventListener('selectionchange', handleSelectionChange);
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
return removeUpdateListener;
|
|
1945
|
+
}, [editor, updateCursorAwareness]);
|
|
1946
|
+
// Force cursor re-rendering when remote cursors change
|
|
1947
|
+
useEffect(() => {
|
|
1948
|
+
// This effect will trigger whenever forceUpdate changes
|
|
1949
|
+
console.log('🔄 Forcing cursor re-render due to remote cursor changes');
|
|
1950
|
+
}, [forceUpdate]);
|
|
1951
|
+
// Get the Lexical editor element and its parent for overlay positioning
|
|
1952
|
+
const getEditorElement = useCallback(() => {
|
|
1953
|
+
const editorContainer = editor.getElementByKey('root');
|
|
1954
|
+
return editorContainer?.closest('[contenteditable]');
|
|
1955
|
+
}, [editor]);
|
|
1956
|
+
// Calculate DOM position from Lexical node position (using Lexical's approach)
|
|
1957
|
+
const getPositionFromLexicalPosition = useCallback((nodeKey, offset) => {
|
|
1958
|
+
const editorElement = getEditorElement();
|
|
1959
|
+
if (!editorElement) {
|
|
1960
|
+
console.warn('🚨 No editor element for cursor positioning');
|
|
1961
|
+
// Return position far off-screen so validation will skip it
|
|
1962
|
+
return { top: -1000, left: -1000 };
|
|
1963
|
+
}
|
|
1964
|
+
try {
|
|
1965
|
+
return editor.getEditorState().read(() => {
|
|
1966
|
+
const node = $getNodeByKey(nodeKey);
|
|
1967
|
+
if (!node) {
|
|
1968
|
+
console.warn('🚨 Node not found for key:', nodeKey);
|
|
1969
|
+
return { top: -1000, left: -1000 };
|
|
1970
|
+
}
|
|
1971
|
+
console.log('🎯 Calculating position for node:', {
|
|
1972
|
+
nodeKey,
|
|
1973
|
+
offset,
|
|
1974
|
+
nodeType: node.getType(),
|
|
1975
|
+
isTextNode: $isTextNode(node),
|
|
1976
|
+
isElementNode: $isElementNode(node),
|
|
1977
|
+
isLineBreakNode: $isLineBreakNode(node),
|
|
1978
|
+
textContent: $isTextNode(node) ? node.getTextContent() : 'N/A'
|
|
1979
|
+
});
|
|
1980
|
+
// Handle line break nodes specially (like Lexical does)
|
|
1981
|
+
if ($isLineBreakNode(node)) {
|
|
1982
|
+
const brElement = editor.getElementByKey(nodeKey);
|
|
1983
|
+
if (brElement) {
|
|
1984
|
+
const brRect = brElement.getBoundingClientRect();
|
|
1985
|
+
console.log('📏 Line break node position:', { top: brRect.top, left: brRect.left });
|
|
1986
|
+
return {
|
|
1987
|
+
top: brRect.top,
|
|
1988
|
+
left: brRect.left
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
// For element nodes (like root, paragraph), we need to find the text position within
|
|
1993
|
+
if ($isElementNode(node)) {
|
|
1994
|
+
console.log('🏗️ Element node, finding text position at offset:', offset);
|
|
1995
|
+
// Get all children and find the text position
|
|
1996
|
+
const children = node.getChildren();
|
|
1997
|
+
let currentOffset = 0;
|
|
1998
|
+
let targetNode = null;
|
|
1999
|
+
let targetOffset = 0;
|
|
2000
|
+
for (let i = 0; i < children.length; i++) {
|
|
2001
|
+
const child = children[i];
|
|
2002
|
+
if ($isTextNode(child)) {
|
|
2003
|
+
const textLength = child.getTextContentSize();
|
|
2004
|
+
console.log('📝 Found text node:', {
|
|
2005
|
+
key: child.getKey(),
|
|
2006
|
+
textLength,
|
|
2007
|
+
currentOffset,
|
|
2008
|
+
targetOffset: offset
|
|
2009
|
+
});
|
|
2010
|
+
if (currentOffset + textLength >= offset) {
|
|
2011
|
+
// Found the target text node
|
|
2012
|
+
targetNode = child;
|
|
2013
|
+
targetOffset = offset - currentOffset;
|
|
2014
|
+
console.log('🎯 Target found in text node:', {
|
|
2015
|
+
targetNodeKey: targetNode.getKey(),
|
|
2016
|
+
targetOffset
|
|
2017
|
+
});
|
|
2018
|
+
break;
|
|
2019
|
+
}
|
|
2020
|
+
currentOffset += textLength;
|
|
2021
|
+
}
|
|
2022
|
+
else if ($isElementNode(child)) {
|
|
2023
|
+
// For element children, count as 1 position
|
|
2024
|
+
console.log('🏗️ Found element node:', {
|
|
2025
|
+
key: child.getKey(),
|
|
2026
|
+
currentOffset,
|
|
2027
|
+
targetOffset: offset
|
|
2028
|
+
});
|
|
2029
|
+
if (currentOffset + 1 > offset) {
|
|
2030
|
+
targetNode = child;
|
|
2031
|
+
targetOffset = 0;
|
|
2032
|
+
console.log('🎯 Target found at element node:', {
|
|
2033
|
+
targetNodeKey: targetNode.getKey(),
|
|
2034
|
+
targetOffset
|
|
2035
|
+
});
|
|
2036
|
+
break;
|
|
2037
|
+
}
|
|
2038
|
+
currentOffset += 1;
|
|
2039
|
+
}
|
|
2040
|
+
else {
|
|
2041
|
+
// Other node types (decorators, etc.)
|
|
2042
|
+
if (currentOffset + 1 > offset) {
|
|
2043
|
+
targetNode = child;
|
|
2044
|
+
targetOffset = 0;
|
|
2045
|
+
break;
|
|
2046
|
+
}
|
|
2047
|
+
currentOffset += 1;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
// If we didn't find a specific target, use the last available position
|
|
2051
|
+
if (!targetNode && children.length > 0) {
|
|
2052
|
+
const lastChild = children[children.length - 1];
|
|
2053
|
+
if ($isTextNode(lastChild)) {
|
|
2054
|
+
targetNode = lastChild;
|
|
2055
|
+
targetOffset = lastChild.getTextContentSize();
|
|
2056
|
+
console.log('🔚 Using last text node position:', {
|
|
2057
|
+
targetNodeKey: targetNode.getKey(),
|
|
2058
|
+
targetOffset
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
else {
|
|
2062
|
+
targetNode = lastChild;
|
|
2063
|
+
targetOffset = 0;
|
|
2064
|
+
console.log('🔚 Using last element node:', {
|
|
2065
|
+
targetNodeKey: targetNode.getKey(),
|
|
2066
|
+
targetOffset
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
// If we found a target node, use it for positioning
|
|
2071
|
+
if (targetNode) {
|
|
2072
|
+
console.log('🎯 Processing target node:', {
|
|
2073
|
+
targetNodeKey: targetNode.getKey(),
|
|
2074
|
+
targetOffset,
|
|
2075
|
+
isTextNode: $isTextNode(targetNode),
|
|
2076
|
+
isElementNode: $isElementNode(targetNode)
|
|
2077
|
+
});
|
|
2078
|
+
// If target is a text node, use it directly
|
|
2079
|
+
if ($isTextNode(targetNode)) {
|
|
2080
|
+
try {
|
|
2081
|
+
// Create DOM range for position calculation
|
|
2082
|
+
const range = createDOMRange(editor, targetNode, targetOffset, targetNode, targetOffset);
|
|
2083
|
+
if (range !== null) {
|
|
2084
|
+
// Use createRectsFromDOMRange for accurate positioning
|
|
2085
|
+
const rects = createRectsFromDOMRange(editor, range);
|
|
2086
|
+
if (rects.length > 0) {
|
|
2087
|
+
const rect = rects[0];
|
|
2088
|
+
// Ensure the rect has valid dimensions
|
|
2089
|
+
if (rect.height > 0 && rect.width >= 0) {
|
|
2090
|
+
console.log('📐 Valid text node range position:', {
|
|
2091
|
+
top: rect.top,
|
|
2092
|
+
left: rect.left,
|
|
2093
|
+
height: rect.height,
|
|
2094
|
+
width: rect.width,
|
|
2095
|
+
targetNodeKey: targetNode.getKey(),
|
|
2096
|
+
targetOffset
|
|
2097
|
+
});
|
|
2098
|
+
return {
|
|
2099
|
+
top: rect.top,
|
|
2100
|
+
left: rect.left
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
else {
|
|
2104
|
+
console.warn('🚨 Invalid rect dimensions, trying fallback approach:', rect);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
// Fallback: Use native DOM range if Lexical rects fail
|
|
2108
|
+
const rangeBounds = range.getBoundingClientRect();
|
|
2109
|
+
if (rangeBounds && rangeBounds.height > 0) {
|
|
2110
|
+
console.log('📐 Fallback DOM range position:', {
|
|
2111
|
+
top: rangeBounds.top,
|
|
2112
|
+
left: rangeBounds.left,
|
|
2113
|
+
height: rangeBounds.height,
|
|
2114
|
+
width: rangeBounds.width
|
|
2115
|
+
});
|
|
2116
|
+
return {
|
|
2117
|
+
top: rangeBounds.top,
|
|
2118
|
+
left: rangeBounds.left
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
// Ultimate fallback: Use direct DOM element positioning
|
|
2123
|
+
const domElement = editor.getElementByKey(targetNode.getKey());
|
|
2124
|
+
if (domElement) {
|
|
2125
|
+
const elementRect = domElement.getBoundingClientRect();
|
|
2126
|
+
console.log('📐 Ultimate fallback - DOM element position:', {
|
|
2127
|
+
top: elementRect.top,
|
|
2128
|
+
left: elementRect.left,
|
|
2129
|
+
height: elementRect.height,
|
|
2130
|
+
width: elementRect.width
|
|
2131
|
+
});
|
|
2132
|
+
// For text nodes, try to calculate character position within the element
|
|
2133
|
+
if (targetOffset > 0 && domElement.textContent) {
|
|
2134
|
+
// Create a temporary range to measure character offset
|
|
2135
|
+
const tempRange = document.createRange();
|
|
2136
|
+
const textNode = domElement.firstChild;
|
|
2137
|
+
if (textNode && textNode.nodeType === Node.TEXT_NODE && textNode.textContent) {
|
|
2138
|
+
const safeOffset = Math.min(targetOffset, textNode.textContent.length);
|
|
2139
|
+
tempRange.setStart(textNode, safeOffset);
|
|
2140
|
+
tempRange.setEnd(textNode, safeOffset);
|
|
2141
|
+
const tempRect = tempRange.getBoundingClientRect();
|
|
2142
|
+
if (tempRect && tempRect.height > 0) {
|
|
2143
|
+
console.log('📐 Character-precise position:', {
|
|
2144
|
+
top: tempRect.top,
|
|
2145
|
+
left: tempRect.left,
|
|
2146
|
+
offset: safeOffset
|
|
2147
|
+
});
|
|
2148
|
+
return {
|
|
2149
|
+
top: tempRect.top,
|
|
2150
|
+
left: tempRect.left
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
return {
|
|
2156
|
+
top: elementRect.top,
|
|
2157
|
+
left: elementRect.left
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
catch (error) {
|
|
2162
|
+
console.warn('🚨 Error creating range for target text node:', error);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
// If target is an element node, try to find first text node within it
|
|
2166
|
+
if ($isElementNode(targetNode)) {
|
|
2167
|
+
console.log('🏗️ Target is element, looking for text within it');
|
|
2168
|
+
const targetChildren = targetNode.getChildren();
|
|
2169
|
+
let firstTextNode = null;
|
|
2170
|
+
for (const child of targetChildren) {
|
|
2171
|
+
if ($isTextNode(child)) {
|
|
2172
|
+
firstTextNode = child;
|
|
2173
|
+
console.log('📝 Found first text node in target element:', child.getKey());
|
|
2174
|
+
break;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
if (firstTextNode) {
|
|
2178
|
+
try {
|
|
2179
|
+
// Improved range creation for element nodes
|
|
2180
|
+
const range = createDOMRange(editor, firstTextNode, 0, // Start of first text node
|
|
2181
|
+
firstTextNode, 0);
|
|
2182
|
+
if (range !== null) {
|
|
2183
|
+
const rects = createRectsFromDOMRange(editor, range);
|
|
2184
|
+
if (rects.length > 0) {
|
|
2185
|
+
const rect = rects[0];
|
|
2186
|
+
// Ensure rect has valid height
|
|
2187
|
+
if (rect.height > 0) {
|
|
2188
|
+
console.log('📐 Valid element->text range position:', {
|
|
2189
|
+
top: rect.top,
|
|
2190
|
+
left: rect.left,
|
|
2191
|
+
height: rect.height,
|
|
2192
|
+
targetNodeKey: targetNode.getKey(),
|
|
2193
|
+
firstTextNodeKey: firstTextNode.getKey()
|
|
2194
|
+
});
|
|
2195
|
+
return {
|
|
2196
|
+
top: rect.top,
|
|
2197
|
+
left: rect.left
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
else {
|
|
2201
|
+
console.warn('🚨 Invalid element rect height, using fallback');
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
// Try native DOM range fallback
|
|
2205
|
+
const rangeBounds = range.getBoundingClientRect();
|
|
2206
|
+
if (rangeBounds && rangeBounds.height > 0) {
|
|
2207
|
+
console.log('📐 Element fallback DOM range position:', {
|
|
2208
|
+
top: rangeBounds.top,
|
|
2209
|
+
left: rangeBounds.left,
|
|
2210
|
+
height: rangeBounds.height
|
|
2211
|
+
});
|
|
2212
|
+
return {
|
|
2213
|
+
top: rangeBounds.top,
|
|
2214
|
+
left: rangeBounds.left
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
catch (error) {
|
|
2220
|
+
console.warn('🚨 Error creating range for text within target element:', error);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
else {
|
|
2224
|
+
// No text nodes in element, use element position directly
|
|
2225
|
+
console.log('📦 No text in target element, using element position');
|
|
2226
|
+
const domElement = editor.getElementByKey(targetNode.getKey());
|
|
2227
|
+
if (domElement) {
|
|
2228
|
+
const elementRect = domElement.getBoundingClientRect();
|
|
2229
|
+
console.log('📐 Direct element position:', {
|
|
2230
|
+
top: elementRect.top,
|
|
2231
|
+
left: elementRect.left,
|
|
2232
|
+
height: elementRect.height,
|
|
2233
|
+
width: elementRect.width
|
|
2234
|
+
});
|
|
2235
|
+
return {
|
|
2236
|
+
top: elementRect.top,
|
|
2237
|
+
left: elementRect.left
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
// Fallback to element position if we can't create a range
|
|
2244
|
+
console.log('⚠️ Falling back to element position for:', nodeKey);
|
|
2245
|
+
const domElement = editor.getElementByKey(nodeKey);
|
|
2246
|
+
if (domElement) {
|
|
2247
|
+
const elementRect = domElement.getBoundingClientRect();
|
|
2248
|
+
return {
|
|
2249
|
+
top: elementRect.top,
|
|
2250
|
+
left: elementRect.left
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
// For text nodes, use Lexical's createDOMRange directly
|
|
2255
|
+
if ($isTextNode(node)) {
|
|
2256
|
+
console.log('📝 Text node, creating range at offset:', offset);
|
|
2257
|
+
try {
|
|
2258
|
+
// Enhanced range creation for text nodes
|
|
2259
|
+
const range = createDOMRange(editor, node, offset, node, offset);
|
|
2260
|
+
if (range !== null) {
|
|
2261
|
+
const rects = createRectsFromDOMRange(editor, range);
|
|
2262
|
+
if (rects.length > 0) {
|
|
2263
|
+
const rect = rects[0];
|
|
2264
|
+
// Validate rect dimensions
|
|
2265
|
+
if (rect.height > 0 && !isNaN(rect.top) && !isNaN(rect.left)) {
|
|
2266
|
+
console.log('📐 Valid text range position:', {
|
|
2267
|
+
top: rect.top,
|
|
2268
|
+
left: rect.left,
|
|
2269
|
+
width: rect.width,
|
|
2270
|
+
height: rect.height,
|
|
2271
|
+
nodeKey,
|
|
2272
|
+
offset
|
|
2273
|
+
});
|
|
2274
|
+
return {
|
|
2275
|
+
top: rect.top,
|
|
2276
|
+
left: rect.left
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
else {
|
|
2280
|
+
console.warn('🚨 Invalid text rect, trying DOM range fallback:', rect);
|
|
2281
|
+
// Use native DOM range fallback
|
|
2282
|
+
const rangeBounds = range.getBoundingClientRect();
|
|
2283
|
+
if (rangeBounds && rangeBounds.height > 0) {
|
|
2284
|
+
console.log('📐 Text DOM range fallback position:', {
|
|
2285
|
+
top: rangeBounds.top,
|
|
2286
|
+
left: rangeBounds.left,
|
|
2287
|
+
height: rangeBounds.height
|
|
2288
|
+
});
|
|
2289
|
+
return {
|
|
2290
|
+
top: rangeBounds.top,
|
|
2291
|
+
left: rangeBounds.left
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
// Additional fallback: Use range getBoundingClientRect directly
|
|
2297
|
+
const directRect = range.getBoundingClientRect();
|
|
2298
|
+
if (directRect && directRect.height > 0) {
|
|
2299
|
+
console.log('📐 Direct range rect position:', {
|
|
2300
|
+
top: directRect.top,
|
|
2301
|
+
left: directRect.left,
|
|
2302
|
+
height: directRect.height
|
|
2303
|
+
});
|
|
2304
|
+
return {
|
|
2305
|
+
top: directRect.top,
|
|
2306
|
+
left: directRect.left
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
catch (error) {
|
|
2312
|
+
console.warn('🚨 Error creating range for text node:', error);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
// Get the DOM element for this node
|
|
2316
|
+
const domElement = editor.getElementByKey(nodeKey);
|
|
2317
|
+
if (!domElement) {
|
|
2318
|
+
console.warn('� DOM element not found for node key:', nodeKey);
|
|
2319
|
+
return { top: -1000, left: -1000 };
|
|
2320
|
+
}
|
|
2321
|
+
if (domElement) {
|
|
2322
|
+
try {
|
|
2323
|
+
// Create a range at the specified offset within the node
|
|
2324
|
+
const range = document.createRange();
|
|
2325
|
+
if (domElement.nodeType === Node.TEXT_NODE) {
|
|
2326
|
+
// For text nodes, set range at the offset
|
|
2327
|
+
range.setStart(domElement, Math.min(offset, domElement?.textContent?.length || 0));
|
|
2328
|
+
range.collapse(true);
|
|
2329
|
+
}
|
|
2330
|
+
else {
|
|
2331
|
+
// For element nodes, find the text content and position
|
|
2332
|
+
const walker = document.createTreeWalker(domElement, NodeFilter.SHOW_TEXT, null);
|
|
2333
|
+
let currentOffset = 0;
|
|
2334
|
+
let textNode = walker.nextNode();
|
|
2335
|
+
while (textNode && currentOffset + textNode.textContent.length < offset) {
|
|
2336
|
+
currentOffset += textNode.textContent.length;
|
|
2337
|
+
textNode = walker.nextNode();
|
|
2338
|
+
}
|
|
2339
|
+
if (textNode) {
|
|
2340
|
+
range.setStart(textNode, Math.min(offset - currentOffset, textNode.textContent.length));
|
|
2341
|
+
range.collapse(true);
|
|
2342
|
+
}
|
|
2343
|
+
else {
|
|
2344
|
+
// Fallback to end of element
|
|
2345
|
+
range.selectNodeContents(domElement);
|
|
2346
|
+
range.collapse(false);
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
const rect = range.getBoundingClientRect();
|
|
2350
|
+
if (rect.width > 0 || rect.height > 0) {
|
|
2351
|
+
return {
|
|
2352
|
+
top: rect.top,
|
|
2353
|
+
left: rect.left
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
catch (rangeError) {
|
|
2358
|
+
console.warn('Range error:', rangeError);
|
|
2359
|
+
}
|
|
2360
|
+
// Fallback to element position
|
|
2361
|
+
const elementRect = domElement.getBoundingClientRect();
|
|
2362
|
+
return {
|
|
2363
|
+
top: elementRect.top,
|
|
2364
|
+
left: elementRect.left
|
|
2365
|
+
};
|
|
2366
|
+
}
|
|
2367
|
+
else {
|
|
2368
|
+
// No DOM element found
|
|
2369
|
+
return { top: -1000, left: -1000 };
|
|
2370
|
+
}
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
catch (error) {
|
|
2374
|
+
console.warn('Error calculating cursor position:', error);
|
|
2375
|
+
const editorRect = editorElement.getBoundingClientRect();
|
|
2376
|
+
return {
|
|
2377
|
+
top: editorRect.top + 20,
|
|
2378
|
+
left: editorRect.left + 20
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
}, [getEditorElement, editor]);
|
|
2382
|
+
// Add scroll listener to update cursor positions when page scrolls
|
|
2383
|
+
useEffect(() => {
|
|
2384
|
+
const handleScroll = () => {
|
|
2385
|
+
console.log('🔄 Scroll detected, forcing cursor re-render');
|
|
2386
|
+
setForceUpdate(prev => prev + 1); // Use existing force update mechanism
|
|
2387
|
+
};
|
|
2388
|
+
// Listen to scroll events on window and any scrollable containers
|
|
2389
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
2390
|
+
document.addEventListener('scroll', handleScroll, { passive: true });
|
|
2391
|
+
// Also listen to editor container scroll if it exists
|
|
2392
|
+
const editorElement = getEditorElement();
|
|
2393
|
+
if (editorElement) {
|
|
2394
|
+
const editorContainer = editorElement.closest('.editor-container, .lexical-editor, [data-lexical-editor]');
|
|
2395
|
+
if (editorContainer) {
|
|
2396
|
+
editorContainer.addEventListener('scroll', handleScroll, { passive: true });
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
return () => {
|
|
2400
|
+
window.removeEventListener('scroll', handleScroll);
|
|
2401
|
+
document.removeEventListener('scroll', handleScroll);
|
|
2402
|
+
const editorElement = getEditorElement();
|
|
2403
|
+
if (editorElement) {
|
|
2404
|
+
const editorContainer = editorElement.closest('.editor-container, .lexical-editor, [data-lexical-editor]');
|
|
2405
|
+
if (editorContainer) {
|
|
2406
|
+
editorContainer.removeEventListener('scroll', handleScroll);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
}, [setForceUpdate, getEditorElement]);
|
|
2411
|
+
console.log('🎬 LoroCollaborativePlugin component render called', {
|
|
2412
|
+
remoteCursorsCount: Object.keys(remoteCursors).length,
|
|
2413
|
+
remoteCursorsPeerIds: Object.keys(remoteCursors),
|
|
2414
|
+
clientId: clientId,
|
|
2415
|
+
peerIdRef: peerIdRef.current,
|
|
2416
|
+
editorElementExists: !!getEditorElement()
|
|
2417
|
+
});
|
|
2418
|
+
// Use React portal for cursor rendering
|
|
2419
|
+
return (_jsx(CursorsContainer, { remoteCursors: remoteCursors, getPositionFromLexicalPosition: getPositionFromLexicalPosition, clientId: clientId, editor: editor }));
|
|
2420
|
+
}
|
|
2421
|
+
export default LoroCollaborativePlugin;
|