mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
22d078b47f
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.
667 lines
19 KiB
TypeScript
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;
|
|
}
|