UNPKG

@lexical/yjs

Version:

The library provides Yjs editor bindings for Lexical.

1,383 lines (1,358 loc) • 99.5 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. * */ 'use strict'; var lexical = require('lexical'); var yjs = require('yjs'); var selection = require('@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. * */ 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 }; } class CollabDecoratorNode { _xmlElem; _key; _parent; _type; 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 lexical.$isDecoratorNode(node) ? node : null; } getNode() { const node = lexical.$getNodeByKey(this._key); return lexical.$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; } /** * 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 { _map; _key; _parent; _type; constructor(map, parent) { this._key = ''; this._map = map; this._parent = parent; this._type = 'linebreak'; } getNode() { const node = lexical.$getNodeByKey(this._key); return lexical.$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; } function $diffTextContentAndApplyDelta(collabNode, key, prevText, nextText) { const selection = lexical.$getSelection(); let cursorOffset = nextText.length; if (lexical.$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 { _map; _key; _parent; _text; _type; _normalized; 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 lexical.$isTextNode(node) ? node : null; } getNode() { const node = lexical.$getNodeByKey(this._key); return lexical.$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) { lexicalNode.setTextContent(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; } class CollabElementNode { _key; _children; _xmlText; _type; _parent; 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 lexical.$isElementNode(node) ? node : null; } getNode() { const node = lexical.$getNodeByKey(this._key); return lexical.$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 = lexical.$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 = lexical.$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 = lexical.$getNodeByKeyOrThrow(lexicalChildKey); lexical.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 = lexical.$getNodeByKeyOrThrow(lexicalChildKey); const collabNode = binding.collabNodeMap.get(lexicalChildKey); if (collabNode !== undefined) { collabNode.destroy(binding); } lexical.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 = lexical.$getNodeByKeyOrThrow(key); if (childCollabNode instanceof CollabElementNode && lexical.$isElementNode(nextChildNode)) { childCollabNode.syncPropertiesFromLexical(binding, nextChildNode, prevNodeMap); childCollabNode.syncChildrenFromLexical(binding, nextChildNode, prevNodeMap, dirtyElements, dirtyLeaves); } else if (childCollabNode instanceof CollabTextNode && lexical.$isTextNode(nextChildNode)) { childCollabNode.syncPropertiesAndTextFromLexical(binding, nextChildNode, prevNodeMap); } else if (childCollabNode instanceof CollabDecoratorNode && lexical.$isDecoratorNode(nextChildNode)) { childCollabNode.syncPropertiesFromLexical(binding, nextChildNode, prevNodeMap); } } syncChildrenFromLexical(binding, nextLexicalNode, prevNodeMap, dirtyElements, dirtyLeaves) { const prevLexicalNode = this.getPrevNode(prevNodeMap); const prevChildren = prevLexicalNode === null ? [] : lexical.$createChildrenArray(prevLexicalNode, prevNodeMap); const nextChildren = lexical.$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 = lexical.$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 = lexical.$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; } // Stores mappings between Yjs shared types and the Lexical nodes they were last associated with. class CollabV2Mapping { _nodeMap = new Map(); _sharedTypeToNodeKeys = new Map(); _nodeKeyToSharedType = new Map(); set(sharedType, node) { const isArray = node instanceof Array; // Clear all existing associations for this key. this.delete(sharedType); // If nodes were associated with other shared types, remove those associations. const nodes = isArray ? node : [node]; for (const n of nodes) { const key = n.getKey(); if (this._nodeKeyToSharedType.has(key)) { const otherSharedType = this._nodeKeyToSharedType.get(key); const keyIndex = this._sharedTypeToNodeKeys.get(otherSharedType).indexOf(key); if (keyIndex !== -1) { this._sharedTypeToNodeKeys.get(otherSharedType).splice(keyIndex, 1); } this._nodeKeyToSharedType.delete(key); this._nodeMap.delete(key); } } if (sharedType instanceof yjs.XmlText) { if (!isArray) { formatDevErrorMessage(`Text nodes must be mapped as an array`); } if (node.length === 0) { return; } this._sharedTypeToNodeKeys.set(sharedType, node.map(n => n.getKey())); for (const n of node) { this._nodeMap.set(n.getKey(), n); this._nodeKeyToSharedType.set(n.getKey(), sharedType); } } else { if (!!isArray) { formatDevErrorMessage(`Element nodes must be mapped as a single node`); } if (!!lexical.$isTextNode(node)) { formatDevErrorMessage(`Text nodes must be mapped to XmlText`); } this._sharedTypeToNodeKeys.set(sharedType, [node.getKey()]); this._nodeMap.set(node.getKey(), node); this._nodeKeyToSharedType.set(node.getKey(), sharedType); } } get(sharedType) { const nodes = this._sharedTypeToNodeKeys.get(sharedType); if (nodes === undefined) { return undefined; } if (sharedType instanceof yjs.XmlText) { const arr = Array.from(nodes.map(nodeKey => this._nodeMap.get(nodeKey))); return arr.length > 0 ? arr : undefined; } return this._nodeMap.get(nodes[0]); } getSharedType(node) { return this._nodeKeyToSharedType.get(node.getKey()); } delete(sharedType) { const nodeKeys = this._sharedTypeToNodeKeys.get(sharedType); if (nodeKeys === undefined) { return; } for (const nodeKey of nodeKeys) { this._nodeMap.delete(nodeKey); this._nodeKeyToSharedType.delete(nodeKey); } this._sharedTypeToNodeKeys.delete(sharedType); } deleteNode(nodeKey) { const sharedType = this._nodeKeyToSharedType.get(nodeKey); if (sharedType) { this.delete(sharedType); } this._nodeMap.delete(nodeKey); } has(sharedType) { return this._sharedTypeToNodeKeys.has(sharedType); } clear() { this._nodeMap.clear(); this._sharedTypeToNodeKeys.clear(); this._nodeKeyToSharedType.clear(); } } function createBaseBinding(editor, id, doc, docMap, excludedProperties) { if (!(doc !== undefined && doc !== null)) { formatDevErrorMessage(`createBinding: doc is null or undefined`); } const binding = { clientID: doc.clientID, cursors: new Map(), cursorsContainer: null, doc, docMap, editor, excludedProperties: excludedProperties || new Map(), id, nodeProperties: new Map() }; initializeNodeProperties(binding); return binding; } 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', yjs.XmlText); const root = $createCollabElementNode(rootXmlText, null, 'root'); root._key = 'root'; return { ...createBaseBinding(editor, id, doc, docMap, excludedProperties), collabNodeMap: new Map(), root }; } function createBindingV2__EXPERIMENTAL(editor, id, doc, docMap, options = {}) { if (!(doc !== undefined && doc !== null)) { formatDevErrorMessage(`createBinding: doc is null or undefined`); } const { excludedProperties, rootName = 'root-v2' } = options; return { ...createBaseBinding(editor, id, doc, docMap, excludedProperties), mapping: new CollabV2Mapping(), root: doc.get(rootName, yjs.XmlElement) }; } function isBindingV1(binding) { return Object.hasOwn(binding, 'collabNodeMap'); } const baseExcludedProperties = new Set(['__key', '__parent', '__next', '__prev', '__state']); const elementExcludedProperties = new Set(['__first', '__last', '__size']); 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 (lexical.$isTextNode(node)) { if (textExcludedProperties.has(name)) { return true; } } else if (lexical.$isElementNode(node)) { if (elementExcludedProperties.has(name) || lexical.$isRootNode(node) && rootExcludedProperties.has(name)) { return true; } } const nodeKlass = node.constructor; const excludedProperties = binding.excludedProperties.get(nodeKlass); return excludedProperties != null && excludedProperties.has(name); } function initializeNodeProperties(binding) { const { editor, nodeProperties } = binding; editor.update(() => { editor._nodes.forEach(nodeInfo => { const node = new nodeInfo.klass(); const defaultProperties = {}; for (const [property, value] of Object.entries(node)) { if (!isExcludedProperty(property, node, binding)) { defaultProperties[property] = value; } } nodeProperties.set(node.__type, Object.freeze(defaultProperties)); }); }); } function getDefaultNodeProperties(node, binding) { const type = node.__type; const { nodeProperties } = binding; const properties = nodeProperties.get(type); if (!(properties !== undefined)) { formatDevErrorMessage(`Node properties for ${type} not initialized for sync`); } return properties; } function $createCollabNodeFromLexicalNode(binding, lexicalNode, parent) { const nodeType = lexicalNode.__type; let collabNode; if (lexical.$isElementNode(lexicalNode)) { const xmlText = new yjs.XmlText(); collabNode = $createCollabElementNode(xmlText, parent, nodeType); collabNode.syncPropertiesFromLexical(binding, lexicalNode, null); collabNode.syncChildrenFromLexical(binding, lexicalNode, null, null, null); } else if (lexical.$isTextNode(lexicalNode)) { // TODO create a token text node for token, segmented nodes. const map = new yjs.Map(); collabNode = $createCollabTextNode(map, lexicalNode.__text, parent, nodeType); collabNode.syncPropertiesAndTextFromLexical(binding, lexicalNode, null); } else if (lexical.$isLineBreakNode(lexicalNode)) { const map = new yjs.Map(); map.set('__type', 'linebreak'); collabNode = $createCollabLineBreakNode(map, parent); } else if (lexical.$isDecoratorNode(lexicalNode)) { const xmlElem = new yjs.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 yjs.XmlText) { return $createCollabElementNode(sharedType, targetParent, type); } else if (sharedType instanceof yjs.Map) { if (type === 'linebreak') { return $createCollabLineBreakNode(sharedType, targetParent); } return $createCollabTextNode(sharedType, '', targetParent, type); } else if (sharedType instanceof yjs.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 yjs.Map ? Array.from(sharedType.keys()) : sharedType instanceof yjs.XmlText || sharedType instanceof yjs.XmlElement ? Object.keys(sharedType.getAttributes()) : Object.keys(sharedType) : 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' && isBindingV1(binding)) { if (!writableNode) { writableNode = lexicalNode.getWritable(); } $syncNodeStateToLexical(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 yjs.Doc) { const yjsDocMap = binding.docMap; if (prevValue instanceof yjs.Doc) { yjsDocMap.delete(prevValue.guid); } const nestedEditor = lexical.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 yjs.Map) { return sharedType.get(property); } else if (sharedType instanceof yjs.XmlText || sharedType instanceof yjs.XmlElement) { return sharedType.getAttribute(property); } else { return sharedType[property]; } } function sharedTypeSet(sharedType, property, nextValue) { if (sharedType instanceof yjs.Map) { sharedType.set(property, nextValue); } else { sharedType.setAttribute(property, nextValue); } } function $syncNodeStateToLexical(sharedType, lexicalNode) { const existingState = sharedTypeGet(sharedType, '__state'); if (!(existingState instanceof yjs.Map)) { return; } // This should only called when creating the node initially, // incremental updates to state come in through YMapEvent // with the __state as the target. lexical.$getWritableNodeState(lexicalNode).updateFromJSON(existingState.toJSON()); } function syncNodeStateFromLexical(binding, sharedType, prevLexicalNode, nextLexicalNode) { const nextState = nextLexicalNode.__state; // Reading from a shared type that hasn't been integrated into a doc yet // logs a "premature access" warning in yjs >= 13.6.10. When the shared // type is detached we know there cannot be any existing state. const existingState = sharedType.doc === null ? undefined : sharedTypeGet(sharedType, '__state'); if (!nextState) { return; } const [unknown, known] = nextState.getInternalState(); const prevState = prevLexicalNode && prevLexicalNode.__state; const stateMap = existingState instanceof yjs.Map ? existingState : new yjs.Map(); 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 properties = Object.keys(getDefaultNodeProperties(nextLexicalNode, binding)); 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 yjs.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 lexical.$isTextNode(anchorNode) && anchor.offset > anchorNode.getTextContentSize() || lexical.$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) { lexical.$getRoot().selectStart(); return; } // Get previous node const prevNodeKey = anchorNode.__prev; let prevNode = null; if (prevNodeKey) { prevNode = lexical.$getNodeByKey(prevNodeKey); } // If previous node not found, get parent node if (prevNode === null && anchorNode.__parent !== null) { prevNode = lexical.$getNodeByKey(anchorNode.__parent); } if (prevNode === null) { lexical.$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); } } // https://docs.yjs.dev/api/shared-types/y.xmlelement // "Define a top-level type; Note that the nodeName is always "undefined"" const isRootElement = el => el.nodeName === 'UNDEFINED'; const $createOrUpdateNodeFromYElement = (el, binding, keysChanged, childListChanged, snapshot, prevSnapshot, computeYChange) => { let node = binding.mapping.get(el); if (node && keysChanged && keysChanged.size === 0 && !childListChanged) { return node; } const type = isRootElement(el) ? lexical.RootNode.getType() : el.nodeName; const registeredNodes = binding.editor._nodes; const nodeInfo = registeredNodes.get(type); if (nodeInfo === undefined) { throw new Error(`$createOrUpdateNodeFromYElement: Node ${type} is not registered`); } if (!node) { node = new nodeInfo.klass(); keysChanged = null; childListChanged = true; } if (childListChanged && node instanceof lexical.ElementNode) { const children = []; const $createChildren = childType => { if (childType instanceof yjs.XmlElement) { const n = $createOrUpdateNodeFromYElement(childType, binding, new Set(), false, snapshot, prevSnapshot, computeYChange); if (n !== null) { children.push(n); } } else if (childType instanceof yjs.XmlText) { const ns = $createOrUpdateTextNodesFromYText(childType, binding, snapshot, prevSnapshot, computeYChange); if (ns !== null) { ns.forEach(textchild => { if (textchild !== null) { children.push(textchild); } }); } } else { { formatDevErrorMessage(`XmlHook is not supported`); } } }; if (snapshot === undefined || prevSnapshot === undefined) { el.toArray().forEach($createChildren); } else { yjs.typeListToArraySnapshot(el, new yjs.Snapshot(prevSnapshot.ds, snapshot.sv)).filter(childType => !childType._item.deleted || isItemVisible(childType._item, snapshot) || isItemVisible(childType._item, prevSnapshot)).forEach($createChildren); } $spliceChildren(node, children); } // TODO(collab-v2): typing for XmlElement generic const attrs = el.getAttributes(snapshot); if (!isRootElement(el) && snapshot !== undefined) { if (!isItemVisible(el._item, snapshot)) { attrs[stateKeyToAttrKey('ychange')] = computeYChange ? computeYChange('removed', el._item.id) : { type: 'removed' }; } else if (!isItemVisible(el._item, prevSnapshot)) { attrs[stateKeyToAttrKey('ychange')] = computeYChange ? computeYChange('added', el._item.id) : { type: 'added' }; } } const properties = { ...getDefaultNodeProperties(node, binding) }; const state = {}; for (const k in attrs) { if (k.startsWith(STATE_KEY_PREFIX)) { state[attrKeyToStateKey(k)] = attrs[k]; } else { properties[k] = attrs[k]; } } $syncPropertiesFromYjs(binding, properties, node, keysChanged); if (!keysChanged) { lexical.$getWritableNodeState(node).updateFromJSON(state); } else { const stateKeysChanged = Object.keys(state).filter(k => keysChanged.has(stateKeyToAttrKey(k))); if (stateKeysChanged.length > 0) { const writableState = lexical.$getWritableNodeState(node); for (const k of stateKeysChanged) { writableState.updateFromUnknown(k, state[k]); } } } const latestNode = node.getLatest(); binding.mapping.set(el, latestNode); return latestNode; }; const $spliceChildren = (node, nextChildren) => { const prevChildren = node.getChildren(); const prevChildrenKeySet = new Set(prevChildren.map(child => child.getKey())); const nextChildrenKeySet = new Set(nextChildren.map(child => child.getKey())); const prevEndIndex = prevChildren.length - 1; const nextEndIndex = nextChildren.length - 1; let prevIndex = 0; let nextIndex = 0; while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) { const prevKey = prevChildren[prevIndex].getKey(); const nextKey = nextChildren[nextIndex].getKey(); if (prevKey === nextKey) { prevIndex++; nextIndex++; continue; } const nextHasPrevKey = nextChildrenKeySet.has(prevKey); const prevHasNextKey = prevChildrenKeySet.has(nextKey); if (!nextHasPrevKey) { // If removing the last node, insert remaining new nodes immediately, otherwise if the node // cannot be empty, it will remove itself from its parent. if (nextIndex === 0 && node.getChildrenSize() === 1) { node.splice(nextIndex, 1, nextChildren.slice(nextIndex)); return; } // Remove node.splice(nextIndex, 1, []); prevIndex++; continue; } // Create or replace const nextChildNode = nextChildren[nextIndex]; if (prevHasNextKey) { node.splice(nextIndex, 1, [nextChildNode]); prevIndex++; nextIndex++; } else { node.splice(nextIndex, 0, [nextChildNode]); nextIndex++; } } const appendNewChildren = prevIndex > prevEndIndex; const removeOldChildren = nextIndex > nextEndIndex; if (appendNewChildren && !removeOldChildren) { node.append(...nextChildren.slice(nextIndex)); } else if (removeOldChildren && !appendNewChildren) { node.splice(nextChildren.length, node.getChildrenSize() - nextChildren.length, []); } }; const isItemVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : snapshot.sv.has(item.id.client) && snapshot.sv.get(item.id.client) > item.id.clock && !yjs.isDeleted(snapshot.ds, item.id); const $createOrUpdateTextNodesFromYText = (text, binding, snapshot, prevSnapshot, computeYChange) => { const deltas = toDelta(text, snapshot, prevSnapshot, computeYChange); // Use existing text nodes if the count and types all align, otherwise throw out the existing // nodes and create new ones. let nodes = binding.mapping.get(text) ?? []; const nodeTypes = deltas.map(delta => delta.attributes.t ?? lexical.TextNode.getType()); const canReuseNodes = nodes.length === nodeTypes.length && nodes.every((node, i) => node.getType() === nodeTypes[i]); if (!canReuseNodes) { const registeredNodes = binding.ed