BookStack/resources/js/wysiwyg/lexical/yjs/CollabElementNode.ts
Dan Brown 22d078b47f
Lexical: Imported core lexical libs
Imported at 0.17.1, Modified to work in-app.
Added & configured test dependancies.
Tests need to be altered to avoid using non-included deps including
react dependancies.
2024-09-18 13:43:39 +01:00

667 lines
19 KiB
TypeScript

/**
* 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} from '.';
import type {ElementNode, NodeKey, NodeMap} from 'lexical';
import type {AbstractType, Map as YMap, XmlElement, XmlText} from 'yjs';
import {$createChildrenArray} from '@lexical/offset';
import {
$getNodeByKey,
$isDecoratorNode,
$isElementNode,
$isTextNode,
} from 'lexical';
import invariant from 'lexical/shared/invariant';
import {CollabDecoratorNode} from './CollabDecoratorNode';
import {CollabLineBreakNode} from './CollabLineBreakNode';
import {CollabTextNode} from './CollabTextNode';
import {
$createCollabNodeFromLexicalNode,
$getNodeByKeyOrThrow,
$getOrInitCollabNodeFromSharedType,
createLexicalNodeFromCollabNode,
getPositionFromElementAndOffset,
removeFromParent,
spliceString,
syncPropertiesFromLexical,
syncPropertiesFromYjs,
} from './Utils';
type IntentionallyMarkedAsDirtyElement = boolean;
export class CollabElementNode {
_key: NodeKey;
_children: Array<
| CollabElementNode
| CollabTextNode
| CollabDecoratorNode
| CollabLineBreakNode
>;
_xmlText: XmlText;
_type: string;
_parent: null | CollabElementNode;
constructor(
xmlText: XmlText,
parent: null | CollabElementNode,
type: string,
) {
this._key = '';
this._children = [];
this._xmlText = xmlText;
this._type = type;
this._parent = parent;
}
getPrevNode(nodeMap: null | NodeMap): null | ElementNode {
if (nodeMap === null) {
return null;
}
const node = nodeMap.get(this._key);
return $isElementNode(node) ? node : null;
}
getNode(): null | ElementNode {
const node = $getNodeByKey(this._key);
return $isElementNode(node) ? node : null;
}
getSharedType(): XmlText {
return this._xmlText;
}
getType(): string {
return this._type;
}
getKey(): NodeKey {
return this._key;
}
isEmpty(): boolean {
return this._children.length === 0;
}
getSize(): number {
return 1;
}
getOffset(): number {
const collabElementNode = this._parent;
invariant(
collabElementNode !== null,
'getOffset: could not find collab element node',
);
return collabElementNode.getChildOffset(this);
}
syncPropertiesFromYjs(
binding: Binding,
keysChanged: null | Set<string>,
): void {
const lexicalNode = this.getNode();
invariant(
lexicalNode !== null,
'syncPropertiesFromYjs: could not find element node',
);
syncPropertiesFromYjs(binding, this._xmlText, lexicalNode, keysChanged);
}
applyChildrenYjsDelta(
binding: Binding,
deltas: Array<{
insert?: string | object | AbstractType<unknown>;
delete?: number;
retain?: number;
attributes?: {
[x: string]: unknown;
};
}>,
): void {
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 as XmlText | YMap<unknown> | XmlElement,
this,
);
children.splice(nodeIndex, 0, collabNode);
currIndex += 1;
}
} else {
throw new Error('Unexpected delta format');
}
}
}
syncChildrenFromYjs(binding: Binding): void {
// Now diff the children of the collab node with that of our existing Lexical node.
const lexicalNode = this.getNode();
invariant(
lexicalNode !== null,
'syncChildrenFromYjs: could not find element node',
);
const key = lexicalNode.__key;
const prevLexicalChildrenKeys = $createChildrenArray(lexicalNode, null);
const nextLexicalChildrenKeys: Array<NodeKey> = [];
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)) {
invariant(
false,
'syncChildrenFromYjs: expected text, element, decorator, or linebreak collab node',
);
}
}
nextLexicalChildrenKeys[i] = lexicalChildKey;
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);
nextLexicalChildrenKeys[i] = childKey;
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: Binding,
nextLexicalNode: ElementNode,
prevNodeMap: null | NodeMap,
): void {
syncPropertiesFromLexical(
binding,
this._xmlText,
this.getPrevNode(prevNodeMap),
nextLexicalNode,
);
}
_syncChildFromLexical(
binding: Binding,
index: number,
key: NodeKey,
prevNodeMap: null | NodeMap,
dirtyElements: null | Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
dirtyLeaves: null | Set<NodeKey>,
): void {
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: Binding,
nextLexicalNode: ElementNode,
prevNodeMap: null | NodeMap,
dirtyElements: null | Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
dirtyLeaves: null | Set<NodeKey>,
): void {
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: Set<NodeKey> | undefined;
let nextChildrenSet: Set<NodeKey> | undefined;
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:
| CollabElementNode
| CollabDecoratorNode
| CollabTextNode
| CollabLineBreakNode,
): void {
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: Binding,
index: number,
delCount: number,
collabNode?:
| CollabElementNode
| CollabDecoratorNode
| CollabTextNode
| CollabLineBreakNode,
): void {
const children = this._children;
const child = children[index];
if (child === undefined) {
invariant(
collabNode !== undefined,
'splice: could not find collab element node',
);
this.append(collabNode);
return;
}
const offset = child.getOffset();
invariant(offset !== -1, '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:
| CollabElementNode
| CollabTextNode
| CollabDecoratorNode
| CollabLineBreakNode,
): number {
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: Binding): void {
const collabNodeMap = binding.collabNodeMap;
const children = this._children;
for (let i = 0; i < children.length; i++) {
children[i].destroy(binding);
}
collabNodeMap.delete(this._key);
}
}
export function $createCollabElementNode(
xmlText: XmlText,
parent: null | CollabElementNode,
type: string,
): CollabElementNode {
const collabNode = new CollabElementNode(xmlText, parent, type);
xmlText._collabNode = collabNode;
return collabNode;
}