@datalayer/lexical-loro 0.0.3 → 0.0.7

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,39 @@
1
+ import { type LexicalEditor } from 'lexical';
2
+ /**
3
+ * DiffMerge System for Lexical Editor
4
+ *
5
+ * This module provides sophisticated differential updates for Lexical editor states,
6
+ * preventing wholesale state replacement that would destroy React decorator nodes
7
+ * like YouTube embeds, counters, and other custom components.
8
+ *
9
+ * Key Features:
10
+ * - Selective node updates (only changed content)
11
+ * - Decorator node preservation (YouTube, Counter nodes remain untouched)
12
+ * - Table structure support with cell-level updates
13
+ * - Graceful fallback for unsupported operations
14
+ * - Deep content comparison to minimize unnecessary updates
15
+ *
16
+ * Usage:
17
+ * ```typescript
18
+ * const success = applyDifferentialUpdate(editor, newState, 'collaboration');
19
+ * if (!success) {
20
+ * // Fall back to setEditorState if differential update fails
21
+ * editor.setEditorState(newState);
22
+ * }
23
+ * ```
24
+ */
25
+ interface EditorStateData {
26
+ root: {
27
+ type: 'root';
28
+ children: any[];
29
+ direction?: string | null;
30
+ format?: number;
31
+ indent?: number;
32
+ version?: number;
33
+ };
34
+ }
35
+ /**
36
+ * Main function to apply differential updates to the editor
37
+ */
38
+ export declare function applyDifferentialUpdate(editor: LexicalEditor, newStateData: EditorStateData | any, source?: string): boolean;
39
+ export {};
@@ -0,0 +1,437 @@
1
+ /*
2
+ * Copyright (c) 2023-2025 Datalayer, Inc.
3
+ * Distributed under the terms of the MIT License.
4
+ */
5
+ import { $getRoot, $createParagraphNode, $createTextNode, $createLineBreakNode, $isElementNode, } from 'lexical';
6
+ import { $createHeadingNode, } from '@lexical/rich-text';
7
+ import { $createCodeNode, } from '@lexical/code';
8
+ import { $createTableNode, $createTableRowNode, $createTableCellNode, } from '@lexical/table';
9
+ /**
10
+ * Checks if a node type can be created/handled by the differential update system
11
+ */
12
+ function canCreateNodeType(type) {
13
+ const supportedTypes = new Set([
14
+ 'root',
15
+ 'paragraph',
16
+ 'text',
17
+ 'linebreak',
18
+ 'heading',
19
+ 'code',
20
+ 'table',
21
+ 'tablerow',
22
+ 'tablecell'
23
+ ]);
24
+ return supportedTypes.has(type);
25
+ }
26
+ /**
27
+ * Checks if a node is a decorator node that should be preserved
28
+ */
29
+ function isDecoratorNodeType(type) {
30
+ const decoratorTypes = new Set([
31
+ 'youtube',
32
+ 'counter',
33
+ 'image',
34
+ 'tweet',
35
+ 'figma',
36
+ 'poll',
37
+ 'sticky'
38
+ ]);
39
+ return decoratorTypes.has(type.toLowerCase());
40
+ }
41
+ /**
42
+ * Creates a Lexical node from serialized data
43
+ */
44
+ function createNodeFromData(data) {
45
+ const type = data.type;
46
+ if (!canCreateNodeType(type)) {
47
+ console.warn(`⚠️ Cannot create node of type: ${type}`);
48
+ return null;
49
+ }
50
+ try {
51
+ switch (type) {
52
+ case 'paragraph': {
53
+ const node = $createParagraphNode();
54
+ if (data.format !== undefined)
55
+ node.setFormat(data.format);
56
+ if (data.indent !== undefined)
57
+ node.setIndent(data.indent);
58
+ if (data.direction !== undefined)
59
+ node.setDirection(data.direction);
60
+ return node;
61
+ }
62
+ case 'heading': {
63
+ const node = $createHeadingNode(data.tag || 'h1');
64
+ if (data.format !== undefined)
65
+ node.setFormat(data.format);
66
+ if (data.indent !== undefined)
67
+ node.setIndent(data.indent);
68
+ if (data.direction !== undefined)
69
+ node.setDirection(data.direction);
70
+ return node;
71
+ }
72
+ case 'code': {
73
+ const node = $createCodeNode(data.language);
74
+ if (data.format !== undefined)
75
+ node.setFormat(data.format);
76
+ if (data.indent !== undefined)
77
+ node.setIndent(data.indent);
78
+ if (data.direction !== undefined)
79
+ node.setDirection(data.direction);
80
+ return node;
81
+ }
82
+ case 'text': {
83
+ const node = $createTextNode(data.text || '');
84
+ if (data.format !== undefined)
85
+ node.setFormat(data.format);
86
+ if (data.style !== undefined)
87
+ node.setStyle(data.style);
88
+ if (data.mode !== undefined)
89
+ node.setMode(data.mode);
90
+ if (data.detail !== undefined)
91
+ node.setDetail(data.detail);
92
+ return node;
93
+ }
94
+ case 'linebreak': {
95
+ return $createLineBreakNode();
96
+ }
97
+ case 'table': {
98
+ return $createTableNode();
99
+ }
100
+ case 'tablerow': {
101
+ const node = $createTableRowNode();
102
+ if (data.height !== undefined)
103
+ node.setHeight(data.height);
104
+ return node;
105
+ }
106
+ case 'tablecell': {
107
+ const node = $createTableCellNode(data.headerState || 0, data.colSpan || 1, data.width || undefined);
108
+ if (data.rowSpan !== undefined)
109
+ node.setRowSpan(data.rowSpan);
110
+ if (data.backgroundColor !== undefined)
111
+ node.setBackgroundColor(data.backgroundColor);
112
+ return node;
113
+ }
114
+ default:
115
+ console.warn(`⚠️ Unsupported node type for creation: ${type}`);
116
+ return null;
117
+ }
118
+ }
119
+ catch (error) {
120
+ console.error(`❌ Error creating node of type ${type}:`, error);
121
+ return null;
122
+ }
123
+ }
124
+ /**
125
+ * Deep comparison of node content to determine if update is needed
126
+ */
127
+ function nodesHaveSameContent(existing, newData) {
128
+ const existingType = existing.getType();
129
+ const newType = newData.type;
130
+ if (existingType !== newType) {
131
+ return false;
132
+ }
133
+ try {
134
+ switch (existingType) {
135
+ case 'text': {
136
+ const textNode = existing;
137
+ return textNode.getTextContent() === (newData.text || '') &&
138
+ textNode.getFormat() === (newData.format || 0) &&
139
+ textNode.getStyle() === (newData.style || '') &&
140
+ textNode.getMode() === (newData.mode || 0) &&
141
+ textNode.getDetail() === (newData.detail || 0);
142
+ }
143
+ case 'paragraph': {
144
+ const elementNode = existing;
145
+ return elementNode.getFormat() === (newData.format || 0) &&
146
+ elementNode.getIndent() === (newData.indent || 0) &&
147
+ elementNode.getDirection() === newData.direction;
148
+ }
149
+ case 'heading': {
150
+ const headingNode = existing;
151
+ return headingNode.getTag() === (newData.tag || 'h1') &&
152
+ headingNode.getFormat() === (newData.format || 0) &&
153
+ headingNode.getIndent() === (newData.indent || 0) &&
154
+ headingNode.getDirection() === newData.direction;
155
+ }
156
+ case 'code': {
157
+ const codeNode = existing;
158
+ return codeNode.getLanguage() === newData.language &&
159
+ codeNode.getFormat() === (newData.format || 0) &&
160
+ codeNode.getIndent() === (newData.indent || 0) &&
161
+ codeNode.getDirection() === newData.direction;
162
+ }
163
+ case 'table':
164
+ case 'tablerow':
165
+ case 'tablecell':
166
+ // For table nodes, we primarily care about structure, which is handled by child comparison
167
+ return true;
168
+ case 'linebreak':
169
+ return true; // Line breaks are always the same
170
+ default:
171
+ // For unknown types, assume they need updating
172
+ return false;
173
+ }
174
+ }
175
+ catch (error) {
176
+ console.error(`❌ Error comparing nodes of type ${existingType}:`, error);
177
+ return false;
178
+ }
179
+ }
180
+ /**
181
+ * Updates properties of an existing node from new data
182
+ */
183
+ function updateNodeFromData(existing, newData) {
184
+ const nodeType = existing.getType();
185
+ try {
186
+ switch (nodeType) {
187
+ case 'text': {
188
+ const textNode = existing;
189
+ const textChanged = textNode.getTextContent() !== (newData.text || '');
190
+ const formatChanged = textNode.getFormat() !== (newData.format || 0);
191
+ const styleChanged = textNode.getStyle() !== (newData.style || '');
192
+ const modeChanged = textNode.getMode() !== (newData.mode || 0);
193
+ const detailChanged = textNode.getDetail() !== (newData.detail || 0);
194
+ if (textChanged)
195
+ textNode.setTextContent(newData.text || '');
196
+ if (formatChanged)
197
+ textNode.setFormat(newData.format || 0);
198
+ if (styleChanged)
199
+ textNode.setStyle(newData.style || '');
200
+ if (modeChanged)
201
+ textNode.setMode(newData.mode || 0);
202
+ if (detailChanged)
203
+ textNode.setDetail(newData.detail || 0);
204
+ return textChanged || formatChanged || styleChanged || modeChanged || detailChanged;
205
+ }
206
+ case 'paragraph': {
207
+ const elementNode = existing;
208
+ const formatChanged = elementNode.getFormat() !== (newData.format || 0);
209
+ const indentChanged = elementNode.getIndent() !== (newData.indent || 0);
210
+ const directionChanged = elementNode.getDirection() !== newData.direction;
211
+ if (formatChanged)
212
+ elementNode.setFormat(newData.format || 0);
213
+ if (indentChanged)
214
+ elementNode.setIndent(newData.indent || 0);
215
+ if (directionChanged)
216
+ elementNode.setDirection(newData.direction);
217
+ return formatChanged || indentChanged || directionChanged;
218
+ }
219
+ case 'heading': {
220
+ const headingNode = existing;
221
+ const tagChanged = headingNode.getTag() !== (newData.tag || 'h1');
222
+ const formatChanged = headingNode.getFormat() !== (newData.format || 0);
223
+ const indentChanged = headingNode.getIndent() !== (newData.indent || 0);
224
+ const directionChanged = headingNode.getDirection() !== newData.direction;
225
+ if (tagChanged)
226
+ headingNode.setTag(newData.tag || 'h1');
227
+ if (formatChanged)
228
+ headingNode.setFormat(newData.format || 0);
229
+ if (indentChanged)
230
+ headingNode.setIndent(newData.indent || 0);
231
+ if (directionChanged)
232
+ headingNode.setDirection(newData.direction);
233
+ return tagChanged || formatChanged || indentChanged || directionChanged;
234
+ }
235
+ case 'code': {
236
+ const codeNode = existing;
237
+ const languageChanged = codeNode.getLanguage() !== newData.language;
238
+ const formatChanged = codeNode.getFormat() !== (newData.format || 0);
239
+ const indentChanged = codeNode.getIndent() !== (newData.indent || 0);
240
+ const directionChanged = codeNode.getDirection() !== newData.direction;
241
+ if (languageChanged)
242
+ codeNode.setLanguage(newData.language);
243
+ if (formatChanged)
244
+ codeNode.setFormat(newData.format || 0);
245
+ if (indentChanged)
246
+ codeNode.setIndent(newData.indent || 0);
247
+ if (directionChanged)
248
+ codeNode.setDirection(newData.direction);
249
+ return languageChanged || formatChanged || indentChanged || directionChanged;
250
+ }
251
+ case 'tablecell': {
252
+ const cellNode = existing;
253
+ let changed = false;
254
+ if (newData.rowSpan !== undefined && cellNode.getRowSpan() !== newData.rowSpan) {
255
+ cellNode.setRowSpan(newData.rowSpan);
256
+ changed = true;
257
+ }
258
+ if (newData.backgroundColor !== undefined && cellNode.getBackgroundColor() !== newData.backgroundColor) {
259
+ cellNode.setBackgroundColor(newData.backgroundColor);
260
+ changed = true;
261
+ }
262
+ return changed;
263
+ }
264
+ case 'tablerow': {
265
+ const rowNode = existing;
266
+ if (newData.height !== undefined && rowNode.getHeight() !== newData.height) {
267
+ rowNode.setHeight(newData.height);
268
+ return true;
269
+ }
270
+ return false;
271
+ }
272
+ default:
273
+ return false;
274
+ }
275
+ }
276
+ catch (error) {
277
+ console.error(`❌ Error updating node of type ${nodeType}:`, error);
278
+ return false;
279
+ }
280
+ }
281
+ /**
282
+ * Recursively merges children, preserving decorator nodes
283
+ */
284
+ function mergeChildren(parent, newChildren) {
285
+ const existingChildren = parent.getChildren();
286
+ let hasChanges = false;
287
+ // Track which existing children to keep
288
+ const childrenToKeep = [];
289
+ const decoratorNodes = [];
290
+ // First pass: identify decorator nodes to preserve
291
+ existingChildren.forEach(child => {
292
+ if (isDecoratorNodeType(child.getType())) {
293
+ decoratorNodes.push(child);
294
+ console.log(`🔒 Preserving decorator node: ${child.getType()}`);
295
+ }
296
+ });
297
+ // Second pass: process new children and match with existing
298
+ for (let i = 0; i < newChildren.length; i++) {
299
+ const newChildData = newChildren[i];
300
+ const newChildType = newChildData.type;
301
+ // Skip unsupported node types
302
+ if (!canCreateNodeType(newChildType)) {
303
+ console.warn(`⚠️ Skipping unsupported node type: ${newChildType}`);
304
+ continue;
305
+ }
306
+ // Find matching existing child at this position
307
+ const existingChild = existingChildren[i];
308
+ if (existingChild && existingChild.getType() === newChildType) {
309
+ // Same type at same position - try to update in place
310
+ const contentSame = nodesHaveSameContent(existingChild, newChildData);
311
+ if (!contentSame) {
312
+ // Update the existing node's properties
313
+ const updated = updateNodeFromData(existingChild, newChildData);
314
+ if (updated) {
315
+ hasChanges = true;
316
+ }
317
+ }
318
+ // Handle children recursively for element nodes
319
+ if ($isElementNode(existingChild) && newChildData.children) {
320
+ const childrenChanged = mergeChildren(existingChild, newChildData.children);
321
+ if (childrenChanged) {
322
+ hasChanges = true;
323
+ }
324
+ }
325
+ childrenToKeep.push(existingChild);
326
+ }
327
+ else {
328
+ // Different type or no existing child - create new node
329
+ const newNode = createNodeFromData(newChildData);
330
+ if (newNode) {
331
+ // Add children if it's an element node
332
+ if ($isElementNode(newNode) && newChildData.children) {
333
+ mergeChildren(newNode, newChildData.children);
334
+ }
335
+ childrenToKeep.push(newNode);
336
+ hasChanges = true;
337
+ }
338
+ }
339
+ }
340
+ // Third pass: re-add preserved decorator nodes at the end
341
+ decoratorNodes.forEach(decoratorNode => {
342
+ childrenToKeep.push(decoratorNode);
343
+ });
344
+ // Update parent's children if there are changes
345
+ if (hasChanges || existingChildren.length !== childrenToKeep.length) {
346
+ // Clear and re-add all children
347
+ existingChildren.forEach(child => {
348
+ if (!childrenToKeep.includes(child)) {
349
+ child.remove();
350
+ }
351
+ });
352
+ // Append new/updated children in order
353
+ childrenToKeep.forEach(child => {
354
+ if (!child.getParent()) {
355
+ parent.append(child);
356
+ }
357
+ });
358
+ return true;
359
+ }
360
+ return hasChanges;
361
+ }
362
+ /**
363
+ * Main function to apply differential updates to the editor
364
+ */
365
+ export function applyDifferentialUpdate(editor, newStateData, source = 'unknown') {
366
+ try {
367
+ // Validate input format
368
+ if (!newStateData || typeof newStateData !== 'object') {
369
+ console.warn('⚠️ Invalid state data for differential update');
370
+ return false;
371
+ }
372
+ // Handle different input formats
373
+ let rootData;
374
+ if (newStateData.root) {
375
+ rootData = newStateData.root;
376
+ }
377
+ else if (newStateData.type === 'root') {
378
+ rootData = newStateData;
379
+ }
380
+ else {
381
+ console.warn('⚠️ Invalid root structure for differential update');
382
+ return false;
383
+ }
384
+ if (!rootData.children || !Array.isArray(rootData.children)) {
385
+ console.warn('⚠️ Invalid children structure for differential update');
386
+ return false;
387
+ }
388
+ // Check if we can handle all node types in the new state
389
+ const unsupportedTypes = new Set();
390
+ function checkNodeSupport(nodeData) {
391
+ const type = nodeData.type;
392
+ if (!canCreateNodeType(type) && !isDecoratorNodeType(type)) {
393
+ unsupportedTypes.add(type);
394
+ return false;
395
+ }
396
+ if (nodeData.children && Array.isArray(nodeData.children)) {
397
+ return nodeData.children.every(checkNodeSupport);
398
+ }
399
+ return true;
400
+ }
401
+ const allSupported = rootData.children.every(checkNodeSupport);
402
+ if (!allSupported) {
403
+ console.warn(`⚠️ Unsupported node types detected: ${Array.from(unsupportedTypes).join(', ')}`);
404
+ console.warn('⚠️ Falling back to setEditorState for full compatibility');
405
+ return false;
406
+ }
407
+ // Apply differential update within editor transaction
408
+ let updateSuccess = false;
409
+ editor.update(() => {
410
+ const root = $getRoot();
411
+ // Update root properties if they differ
412
+ if (rootData.direction !== undefined && root.getDirection() !== rootData.direction) {
413
+ root.setDirection(rootData.direction);
414
+ }
415
+ if (rootData.format !== undefined && root.getFormat() !== rootData.format) {
416
+ root.setFormat(rootData.format);
417
+ }
418
+ if (rootData.indent !== undefined && root.getIndent() !== rootData.indent) {
419
+ root.setIndent(rootData.indent);
420
+ }
421
+ // Merge children with preservation of decorator nodes
422
+ const hasChanges = mergeChildren(root, rootData.children);
423
+ if (hasChanges) {
424
+ console.log(`✅ Applied differential update from ${source}`);
425
+ }
426
+ else {
427
+ console.log(`ℹ️ No changes needed for update from ${source}`);
428
+ }
429
+ updateSuccess = true;
430
+ }, { tag: `diff-merge-${source}` });
431
+ return updateSuccess;
432
+ }
433
+ catch (error) {
434
+ console.error(`❌ Error in differential update from ${source}:`, error);
435
+ return false;
436
+ }
437
+ }
@@ -12,5 +12,51 @@ interface LoroCollaborativePluginProps {
12
12
  onInitialization?: (success: boolean) => void;
13
13
  onSendMessageReady?: (sendMessageFn: (message: any) => void) => void;
14
14
  }
