561 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			561 lines
		
	
	
		
			15 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 {Binding, YjsNode} from '.';
 | |
| import type {
 | |
|   DecoratorNode,
 | |
|   EditorState,
 | |
|   ElementNode,
 | |
|   LexicalNode,
 | |
|   RangeSelection,
 | |
|   TextNode,
 | |
| } from 'lexical';
 | |
| 
 | |
| import {
 | |
|   $getNodeByKey,
 | |
|   $getRoot,
 | |
|   $isDecoratorNode,
 | |
|   $isElementNode,
 | |
|   $isLineBreakNode,
 | |
|   $isRootNode,
 | |
|   $isTextNode,
 | |
|   createEditor,
 | |
|   NodeKey,
 | |
| } from 'lexical';
 | |
| import invariant from 'lexical/shared/invariant';
 | |
| import {Doc, Map as YMap, XmlElement, XmlText} from 'yjs';
 | |
| 
 | |
| import {
 | |
|   $createCollabDecoratorNode,
 | |
|   CollabDecoratorNode,
 | |
| } from './CollabDecoratorNode';
 | |
| import {$createCollabElementNode, CollabElementNode} from './CollabElementNode';
 | |
| import {
 | |
|   $createCollabLineBreakNode,
 | |
|   CollabLineBreakNode,
 | |
| } from './CollabLineBreakNode';
 | |
| import {$createCollabTextNode, CollabTextNode} from './CollabTextNode';
 | |
| 
 | |
| const baseExcludedProperties = new Set<string>([
 | |
|   '__key',
 | |
|   '__parent',
 | |
|   '__next',
 | |
|   '__prev',
 | |
| ]);
 | |
| const elementExcludedProperties = new Set<string>([
 | |
|   '__first',
 | |
|   '__last',
 | |
|   '__size',
 | |
| ]);
 | |
| const rootExcludedProperties = new Set<string>(['__cachedText']);
 | |
| const textExcludedProperties = new Set<string>(['__text']);
 | |
| 
 | |
