/** * 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([ '__key', '__parent', '__next', '__prev', ]); const elementExcludedProperties = new Set([ '__first', '__last', '__size', ]); const rootExcludedProperties = new Set(['__cachedText']); const textExcludedProperties = new Set(['__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 | 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 | 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 | 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 | 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 | XmlElement, lexicalNode: LexicalNode, keysChanged: null | Set, ): 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 | 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); } }