Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Failing after 34s
Build & Deploy / 🏗️ Build (push) Has started running
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
1668 lines
58 KiB
Plaintext
1668 lines
58 KiB
Plaintext
/**
|
|
* 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) {
|
|
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;
|
|
}
|
|
|
|
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 ($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.getLocalState();
|
|
if (localState === null) {
|
|
return;
|
|
}
|
|
const {
|
|
anchorPos: currentAnchorPos,
|
|
focusPos: currentFocusPos,
|
|
name,
|
|
color,
|
|
focusing,
|
|
awarenessData
|
|
} = localState;
|
|
let anchorPos = null;
|
|
let focusPos = null;
|
|
if (nextSelection === null || currentAnchorPos !== null && !nextSelection.is(prevSelection)) {
|
|
if (prevSelection === null) {
|
|
return;
|
|
}
|
|
}
|
|
if ($isRangeSelection(nextSelection)) {
|
|
anchorPos = createRelativePosition(nextSelection.anchor, binding);
|
|
focusPos = createRelativePosition(nextSelection.focus, binding);
|
|
}
|
|
if (shouldUpdatePosition(currentAnchorPos, anchorPos) || shouldUpdatePosition(currentFocusPos, focusPos)) {
|
|
awareness.setLocalState({
|
|
...localState,
|
|
anchorPos,
|
|
awarenessData,
|
|
color,
|
|
focusPos,
|
|
focusing,
|
|
name
|
|
});
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function $syncStateEvent(binding, event) {
|
|
const {
|
|
target
|
|
} = event;
|
|
if (!(target._item && target._item.parentSub === '__state' && getNodeTypeFromSharedType(target) === undefined && (target.parent instanceof XmlText || target.parent instanceof XmlElement || target.parent instanceof Map$1))) {
|
|
// TODO there might be a case to handle in here when a YMap
|
|
// is used as a value of __state? It would probably be desirable
|
|
// to mark the node as dirty when that happens.
|
|
return false;
|
|
}
|
|
const collabNode = $getOrInitCollabNodeFromSharedType(binding, target.parent);
|
|
const node = collabNode.getNode();
|
|
if (node) {
|
|
const state = $getWritableNodeState(node.getWritable());
|
|
for (const k of event.keysChanged) {
|
|
state.updateFromUnknown(k, target.get(k));
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function $syncEvent(binding, event) {
|
|
if (event instanceof YMapEvent && $syncStateEvent(binding, event)) {
|
|
return;
|
|
}
|
|
const {
|
|
target
|
|
} = event;
|
|
const collabNode = $getOrInitCollabNodeFromSharedType(binding, target);
|
|
if (collabNode instanceof CollabElementNode && event instanceof YTextEvent) {
|
|
// @ts-expect-error We need to access the private childListChanged 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 YMapEvent) {
|
|
const {
|
|
keysChanged
|
|
} = event;
|
|
|
|
// Update
|
|
if (keysChanged.size > 0) {
|
|
collabNode.syncPropertiesAndTextFromYjs(binding, keysChanged);
|
|
}
|
|
} else if (collabNode instanceof CollabDecoratorNode && event instanceof YXmlEvent) {
|
|
const {
|
|
attributesChanged
|
|
} = event;
|
|
|
|
// Update
|
|
if (attributesChanged.size > 0) {
|
|
collabNode.syncPropertiesFromYjs(binding, attributesChanged);
|
|
}
|
|
} else {
|
|
{
|
|
formatDevErrorMessage(`Expected text, element, or decorator event`);
|
|
}
|
|
}
|
|
}
|
|
function syncYjsChangesToLexical(binding, provider, events, isFromUndoManger, syncCursorPositionsFn = syncCursorPositions) {
|
|
const editor = binding.editor;
|
|
const currentEditorState = editor._editorState;
|
|
|
|
// This line precompute the delta before editor update. The reason is
|
|
// delta is computed when it is accessed. Note that this can only be
|
|
// safely computed during the event call. If it is accessed after event
|
|
// call it might result in unexpected behavior.
|
|
// https://github.com/yjs/yjs/blob/00ef472d68545cb260abd35c2de4b3b78719c9e4/src/utils/YEvent.js#L132
|
|
events.forEach(event => event.delta);
|
|
editor.update(() => {
|
|
for (let i = 0; i < events.length; i++) {
|
|
const event = events[i];
|
|
$syncEvent(binding, event);
|
|
}
|
|
const selection = $getSelection();
|
|
if ($isRangeSelection(selection)) {
|
|
if (doesSelectionNeedRecovering(selection)) {
|
|
const prevSelection = currentEditorState._selection;
|
|
if ($isRangeSelection(prevSelection)) {
|
|
$syncLocalCursorPosition(binding, provider);
|
|
if (doesSelectionNeedRecovering(selection)) {
|
|
// If the selected node is deleted, move the selection to the previous or parent node.
|
|
const anchorNodeKey = selection.anchor.key;
|
|
$moveSelectionToPreviousNode(anchorNodeKey, currentEditorState);
|
|
}
|
|
}
|
|
syncLexicalSelectionToYjs(binding, provider, prevSelection, $getSelection());
|
|
} else {
|
|
$syncLocalCursorPosition(binding, provider);
|
|
}
|
|
}
|
|
if (!isFromUndoManger) {
|
|
// If it is an external change, we don't want the current scroll position to get changed
|
|
// since the user might've intentionally scrolled somewhere else in the document.
|
|
$addUpdateTag(SKIP_SCROLL_INTO_VIEW_TAG);
|
|
}
|
|
}, {
|
|
onUpdate: () => {
|
|
syncCursorPositionsFn(binding, provider);
|
|
// If there was a collision on the top level paragraph
|
|
// we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
|
|
// it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
|
|
editor.update(() => {
|
|
if ($getRoot().getChildrenSize() === 0) {
|
|
$getRoot().append($createParagraphNode());
|
|
}
|
|
});
|
|
},
|
|
skipTransforms: true,
|
|
tag: isFromUndoManger ? HISTORIC_TAG : COLLABORATION_TAG
|
|
});
|
|
}
|
|
function $handleNormalizationMergeConflicts(binding, normalizedNodes) {
|
|
// We handle the merge operations here
|
|
const normalizedNodesKeys = Array.from(normalizedNodes);
|
|
const collabNodeMap = binding.collabNodeMap;
|
|
const mergedNodes = [];
|
|
const removedNodes = [];
|
|
for (let i = 0; i < normalizedNodesKeys.length; i++) {
|
|
const nodeKey = normalizedNodesKeys[i];
|
|
const lexicalNode = $getNodeByKey(nodeKey);
|
|
const collabNode = collabNodeMap.get(nodeKey);
|
|
if (collabNode instanceof CollabTextNode) {
|
|
if ($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);
|
|
removedNodes.push(collabNode);
|
|
}
|
|
}
|
|
}
|
|
for (let i = 0; i < removedNodes.length; i++) {
|
|
const collabNode = removedNodes[i];
|
|
const nodeKey = collabNode.getKey();
|
|
collabNodeMap.delete(nodeKey);
|
|
const parentChildren = collabNode._parent._children;
|
|
const index = parentChildren.indexOf(collabNode);
|
|
parentChildren.splice(index, 1);
|
|
}
|
|
for (let i = 0; i < mergedNodes.length; i++) {
|
|
const [collabNode, text] = mergedNodes[i];
|
|
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_TAG) || tags.has(HISTORIC_TAG)) {
|
|
if (normalizedNodes.size > 0) {
|
|
$handleNormalizationMergeConflicts(binding, normalizedNodes);
|
|
}
|
|
return;
|
|
}
|
|
if (dirtyElements.has('root')) {
|
|
const prevNodeMap = prevEditorState._nodeMap;
|
|
const nextLexicalRoot = $getRoot();
|
|
const collabRoot = binding.root;
|
|
collabRoot.syncPropertiesFromLexical(binding, nextLexicalRoot, prevNodeMap);
|
|
collabRoot.syncChildrenFromLexical(binding, nextLexicalRoot, prevNodeMap, dirtyElements, dirtyLeaves);
|
|
}
|
|
const selection = $getSelection();
|
|
const prevSelection = prevEditorState._selection;
|
|
syncLexicalSelectionToYjs(binding, provider, prevSelection, 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.
|
|
*
|
|
*/
|
|
|
|
const CONNECTED_COMMAND = createCommand('CONNECTED_COMMAND');
|
|
const TOGGLE_CONNECT_COMMAND = createCommand('TOGGLE_CONNECT_COMMAND');
|
|
function createUndoManager(binding, root) {
|
|
return new UndoManager(root, {
|
|
trackedOrigins: new Set([binding, null])
|
|
});
|
|
}
|
|
function initLocalState(provider, name, color, focusing, awarenessData) {
|
|
provider.awareness.setLocalState({
|
|
anchorPos: null,
|
|
awarenessData,
|
|
color,
|
|
focusPos: null,
|
|
focusing: focusing,
|
|
name
|
|
});
|
|
}
|
|
function setLocalStateFocus(provider, name, color, focusing, awarenessData) {
|
|
const {
|
|
awareness
|
|
} = provider;
|
|
let localState = awareness.getLocalState();
|
|
if (localState === null) {
|
|
localState = {
|
|
anchorPos: null,
|
|
awarenessData,
|
|
color,
|
|
focusPos: null,
|
|
focusing: focusing,
|
|
name
|
|
};
|
|
}
|
|
localState.focusing = focusing;
|
|
awareness.setLocalState(localState);
|
|
}
|
|
|
|
export { CONNECTED_COMMAND, TOGGLE_CONNECT_COMMAND, createBinding, createUndoManager, getAnchorAndFocusCollabNodesForUserState, initLocalState, setLocalStateFocus, syncCursorPositions, syncLexicalUpdateToYjs, syncYjsChangesToLexical };
|