| function isExcludedProperty(
 | |
|   name: string,
 | |
|   node: LexicalNode,
 | |
|   binding: Binding,
 | |
| ): boolean {
 | |
|   if (baseExcludedProperties.has(name)) {
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   if ($isTextNode(node)) {
 | |
|     if (textExcludedProperties.has(name)) {
 | |
|       return true;
 | |
|     }
 | |
|   } else if ($isElementNode(node)) {
 | |
|     if (
 | |
|       elementExcludedProperties.has(name) ||
 | |
|       ($isRootNode(node) && rootExcludedProperties.has(name))
 | |
|     ) {
 | |
|       return true;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const nodeKlass = node.constructor;
 | |
|   const excludedProperties = binding.excludedProperties.get(nodeKlass);
 | |
|   return excludedProperties != null && excludedProperties.has(name);
 | |
| }
 | |
| 
 | |
| export function getIndexOfYjsNode(
 | |
|   yjsParentNode: YjsNode,
 | |
|   yjsNode: YjsNode,
 | |
| ): number {
 | |
|   let node = yjsParentNode.firstChild;
 | |
|   let i = -1;
 | |
| 
 | |
|   if (node === null) {
 | |
|     return -1;
 | |
|   }
 | |
| 
 | |
|   do {
 | |
|     i++;
 | |
| 
 | |
|     if (node === yjsNode) {
 | |
|       return i;
 | |
|     }
 | |
| 
 | |
|     // @ts-expect-error Sibling exists but type is not available from YJS.
 | |
|     node = node.nextSibling;
 | |
| 
 | |
|     if (node === null) {
 | |
|       return -1;
 | |
|     }
 | |
|   } while (node !== null);
 | |
| 
 | |
|   return i;
 | |
| }
 | |
| 
 | |
| export function $getNodeByKeyOrThrow(key: NodeKey): LexicalNode {
 | |
|   const node = $getNodeByKey(key);
 | |
|   invariant(node !== null, 'could not find node by key');
 | |
|   return node;
 | |
| }
 | |
| 
 | |
| export function $createCollabNodeFromLexicalNode(
 | |
|   binding: Binding,
 | |
|   lexicalNode: LexicalNode,
 | |
|   parent: CollabElementNode,
 | |
| ):
 | |
|   | CollabElementNode
 | |
|   | CollabTextNode
 | |
|   | CollabLineBreakNode
 | |
|   | CollabDecoratorNode {
 | |
|   const nodeType = lexicalNode.__type;
 | |
|   let collabNode;
 | |
| 
 | |
|   if ($isElementNode(lexicalNode)) {
 | |
|     const xmlText = new XmlText();
 | |
|     collabNode = $createCollabElementNode(xmlText, parent, nodeType);
 | |
|     collabNode.syncPropertiesFromLexical(binding, lexicalNode, null);
 | |
|     collabNode.syncChildrenFromLexical(binding, lexicalNode, null, null, null);
 | |
|   } else if ($isTextNode(lexicalNode)) {
 | |
|     // TODO create a token text node for token, segmented nodes.
 | |
|     const map = new YMap();
 | |
|     collabNode = $createCollabTextNode(
 | |
|       map,
 | |
|       lexicalNode.__text,
 | |
|       parent,
 | |
|       nodeType,
 | |
|     );
 | |
|     collabNode.syncPropertiesAndTextFromLexical(binding, lexicalNode, null);
 | |
|   } else if ($isLineBreakNode(lexicalNode)) {
 | |
|     const map = new YMap();
 | |
|     map.set('__type', 'linebreak');
 | |
|     collabNode = $createCollabLineBreakNode(map, parent);
 | |
|   } else if ($isDecoratorNode(lexicalNode)) {
 | |
|     const xmlElem = new XmlElement();
 | |
|     collabNode = $createCollabDecoratorNode(xmlElem, parent, nodeType);
 | |
|     collabNode.syncPropertiesFromLexical(binding, lexicalNode, null);
 | |
|   } else {
 | |
|     invariant(false, 'Expected text, element, decorator, or linebreak node');
 | |
|   }
 | |
| 
 | |
|   collabNode._key = lexicalNode.__key;
 | |
|   return collabNode;
 | |
| }
 | |
| 
 | |
| function getNodeTypeFromSharedType(
 | |
|   sharedType: XmlText | YMap<unknown> | XmlElement,
 | |
| ): string {
 | |
|   const type =
 | |
|     sharedType instanceof YMap
 | |
|       ? sharedType.get('__type')
 | |
|       : sharedType.getAttribute('__type');
 | |
|   invariant(type != null, 'Expected shared type to include type attribute');
 | |
|   return type;
 | |
| }
 | |
| 
 | |
| export function $getOrInitCollabNodeFromSharedType(
 | |
|   binding: Binding,
 | |
|   sharedType: XmlText | YMap<unknown> | XmlElement,
 | |
|   parent?: CollabElementNode,
 | |
| ):
 | |
|   | CollabElementNode
 | |
|   | CollabTextNode
 | |
|   | CollabLineBreakNode
 | |
|   | CollabDecoratorNode {
 | |
|   const collabNode = sharedType._collabNode;
 | |
| 
 | |
|   if (collabNode === undefined) {
 | |
|     const registeredNodes = binding.editor._nodes;
 | |
|     const type = getNodeTypeFromSharedType(sharedType);
 | |
|     const nodeInfo = registeredNodes.get(type);
 | |
|     invariant(nodeInfo !== undefined, 'Node %s is not registered', type);
 | |
| 
 | |
|     const sharedParent = sharedType.parent;
 | |
|     const targetParent =
 | |
|       parent === undefined && sharedParent !== null
 | |
|         ? $getOrInitCollabNodeFromSharedType(
 | |
|             binding,
 | |
|             sharedParent as XmlText | YMap<unknown> | XmlElement,
 | |
|           )
 | |
|         : parent || null;
 | |
| 
 | |
|     invariant(
 | |
|       targetParent instanceof CollabElementNode,
 | |
|       'Expected parent to be a collab element node',
 | |
|     );
 | |
| 
 | |
|     if (sharedType instanceof XmlText) {
 | |
|       return $createCollabElementNode(sharedType, targetParent, type);
 | |
|     } else if (sharedType instanceof YMap) {
 | |
|       if (type === 'linebreak') {
 | |
|         return $createCollabLineBreakNode(sharedType, targetParent);
 | |
|       }
 | |
|       return $createCollabTextNode(sharedType, '', targetParent, type);
 | |
|     } else if (sharedType instanceof XmlElement) {
 | |
|       return $createCollabDecoratorNode(sharedType, targetParent, type);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return collabNode;
 | |
| }
 | |
| 
 | |
| export function createLexicalNodeFromCollabNode(
 | |
|   binding: Binding,
 | |
|   collabNode:
 | |
|     | CollabElementNode
 | |
|     | CollabTextNode
 | |
|     | CollabDecoratorNode
 | |
|     | CollabLineBreakNode,
 | |
|   parentKey: NodeKey,
 | |
| ): LexicalNode {
 | |
|   const type = collabNode.getType();
 | |
|   const registeredNodes = binding.editor._nodes;
 | |
|   const nodeInfo = registeredNodes.get(type);
 | |
|   invariant(nodeInfo !== undefined, 'Node %s is not registered', type);
 | |
|   const lexicalNode:
 | |
|     | DecoratorNode<unknown>
 | |
|     | TextNode
 | |
|     | ElementNode
 | |
|     | LexicalNode = new nodeInfo.klass();
 | |
|   lexicalNode.__parent = parentKey;
 | |
|   collabNode._key = lexicalNode.__key;
 | |
| 
 | |
|   if (collabNode instanceof CollabElementNode) {
 | |
|     const xmlText = collabNode._xmlText;
 | |
|     collabNode.syncPropertiesFromYjs(binding, null);
 | |
|     collabNode.applyChildrenYjsDelta(binding, xmlText.toDelta());
 | |
|     collabNode.syncChildrenFromYjs(binding);
 | |
|   } else if (collabNode instanceof CollabTextNode) {
 | |
|     collabNode.syncPropertiesAndTextFromYjs(binding, null);
 | |
|   } else if (collabNode instanceof CollabDecoratorNode) {
 | |
|     collabNode.syncPropertiesFromYjs(binding, null);
 | |
|   }
 | |
| 
 | |
|   binding.collabNodeMap.set(lexicalNode.__key, collabNode);
 | |
|   return lexicalNode;
 | |
| }
 | |
| 
 | |
| export function syncPropertiesFromYjs(
 | |
|   binding: Binding,
 | |
|   sharedType: XmlText | YMap<unknown> | XmlElement,
 | |
|   lexicalNode: LexicalNode,
 | |
|   keysChanged: null | Set<string>,
 | |
| ): void {
 | |
|   const properties =
 | |
|     keysChanged === null
 | |
|       ? sharedType instanceof YMap
 | |
|         ? Array.from(sharedType.keys())
 | |
|         : Object.keys(sharedType.getAttributes())
 | |
|       : Array.from(keysChanged);
 | |
|   let writableNode;
 | |
| 
 | |
|   for (let i = 0; i < properties.length; i++) {
 | |
|     const property = properties[i];
 | |
|     if (isExcludedProperty(property, lexicalNode, binding)) {
 | |
|       continue;
 | |
|     }
 | |
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | |
|     const prevValue = (lexicalNode as any)[property];
 | |
|     let nextValue =
 | |
|       sharedType instanceof YMap
 | |
|         ? sharedType.get(property)
 | |
|         : sharedType.getAttribute(property);
 | |
| 
 | |
|     if (prevValue !== nextValue) {
 | |
|       if (nextValue instanceof Doc) {
 | |
|         const yjsDocMap = binding.docMap;
 | |
| 
 | |
|         if (prevValue instanceof Doc) {
 | |
|           yjsDocMap.delete(prevValue.guid);
 | |
|         }
 | |
| 
 | |
|         const nestedEditor = createEditor();
 | |
|         const key = nextValue.guid;
 | |
|         nestedEditor._key = key;
 | |
|         yjsDocMap.set(key, nextValue);
 | |
| 
 | |
|         nextValue = nestedEditor;
 | |
|       }
 | |
| 
 | |
|       if (writableNode === undefined) {
 | |
|         writableNode = lexicalNode.getWritable();
 | |
|       }
 | |
| 
 | |
|       writableNode[property as keyof typeof writableNode] = nextValue;
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function syncPropertiesFromLexical(
 | |
|   binding: Binding,
 | |
|   sharedType: XmlText | YMap<unknown> | XmlElement,
 | |
|   prevLexicalNode: null | LexicalNode,
 | |
|   nextLexicalNode: LexicalNode,
 | |
| ): void {
 | |
|   const type = nextLexicalNode.__type;
 | |
|   const nodeProperties = binding.nodeProperties;
 | |
|   let properties = nodeProperties.get(type);
 | |
|   if (properties === undefined) {
 | |
|     properties = Object.keys(nextLexicalNode).filter((property) => {
 | |
|       return !isExcludedProperty(property, nextLexicalNode, binding);
 | |
|     });
 | |
|     nodeProperties.set(type, properties);
 | |
|   }
 | |
| 
 | |
|   const EditorClass = binding.editor.constructor;
 | |
| 
 | |
|   for (let i = 0; i < properties.length; i++) {
 | |
|     const property = properties[i];
 | |
|     const prevValue =
 | |
|       // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | |
|       prevLexicalNode === null ? undefined : (prevLexicalNode as any)[property];
 | |
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | |
|     let nextValue = (nextLexicalNode as any)[property];
 | |
| 
 | |
|     if (prevValue !== nextValue) {
 | |
|       if (nextValue instanceof EditorClass) {
 | |
|         const yjsDocMap = binding.docMap;
 | |
|         let prevDoc;
 | |
| 
 | |
|         if (prevValue instanceof EditorClass) {
 | |
|           const prevKey = prevValue._key;
 | |
|           prevDoc = yjsDocMap.get(prevKey);
 | |
|           yjsDocMap.delete(prevKey);
 | |
|         }
 | |
| 
 | |
|         // If we already have a document, use it.
 | |
|         const doc = prevDoc || new Doc();
 | |
|         const key = doc.guid;
 | |
|         nextValue._key = key;
 | |
|         yjsDocMap.set(key, doc);
 | |
|         nextValue = doc;
 | |
|         // Mark the node dirty as we've assigned a new key to it
 | |
|         binding.editor.update(() => {
 | |
|           nextLexicalNode.markDirty();
 | |
|         });
 | |
|       }
 | |
| 
 | |
|       if (sharedType instanceof YMap) {
 | |
|         sharedType.set(property, nextValue);
 | |
|       } else {
 | |
|         sharedType.setAttribute(property, nextValue);
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function spliceString(
 | |
|   str: string,
 | |
|   index: number,
 | |
|   delCount: number,
 | |
|   newText: string,
 | |
| ): string {
 | |
|   return str.slice(0, index) + newText + str.slice(index + delCount);
 | |
| }
 | |
| 
 | |
| export function getPositionFromElementAndOffset(
 | |
|   node: CollabElementNode,
 | |
|   offset: number,
 | |
|   boundaryIsEdge: boolean,
 | |
| ): {
 | |
|   length: number;
 | |
|   node:
 | |
|     | CollabElementNode
 | |
|     | CollabTextNode
 | |
|     | CollabDecoratorNode
 | |
|     | CollabLineBreakNode
 | |
|     | null;
 | |
|   nodeIndex: number;
 | |
|   offset: number;
 | |
| } {
 | |
|   let index = 0;
 | |
|   let i = 0;
 | |
|   const children = node._children;
 | |
|   const childrenLength = children.length;
 | |
| 
 | |
|   for (; i < childrenLength; i++) {
 | |
|     const child = children[i];
 | |
|     const childOffset = index;
 | |
|     const size = child.getSize();
 | |
|     index += size;
 | |
|     const exceedsBoundary = boundaryIsEdge ? index >= offset : index > offset;
 | |
| 
 | |
|     if (exceedsBoundary && child instanceof CollabTextNode) {
 | |
|       let textOffset = offset - childOffset - 1;
 | |
| 
 | |
|       if (textOffset < 0) {
 | |
|         textOffset = 0;
 | |
|       }
 | |
| 
 | |
|       const diffLength = index - offset;
 | |
|       return {
 | |
|         length: diffLength,
 | |
|         node: child,
 | |
|         nodeIndex: i,
 | |
|         offset: textOffset,
 | |
|       };
 | |
|     }
 | |
| 
 | |
|     if (index > offset) {
 | |
|       return {
 | |
|         length: 0,
 | |
|         node: child,
 | |
|         nodeIndex: i,
 | |
|         offset: childOffset,
 | |
|       };
 | |
|     } else if (i === childrenLength - 1) {
 | |
|       return {
 | |
|         length: 0,
 | |
|         node: null,
 | |
|         nodeIndex: i + 1,
 | |
|         offset: childOffset + 1,
 | |
|       };
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return {
 | |
|     length: 0,
 | |
|     node: null,
 | |
|     nodeIndex: 0,
 | |
|     offset: 0,
 | |
|   };
 | |
| }
 | |
| 
 | |
| export function doesSelectionNeedRecovering(
 | |
|   selection: RangeSelection,
 | |
| ): boolean {
 | |
|   const anchor = selection.anchor;
 | |
|   const focus = selection.focus;
 | |
|   let recoveryNeeded = false;
 | |
| 
 | |
|   try {
 | |
|     const anchorNode = anchor.getNode();
 | |
|     const focusNode = focus.getNode();
 | |
| 
 | |
|     if (
 | |
|       // We might have removed a node that no longer exists
 | |
|       !anchorNode.isAttached() ||
 | |
|       !focusNode.isAttached() ||
 | |
|       // If we've split a node, then the offset might not be right
 | |
|       ($isTextNode(anchorNode) &&
 | |
|         anchor.offset > anchorNode.getTextContentSize()) ||
 | |
|       ($isTextNode(focusNode) && focus.offset > focusNode.getTextContentSize())
 | |
|     ) {
 | |
|       recoveryNeeded = true;
 | |
|     }
 | |
|   } catch (e) {
 | |
|     // Sometimes checking nor a node via getNode might trigger
 | |
|     // an error, so we need recovery then too.
 | |
|     recoveryNeeded = true;
 | |
|   }
 | |
| 
 | |
|   return recoveryNeeded;
 | |
| }
 | |
| 
 | |
| export function syncWithTransaction(binding: Binding, fn: () => void): void {
 | |
|   binding.doc.transact(fn, binding);
 | |
| }
 | |
| 
 | |
| export function removeFromParent(node: LexicalNode): void {
 | |
|   const oldParent = node.getParent();
 | |
|   if (oldParent !== null) {
 | |
|     const writableNode = node.getWritable();
 | |
|     const writableParent = oldParent.getWritable();
 | |
|     const prevSibling = node.getPreviousSibling();
 | |
|     const nextSibling = node.getNextSibling();
 | |
|     // TODO: this function duplicates a bunch of operations, can be simplified.
 | |
|     if (prevSibling === null) {
 | |
|       if (nextSibling !== null) {
 | |
|         const writableNextSibling = nextSibling.getWritable();
 | |
|         writableParent.__first = nextSibling.__key;
 | |
|         writableNextSibling.__prev = null;
 | |
|       } else {
 | |
|         writableParent.__first = null;
 | |
|       }
 | |
|     } else {
 | |
|       const writablePrevSibling = prevSibling.getWritable();
 | |
|       if (nextSibling !== null) {
 | |
|         const writableNextSibling = nextSibling.getWritable();
 | |
|         writableNextSibling.__prev = writablePrevSibling.__key;
 | |
|         writablePrevSibling.__next = writableNextSibling.__key;
 | |
|       } else {
 | |
|         writablePrevSibling.__next = null;
 | |
|       }
 | |
|       writableNode.__prev = null;
 | |
|     }
 | |
|     if (nextSibling === null) {
 | |
|       if (prevSibling !== null) {
 | |
|         const writablePrevSibling = prevSibling.getWritable();
 | |
|         writableParent.__last = prevSibling.__key;
 | |
|         writablePrevSibling.__next = null;
 | |
|       } else {
 | |
|         writableParent.__last = null;
 | |
|       }
 | |
|     } else {
 | |
|       const writableNextSibling = nextSibling.getWritable();
 | |
|       if (prevSibling !== null) {
 | |
|         const writablePrevSibling = prevSibling.getWritable();
 | |
|         writablePrevSibling.__next = writableNextSibling.__key;
 | |
|         writableNextSibling.__prev = writablePrevSibling.__key;
 | |
|       } else {
 | |
|         writableNextSibling.__prev = null;
 | |
|       }
 | |
|       writableNode.__next = null;
 | |
|     }
 | |
|     writableParent.__size--;
 | |
|     writableNode.__parent = null;
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function $moveSelectionToPreviousNode(
 | |
|   anchorNodeKey: string,
 | |
|   currentEditorState: EditorState,
 | |
| ) {
 | |
|   const anchorNode = currentEditorState._nodeMap.get(anchorNodeKey);
 | |
|   if (!anchorNode) {
 | |
|     $getRoot().selectStart();
 | |
|     return;
 | |
|   }
 | |
|   // Get previous node
 | |
|   const prevNodeKey = anchorNode.__prev;
 | |
|   let prevNode: ElementNode | null = null;
 | |
|   if (prevNodeKey) {
 | |
|     prevNode = $getNodeByKey(prevNodeKey);
 | |
|   }
 | |
| 
 | |
|   // If previous node not found, get parent node
 | |
|   if (prevNode === null && anchorNode.__parent !== null) {
 | |
|     prevNode = $getNodeByKey(anchorNode.__parent);
 | |
|   }
 | |
|   if (prevNode === null) {
 | |
|     $getRoot().selectStart();
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (prevNode !== null && prevNode.isAttached()) {
 | |
|     prevNode.selectEnd();
 | |
|     return;
 | |
|   } else {
 | |
|     // If the found node is also deleted, select the next one
 | |
|     $moveSelectionToPreviousNode(prevNode.__key, currentEditorState);
 | |
|   }
 | |
| }
 |