UNPKG

@lexical/yjs

Version:

The library provides Yjs editor bindings for Lexical.

1,394 lines (1,375 loc) 59.9 kB
/** * 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 { $getNodeByKey, $isLineBreakNode, $isTextNode, $getSelection, $isRangeSelection, $isElementNode, $isDecoratorNode, createEditor, $getWritableNodeState, $getRoot, $isRootNode, $getNodeByKeyOrThrow, removeFromParent, $addUpdateTag, SKIP_SCROLL_INTO_VIEW_TAG, $createParagraphNode, HISTORIC_TAG, COLLABORATION_TAG, createCommand } from 'lexical'; import { XmlText, Map as Map$1, XmlElement, Doc, createAbsolutePositionFromRelativePosition, createRelativePositionFromTypeIndex, compareRelativePositions, YMapEvent, YTextEvent, YXmlEvent, UndoManager } from 'yjs'; import { $createChildrenArray } from '@lexical/offset'; import { createDOMRange, createRectsFromDOMRange } from '@lexical/selection'; /** * 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. * */ // Do not require this module directly! Use normal `invariant` calls. function formatDevErrorMessage(message) { throw new Error(message); } /** * 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. * */ class CollabLineBreakNode { constructor(map, parent) { this._key = ''; this._map = map; this._parent = parent; this._type = 'linebreak'; } getNode() { const node = $getNodeByKey(this._key); return $isLineBreakNode(node) ? node : null; } getKey() { return this._key; } getSharedType() { return this._map; } getType() { return this._type; } getSize() { return 1; } getOffset() { const collabElementNode = this._parent; return collabElementNode.getChildOffset(this); } destroy(binding) { const collabNodeMap = binding.collabNodeMap; if (collabNodeMap.get(this._key) === this) { collabNodeMap.delete(this._key); } } } function $createCollabLineBreakNode(map, parent) { const collabNode = new CollabLineBreakNode(map, parent); map._collabNode = collabNode; return collabNode; } /** * 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. * */ function simpleDiffWithCursor(a, b, cursor) { const aLength = a.length; const bLength = b.length; let left = 0; // number of same characters counting from left let right = 0; // number of same characters counting from right // Iterate left to the right until we find a changed character // First iteration considers the current cursor position while (left < aLength && left < bLength && a[left] === b[left] && left < cursor) { left++; } // Iterate right to the left until we find a changed character while (right + left < aLength && right + left < bLength && a[aLength - right - 1] === b[bLength - right - 1]) { right++; } // Try to iterate left further to the right without caring about the current cursor position while (right + left < aLength && right + left < bLength && a[left] === b[left]) { left++; } return { index: left, insert: b.slice(left, bLength - right), remove: aLength - left - right }; } function $diffTextContentAndApplyDelta(collabNode, key, prevText, nextText) { const selection = $getSelection(); let cursorOffset = nextText.length; if ($isRangeSelection(selection) && selection.isCollapsed()) { const anchor = selection.anchor; if (anchor.key === key) { cursorOffset = anchor.offset; } } const diff = simpleDiffWithCursor(prevText, nextText, cursorOffset); collabNode.spliceText(diff.index, diff.remove, diff.insert); } class CollabTextNode { constructor(map, text, parent, type) { this._key = ''; this._map = map; this._parent = parent; this._text = text; this._type = type; this._normalized = false; } getPrevNode(nodeMap) { if (nodeMap === null) { return null; } const node = nodeMap.get(this._key); return $isTextNode(node) ? node : null; } getNode() { const node = $getNodeByKey(this._key); return $isTextNode(node) ? node : null; } getSharedType() { return this._map; } getType() { return this._type; } getKey() { return this._key; } getSize() { return this._text.length + (this._normalized ? 0 : 1); } getOffset() { const collabElementNode = this._parent; return collabElementNode.getChildOffset(this); } spliceText(index, delCount, newText) { const collabElementNode = this._parent; const xmlText = collabElementNode._xmlText; const offset = this.getOffset() + 1 + index; if (delCount !== 0) { xmlText.delete(offset, delCount); } if (newText !== '') { xmlText.insert(offset, newText); } } syncPropertiesAndTextFromLexical(binding, nextLexicalNode, prevNodeMap) { const prevLexicalNode = this.getPrevNode(prevNodeMap); const nextText = nextLexicalNode.__text; syncPropertiesFromLexical(binding, this._map, prevLexicalNode, nextLexicalNode); if (prevLexicalNode !== null) { const prevText = prevLexicalNode.__text; if (prevText !== nextText) { const key = nextLexicalNode.__key; $diffTextContentAndApplyDelta(this, key, prevText, nextText); this._text = nextText; } } } syncPropertiesAndTextFromYjs(binding, keysChanged) { const lexicalNode = this.getNode(); if (!(lexicalNode !== null)) { formatDevErrorMessage(`syncPropertiesAndTextFromYjs: could not find decorator node`); } $syncPropertiesFromYjs(binding, this._map, lexicalNode, keysChanged); const collabText = this._text; if (lexicalNode.__text !== collabText) { const writable = lexicalNode.getWritable(); writable.__text = collabText; } } destroy(binding) { const collabNodeMap = binding.collabNodeMap; if (collabNodeMap.get(this._key) === this) { collabNodeMap.delete(this._key); } } } function $createCollabTextNode(map, text, parent, type) { const collabNode = new CollabTextNode(map, text, parent, type); map._collabNode = collabNode; return collabNode; } const baseExcludedProperties = new Set(['__key', '__parent', '__next', '__prev', '__state']); const elementExcludedProperties = new Set(['__first', '__last', '__size', '__dir']); const rootExcludedProperties = new Set(['__cachedText']); const textExcludedProperties = new Set(['__text']); function isExcludedProperty(name, node, binding) { if (baseExcludedProperties.has(name) || typeof node[name] === 'function') { 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); } function $createCollabNodeFromLexicalNode(binding, lexicalNode, parent) { 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 Map$1(); collabNode = $createCollabTextNode(map, lexicalNode.__text, parent, nodeType); collabNode.syncPropertiesAndTextFromLexical(binding, lexicalNode, null); } else if ($isLineBreakNode(lexicalNode)) { const map = new Map$1(); 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 { { formatDevErrorMessage(`Expected text, element, decorator, or linebreak node`); } } collabNode._key = lexicalNode.__key; return collabNode; } function getNodeTypeFromSharedType(sharedType) { const type = sharedTypeGet(sharedType, '__type'); if (!(typeof type === 'string' || typeof type === 'undefined')) { formatDevErrorMessage(`Expected shared type to include type attribute`); } return type; } function $getOrInitCollabNodeFromSharedType(binding, sharedType, parent) { const collabNode = sharedType._collabNode; if (collabNode === undefined) { const registeredNodes = binding.editor._nodes; const type = getNodeTypeFromSharedType(sharedType); if (!(typeof type === 'string')) { formatDevErrorMessage(`Expected shared type to include type attribute`); } const nodeInfo = registeredNodes.get(type); if (!(nodeInfo !== undefined)) { formatDevErrorMessage(`Node ${type} is not registered`); } const sharedParent = sharedType.parent; const targetParent = parent === undefined && sharedParent !== null ? $getOrInitCollabNodeFromSharedType(binding, sharedParent) : parent || null; if (!(targetParent instanceof CollabElementNode)) { formatDevErrorMessage(`Expected parent to be a collab element node`); } if (sharedType instanceof XmlText) { return $createCollabElementNode(sharedType, targetParent, type); } else if (sharedType instanceof Map$1) { if (type === 'linebreak') { return $createCollabLineBreakNode(sharedType, targetParent); } return $createCollabTextNode(sharedType, '', targetParent, type); } else if (sharedType instanceof XmlElement) { return $createCollabDecoratorNode(sharedType, targetParent, type); } } return collabNode; } function createLexicalNodeFromCollabNode(binding, collabNode, parentKey) { const type = collabNode.getType(); const registeredNodes = binding.editor._nodes; const nodeInfo = registeredNodes.get(type); if (!(nodeInfo !== undefined)) { formatDevErrorMessage(`Node ${type} is not registered`); } const 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; } function $syncPropertiesFromYjs(binding, sharedType, lexicalNode, keysChanged) { const properties = keysChanged === null ? sharedType instanceof Map$1 ? 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)) { if (property === '__state') { if (!writableNode) { writableNode = lexicalNode.getWritable(); } $syncNodeStateToLexical(binding, sharedType, writableNode); } continue; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const prevValue = lexicalNode[property]; let nextValue = sharedTypeGet(sharedType, 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(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any writableNode[property] = nextValue; } } } function sharedTypeGet(sharedType, property) { if (sharedType instanceof Map$1) { return sharedType.get(property); } else { return sharedType.getAttribute(property); } } function sharedTypeSet(sharedType, property, nextValue) { if (sharedType instanceof Map$1) { sharedType.set(property, nextValue); } else { sharedType.setAttribute(property, nextValue); } } function $syncNodeStateToLexical(binding, sharedType, lexicalNode) { const existingState = sharedTypeGet(sharedType, '__state'); if (!(existingState instanceof Map$1)) { return; } // This should only called when creating the node initially, // incremental updates to state come in through YMapEvent // with the __state as the target. $getWritableNodeState(lexicalNode).updateFromJSON(existingState.toJSON()); } function syncNodeStateFromLexical(binding, sharedType, prevLexicalNode, nextLexicalNode) { const nextState = nextLexicalNode.__state; const existingState = sharedTypeGet(sharedType, '__state'); if (!nextState) { return; } const [unknown, known] = nextState.getInternalState(); const prevState = prevLexicalNode && prevLexicalNode.__state; const stateMap = existingState instanceof Map$1 ? existingState : new Map$1(); if (prevState === nextState) { return; } const [prevUnknown, prevKnown] = prevState && stateMap.doc ? prevState.getInternalState() : [undefined, new Map()]; if (unknown) { for (const [k, v] of Object.entries(unknown)) { if (prevUnknown && v !== prevUnknown[k]) { stateMap.set(k, v); } } } for (const [stateConfig, v] of known) { if (prevKnown.get(stateConfig) !== v) { stateMap.set(stateConfig.key, stateConfig.unparse(v)); } } if (!existingState) { sharedTypeSet(sharedType, '__state', stateMap); } } function syncPropertiesFromLexical(binding, sharedType, prevLexicalNode, nextLexicalNode) { 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; syncNodeStateFromLexical(binding, sharedType, prevLexicalNode, nextLexicalNode); 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[property]; // eslint-disable-next-line @typescript-eslint/no-explicit-any let nextValue = nextLexicalNode[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(); }); } sharedTypeSet(sharedType, property, nextValue); } } } function spliceString(str, index, delCount, newText) { return str.slice(0, index) + newText + str.slice(index + delCount); } function getPositionFromElementAndOffset(node, offset, boundaryIsEdge) { 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 }; } function doesSelectionNeedRecovering(selection) { 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; } function syncWithTransaction(binding, fn) { binding.doc.transact(fn, binding); } function $moveSelectionToPreviousNode(anchorNodeKey, currentEditorState) { const anchorNode = currentEditorState._nodeMap.get(anchorNodeKey); if (!anchorNode) { $getRoot().selectStart(); return; } // Get previous node const prevNodeKey = anchorNode.__prev; let prevNode = 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); } } class CollabDecoratorNode { constructor(xmlElem, parent, type) { this._key = ''; this._xmlElem = xmlElem; this._parent = parent; this._type = type; } getPrevNode(nodeMap) { if (nodeMap === null) { return null; } const node = nodeMap.get(this._key); return $isDecoratorNode(node) ? node : null; } getNode() { const node = $getNodeByKey(this._key); return $isDecoratorNode(node) ? node : null; } getSharedType() { return this._xmlElem; } getType() { return this._type; } getKey() { return this._key; } getSize() { return 1; } getOffset() { const collabElementNode = this._parent; return collabElementNode.getChildOffset(this); } syncPropertiesFromLexical(binding, nextLexicalNode, prevNodeMap) { const prevLexicalNode = this.getPrevNode(prevNodeMap); const xmlElem = this._xmlElem; syncPropertiesFromLexical(binding, xmlElem, prevLexicalNode, nextLexicalNode); } syncPropertiesFromYjs(binding, keysChanged) { const lexicalNode = this.getNode(); if (!(lexicalNode !== null)) { formatDevErrorMessage(`syncPropertiesFromYjs: could not find decorator node`); } const xmlElem = this._xmlElem; $syncPropertiesFromYjs(binding, xmlElem, lexicalNode, keysChanged); } destroy(binding) { const collabNodeMap = binding.collabNodeMap; if (collabNodeMap.get(this._key) === this) { collabNodeMap.delete(this._key); } } } function $createCollabDecoratorNode(xmlElem, parent, type) { const collabNode = new CollabDecoratorNode(xmlElem, parent, type); xmlElem._collabNode = collabNode; return collabNode; } class CollabElementNode { constructor(xmlText, parent, type) { this._key = ''; this._children = []; this._xmlText = xmlText; this._type = type; this._parent = parent; } getPrevNode(nodeMap) { if (nodeMap === null) { return null; } const node = nodeMap.get(this._key); return $isElementNode(node) ? node : null; } getNode() { const node = $getNodeByKey(this._key); return $isElementNode(node) ? node : null; } getSharedType() { return this._xmlText; } getType() { return this._type; } getKey() { return this._key; } isEmpty() { return this._children.length === 0; } getSize() { return 1; } getOffset() { const collabElementNode = this._parent; if (!(collabElementNode !== null)) { formatDevErrorMessage(`getOffset: could not find collab element node`); } return collabElementNode.getChildOffset(this); } syncPropertiesFromYjs(binding, keysChanged) { const lexicalNode = this.getNode(); if (!(lexicalNode !== null)) { formatDevErrorMessage(`syncPropertiesFromYjs: could not find element node`); } $syncPropertiesFromYjs(binding, this._xmlText, lexicalNode, keysChanged); } applyChildrenYjsDelta(binding, deltas) { const children = this._children; let currIndex = 0; let pendingSplitText = null; 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 && length === nodeSize) { // Text node has been deleted. children.splice(nodeIndex, 1); // If this was caused by an undo from YJS, there could be dangling text. const danglingText = spliceString(node._text, offset, delCount - 1, ''); if (danglingText.length > 0) { if (prevCollabNode instanceof CollabTextNode) { // Merge the text node with previous. prevCollabNode._text += danglingText; } else { // No previous text node to merge into, just delete the text. this._xmlText.delete(offset, danglingText.length); } } } 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 { node, nodeIndex, length } = getPositionFromElementAndOffset(this, currIndex, false); const collabNode = $getOrInitCollabNodeFromSharedType(binding, sharedType, this); if (node instanceof CollabTextNode && length > 0 && length < node._text.length) { // Trying to insert in the middle of a text node; split the text. const text = node._text; const splitIdx = text.length - length; node._text = spliceString(text, splitIdx, length, ''); children.splice(nodeIndex + 1, 0, collabNode); // The insert that triggers the text split might not be a text node. Need to keep a // reference to the remaining text so that it can be added when we do create one. pendingSplitText = spliceString(text, 0, splitIdx, ''); } else { children.splice(nodeIndex, 0, collabNode); } if (pendingSplitText !== null && collabNode instanceof CollabTextNode) { // Found a text node to insert the pending text into. collabNode._text = pendingSplitText + collabNode._text; pendingSplitText = null; } currIndex += 1; } } else { throw new Error('Unexpected delta format'); } } } syncChildrenFromYjs(binding) { // Now diff the children of the collab node with that of our existing Lexical node. const lexicalNode = this.getNode(); if (!(lexicalNode !== null)) { formatDevErrorMessage(`syncChildrenFromYjs: could not find element node`); } const key = lexicalNode.__key; const prevLexicalChildrenKeys = $createChildrenArray(lexicalNode, null); 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)) { { formatDevErrorMessage(`syncChildrenFromYjs: expected text, element, decorator, or linebreak collab node`); } } } 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); 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, nextLexicalNode, prevNodeMap) { syncPropertiesFromLexical(binding, this._xmlText, this.getPrevNode(prevNodeMap), nextLexicalNode); } _syncChildFromLexical(binding, index, key, prevNodeMap, dirtyElements, dirtyLeaves) { 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, nextLexicalNode, prevNodeMap, dirtyElements, dirtyLeaves) { 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; let nextChildrenSet; 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) { 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, index, delCount, collabNode) { const children = this._children; const child = children[index]; if (child === undefined) { if (!(collabNode !== undefined)) { formatDevErrorMessage(`splice: could not find collab element node`); } this.append(collabNode); return; } const offset = child.getOffset(); if (!(offset !== -1)) { formatDevErrorMessage(`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) { 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) { const collabNodeMap = binding.collabNodeMap; const children = this._children; for (let i = 0; i < children.length; i++) { children[i].destroy(binding); } if (collabNodeMap.get(this._key) === this) { collabNodeMap.delete(this._key); } } } function $createCollabElementNode(xmlText, parent, type) { const collabNode = new CollabElementNode(xmlText, parent, type); xmlText._collabNode = collabNode; return collabNode; } function createBinding(editor, provider, id, doc, docMap, excludedProperties) { if (!(doc !== undefined && doc !== null)) { formatDevErrorMessage(`createBinding: doc is null or undefined`); } const rootXmlText = doc.get('root', XmlText); const root = $createCollabElementNode(rootXmlText, null, 'root'); root._key = 'root'; return { clientID: doc.clientID, collabNodeMap: new Map(), cursors: new Map(), cursorsContainer: null, doc, docMap, editor, excludedProperties: excludedProperties || new Map(), id, nodeProperties: new Map(), root }; } function createRelativePosition(point, binding) { const collabNodeMap = binding.collabNodeMap; const collabNode = collabNodeMap.get(point.key); if (collabNode === undefined) { return null; } let offset = point.offset; let sharedType = collabNode.getSharedType(); if (collabNode instanceof CollabTextNode) { sharedType = collabNode._parent._xmlText; const currentOffset = collabNode.getOffset(); if (currentOffset === -1) { return null; } offset = currentOffset + 1 + offset; } else if (collabNode instanceof CollabElementNode && point.type === 'element') { const parent = point.getNode(); if (!$isElementNode(parent)) { formatDevErrorMessage(`Element point must be an element node`); } let accumulatedOffset = 0; let i = 0; let node = parent.getFirstChild(); while (node !== null && i++ < offset) { if ($isTextNode(node)) { accumulatedOffset += node.getTextContentSize() + 1; } else { accumulatedOffset++; } node = node.getNextSibling(); } offset = accumulatedOffset; } return createRelativePositionFromTypeIndex(sharedType, offset); } function createAbsolutePosition(relativePosition, binding) { return createAbsolutePositionFromRelativePosition(relativePosition, binding.doc); } function shouldUpdatePosition(currentPos, pos) { if (currentPos == null) { if (pos != null) { return true; } } else if (pos == null || !compareRelativePositions(currentPos, pos)) { return true; } return false; } function createCursor(name, color) { return { color: color, name: name, selection: null }; } function destroySelection(binding, selection) { const cursorsContainer = binding.cursorsContainer; if (cursorsContainer !== null) { const selections = selection.selections; const selectionsLength = selections.length; for (let i = 0; i < selectionsLength; i++) { cursorsContainer.removeChild(selections[i]); } } } function destroyCursor(binding, cursor) { const selection = cursor.selection; if (selection !== null) { destroySelection(binding, selection); } } function createCursorSelection(cursor, anchorKey, anchorOffset, focusKey, focusOffset) { const color = cursor.color; const caret = document.createElement('span'); caret.style.cssText = `position:absolute;top:0;bottom:0;right:-1px;width:1px;background-color:${color};z-index:10;`; const name = document.createElement('span'); name.textContent = cursor.name; name.style.cssText = `position:absolute;left:-2px;top:-16px;background-color:${color};color:#fff;line-height:12px;font-size:12px;padding:2px;font-family:Arial;font-weight:bold;white-space:nowrap;`; caret.appendChild(name); return { anchor: { key: anchorKey, offset: anchorOffset }, caret, color, focus: { key: focusKey, offset: focusOffset }, name, selections: [] }; } function updateCursor(binding, cursor, nextSelection, nodeMap) { const editor = binding.editor; const rootElement = editor.getRootElement(); const cursorsContainer = binding.cursorsContainer; if (cursorsContainer === null || rootElement === null) { return; } const cursorsContainerOffsetParent = cursorsContainer.offsetParent; if (cursorsContainerOffsetParent === null) { return; } const containerRect = cursorsContainerOffsetParent.getBoundingClientRect(); const prevSelection = cursor.selection; if (nextSelection === null) { if (prevSelection === null) { return; } else { cursor.selection = null; destroySelection(binding, prevSelection); return; } } else { cursor.selection = nextSelection; } const caret = nextSelection.caret; const color = nextSelection.color; const selections = nextSelection.selections; const anchor = nextSelection.anchor; const focus = nextSelection.focus; const anchorKey = anchor.key; const focusKey = focus.key; const anchorNode = nodeMap.get(anchorKey); const focusNode = nodeMap.get(focusKey); if (anchorNode == null || focusNode == null) { return; } let selectionRects; // In the case of a collapsed selection on a linebreak, we need // to improvise as the browser will return nothing here as <br> // apparently take up no visual space :/ // This won't work in all cases, but it's better than just showing // nothing all the time. if (anchorNode === focusNode && $isLineBreakNode(anchorNode)) { const brRect = editor.getElementByKey(anchorKey).getBoundingClientRect(); selectionRects = [brRect]; } else { const range = createDOMRange(editor, anchorNode, anchor.offset, focusNode, focus.offset); if (range === null) { return; } selectionRects = createRectsFromDOMRange(editor, range); } const selectionsLength = selections.length; const selectionRectsLength = selectionRects.length; for (let i = 0; i < selectionRectsLength; i++) { const selectionRect = selectionRects[i]; let selection = selections[i]; if (selection === undefined) { selection = document.createElement('span'); selections[i] = selection; const selectionBg = document.createElement('span'); selection.appendChild(selectionBg); cursorsContainer.appendChild(selection); } const top = selectionRect.top - containerRect.top; const left = selectionRect.left - containerRect.left; const style = `position:absolute;top:${top}px;left:${left}px;height:${selectionRect.height}px;width:${selectionRect.width}px;pointer-events:none;z-index:5;`; selection.style.cssText = style; selection.firstChild.style.cssText = `${style}left:0;top:0;background-color:${color};opacity:0.3;`; if (i === selectionRectsLength - 1) { if (caret.parentNode !== selection) { selection.appendChild(caret); } } } for (let i = selectionsLength - 1; i >= selectionRectsLength; i--) { const selection = selections[i]; cursorsContainer.removeChild(selection); selections.pop(); } } function getAnchorAndFocusCollabNodesForUserState(binding, userState) { const { anchorPos, focusPos } = userState; let anchorCollabNode = null; let anchorOffset = 0; let focusCollabNode = null; let focusOffset = 0; if (anchorPos !== null && focusPos !== null) { const anchorAbsPos = createAbsolutePosition(anchorPos, binding); const focusAbsPos = createAbsolutePosition(focusPos, binding); if (anchorAbsPos !== null && focusAbsPos !== null) { [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(anchorAbsPos.type, anchorAbsPos.index); [focusCollabNode, focusOffset] = getCollabNodeAndOffset(focusAbsPos.type, focusAbsPos.index); } } return { anchorCollabNode, anchorOffset, focusCollabNode, focusOffset }; } function $syncLocalCursorPosition(binding, provider) { const awareness = provider.awareness; const localState = awareness.getLocalState(); if (localState === null) { return; } const { anchorCollabNode, anchorOffset, focusCollabNode, focusOffset } = getAnchorAndFocusCollabNodesForUserState(binding, localState); if (anchorCollabNode !== null && focusCollabNode !== null) { const anchorKey = anchorCollabNode.getKey(); const focusKey = focusCollabNode.getKey(); const selection = $getSelection(); if (!$isRangeSelection(selection)) { return; } $setPoint(selection.anchor, anchorKey, anchorOffset); $setPoint(selection.focus, focusKey, focusOffset); } } function $setPoint(point, key, offset) { if (point.key !== key || point.offset !== offset) { let anchorNode = $getNodeByKey(key); if (anchorNode !== null && !$isElementNode(anchorNode) && !$isTextNode(anchorNode)) { const parent = anchorNode.getParentOrThrow(); key = parent.getKey(); offset = anchorNode.getIndexWithinParent(); anchorNode = parent; } point.set(key, offset, $isElementNode(anchorNode) ? 'element' : 'text'); } } function getCollabNodeAndOffset( // eslint-disable-next-line @typescript-eslint/no-explicit-any sharedType, offset) { const collabNode = sharedType._collabNode; if (collabNode === undefined) { return [null, 0]; } if (collabNode instanceof CollabElementNode) { const { node, offset: collabNodeOffset } = getPositionFromElementAndOffset(collabNode, offset, true); if (node === null) { return [collabNode, 0]; } else { return [node, collabNodeOffset]; } } return [null, 0]; } function getAwarenessStatesDefault(_binding, provider) { return provider.awareness.getStates(); } function syncCursorPositions(binding, provider, options) { const { getAwarenessStates = getAwarenessStatesDefault } = options ?? {}; const awarenessStates = Array.from(getAwarenessStates(binding, provider)); const localClientID = binding.clientID; const cursors = binding.cursors; const editor = binding.editor; const nodeMap = editor._editorState._nodeMap; const visitedClientIDs = new Set(); for (let i = 0; i < awarenessStates.length; i++) { const awarenessState = awarenessStates[i]; const [clientID, awareness] = awarenessState; if (clientID !== localClientID) { visitedClientIDs.add(clientID); const { name, color, focusing } = awareness; let selection = null; let cursor = cursors.get(clientID); if (cursor === undefined) { cursor = createCursor(name, color); cursors.set(clientID, cursor); } if (focusing) { const { anchorCollabNode, anchorOffset, focusCollabNode, focusOffset } = getAnchorAndFocusCollabNodesForUserState(binding, awareness); if (anchorCollabNode !== null && focusCollabNode !== null) { const anchorKey = anchorCollabNode.getKey(); const focusKey = focusCollabNode.getKey(); selection = cursor.selection; if (selection === null) { selection = createCursorSelection(cursor, anchorKey, anchorOffset, focusKey, focusOffset); } else { const anchor = selection.anchor; const focus = selection.focus; anchor.key = anchorKey; anchor.offset = anchorOffset; focus.key = focusKey; focus.offset = focusOffset; } } } updateCursor(binding, cursor, selection, nodeMap); } } const allClientIDs = Array.from(cursors.keys()); for (let i = 0; i < allClientIDs.length; i++) { const clientID = allClientIDs[i]; if (!visitedClientIDs.has(clientID)) { const cursor = cursors.get(clientID); if (cursor !== undefined) { destroyCursor(binding, cursor); cursors.delete(clientID); } } } } function syncLexicalSelectionToYjs(binding, provider, prevSelection, nextSelection) { const awareness = provider.awareness; const localState = awareness.