2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import type {EditorState, NodeKey} from 'lexical';
19 import invariant from 'lexical/shared/invariant';
20 import {Text as YText, YEvent, YMapEvent, YTextEvent, YXmlEvent} from 'yjs';
22 import {Binding, Provider} from '.';
23 import {CollabDecoratorNode} from './CollabDecoratorNode';
24 import {CollabElementNode} from './CollabElementNode';
25 import {CollabTextNode} from './CollabTextNode';
27 $syncLocalCursorPosition,
29 syncLexicalSelectionToYjs,
30 } from './SyncCursors';
32 $getOrInitCollabNodeFromSharedType,
33 $moveSelectionToPreviousNode,
34 doesSelectionNeedRecovering,
38 // eslint-disable-next-line @typescript-eslint/no-explicit-any
39 function $syncEvent(binding: Binding, event: any): void {
40 const {target} = event;
41 const collabNode = $getOrInitCollabNodeFromSharedType(binding, target);
43 if (collabNode instanceof CollabElementNode && event instanceof YTextEvent) {
44 // @ts-expect-error We need to access the private property of the class
45 const {keysChanged, childListChanged, delta} = event;
48 if (keysChanged.size > 0) {
49 collabNode.syncPropertiesFromYjs(binding, keysChanged);
52 if (childListChanged) {
53 collabNode.applyChildrenYjsDelta(binding, delta);
54 collabNode.syncChildrenFromYjs(binding);
57 collabNode instanceof CollabTextNode &&
58 event instanceof YMapEvent
60 const {keysChanged} = event;
63 if (keysChanged.size > 0) {
64 collabNode.syncPropertiesAndTextFromYjs(binding, keysChanged);
67 collabNode instanceof CollabDecoratorNode &&
68 event instanceof YXmlEvent
70 const {attributesChanged} = event;
73 if (attributesChanged.size > 0) {
74 collabNode.syncPropertiesFromYjs(binding, attributesChanged);
77 invariant(false, 'Expected text, element, or decorator event');
81 export function syncYjsChangesToLexical(
84 events: Array<YEvent<YText>>,
85 isFromUndoManger: boolean,
87 const editor = binding.editor;
88 const currentEditorState = editor._editorState;
90 // This line precompute the delta before editor update. The reason is
91 // delta is computed when it is accessed. Note that this can only be
92 // safely computed during the event call. If it is accessed after event
93 // call it might result in unexpected behavior.
94 // https://github.com/yjs/yjs/blob/00ef472d68545cb260abd35c2de4b3b78719c9e4/src/utils/YEvent.js#L132
95 events.forEach((event) => event.delta);
99 for (let i = 0; i < events.length; i++) {
100 const event = events[i];
101 $syncEvent(binding, event);
104 const selection = $getSelection();
106 if ($isRangeSelection(selection)) {
107 if (doesSelectionNeedRecovering(selection)) {
108 const prevSelection = currentEditorState._selection;
110 if ($isRangeSelection(prevSelection)) {
111 $syncLocalCursorPosition(binding, provider);
112 if (doesSelectionNeedRecovering(selection)) {
113 // If the selected node is deleted, move the selection to the previous or parent node.
114 const anchorNodeKey = selection.anchor.key;
115 $moveSelectionToPreviousNode(anchorNodeKey, currentEditorState);
119 syncLexicalSelectionToYjs(
126 $syncLocalCursorPosition(binding, provider);
132 syncCursorPositions(binding, provider);
133 // If there was a collision on the top level paragraph
134 // we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
135 // it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
136 editor.update(() => {
137 if ($getRoot().getChildrenSize() === 0) {
138 $getRoot().append($createParagraphNode());
142 skipTransforms: true,
143 tag: isFromUndoManger ? 'historic' : 'collaboration',
148 function $handleNormalizationMergeConflicts(
150 normalizedNodes: Set<NodeKey>,
152 // We handle the merge operations here
153 const normalizedNodesKeys = Array.from(normalizedNodes);
154 const collabNodeMap = binding.collabNodeMap;
155 const mergedNodes = [];
157 for (let i = 0; i < normalizedNodesKeys.length; i++) {
158 const nodeKey = normalizedNodesKeys[i];
159 const lexicalNode = $getNodeByKey(nodeKey);
160 const collabNode = collabNodeMap.get(nodeKey);
162 if (collabNode instanceof CollabTextNode) {
163 if ($isTextNode(lexicalNode)) {
164 // We mutate the text collab nodes after removing
165 // all the dead nodes first, otherwise offsets break.
166 mergedNodes.push([collabNode, lexicalNode.__text]);
168 const offset = collabNode.getOffset();
174 const parent = collabNode._parent;
175 collabNode._normalized = true;
177 parent._xmlText.delete(offset, 1);
179 collabNodeMap.delete(nodeKey);
180 const parentChildren = parent._children;
181 const index = parentChildren.indexOf(collabNode);
182 parentChildren.splice(index, 1);
187 for (let i = 0; i < mergedNodes.length; i++) {
188 const [collabNode, text] = mergedNodes[i];
189 if (collabNode instanceof CollabTextNode && typeof text === 'string') {
190 collabNode._text = text;
195 type IntentionallyMarkedAsDirtyElement = boolean;
197 export function syncLexicalUpdateToYjs(
200 prevEditorState: EditorState,
201 currEditorState: EditorState,
202 dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
203 dirtyLeaves: Set<NodeKey>,
204 normalizedNodes: Set<NodeKey>,
207 syncWithTransaction(binding, () => {
208 currEditorState.read(() => {
209 // We check if the update has come from a origin where the origin
210 // was the collaboration binding previously. This can help us
211 // prevent unnecessarily re-diffing and possible re-applying
212 // the same change editor state again. For example, if a user
213 // types a character and we get it, we don't want to then insert
214 // the same character again. The exception to this heuristic is
215 // when we need to handle normalization merge conflicts.
216 if (tags.has('collaboration') || tags.has('historic')) {
217 if (normalizedNodes.size > 0) {
218 $handleNormalizationMergeConflicts(binding, normalizedNodes);
224 if (dirtyElements.has('root')) {
225 const prevNodeMap = prevEditorState._nodeMap;
226 const nextLexicalRoot = $getRoot();
227 const collabRoot = binding.root;
228 collabRoot.syncPropertiesFromLexical(
233 collabRoot.syncChildrenFromLexical(
242 const selection = $getSelection();
243 const prevSelection = prevEditorState._selection;
244 syncLexicalSelectionToYjs(binding, provider, prevSelection, selection);