@lexical/yjs
Version:
The library provides Yjs editor bindings for Lexical.
1,394 lines (1,375 loc) • 59.9 kB
JavaScript
/**
* 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.