/** * 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} from '.'; import type {ElementNode, NodeKey, NodeMap} from 'lexical'; import type {AbstractType, Map as YMap, XmlElement, XmlText} from 'yjs'; import {$createChildrenArray} from '@lexical/offset'; import { $getNodeByKey, $isDecoratorNode, $isElementNode, $isTextNode, } from 'lexical'; import invariant from 'lexical/shared/invariant'; import {CollabDecoratorNode} from './CollabDecoratorNode'; import {CollabLineBreakNode} from './CollabLineBreakNode'; import {CollabTextNode} from './CollabTextNode'; import { $createCollabNodeFromLexicalNode, $getNodeByKeyOrThrow, $getOrInitCollabNodeFromSharedType, createLexicalNodeFromCollabNode, getPositionFromElementAndOffset, removeFromParent, spliceString, syncPropertiesFromLexical, syncPropertiesFromYjs, } from './Utils'; type IntentionallyMarkedAsDirtyElement = boolean; export class CollabElementNode { _key: NodeKey; _children: Array< | CollabElementNode | CollabTextNode | CollabDecoratorNode | CollabLineBreakNode >; _xmlText: XmlText; _type: string; _parent: null | CollabElementNode; constructor( xmlText: XmlText, parent: null | CollabElementNode, type: string, ) { this._key = ''; this._children = []; this._xmlText = xmlText; this._type = type; this._parent = parent; } getPrevNode(nodeMap: null | NodeMap): null | ElementNode { if (nodeMap === null) { return null; } const node = nodeMap.get(this._key); return $isElementNode(node) ? node : null; } getNode(): null | ElementNode { const node = $getNodeByKey(this._key); return $isElementNode(node) ? node : null; } getSharedType(): XmlText { return this._xmlText; } getType(): string { return this._type; } getKey(): NodeKey { return this._key; } isEmpty(): boolean { return this._children.length === 0; } getSize(): number { return 1; } getOffset(): number { const collabElementNode = this._parent; invariant( collabElementNode !== null, 'getOffset: could not find collab element node', ); return collabElementNode.getChildOffset(this); } syncPropertiesFromYjs( binding: Binding, keysChanged: null | Set, ): void { const lexicalNode = this.getNode(); invariant( lexicalNode !== null, 'syncPropertiesFromYjs: could not find element node', ); syncPropertiesFromYjs(binding, this._xmlText, lexicalNode, keysChanged); } applyChildrenYjsDelta( binding: Binding, deltas: Array<{ insert?: string | object | AbstractType; delete?: number; retain?: number; attributes?: { [x: string]: unknown; }; }>, ): void { const children = this._children; let currIndex = 0; for (let i = 0; i < deltas.length; i++) { const delta = deltas[i]; const insertDelta = delta.insert; const deleteDelta = delta.delete; if (delta.retain != null) { currIndex += delta.retain; } else if (typeof deleteDelta === 'number') { let deletionSize = deleteDelta; while (deletionSize > 0) { const {node, nodeIndex, offset, length} = getPositionFromElementAndOffset(this, currIndex, false); if ( node instanceof CollabElementNode || node instanceof CollabLineBreakNode || node instanceof CollabDecoratorNode ) { children.splice(nodeIndex, 1); deletionSize -= 1; } else if (node instanceof CollabTextNode) { const delCount = Math.min(deletionSize, length); const prevCollabNode = nodeIndex !== 0 ? children[nodeIndex - 1] : null; const nodeSize = node.getSize(); if ( offset === 0 && delCount === 1 && nodeIndex > 0 && prevCollabNode instanceof CollabTextNode && length === nodeSize && // If the node has no keys, it's been deleted Array.from(node._map.keys()).length === 0 ) { // Merge the text node with previous. prevCollabNode._text += node._text; children.splice(nodeIndex, 1); } else if (offset === 0 && delCount === nodeSize) { // The entire thing needs removing children.splice(nodeIndex, 1); } else { node._text = spliceString(node._text, offset, delCount, ''); } deletionSize -= delCount; } else { // Can occur due to the deletion from the dangling text heuristic below. break; } } } else if (insertDelta != null) { if (typeof insertDelta === 'string') { const {node, offset} = getPositionFromElementAndOffset( this, currIndex, true, ); if (node instanceof CollabTextNode) { node._text = spliceString(node._text, offset, 0, insertDelta); } else { // TODO: maybe we can improve this by keeping around a redundant // text node map, rather than removing all the text nodes, so there // never can be dangling text. // We have a conflict where there was likely a CollabTextNode and // an Lexical TextNode too, but they were removed in a merge. So // let's just ignore the text and trigger a removal for it from our // shared type. this._xmlText.delete(offset, insertDelta.length); } currIndex += insertDelta.length; } else { const sharedType = insertDelta; const {nodeIndex} = getPositionFromElementAndOffset( this, currIndex, false, ); const collabNode = $getOrInitCollabNodeFromSharedType( binding, sharedType as XmlText | YMap | XmlElement, this, ); children.splice(nodeIndex, 0, collabNode); currIndex += 1; } } else { throw new Error('Unexpected delta format'); } } } syncChildrenFromYjs(binding: Binding): void { // Now diff the children of the collab node with that of our existing Lexical node. const lexicalNode = this.getNode(); invariant( lexicalNode !== null, 'syncChildrenFromYjs: could not find element node', ); const key = lexicalNode.__key; const prevLexicalChildrenKeys = $createChildrenArray(lexicalNode, null); const nextLexicalChildrenKeys: Array = []; const lexicalChildrenKeysLength = prevLexicalChildrenKeys.length; const collabChildren = this._children; const collabChildrenLength = collabChildren.length; const collabNodeMap = binding.collabNodeMap; const visitedKeys = new Set(); let collabKeys; let writableLexicalNode; let prevIndex = 0; let prevChildNode = null; if (collabChildrenLength !== lexicalChildrenKeysLength) { writableLexicalNode = lexicalNode.getWritable(); } for (let i = 0; i < collabChildrenLength; i++) { const lexicalChildKey = prevLexicalChildrenKeys[prevIndex]; const childCollabNode = collabChildren[i]; const collabLexicalChildNode = childCollabNode.getNode(); const collabKey = childCollabNode._key; if (collabLexicalChildNode !== null && lexicalChildKey === collabKey) { const childNeedsUpdating = $isTextNode(collabLexicalChildNode); // Update visitedKeys.add(lexicalChildKey); if (childNeedsUpdating) { childCollabNode._key = lexicalChildKey; if (childCollabNode instanceof CollabElementNode) { const xmlText = childCollabNode._xmlText; childCollabNode.syncPropertiesFromYjs(binding, null); childCollabNode.applyChildrenYjsDelta(binding, xmlText.toDelta()); childCollabNode.syncChildrenFromYjs(binding); } else if (childCollabNode instanceof CollabTextNode) { childCollabNode.syncPropertiesAndTextFromYjs(binding, null); } else if (childCollabNode instanceof CollabDecoratorNode) { childCollabNode.syncPropertiesFromYjs(binding, null); } else if (!(childCollabNode instanceof CollabLineBreakNode)) { invariant( false, 'syncChildrenFromYjs: expected text, element, decorator, or linebreak collab node', ); } } nextLexicalChildrenKeys[i] = lexicalChildKey; prevChildNode = collabLexicalChildNode; prevIndex++; } else { if (collabKeys === undefined) { collabKeys = new Set(); for (let s = 0; s < collabChildrenLength; s++) { const child = collabChildren[s]; const childKey = child._key; if (childKey !== '') { collabKeys.add(childKey); } } } if ( collabLexicalChildNode !== null && lexicalChildKey !== undefined && !collabKeys.has(lexicalChildKey) ) { const nodeToRemove = $getNodeByKeyOrThrow(lexicalChildKey); removeFromParent(nodeToRemove); i--; prevIndex++; continue; } writableLexicalNode = lexicalNode.getWritable(); // Create/Replace const lexicalChildNode = createLexicalNodeFromCollabNode( binding, childCollabNode, key, ); const childKey = lexicalChildNode.__key; collabNodeMap.set(childKey, childCollabNode); nextLexicalChildrenKeys[i] = childKey; if (prevChildNode === null) { const nextSibling = writableLexicalNode.getFirstChild(); writableLexicalNode.__first = childKey; if (nextSibling !== null) { const writableNextSibling = nextSibling.getWritable(); writableNextSibling.__prev = childKey; lexicalChildNode.__next = writableNextSibling.__key; } } else { const writablePrevChildNode = prevChildNode.getWritable(); const nextSibling = prevChildNode.getNextSibling(); writablePrevChildNode.__next = childKey; lexicalChildNode.__prev = prevChildNode.__key; if (nextSibling !== null) { const writableNextSibling = nextSibling.getWritable(); writableNextSibling.__prev = childKey; lexicalChildNode.__next = writableNextSibling.__key; } } if (i === collabChildrenLength - 1) { writableLexicalNode.__last = childKey; } writableLexicalNode.__size++; prevChildNode = lexicalChildNode; } } for (let i = 0; i < lexicalChildrenKeysLength; i++) { const lexicalChildKey = prevLexicalChildrenKeys[i]; if (!visitedKeys.has(lexicalChildKey)) { // Remove const lexicalChildNode = $getNodeByKeyOrThrow(lexicalChildKey); const collabNode = binding.collabNodeMap.get(lexicalChildKey); if (collabNode !== undefined) { collabNode.destroy(binding); } removeFromParent(lexicalChildNode); } } } syncPropertiesFromLexical( binding: Binding, nextLexicalNode: ElementNode, prevNodeMap: null | NodeMap, ): void { syncPropertiesFromLexical( binding, this._xmlText, this.getPrevNode(prevNodeMap), nextLexicalNode, ); } _syncChildFromLexical( binding: Binding, index: number, key: NodeKey, prevNodeMap: null | NodeMap, dirtyElements: null | Map, dirtyLeaves: null | Set, ): void { const childCollabNode = this._children[index]; // Update const nextChildNode = $getNodeByKeyOrThrow(key); if ( childCollabNode instanceof CollabElementNode && $isElementNode(nextChildNode) ) { childCollabNode.syncPropertiesFromLexical( binding, nextChildNode, prevNodeMap, ); childCollabNode.syncChildrenFromLexical( binding, nextChildNode, prevNodeMap, dirtyElements, dirtyLeaves, ); } else if ( childCollabNode instanceof CollabTextNode && $isTextNode(nextChildNode) ) { childCollabNode.syncPropertiesAndTextFromLexical( binding, nextChildNode, prevNodeMap, ); } else if ( childCollabNode instanceof CollabDecoratorNode && $isDecoratorNode(nextChildNode) ) { childCollabNode.syncPropertiesFromLexical( binding, nextChildNode, prevNodeMap, ); } } syncChildrenFromLexical( binding: Binding, nextLexicalNode: ElementNode, prevNodeMap: null | NodeMap, dirtyElements: null | Map, dirtyLeaves: null | Set, ): void { const prevLexicalNode = this.getPrevNode(prevNodeMap); const prevChildren = prevLexicalNode === null ? [] : $createChildrenArray(prevLexicalNode, prevNodeMap); const nextChildren = $createChildrenArray(nextLexicalNode, null); const prevEndIndex = prevChildren.length - 1; const nextEndIndex = nextChildren.length - 1; const collabNodeMap = binding.collabNodeMap; let prevChildrenSet: Set | undefined; let nextChildrenSet: Set | undefined; let prevIndex = 0; let nextIndex = 0; while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) { const prevKey = prevChildren[prevIndex]; const nextKey = nextChildren[nextIndex]; if (prevKey === nextKey) { // Nove move, create or remove this._syncChildFromLexical( binding, nextIndex, nextKey, prevNodeMap, dirtyElements, dirtyLeaves, ); prevIndex++; nextIndex++; } else { if (prevChildrenSet === undefined) { prevChildrenSet = new Set(prevChildren); } if (nextChildrenSet === undefined) { nextChildrenSet = new Set(nextChildren); } const nextHasPrevKey = nextChildrenSet.has(prevKey); const prevHasNextKey = prevChildrenSet.has(nextKey); if (!nextHasPrevKey) { // Remove this.splice(binding, nextIndex, 1); prevIndex++; } else { // Create or replace const nextChildNode = $getNodeByKeyOrThrow(nextKey); const collabNode = $createCollabNodeFromLexicalNode( binding, nextChildNode, this, ); collabNodeMap.set(nextKey, collabNode); if (prevHasNextKey) { this.splice(binding, nextIndex, 1, collabNode); prevIndex++; nextIndex++; } else { this.splice(binding, nextIndex, 0, collabNode); nextIndex++; } } } } const appendNewChildren = prevIndex > prevEndIndex; const removeOldChildren = nextIndex > nextEndIndex; if (appendNewChildren && !removeOldChildren) { for (; nextIndex <= nextEndIndex; ++nextIndex) { const key = nextChildren[nextIndex]; const nextChildNode = $getNodeByKeyOrThrow(key); const collabNode = $createCollabNodeFromLexicalNode( binding, nextChildNode, this, ); this.append(collabNode); collabNodeMap.set(key, collabNode); } } else if (removeOldChildren && !appendNewChildren) { for (let i = this._children.length - 1; i >= nextIndex; i--) { this.splice(binding, i, 1); } } } append( collabNode: | CollabElementNode | CollabDecoratorNode | CollabTextNode | CollabLineBreakNode, ): void { const xmlText = this._xmlText; const children = this._children; const lastChild = children[children.length - 1]; const offset = lastChild !== undefined ? lastChild.getOffset() + lastChild.getSize() : 0; if (collabNode instanceof CollabElementNode) { xmlText.insertEmbed(offset, collabNode._xmlText); } else if (collabNode instanceof CollabTextNode) { const map = collabNode._map; if (map.parent === null) { xmlText.insertEmbed(offset, map); } xmlText.insert(offset + 1, collabNode._text); } else if (collabNode instanceof CollabLineBreakNode) { xmlText.insertEmbed(offset, collabNode._map); } else if (collabNode instanceof CollabDecoratorNode) { xmlText.insertEmbed(offset, collabNode._xmlElem); } this._children.push(collabNode); } splice( binding: Binding, index: number, delCount: number, collabNode?: | CollabElementNode | CollabDecoratorNode | CollabTextNode | CollabLineBreakNode, ): void { const children = this._children; const child = children[index]; if (child === undefined) { invariant( collabNode !== undefined, 'splice: could not find collab element node', ); this.append(collabNode); return; } const offset = child.getOffset(); invariant(offset !== -1, 'splice: expected offset to be greater than zero'); const xmlText = this._xmlText; if (delCount !== 0) { // What if we delete many nodes, don't we need to get all their // sizes? xmlText.delete(offset, child.getSize()); } if (collabNode instanceof CollabElementNode) { xmlText.insertEmbed(offset, collabNode._xmlText); } else if (collabNode instanceof CollabTextNode) { const map = collabNode._map; if (map.parent === null) { xmlText.insertEmbed(offset, map); } xmlText.insert(offset + 1, collabNode._text); } else if (collabNode instanceof CollabLineBreakNode) { xmlText.insertEmbed(offset, collabNode._map); } else if (collabNode instanceof CollabDecoratorNode) { xmlText.insertEmbed(offset, collabNode._xmlElem); } if (delCount !== 0) { const childrenToDelete = children.slice(index, index + delCount); for (let i = 0; i < childrenToDelete.length; i++) { childrenToDelete[i].destroy(binding); } } if (collabNode !== undefined) { children.splice(index, delCount, collabNode); } else { children.splice(index, delCount); } } getChildOffset( collabNode: | CollabElementNode | CollabTextNode | CollabDecoratorNode | CollabLineBreakNode, ): number { let offset = 0; const children = this._children; for (let i = 0; i < children.length; i++) { const child = children[i]; if (child === collabNode) { return offset; } offset += child.getSize(); } return -1; } destroy(binding: Binding): void { const collabNodeMap = binding.collabNodeMap; const children = this._children; for (let i = 0; i < children.length; i++) { children[i].destroy(binding); } collabNodeMap.delete(this._key); } } export function $createCollabElementNode( xmlText: XmlText, parent: null | CollabElementNode, type: string, ): CollabElementNode { const collabNode = new CollabElementNode(xmlText, parent, type); xmlText._collabNode = collabNode; return collabNode; }