15
+ /**
16
+ * LoroCollaborativePlugin - Enhanced Cursor Management
17
+ *
18
+ * IMPROVEMENTS IMPLEMENTED based on Loro Cursor documentation and YJS SyncCursors patterns:
19
+ *
20
+ * 1. Enhanced CursorAwareness class with Loro document reference
21
+ * - Added loroDoc parameter for proper cursor operations
22
+ * - Provides framework for stable cursor positioning
23
+ *
24
+ * 2. Added createCursorFromLexicalPoint method
25
+ * - Inspired by YJS SyncCursors createRelativePosition pattern
26
+ * - Creates stable Loro cursors from Lexical selection points
27
+ * - Replaces approximation with proper cursor positioning
28
+ *
29
+ * 3. Added getStableCursorPosition method
30
+ * - Inspired by YJS SyncCursors createAbsolutePosition pattern
31
+ * - Converts Loro cursors back to stable positions
32
+ * - Provides better positioning than current approximations
33
+ *
34
+ * 4. Enhanced cursor side information support
35
+ * - Added anchorSide and focusSide to stable cursor data
36
+ * - Follows Loro Cursor documentation patterns for precise positioning
37
+ * - Equivalent to YJS RelativePosition side information
38
+ *
39
+ * 5. Improved cursor creation with framework for better methods
40
+ * - Added TODO comments showing enhanced cursor creation approach
41
+ * - Framework ready for using createCursorFromLexicalPoint
42
+ * - Maintains backward compatibility while providing upgrade path
43
+ *
44
+ * 6. Enhanced remote cursor processing
45
+ * - Added support for cursor side information in stable cursor data
46
+ * - Provides framework for direct Loro cursor conversion
47
+ * - Better handling of cursor position stability across edits
48
+ *
49
+ * TECHNICAL APPROACH:
50
+ * - Loro Cursor type is equivalent to YJS RelativePosition (as documented)
51
+ * - Stable positions survive document edits (like YJS RelativePosition)
52
+ * - Cursor side information provides precise positioning
53
+ * - Framework supports proper createRelativePosition/createAbsolutePosition patterns
54
+ *
55
+ * NEXT STEPS for full implementation:
56
+ * - Implement calculateGlobalPosition method with proper document traversal
57
+ * - Add convertGlobalPositionToLexical helper function
58
+ * - Enable the enhanced cursor creation methods by uncommenting TODO sections
59
+ * - Complete the direct Loro cursor conversion path
60
+ */
15
61
  export declare function LoroCollaborativePlugin({ websocketUrl, docId, onConnectionChange, onDisconnectReady, onPeerIdChange, onAwarenessChange, onInitialization, onSendMessageReady }: LoroCollaborativePluginProps): import("react/jsx-runtime").JSX.Element;
16
62
  export default LoroCollaborativePlugin;