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); | ||
|  |     }); | ||
|  |   }); | ||
|  | } |