248 lines
		
	
	
		
			7.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			248 lines
		
	
	
		
			7.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
/**
 | 
						|
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 | 
						|
 *
 | 
						|
 * This source code is licensed under the MIT license found in the
 | 
						|
 * LICENSE file in the root directory of this source tree.
 | 
						|
 *
 | 
						|
 */
 | 
						|
 | 
						|
import type {EditorState, NodeKey} from 'lexical';
 | 
						|
 | 
						|
import {
 | 
						|
  $createParagraphNode,
 | 
						|
  $getNodeByKey,
 | 
						|
  $getRoot,
 | 
						|
  $getSelection,
 | 
						|
  $isRangeSelection,
 | 
						|
  $isTextNode,
 | 
						|
} from 'lexical';
 | 
						|
import invariant from 'lexical/shared/invariant';
 | 
						|
import {Text as YText, YEvent, YMapEvent, YTextEvent, YXmlEvent} from 'yjs';
 | 
						|
 | 
						|
import {Binding, Provider} from '.';
 | 
						|
import {CollabDecoratorNode} from './CollabDecoratorNode';
 | 
						|
import {CollabElementNode} from './CollabElementNode';
 | 
						|
import {CollabTextNode} from './CollabTextNode';
 | 
						|
import {
 | 
						|
  $syncLocalCursorPosition,
 | 
						|
  syncCursorPositions,
 | 
						|
  syncLexicalSelectionToYjs,
 | 
						|
} from './SyncCursors';
 | 
						|
import {
 | 
						|
  $getOrInitCollabNodeFromSharedType,
 | 
						|
  $moveSelectionToPreviousNode,
 | 
						|
  doesSelectionNeedRecovering,
 | 
						|
  syncWithTransaction,
 | 
						|
} from './Utils';
 | 
						|
 | 
						|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
						|
