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.
561 lines
15 KiB
TypeScript
561 lines
15 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, 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);
|
|
}
|
|
}
|