@lexical/yjs 0.44.1-nightly.20260519.0 → 0.45.1-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/{RenderSnapshot.d.ts → dist/RenderSnapshot.d.ts} +0 -7
  2. package/dist/simpleDiffWithCursor.d.ts +12 -0
  3. package/package.json +31 -16
  4. package/src/Bindings.ts +135 -0
  5. package/src/CollabDecoratorNode.ts +112 -0
  6. package/src/CollabElementNode.ts +696 -0
  7. package/src/CollabLineBreakNode.ts +70 -0
  8. package/src/CollabTextNode.ts +179 -0
  9. package/src/CollabV2Mapping.ts +117 -0
  10. package/src/RenderSnapshot.ts +97 -0
  11. package/src/SyncCursors.ts +823 -0
  12. package/src/SyncEditorStates.ts +469 -0
  13. package/src/SyncV2.ts +871 -0
  14. package/src/Utils.ts +648 -0
  15. package/src/index.ts +151 -0
  16. package/src/simpleDiffWithCursor.ts +49 -0
  17. package/src/types.ts +27 -0
  18. /package/{Bindings.d.ts → dist/Bindings.d.ts} +0 -0
  19. /package/{CollabDecoratorNode.d.ts → dist/CollabDecoratorNode.d.ts} +0 -0
  20. /package/{CollabElementNode.d.ts → dist/CollabElementNode.d.ts} +0 -0
  21. /package/{CollabLineBreakNode.d.ts → dist/CollabLineBreakNode.d.ts} +0 -0
  22. /package/{CollabTextNode.d.ts → dist/CollabTextNode.d.ts} +0 -0
  23. /package/{CollabV2Mapping.d.ts → dist/CollabV2Mapping.d.ts} +0 -0
  24. /package/{LexicalYjs.dev.js → dist/LexicalYjs.dev.js} +0 -0
  25. /package/{LexicalYjs.dev.mjs → dist/LexicalYjs.dev.mjs} +0 -0
  26. /package/{LexicalYjs.js → dist/LexicalYjs.js} +0 -0
  27. /package/{LexicalYjs.js.flow → dist/LexicalYjs.js.flow} +0 -0
  28. /package/{LexicalYjs.mjs → dist/LexicalYjs.mjs} +0 -0
  29. /package/{LexicalYjs.node.mjs → dist/LexicalYjs.node.mjs} +0 -0
  30. /package/{LexicalYjs.prod.js → dist/LexicalYjs.prod.js} +0 -0
  31. /package/{LexicalYjs.prod.mjs → dist/LexicalYjs.prod.mjs} +0 -0
  32. /package/{SyncCursors.d.ts → dist/SyncCursors.d.ts} +0 -0
  33. /package/{SyncEditorStates.d.ts → dist/SyncEditorStates.d.ts} +0 -0
  34. /package/{SyncV2.d.ts → dist/SyncV2.d.ts} +0 -0
  35. /package/{Utils.d.ts → dist/Utils.d.ts} +0 -0
  36. /package/{index.d.ts → dist/index.d.ts} +0 -0
  37. /package/{types.d.ts → dist/types.d.ts} +0 -0
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import type {EditorState, NodeKey} from 'lexical';
10
+ import type {ContentType, Transaction as YTransaction} from 'yjs';
11
+
12
+ import invariant from '@lexical/internal/invariant';
13
+ import {
14
+ $addUpdateTag,
15
+ $createParagraphNode,
16
+ $getNodeByKey,
17
+ $getRoot,
18
+ $getSelection,
19
+ $getWritableNodeState,
20
+ $isRangeSelection,
21
+ $isTextNode,
22
+ COLLABORATION_TAG,
23
+ HISTORIC_TAG,
24
+ SKIP_SCROLL_INTO_VIEW_TAG,
25
+ } from 'lexical';
26
+ import {
27
+ Item,
28
+ iterateDeletedStructs,
29
+ Map as YMap,
30
+ Text as YText,
31
+ XmlElement,
32
+ XmlText,
33
+ YEvent,
34
+ YMapEvent,
35
+ YTextEvent,
36
+ YXmlEvent,
37
+ } from 'yjs';
38
+
39
+ import {Binding, BindingV2, Provider} from '.';
40
+ import {AnyBinding} from './Bindings';
41
+ import {CollabDecoratorNode} from './CollabDecoratorNode';
42
+ import {CollabElementNode} from './CollabElementNode';
43
+ import {CollabTextNode} from './CollabTextNode';
44
+ import {
45
+ $syncLocalCursorPosition,
46
+ syncCursorPositions,
47
+ SyncCursorPositionsFn,
48
+ syncLexicalSelectionToYjs,
49
+ } from './SyncCursors';
50
+ import {$createOrUpdateNodeFromYElement, $updateYFragment} from './SyncV2';
51
+ import {
52
+ $getOrInitCollabNodeFromSharedType,
53
+ $moveSelectionToPreviousNode,
54
+ doesSelectionNeedRecovering,
55
+ getNodeTypeFromSharedType,
56
+ syncWithTransaction,
57
+ } from './Utils';
58
+
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ function $syncStateEvent(binding: Binding, event: YMapEvent<any>): boolean {
61
+ const {target} = event;
62
+ if (
63
+ !(
64
+ target._item &&
65
+ target._item.parentSub === '__state' &&
66
+ getNodeTypeFromSharedType(target) === undefined &&
67
+ (target.parent instanceof XmlText ||
68
+ target.parent instanceof XmlElement ||
69
+ target.parent instanceof YMap)
70
+ )
71
+ ) {
72
+ // TODO there might be a case to handle in here when a YMap
73
+ // is used as a value of __state? It would probably be desirable
74
+ // to mark the node as dirty when that happens.
75
+ return false;
76
+ }
77
+ const collabNode = $getOrInitCollabNodeFromSharedType(binding, target.parent);
78
+ const node = collabNode.getNode();
79
+ if (node) {
80
+ const state = $getWritableNodeState(node.getWritable());
81
+ for (const k of event.keysChanged) {
82
+ state.updateFromUnknown(k, target.get(k));
83
+ }
84
+ }
85
+ return true;
86
+ }
87
+
88
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
89
+ function $syncEvent(binding: Binding, event: any): void {
90
+ if (event instanceof YMapEvent && $syncStateEvent(binding, event)) {
91
+ return;
92
+ }
93
+ const {target} = event;
94
+ const collabNode = $getOrInitCollabNodeFromSharedType(binding, target);
95
+
96
+ if (collabNode instanceof CollabElementNode && event instanceof YTextEvent) {
97
+ // @ts-expect-error We need to access the private childListChanged property of the class
98
+ const {keysChanged, childListChanged, delta} = event;
99
+
100
+ // Update
101
+ if (keysChanged.size > 0) {
102
+ collabNode.syncPropertiesFromYjs(binding, keysChanged);
103
+ }
104
+
105
+ if (childListChanged) {
106
+ collabNode.applyChildrenYjsDelta(binding, delta);
107
+ collabNode.syncChildrenFromYjs(binding);
108
+ }
109
+ } else if (
110
+ collabNode instanceof CollabTextNode &&
111
+ event instanceof YMapEvent
112
+ ) {
113
+ const {keysChanged} = event;
114
+
115
+ // Update
116
+ if (keysChanged.size > 0) {
117
+ collabNode.syncPropertiesAndTextFromYjs(binding, keysChanged);
118
+ }
119
+ } else if (
120
+ collabNode instanceof CollabDecoratorNode &&
121
+ event instanceof YXmlEvent
122
+ ) {
123
+ const {attributesChanged} = event;
124
+
125
+ // Update
126
+ if (attributesChanged.size > 0) {
127
+ collabNode.syncPropertiesFromYjs(binding, attributesChanged);
128
+ }
129
+ } else {
130
+ invariant(false, 'Expected text, element, or decorator event');
131
+ }
132
+ }
133
+
134
+ export function syncYjsChangesToLexical(
135
+ binding: Binding,
136
+ provider: Provider,
137
+ events: Array<YEvent<YText>>,
138
+ isFromUndoManger: boolean,
139
+ syncCursorPositionsFn: SyncCursorPositionsFn = syncCursorPositions,
140
+ ): void {
141
+ const editor = binding.editor;
142
+ const currentEditorState = editor._editorState;
143
+
144
+ // This line precompute the delta before editor update. The reason is
145
+ // delta is computed when it is accessed. Note that this can only be
146
+ // safely computed during the event call. If it is accessed after event
147
+ // call it might result in unexpected behavior.
148
+ // https://github.com/yjs/yjs/blob/00ef472d68545cb260abd35c2de4b3b78719c9e4/src/utils/YEvent.js#L132
149
+ events.forEach(event => event.delta);
150
+
151
+ editor.update(
152
+ () => {
153
+ for (let i = 0; i < events.length; i++) {
154
+ const event = events[i];
155
+ $syncEvent(binding, event);
156
+ }
157
+
158
+ $syncCursorFromYjs(currentEditorState, binding, provider);
159
+
160
+ if (!isFromUndoManger) {
161
+ // If it is an external change, we don't want the current scroll position to get changed
162
+ // since the user might've intentionally scrolled somewhere else in the document.
163
+ $addUpdateTag(SKIP_SCROLL_INTO_VIEW_TAG);
164
+ }
165
+ },
166
+ {
167
+ onUpdate: () => {
168
+ syncCursorPositionsFn(binding, provider);
169
+ editor.update(() => $ensureEditorNotEmpty());
170
+ },
171
+ skipTransforms: true,
172
+ tag: isFromUndoManger ? HISTORIC_TAG : COLLABORATION_TAG,
173
+ },
174
+ );
175
+ }
176
+
177
+ function $syncCursorFromYjs(
178
+ editorState: EditorState,
179
+ binding: AnyBinding,
180
+ provider: Provider,
181
+ ) {
182
+ const selection = $getSelection();
183
+
184
+ if ($isRangeSelection(selection)) {
185
+ if (doesSelectionNeedRecovering(selection)) {
186
+ const prevSelection = editorState._selection;
187
+
188
+ if ($isRangeSelection(prevSelection)) {
189
+ $syncLocalCursorPosition(binding, provider);
190
+ if (doesSelectionNeedRecovering(selection)) {
191
+ // If the selected node is deleted, move the selection to the previous or parent node.
192
+ const anchorNodeKey = selection.anchor.key;
193
+ $moveSelectionToPreviousNode(anchorNodeKey, editorState);
194
+ }
195
+ }
196
+
197
+ syncLexicalSelectionToYjs(
198
+ binding,
199
+ provider,
200
+ prevSelection,
201
+ $getSelection(),
202
+ );
203
+ } else {
204
+ $syncLocalCursorPosition(binding, provider);
205
+ }
206
+ }
207
+ }
208
+
209
+ function $handleNormalizationMergeConflicts(
210
+ binding: Binding,
211
+ normalizedNodes: Set<NodeKey>,
212
+ ): void {
213
+ // We handle the merge operations here
214
+ const normalizedNodesKeys = Array.from(normalizedNodes);
215
+ const collabNodeMap = binding.collabNodeMap;
216
+ const mergedNodes: [CollabTextNode, string][] = [];
217
+ const removedNodes: CollabTextNode[] = [];
218
+
219
+ for (let i = 0; i < normalizedNodesKeys.length; i++) {
220
+ const nodeKey = normalizedNodesKeys[i];
221
+ const lexicalNode = $getNodeByKey(nodeKey);
222
+ const collabNode = collabNodeMap.get(nodeKey);
223
+
224
+ if (collabNode instanceof CollabTextNode) {
225
+ if ($isTextNode(lexicalNode)) {
226
+ // We mutate the text collab nodes after removing
227
+ // all the dead nodes first, otherwise offsets break.
228
+ mergedNodes.push([collabNode, lexicalNode.__text]);
229
+ } else {
230
+ const offset = collabNode.getOffset();
231
+
232
+ if (offset === -1) {
233
+ continue;
234
+ }
235
+
236
+ const parent = collabNode._parent;
237
+ collabNode._normalized = true;
238
+ parent._xmlText.delete(offset, 1);
239
+
240
+ removedNodes.push(collabNode);
241
+ }
242
+ }
243
+ }
244
+
245
+ for (let i = 0; i < removedNodes.length; i++) {
246
+ const collabNode = removedNodes[i];
247
+ const nodeKey = collabNode.getKey();
248
+ collabNodeMap.delete(nodeKey);
249
+ const parentChildren = collabNode._parent._children;
250
+ const index = parentChildren.indexOf(collabNode);
251
+ parentChildren.splice(index, 1);
252
+ }
253
+
254
+ for (let i = 0; i < mergedNodes.length; i++) {
255
+ const [collabNode, text] = mergedNodes[i];
256
+ collabNode._text = text;
257
+ }
258
+ }
259
+
260
+ // If there was a collision on the top level paragraph
261
+ // we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
262
+ // it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
263
+ function $ensureEditorNotEmpty() {
264
+ if ($getRoot().getChildrenSize() === 0) {
265
+ $getRoot().append($createParagraphNode());
266
+ }
267
+ }
268
+
269
+ type IntentionallyMarkedAsDirtyElement = boolean;
270
+
271
+ export function syncLexicalUpdateToYjs(
272
+ binding: Binding,
273
+ provider: Provider,
274
+ prevEditorState: EditorState,
275
+ currEditorState: EditorState,
276
+ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
277
+ dirtyLeaves: Set<NodeKey>,
278
+ normalizedNodes: Set<NodeKey>,
279
+ tags: Set<string>,
280
+ ): void {
281
+ syncWithTransaction(binding, () => {
282
+ currEditorState.read(() => {
283
+ // We check if the update has come from a origin where the origin
284
+ // was the collaboration binding previously. This can help us
285
+ // prevent unnecessarily re-diffing and possible re-applying
286
+ // the same change editor state again. For example, if a user
287
+ // types a character and we get it, we don't want to then insert
288
+ // the same character again. The exception to this heuristic is
289
+ // when we need to handle normalization merge conflicts.
290
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
291
+ if (normalizedNodes.size > 0) {
292
+ $handleNormalizationMergeConflicts(binding, normalizedNodes);
293
+ }
294
+
295
+ return;
296
+ }
297
+
298
+ if (dirtyElements.has('root')) {
299
+ const prevNodeMap = prevEditorState._nodeMap;
300
+ const nextLexicalRoot = $getRoot();
301
+ const collabRoot = binding.root;
302
+ collabRoot.syncPropertiesFromLexical(
303
+ binding,
304
+ nextLexicalRoot,
305
+ prevNodeMap,
306
+ );
307
+ collabRoot.syncChildrenFromLexical(
308
+ binding,
309
+ nextLexicalRoot,
310
+ prevNodeMap,
311
+ dirtyElements,
312
+ dirtyLeaves,
313
+ );
314
+ }
315
+
316
+ const selection = $getSelection();
317
+ const prevSelection = prevEditorState._selection;
318
+ syncLexicalSelectionToYjs(binding, provider, prevSelection, selection);
319
+ });
320
+ });
321
+ }
322
+
323
+ function $syncEventV2(
324
+ binding: BindingV2,
325
+ event: YEvent<XmlElement | XmlText>,
326
+ ): void {
327
+ const {target} = event;
328
+ if (target instanceof XmlElement && event instanceof YXmlEvent) {
329
+ $createOrUpdateNodeFromYElement(
330
+ target,
331
+ binding,
332
+ event.attributesChanged,
333
+ // @ts-expect-error childListChanged is private
334
+ event.childListChanged,
335
+ );
336
+ } else if (target instanceof XmlText && event instanceof YTextEvent) {
337
+ const parent = target.parent;
338
+ if (parent instanceof XmlElement) {
339
+ // Need to sync via parent element in order to attach new next nodes.
340
+ $createOrUpdateNodeFromYElement(parent, binding, new Set(), true);
341
+ } else {
342
+ invariant(false, 'Expected XmlElement parent for XmlText');
343
+ }
344
+ } else {
345
+ invariant(false, 'Expected xml or text event');
346
+ }
347
+ }
348
+
349
+ export function syncYjsChangesToLexicalV2__EXPERIMENTAL(
350
+ binding: BindingV2,
351
+ provider: Provider,
352
+ events: Array<YEvent<XmlElement | XmlText>>,
353
+ transaction: YTransaction,
354
+ isFromUndoManger: boolean,
355
+ ): void {
356
+ const editor = binding.editor;
357
+ const editorState = editor._editorState;
358
+
359
+ // Remove deleted nodes from the mapping
360
+ iterateDeletedStructs(transaction, transaction.deleteSet, struct => {
361
+ if (struct.constructor === Item) {
362
+ const content = struct.content as ContentType;
363
+ const type = content.type;
364
+ if (type) {
365
+ binding.mapping.delete(type as XmlElement | XmlText);
366
+ }
367
+ }
368
+ });
369
+
370
+ // This line precompute the delta before editor update. The reason is
371
+ // delta is computed when it is accessed. Note that this can only be
372
+ // safely computed during the event call. If it is accessed after event
373
+ // call it might result in unexpected behavior.
374
+ // https://github.com/yjs/yjs/blob/00ef472d68545cb260abd35c2de4b3b78719c9e4/src/utils/YEvent.js#L132
375
+ events.forEach(event => event.delta);
376
+
377
+ editor.update(
378
+ () => {
379
+ for (let i = 0; i < events.length; i++) {
380
+ const event = events[i];
381
+ $syncEventV2(binding, event);
382
+ }
383
+
384
+ $syncCursorFromYjs(editorState, binding, provider);
385
+
386
+ if (!isFromUndoManger) {
387
+ // If it is an external change, we don't want the current scroll position to get changed
388
+ // since the user might've intentionally scrolled somewhere else in the document.
389
+ $addUpdateTag(SKIP_SCROLL_INTO_VIEW_TAG);
390
+ }
391
+ },
392
+ {
393
+ // Need any text node normalization to be synchronously updated back to Yjs, otherwise the
394
+ // binding.mapping will get out of sync.
395
+ discrete: true,
396
+ onUpdate: () => {
397
+ syncCursorPositions(binding, provider);
398
+ editor.update(() => $ensureEditorNotEmpty());
399
+ },
400
+ skipTransforms: true,
401
+ tag: isFromUndoManger ? HISTORIC_TAG : COLLABORATION_TAG,
402
+ },
403
+ );
404
+ }
405
+
406
+ export function syncYjsStateToLexicalV2__EXPERIMENTAL(
407
+ binding: BindingV2,
408
+ provider: Provider,
409
+ ) {
410
+ binding.mapping.clear();
411
+ const editor = binding.editor;
412
+ editor.update(
413
+ () => {
414
+ $getRoot().clear();
415
+ $createOrUpdateNodeFromYElement(binding.root, binding, null, true);
416
+ $addUpdateTag(COLLABORATION_TAG);
417
+ },
418
+ {
419
+ // Need any text node normalization to be synchronously updated back to Yjs, otherwise the
420
+ // binding.mapping will get out of sync.
421
+ discrete: true,
422
+ onUpdate: () => {
423
+ syncCursorPositions(binding, provider);
424
+ editor.update(() => $ensureEditorNotEmpty());
425
+ },
426
+ skipTransforms: true,
427
+ tag: COLLABORATION_TAG,
428
+ },
429
+ );
430
+ }
431
+
432
+ export function syncLexicalUpdateToYjsV2__EXPERIMENTAL(
433
+ binding: BindingV2,
434
+ provider: Provider,
435
+ prevEditorState: EditorState,
436
+ currEditorState: EditorState,
437
+ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
438
+ normalizedNodes: Set<NodeKey>,
439
+ tags: Set<string>,
440
+ ): void {
441
+ const isFromYjs = tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG);
442
+ if (isFromYjs && normalizedNodes.size === 0) {
443
+ return;
444
+ }
445
+
446
+ // Nodes are normalized synchronously (`discrete: true` above), so the mapping may now be
447
+ // incorrect for these nodes, as they point to `getLatest` which is mutable within an update.
448
+ normalizedNodes.forEach(nodeKey => {
449
+ binding.mapping.deleteNode(nodeKey);
450
+ });
451
+
452
+ syncWithTransaction(binding, () => {
453
+ currEditorState.read(() => {
454
+ if (dirtyElements.has('root')) {
455
+ $updateYFragment(
456
+ binding.doc,
457
+ binding.root,
458
+ $getRoot(),
459
+ binding,
460
+ new Set(dirtyElements.keys()),
461
+ );
462
+ }
463
+
464
+ const selection = $getSelection();
465
+ const prevSelection = prevEditorState._selection;
466
+ syncLexicalSelectionToYjs(binding, provider, prevSelection, selection);
467
+ });
468
+ });
469
+ }