@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.
@@ -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;