@lewiiiis/lexical-yjs
Version:
The library provides Yjs editor bindings for Lexical.
1,661 lines (1,362 loc) • 51.8 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.
*/
'use strict';
var lexical = require('@lewiiiis/lexical');
var yjs = require('yjs');
var lexicalSelection = require('@lewiiiis/lexical-selection');
var lexicalOffset = require('@lewiiiis/lexical-offset');
/**
* 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 = 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;
collabNodeMap.delete(this._key);
}
}
function $createCollabLineBreakNode(map, parent) {
const collabNode = new CollabLineBreakNode(map, parent); // @ts-expect-error: internal field
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
};
}
/**
* 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 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 {
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) {
throw new Error('Should never happen');
}
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;
collabNodeMap.delete(this._key);
}
}
function $createCollabTextNode(map, text, parent, type) {
const collabNode = new CollabTextNode(map, text, parent, type); // @ts-expect-error: internal field
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.
*
*/
const excludedProperties = new Set(['__key', '__children', '__parent', '__cachedText', '__text']);
function $getNodeByKeyOrThrow(key) {
const node = lexical.$getNodeByKey(key);
if (node === null) {
throw new Error('Should never happen');
}
return node;
}
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 or inert 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 {
throw new Error('Should never happen');
}
collabNode._key = lexicalNode.__key;
return collabNode;
}
function getNodeTypeFromSharedType(sharedType) {
const type = sharedType instanceof yjs.Map ? sharedType.get('__type') : sharedType.getAttribute('__type');
if (type == null) {
throw new Error('Should never happen');
}
return type;
}
function getOrInitCollabNodeFromSharedType(binding, sharedType, parent) {
// @ts-expect-error: internal field
const collabNode = sharedType._collabNode;
if (collabNode === undefined) {
const registeredNodes = binding.editor._nodes;
const type = getNodeTypeFromSharedType(sharedType);
const nodeInfo = registeredNodes.get(type);
if (nodeInfo === undefined) {
throw new Error('Should never happen');
}
const sharedParent = sharedType.parent;
const targetParent = parent === undefined && sharedParent !== null ? getOrInitCollabNodeFromSharedType(binding, sharedParent) : parent || null;
if (!(targetParent instanceof CollabElementNode)) {
throw new Error('Should never happen');
}
if (sharedType instanceof yjs.XmlText) {
return $createCollabElementNode(sharedType, targetParent, type);
} else if (sharedType instanceof yjs.Map) {
if (targetParent === null) {
throw new Error('Should never happen');
}
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) {
throw new Error('createLexicalNode failed');
}
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()) : Object.keys(sharedType.getAttributes()) : Array.from(keysChanged);
let writableNode;
for (let i = 0; i < properties.length; i++) {
const property = properties[i];
if (excludedProperties.has(property)) {
continue;
}
const prevValue = lexicalNode[property];
let nextValue = sharedType instanceof yjs.Map ? sharedType.get(property) : sharedType.getAttribute(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();
}
writableNode[property] = nextValue;
}
}
}
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 !excludedProperties.has(property);
});
nodeProperties.set(type, properties);
}
const EditorClass = binding.editor.constructor;
for (let i = 0; i < properties.length; i++) {
const property = properties[i];
const prevValue = prevLexicalNode === null ? undefined : prevLexicalNode[property];
let nextValue = nextLexicalNode[property];
if (prevValue !== nextValue) {
if (nextValue instanceof EditorClass) {
const yjsDocMap = binding.docMap;
let prevDoc;
if (prevValue instanceof EditorClass) {
// @ts-expect-error Lexical node
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; // @ts-expect-error Lexical node
nextValue._key = key;
yjsDocMap.set(key, doc);
nextValue = doc; // Mark the node dirty as we've assigned a new key to it
binding.editor.update(() => {
nextLexicalNode.markDirty();
});
}
if (sharedType instanceof yjs.Map) {
sharedType.set(property, nextValue);
} else {
sharedType.setAttribute(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);
}
/**
* 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 CollabDecoratorNode {
constructor(xmlElem, parent, type) {
this._key = '';
this._xmlElem = xmlElem;
this._parent = parent;
this._type = type;
this._unobservers = new Set();
}
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) {
throw new Error('Should never happen');
}
const xmlElem = this._xmlElem;
syncPropertiesFromYjs(binding, xmlElem, lexicalNode, keysChanged);
}
destroy(binding) {
const collabNodeMap = binding.collabNodeMap;
collabNodeMap.delete(this._key);
this._unobservers.forEach(unobserver => unobserver());
this._unobservers.clear();
}
}
function $createCollabDecoratorNode(xmlElem, parent, type) {
const collabNode = new CollabDecoratorNode(xmlElem, parent, type); // @ts-expect-error: internal field
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 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 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) {
throw new Error('Should never happen');
}
return collabElementNode.getChildOffset(this);
}
syncPropertiesFromYjs(binding, keysChanged) {
const lexicalNode = this.getNode();
if (lexicalNode === null) {
this.getNode();
throw new Error('Should never happen');
}
syncPropertiesFromYjs(binding, this._xmlText, lexicalNode, keysChanged);
}
applyChildrenYjsDelta(binding, deltas) {
const children = this._children;
let currIndex = 0;
for (let i = 0; i < deltas.length; i++) {
const delta = deltas[i];
const insertDelta = delta.insert;
const deleteDelta = delta.delete;
if (delta.retain != null) {
currIndex += delta.retain;
} else if (typeof deleteDelta === 'number') {
let deletionSize = deleteDelta;
while (deletionSize > 0) {
const {
node,
nodeIndex,
offset,
length
} = getPositionFromElementAndOffset(this, currIndex, false);
if (node instanceof CollabElementNode || node instanceof CollabLineBreakNode || node instanceof CollabDecoratorNode) {
children.splice(nodeIndex, 1);
deletionSize -= 1;
} else if (node instanceof CollabTextNode) {
const delCount = Math.min(deletionSize, length);
const prevCollabNode = nodeIndex !== 0 ? children[nodeIndex - 1] : null;
const nodeSize = node.getSize();
if (offset === 0 && delCount === 1 && nodeIndex > 0 && prevCollabNode instanceof CollabTextNode && length === nodeSize && // If the node has no keys, it's been deleted
Array.from(node._map.keys()).length === 0) {
// Merge the text node with previous.
prevCollabNode._text += node._text;
children.splice(nodeIndex, 1);
} else if (offset === 0 && delCount === nodeSize) {
// The entire thing needs removing
children.splice(nodeIndex, 1);
} else {
node._text = spliceString(node._text, offset, delCount, '');
}
deletionSize -= delCount;
} else {
// Can occur due to the deletion from the dangling text heuristic below.
break;
}
}
} else if (insertDelta != null) {
if (typeof insertDelta === 'string') {
const {
node,
offset
} = getPositionFromElementAndOffset(this, currIndex, true);
if (node instanceof CollabTextNode) {
node._text = spliceString(node._text, offset, 0, insertDelta);
} else {
// TODO: maybe we can improve this by keeping around a redundant
// text node map, rather than removing all the text nodes, so there
// never can be dangling text.
// We have a conflict where there was likely a CollabTextNode and
// an Lexical TextNode too, but they were removed in a merge. So
// let's just ignore the text and trigger a removal for it from our
// shared type.
this._xmlText.delete(offset, insertDelta.length);
}
currIndex += insertDelta.length;
} else {
const sharedType = insertDelta;
const {
nodeIndex
} = getPositionFromElementAndOffset(this, currIndex, false);
const collabNode = getOrInitCollabNodeFromSharedType(binding, sharedType, this);
children.splice(nodeIndex, 0, collabNode);
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) {
this.getNode();
throw new Error('Should never happen');
}
const key = lexicalNode.__key;
const prevLexicalChildrenKeys = lexicalNode.__children;
const nextLexicalChildrenKeys = [];
const lexicalChildrenKeysLength = prevLexicalChildrenKeys.length;
const collabChildren = this._children;
const collabChildrenLength = collabChildren.length;
const collabNodeMap = binding.collabNodeMap;
const visitedKeys = new Set();
let collabKeys; // Assign the new children key array that we're about to mutate
let writableLexicalNode;
if (collabChildrenLength !== lexicalChildrenKeysLength) {
writableLexicalNode = lazilyCloneElementNode(lexicalNode, writableLexicalNode, nextLexicalChildrenKeys);
}
let prevIndex = 0;
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)) {
throw new Error('Should never happen');
}
}
nextLexicalChildrenKeys[i] = lexicalChildKey;
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)) {
i--;
prevIndex++;
continue;
}
writableLexicalNode = lazilyCloneElementNode(lexicalNode, writableLexicalNode, nextLexicalChildrenKeys); // Create/Replace
const lexicalChildNode = createLexicalNodeFromCollabNode(binding, childCollabNode, key);
const childKey = lexicalChildNode.__key;
collabNodeMap.set(childKey, childCollabNode);
nextLexicalChildrenKeys[i] = childKey;
}
}
for (let i = 0; i < lexicalChildrenKeysLength; i++) {
const lexicalChildKey = prevLexicalChildrenKeys[i];
if (!visitedKeys.has(lexicalChildKey)) {
// Remove
const lexicalChildNode = $getNodeByKeyOrThrow(lexicalChildKey).getWritable();
const collabNode = binding.collabNodeMap.get(lexicalChildKey);
if (collabNode !== undefined) {
collabNode.destroy(binding);
}
lexicalChildNode.__parent = null;
}
}
}
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 && 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 ? [] : prevLexicalNode.__children;
const nextChildren = nextLexicalNode.__children;
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) {
this.append(collabNode);
} else {
throw new Error('Should never happen');
}
return;
}
const offset = child.getOffset();
if (offset === -1) {
throw new Error('Should never happen');
}
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);
}
collabNodeMap.delete(this._key);
}
}
function lazilyCloneElementNode(lexicalNode, writableLexicalNode, nextLexicalChildrenKeys) {
if (writableLexicalNode === undefined) {
const clone = lexicalNode.getWritable();
clone.__children = nextLexicalChildrenKeys;
return clone;
}
return writableLexicalNode;
}
function $createCollabElementNode(xmlText, parent, type) {
const collabNode = new CollabElementNode(xmlText, parent, type); // @ts-expect-error: internal field
xmlText._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 createBinding(editor, provider, id, doc, docMap) {
if (doc === undefined || doc === null) {
throw new Error('Should never happen');
}
const rootXmlText = doc.get('root', yjs.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,
id,
nodeProperties: new Map(),
root
};
}
/**
* 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 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;
}
return yjs.createRelativePositionFromTypeIndex(sharedType, offset);
}
function createAbsolutePosition(relativePosition, binding) {
return yjs.createAbsolutePositionFromRelativePosition(relativePosition, binding.doc);
}
function shouldUpdatePosition(currentPos, pos) {
if (currentPos == null) {
if (pos != null) {
return true;
}
} else if (pos == null || !yjs.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:rgb(${color});z-index:10;`;
const name = document.createElement('span');
name.textContent = cursor.name;
name.style.cssText = `position:absolute;left:-2px;top:-16px;background-color:rgb(${color});color:#fff;line-height:12px;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 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;
}
const range = lexicalSelection.createDOMRange(editor, anchorNode, anchor.offset, focusNode, focus.offset);
if (range === null) {
return;
}
const selectionsLength = selections.length;
const selectionRects = lexicalSelection.createRectsFromDOMRange(editor, range);
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;
cursorsContainer.appendChild(selection);
}
const style = `position:absolute;top:${selectionRect.top}px;left:${selectionRect.left}px;height:${selectionRect.height}px;width:${selectionRect.width}px;background-color:rgba(${color}, 0.3);pointer-events:none;z-index:5;`;
selection.style.cssText = style;
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 syncLocalCursorPosition(binding, provider) {
const awareness = provider.awareness;
const localState = awareness.getLocalState();
if (localState === null) {
return;
}
const anchorPos = localState.anchorPos;
const focusPos = localState.focusPos;
if (anchorPos !== null && focusPos !== null) {
const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
const focusAbsPos = createAbsolutePosition(focusPos, binding);
if (anchorAbsPos !== null && focusAbsPos !== null) {
const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(anchorAbsPos.type, anchorAbsPos.index);
const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(focusAbsPos.type, focusAbsPos.index);
if (anchorCollabNode !== null && focusCollabNode !== null) {
const anchorKey = anchorCollabNode.getKey();
const focusKey = focusCollabNode.getKey();
const selection = lexical.$getSelection();
if (!lexical.$isRangeSelection(selection)) {
return;
}
const anchor = selection.anchor;
const focus = selection.focus;
if (anchor.key !== anchorKey || anchor.offset !== anchorOffset) {
const anchorNode = lexical.$getNodeByKey(anchorKey);
selection.anchor.set(anchorKey, anchorOffset, lexical.$isElementNode(anchorNode) ? 'element' : 'text');
}
if (focus.key !== focusKey || focus.offset !== focusOffset) {
const focusNode = lexical.$getNodeByKey(focusKey);
selection.focus.set(focusKey, focusOffset, lexical.$isElementNode(focusNode) ? '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 syncCursorPositions(binding, provider) {
const awarenessStates = Array.from(provider.awareness.getStates());
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 {
anchorPos,
focusPos,
name,
color,
focusing
} = awareness;
let selection = null;
let cursor = cursors.get(clientID);
if (cursor === undefined) {
cursor = createCursor(name, color);
cursors.set(clientID, cursor);
}
if (anchorPos !== null && focusPos !== null && focusing) {
const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
const focusAbsPos = createAbsolutePosition(focusPos, binding);
if (anchorAbsPos !== null && focusAbsPos !== null) {
const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(anchorAbsPos.type, anchorAbsPos.index);
const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(focusAbsPos.type, focusAbsPos.index);
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.getLocalState();
if (localState === null) {
return;
}
const {
anchorPos: currentAnchorPos,
focusPos: currentFocusPos,
name,
color,
focusing
} = localState;
let anchorPos = null;
let focusPos = null;
if (nextSelection === null || currentAnchorPos !== null && !nextSelection.is(prevSelection)) {
if (prevSelection === null) {
return;
}
}
if (lexical.$isRangeSelection(nextSelection)) {
anchorPos = createRelativePosition(nextSelection.anchor, binding);
focusPos = createRelativePosition(nextSelection.focus, binding);
}
if (shouldUpdatePosition(currentAnchorPos, anchorPos) || shouldUpdatePosition(currentFocusPos, focusPos)) {
awareness.setLocalState({
anchorPos,
color,
focusPos,
focusing,
name
});
}
}
/**
* 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 syncEvent(binding, event) {
const {
target
} = event;
const collabNode = getOrInitCollabNodeFromSharedType(binding, target);
if (collabNode instanceof CollabElementNode && event instanceof yjs.YTextEvent) {
// @ts-expect-error We need to access the private property of the class
const {
keysChanged,
childListChanged,
delta
} = event; // Update
if (keysChanged.size > 0) {
collabNode.syncPropertiesFromYjs(binding, keysChanged);
}
if (childListChanged) {
collabNode.applyChildrenYjsDelta(binding, delta);
collabNode.syncChildrenFromYjs(binding);
}
} else if (collabNode instanceof CollabTextNode && event instanceof yjs.YMapEvent) {
const {
keysChanged
} = event; // Update
if (keysChanged.size > 0) {
collabNode.syncPropertiesAndTextFromYjs(binding, keysChanged);
}
} else if (collabNode instanceof CollabDecoratorNode && event instanceof yjs.YXmlEvent) {
const {
attributesChanged
} = event; // Update
if (attributesChanged.size > 0) {
collabNode.syncPropertiesFromYjs(binding, attributesChanged);
}
} else {
throw new Error('Should never happen');
}
}
function syncYjsChangesToLexical(binding, provider, events) {
const editor = binding.editor;
const currentEditorState = editor._editorState;
editor.update(() => {
const pendingEditorState = editor._pendingEditorState;
for (let i = 0; i < events.length; i++) {
const event = events[i];
syncEvent(binding, event);
}
const selection = lexical.$getSelection();
if (lexical.$isRangeSelection(selection)) {
// We can't use Yjs's cursor position here, as it doesn't always
// handle selection recovery correctly – especially on elements that
// get moved around or split. So instead, we roll our own solution.
if (doesSelectionNeedRecovering(selection)) {
const prevSelection = currentEditorState._selection;
if (lexical.$isRangeSelection(prevSelection)) {
const prevOffsetView = lexicalOffset.$createOffsetView(editor, 0, currentEditorState);
const nextOffsetView = lexicalOffset.$createOffsetView(editor, 0, pendingEditorState);
const [start, end] = prevOffsetView.getOffsetsFromSelection(prevSelection);
const nextSelection = nextOffsetView.createSelectionFromOffsets(start, end, prevOffsetView);
if (nextSelection !== null) {
lexical.$setSelection(nextSelection);
} else {
// Fallback is to use the Yjs cursor position
syncLocalCursorPosition(binding, provider);
if (doesSelectionNeedRecovering(selection)) {
const root = lexical.$getRoot(); // If there was a collision on the top level paragraph
// we need to re-add a paragraph
if (root.getChildrenSize() === 0) {
root.append(lexical.$createParagraphNode());
} // Fallback
lexical.$getRoot().selectEnd();
}
}
}
syncLexicalSelectionToYjs(binding, provider, prevSelection, lexical.$getSelection());
} else {
syncLocalCursorPosition(binding, provider);
}
}
}, {
onUpdate: () => {
syncCursorPositions(binding, provider);
},
skipTransforms: true,
tag: 'collaboration'
});
}
function handleNormalizationMergeConflicts(binding, normalizedNodes) {
// We handle the merge operations here
const normalizedNodesKeys = Array.from(normalizedNodes);
const collabNodeMap = binding.collabNodeMap;
const mergedNodes = [];
for (let i = 0; i < normalizedNodesKeys.length; i++) {
const nodeKey = normalizedNodesKeys[i];
const lexicalNode = lexical.$getNodeByKey(nodeKey);
const collabNode = collabNodeMap.get(nodeKey);
if (collabNode instanceof CollabTextNode) {
if (lexical.$isTextNode(lexicalNode)) {
// We mutate the text collab nodes after removing
// all the dead nodes first, otherwise offsets break.
mergedNodes.push([collabNode, lexicalNode.__text]);
} else {
const offset = collabNode.getOffset();
if (offset === -1) {
continue;
}
const parent = collabNode._parent;
collabNode._normalized = true;
parent._xmlText.delete(offset, 1);
collabNodeMap.delete(nodeKey);
const parentChildren = parent._children;
const index = parentChildren.indexOf(collabNode);
parentChildren.splice(index, 1);
}
}
}
for (let i = 0; i < mergedNodes.length; i++) {
const [collabNode, text] = mergedNodes[i];
if (collabNode instanceof CollabTextNode && typeof text === 'string') {
collabNode._text = text;
}
}
}
function syncLexicalUpdateToYjs(binding, provider, prevEditorState, currEditorState, dirtyElements, dirtyLeaves, normalizedNodes, tags) {
syncWithTransaction(binding, () => {
currEditorState.read(() => {
// We check if the update has come from a origin where the origin
// was the collaboration binding previously. This can help us
// prevent unnecessarily re-diffing and possible re-applying
// the same change editor state again. For example, if a user
// types a character and we get it, we don't want to then insert
// the same character again. The exception to this heuristic is
// when we need to handle normalization merge conflicts.
if (tags.has('collaboration')) {
if (normalizedNodes.size > 0) {
handleNormalizationMergeConflicts(binding, normalizedNodes);
}
return;
}
if (dirtyElements.has('root')) {
const prevNodeMap = prevEditorState._nodeMap;
const nextLexicalRoot = lexical.$getRoot();
const collabRoot = binding.root;
collabRoot.syncPropertiesFromLexical(binding, nextLexicalRoot, prevNod