resources/js/wysiwyg/lexical/yjs/Utils.ts
/**
* 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 type {Binding, YjsNode} from '.';
import type {
DecoratorNode,
EditorState,
ElementNode,
LexicalNode,
RangeSelection,
TextNode,
} from 'lexical';
import {
$getNodeByKey,
$getRoot,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
$isRootNode,
$isTextNode,
createEditor,
NodeKey,
} from 'lexical';
import invariant from 'lexical/shared/invariant';
import {Doc, Map as YMap, XmlElement, XmlText} from 'yjs';
import {
$createCollabDecoratorNode,
CollabDecoratorNode,
} from './CollabDecoratorNode';
import {$createCollabElementNode, CollabElementNode} from './CollabElementNode';
import {
$createCollabLineBreakNode,
CollabLineBreakNode,
} from './CollabLineBreakNode';
import {$createCollabTextNode, CollabTextNode} from './CollabTextNode';
const baseExcludedProperties = new Set<string>([
'__key',
'__parent',
'__next',
'__prev',
]);
const elementExcludedProperties = new Set<string>([
'__first',
'__last',
'__size',
]);
const rootExcludedProperties = new Set<string>(['__cachedText']);
const textExcludedProperties = new Set<string>(['__text']);
function isExcludedProperty(
name: string,
node: LexicalNode,
binding: Binding,
): boolean {
if (baseExcludedProperties.has(name)) {
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);
}
export function getIndexOfYjsNode(
yjsParentNode: YjsNode,
yjsNode: YjsNode,
): number {
let node = yjsParentNode.firstChild;
let i = -1;
if (node === null) {
return -1;
}
do {
i++;
if (node === yjsNode) {
return i;
}
// @ts-expect-error Sibling exists but type is not available from YJS.
node = node.nextSibling;
if (node === null) {
return -1;
}
} while (node !== null);
return i;
}
export function $getNodeByKeyOrThrow(key: NodeKey): LexicalNode {
const node = $getNodeByKey(key);
invariant(node !== null, 'could not find node by key');
return node;
}
export function $createCollabNodeFromLexicalNode(
binding: Binding,
lexicalNode: LexicalNode,
parent: CollabElementNode,
):
| CollabElementNode
| CollabTextNode
| CollabLineBreakNode
| CollabDecoratorNode {
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 YMap();
collabNode = $createCollabTextNode(
map,
lexicalNode.__text,
parent,
nodeType,
);
collabNode.syncPropertiesAndTextFromLexical(binding, lexicalNode, null);
} else if ($isLineBreakNode(lexicalNode)) {
const map = new YMap();
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 {
invariant(false, 'Expected text, element, decorator, or linebreak node');
}
collabNode._key = lexicalNode.__key;
return collabNode;
}
function getNodeTypeFromSharedType(
sharedType: XmlText | YMap<unknown> | XmlElement,
): string {
const type =
sharedType instanceof YMap
? sharedType.get('__type')
: sharedType.getAttribute('__type');
invariant(type != null, 'Expected shared type to include type attribute');
return type;
}
export function $getOrInitCollabNodeFromSharedType(
binding: Binding,
sharedType: XmlText | YMap<unknown> | XmlElement,
parent?: CollabElementNode,
):
| CollabElementNode
| CollabTextNode
| CollabLineBreakNode
| CollabDecoratorNode {
const collabNode = sharedType._collabNode;
if (collabNode === undefined) {
const registeredNodes = binding.editor._nodes;
const type = getNodeTypeFromSharedType(sharedType);
const nodeInfo = registeredNodes.get(type);
invariant(nodeInfo !== undefined, 'Node %s is not registered', type);
const sharedParent = sharedType.parent;
const targetParent =
parent === undefined && sharedParent !== null
? $getOrInitCollabNodeFromSharedType(
binding,
sharedParent as XmlText | YMap<unknown> | XmlElement,
)
: parent || null;
invariant(
targetParent instanceof CollabElementNode,
'Expected parent to be a collab element node',
);
if (sharedType instanceof XmlText) {
return $createCollabElementNode(sharedType, targetParent, type);
} else if (sharedType instanceof YMap) {
if (type === 'linebreak') {
return $createCollabLineBreakNode(sharedType, targetParent);
}
return $createCollabTextNode(sharedType, '', targetParent, type);
} else if (sharedType instanceof XmlElement) {
return $createCollabDecoratorNode(sharedType, targetParent, type);
}
}
return collabNode;
}
export function createLexicalNodeFromCollabNode(
binding: Binding,
collabNode:
| CollabElementNode
| CollabTextNode
| CollabDecoratorNode
| CollabLineBreakNode,
parentKey: NodeKey,
): LexicalNode {
const type = collabNode.getType();
const registeredNodes = binding.editor._nodes;
const nodeInfo = registeredNodes.get(type);
invariant(nodeInfo !== undefined, 'Node %s is not registered', type);
const lexicalNode:
| DecoratorNode<unknown>
| TextNode
| ElementNode
| 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;
}
export function syncPropertiesFromYjs(
binding: Binding,
sharedType: XmlText | YMap<unknown> | XmlElement,
lexicalNode: LexicalNode,
keysChanged: null | Set<string>,
): void {
const properties =
keysChanged === null
? sharedType instanceof YMap
? 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)) {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prevValue = (lexicalNode as any)[property];
let nextValue =
sharedType instanceof YMap
? sharedType.get(property)
: sharedType.getAttribute(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();
}
writableNode[property as keyof typeof writableNode] = nextValue;
}
}
}
export function syncPropertiesFromLexical(
binding: Binding,
sharedType: XmlText | YMap<unknown> | XmlElement,
prevLexicalNode: null | LexicalNode,
nextLexicalNode: LexicalNode,
): void {
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;
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 as any)[property];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let nextValue = (nextLexicalNode as any)[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();
});
}
if (sharedType instanceof YMap) {
sharedType.set(property, nextValue);
} else {
sharedType.setAttribute(property, nextValue);
}
}
}
}
export function spliceString(
str: string,
index: number,
delCount: number,
newText: string,
): string {
return str.slice(0, index) + newText + str.slice(index + delCount);
}
export function getPositionFromElementAndOffset(
node: CollabElementNode,
offset: number,
boundaryIsEdge: boolean,
): {
length: number;
node:
| CollabElementNode
| CollabTextNode
| CollabDecoratorNode
| CollabLineBreakNode
| null;
nodeIndex: number;
offset: number;
} {
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,
};
}
export function doesSelectionNeedRecovering(
selection: RangeSelection,
): boolean {
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;
}
export function syncWithTransaction(binding: Binding, fn: () => void): void {
binding.doc.transact(fn, binding);
}
export function removeFromParent(node: LexicalNode): void {
const oldParent = node.getParent();
if (oldParent !== null) {
const writableNode = node.getWritable();
const writableParent = oldParent.getWritable();
const prevSibling = node.getPreviousSibling();
const nextSibling = node.getNextSibling();
// TODO: this function duplicates a bunch of operations, can be simplified.
if (prevSibling === null) {
if (nextSibling !== null) {
const writableNextSibling = nextSibling.getWritable();
writableParent.__first = nextSibling.__key;
writableNextSibling.__prev = null;
} else {
writableParent.__first = null;
}
} else {
const writablePrevSibling = prevSibling.getWritable();
if (nextSibling !== null) {
const writableNextSibling = nextSibling.getWritable();
writableNextSibling.__prev = writablePrevSibling.__key;
writablePrevSibling.__next = writableNextSibling.__key;
} else {
writablePrevSibling.__next = null;
}
writableNode.__prev = null;
}
if (nextSibling === null) {
if (prevSibling !== null) {
const writablePrevSibling = prevSibling.getWritable();
writableParent.__last = prevSibling.__key;
writablePrevSibling.__next = null;
} else {
writableParent.__last = null;
}
} else {
const writableNextSibling = nextSibling.getWritable();
if (prevSibling !== null) {
const writablePrevSibling = prevSibling.getWritable();
writablePrevSibling.__next = writableNextSibling.__key;
writableNextSibling.__prev = writablePrevSibling.__key;
} else {
writableNextSibling.__prev = null;
}
writableNode.__next = null;
}
writableParent.__size--;
writableNode.__parent = null;
}
}
export function $moveSelectionToPreviousNode(
anchorNodeKey: string,
currentEditorState: EditorState,
) {
const anchorNode = currentEditorState._nodeMap.get(anchorNodeKey);
if (!anchorNode) {
$getRoot().selectStart();
return;
}
// Get previous node
const prevNodeKey = anchorNode.__prev;
let prevNode: ElementNode | null = 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);
}
}