function $syncEvent(binding: Binding, event: any): void {
 | 
						|
  const {target} = event;
 | 
						|
  const collabNode = $getOrInitCollabNodeFromSharedType(binding, target);
 | 
						|
 | 
						|
  if (collabNode instanceof CollabElementNode && event instanceof YTextEvent) {
 | 
						|
    // @ts-expect-error We need to access the private property of the class
 | 
						|
    const {keysChanged, childListChanged, delta} = event;
 | 
						|
 | 
						|
    // Update
 | 
						|
    if (keysChanged.size > 0) {
 | 
						|
      collabNode.syncPropertiesFromYjs(binding, keysChanged);
 | 
						|
    }
 | 
						|
 | 
						|
    if (childListChanged) {
 | 
						|
      collabNode.applyChildrenYjsDelta(binding, delta);
 | 
						|
      collabNode.syncChildrenFromYjs(binding);
 | 
						|
    }
 | 
						|
  } else if (
 | 
						|
    collabNode instanceof CollabTextNode &&
 | 
						|
    event instanceof YMapEvent
 | 
						|
  ) {
 | 
						|
    const {keysChanged} = event;
 | 
						|
 | 
						|
    // Update
 | 
						|
    if (keysChanged.size > 0) {
 | 
						|
      collabNode.syncPropertiesAndTextFromYjs(binding, keysChanged);
 | 
						|
    }
 | 
						|
  } else if (
 | 
						|
    collabNode instanceof CollabDecoratorNode &&
 | 
						|
    event instanceof YXmlEvent
 | 
						|
  ) {
 | 
						|
    const {attributesChanged} = event;
 | 
						|
 | 
						|
    // Update
 | 
						|
    if (attributesChanged.size > 0) {
 | 
						|
      collabNode.syncPropertiesFromYjs(binding, attributesChanged);
 | 
						|
    }
 | 
						|
  } else {
 | 
						|
    invariant(false, 'Expected text, element, or decorator event');
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
export function syncYjsChangesToLexical(
 | 
						|
  binding: Binding,
 | 
						|
  provider: Provider,
 | 
						|
  events: Array<YEvent<YText>>,
 | 
						|
  isFromUndoManger: boolean,
 | 
						|
): void {
 | 
						|
  const editor = binding.editor;
 | 
						|
  const currentEditorState = editor._editorState;
 | 
						|
 | 
						|
  // This line precompute the delta before editor update. The reason is
 | 
						|
  // delta is computed when it is accessed. Note that this can only be
 | 
						|
  // safely computed during the event call. If it is accessed after event
 | 
						|
  // call it might result in unexpected behavior.
 | 
						|
  // https://github.com/yjs/yjs/blob/00ef472d68545cb260abd35c2de4b3b78719c9e4/src/utils/YEvent.js#L132
 | 
						|
  events.forEach((event) => event.delta);
 | 
						|
 | 
						|
  editor.update(
 | 
						|
    () => {
 | 
						|
      for (let i = 0; i < events.length; i++) {
 | 
						|
        const event = events[i];
 | 
						|
        $syncEvent(binding, event);
 | 
						|
      }
 | 
						|
 | 
						|
      const selection = $getSelection();
 | 
						|
 | 
						|
      if ($isRangeSelection(selection)) {
 | 
						|
        if (doesSelectionNeedRecovering(selection)) {
 | 
						|
          const prevSelection = currentEditorState._selection;
 | 
						|
 | 
						|
          if ($isRangeSelection(prevSelection)) {
 | 
						|
            $syncLocalCursorPosition(binding, provider);
 | 
						|
            if (doesSelectionNeedRecovering(selection)) {
 | 
						|
              // If the selected node is deleted, move the selection to the previous or parent node.
 | 
						|
              const anchorNodeKey = selection.anchor.key;
 | 
						|
              $moveSelectionToPreviousNode(anchorNodeKey, currentEditorState);
 | 
						|
            }
 | 
						|
          }
 | 
						|
 | 
						|
          syncLexicalSelectionToYjs(
 | 
						|
            binding,
 | 
						|
            provider,
 | 
						|
            prevSelection,
 | 
						|
            $getSelection(),
 | 
						|
          );
 | 
						|
        } else {
 | 
						|
          $syncLocalCursorPosition(binding, provider);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    },
 | 
						|
    {
 | 
						|
      onUpdate: () => {
 | 
						|
        syncCursorPositions(binding, provider);
 | 
						|
        // If there was a collision on the top level paragraph
 | 
						|
        // we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
 | 
						|
        // it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
 | 
						|
        editor.update(() => {
 | 
						|
          if ($getRoot().getChildrenSize() === 0) {
 | 
						|
            $getRoot().append($createParagraphNode());
 | 
						|
          }
 | 
						|
        });
 | 
						|
      },
 | 
						|
      skipTransforms: true,
 | 
						|
      tag: isFromUndoManger ? 'historic' : 'collaboration',
 | 
						|
    },
 | 
						|
  );
 | 
						|
}
 | 
						|
 | 
						|
function $handleNormalizationMergeConflicts(
 | 
						|
  binding: Binding,
 | 
						|
  normalizedNodes: Set<NodeKey>,
 | 
						|
): void {
 | 
						|
  // We handle the merge operations here
 | 
						|
  const normalizedNodesKeys = Array.from(normalizedNodes);
 | 
						|
  const collabNodeMap = binding.collabNodeMap;
 | 
						|
  const mergedNodes = [];
 | 
						|
 | 
						|
  for (let i = 0; i < normalizedNodesKeys.length; i++) {
 | 
						|
    const nodeKey = normalizedNodesKeys[i];
 | 
						|
    const lexicalNode = $getNodeByKey(nodeKey);
 | 
						|
    const collabNode = collabNodeMap.get(nodeKey);
 | 
						|
 | 
						|
    if (collabNode instanceof CollabTextNode) {
 | 
						|
      if ($isTextNode(lexicalNode)) {
 | 
						|
        // We mutate the text collab nodes after removing
 | 
						|
        // all the dead nodes first, otherwise offsets break.
 | 
						|
        mergedNodes.push([collabNode, lexicalNode.__text]);
 | 
						|
      } else {
 | 
						|
        const offset = collabNode.getOffset();
 | 
						|
 | 
						|
        if (offset === -1) {
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
 | 
						|
        const parent = collabNode._parent;
 | 
						|
        collabNode._normalized = true;
 | 
						|
 | 
						|
        parent._xmlText.delete(offset, 1);
 | 
						|
 | 
						|
        collabNodeMap.delete(nodeKey);
 | 
						|
        const parentChildren = parent._children;
 | 
						|
        const index = parentChildren.indexOf(collabNode);
 | 
						|
        parentChildren.splice(index, 1);
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  for (let i = 0; i < mergedNodes.length; i++) {
 | 
						|
    const [collabNode, text] = mergedNodes[i];
 | 
						|
    if (collabNode instanceof CollabTextNode && typeof text === 'string') {
 | 
						|
      collabNode._text = text;
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
type IntentionallyMarkedAsDirtyElement = boolean;
 | 
						|
 | 
						|
export function syncLexicalUpdateToYjs(
 | 
						|
  binding: Binding,
 | 
						|
  provider: Provider,
 | 
						|
  prevEditorState: EditorState,
 | 
						|
  currEditorState: EditorState,
 | 
						|
  dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
 | 
						|
  dirtyLeaves: Set<NodeKey>,
 | 
						|
  normalizedNodes: Set<NodeKey>,
 | 
						|
  tags: Set<string>,
 | 
						|
): void {
 | 
						|
  syncWithTransaction(binding, () => {
 | 
						|
    currEditorState.read(() => {
 | 
						|
      // We check if the update has come from a origin where the origin
 | 
						|
      // was the collaboration binding previously. This can help us
 | 
						|
      // prevent unnecessarily re-diffing and possible re-applying
 | 
						|
      // the same change editor state again. For example, if a user
 | 
						|
      // types a character and we get it, we don't want to then insert
 | 
						|
      // the same character again. The exception to this heuristic is
 | 
						|
      // when we need to handle normalization merge conflicts.
 | 
						|
      if (tags.has('collaboration') || tags.has('historic')) {
 | 
						|
        if (normalizedNodes.size > 0) {
 | 
						|
          $handleNormalizationMergeConflicts(binding, normalizedNodes);
 | 
						|
        }
 | 
						|
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      if (dirtyElements.has('root')) {
 | 
						|
        const prevNodeMap = prevEditorState._nodeMap;
 | 
						|
        const nextLexicalRoot = $getRoot();
 | 
						|
        const collabRoot = binding.root;
 | 
						|
        collabRoot.syncPropertiesFromLexical(
 | 
						|
          binding,
 | 
						|
          nextLexicalRoot,
 | 
						|
          prevNodeMap,
 | 
						|
        );
 | 
						|
        collabRoot.syncChildrenFromLexical(
 | 
						|
          binding,
 | 
						|
          nextLexicalRoot,
 | 
						|
          prevNodeMap,
 | 
						|
          dirtyElements,
 | 
						|
          dirtyLeaves,
 | 
						|
        );
 | 
						|
      }
 | 
						|
 | 
						|
      const selection = $getSelection();
 | 
						|
      const prevSelection = prevEditorState._selection;
 | 
						|
      syncLexicalSelectionToYjs(binding, provider, prevSelection, selection);
 | 
						|
    });
 | 
						|
  });
 | 
						|
}
 |