mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-22 07:12:36 +00: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.
248 lines
7.6 KiB
TypeScript
248 lines
7.6 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 {EditorState, NodeKey} from 'lexical';
|
|
|
|
import {
|
|
$createParagraphNode,
|
|
$getNodeByKey,
|
|
$getRoot,
|
|
$getSelection,
|
|
$isRangeSelection,
|
|
$isTextNode,
|
|
} from 'lexical';
|
|
import invariant from 'lexical/shared/invariant';
|
|
import {Text as YText, YEvent, YMapEvent, YTextEvent, YXmlEvent} from 'yjs';
|
|
|
|
import {Binding, Provider} from '.';
|
|
import {CollabDecoratorNode} from './CollabDecoratorNode';
|
|
import {CollabElementNode} from './CollabElementNode';
|
|
import {CollabTextNode} from './CollabTextNode';
|
|
import {
|
|
$syncLocalCursorPosition,
|
|
syncCursorPositions,
|
|
syncLexicalSelectionToYjs,
|
|
} from './SyncCursors';
|
|
import {
|
|
$getOrInitCollabNodeFromSharedType,
|
|
$moveSelectionToPreviousNode,
|
|
doesSelectionNeedRecovering,
|
|
syncWithTransaction,
|
|
} from './Utils';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function $syncEvent(binding: Binding, event: any): void {
|
|
const {target} = event;
|
|
const collabNode = $getOrInitCollabNodeFromSharedType(binding, target);
|
|
|
|
if (collabNode instanceof CollabElementNode && event instanceof YTextEvent) {
|
|
// @ts-expect-error We need to access the private 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 {
|
|
invariant(false, 'Expected text, element, or decorator event');
|
|
}
|
|
}
|
|
|
|
export function syncYjsChangesToLexical(
|
|
binding: Binding,
|
|
provider: Provider,
|
|
events: Array<YEvent<YText>>,
|
|
isFromUndoManger: boolean,
|
|
): void {
|
|
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);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
onUpdate: () => {
|
|
syncCursorPositions(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' : 'collaboration',
|
|
},
|
|
);
|
|
}
|
|
|
|
function $handleNormalizationMergeConflicts(
|
|
binding: Binding,
|
|
normalizedNodes: Set<NodeKey>,
|
|
): void {
|
|
// We handle the merge operations here
|
|
const normalizedNodesKeys = Array.from(normalizedNodes);
|
|
const collabNodeMap = binding.collabNodeMap;
|
|
const mergedNodes = [];
|
|
|
|
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);
|
|
|
|
collabNodeMap.delete(nodeKey);
|
|
const parentChildren = parent._children;
|
|
const index = parentChildren.indexOf(collabNode);
|
|
parentChildren.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < mergedNodes.length; i++) {
|
|
const [collabNode, text] = mergedNodes[i];
|
|
if (collabNode instanceof CollabTextNode && typeof text === 'string') {
|
|
collabNode._text = text;
|
|
}
|
|
}
|
|
}
|
|
|
|
type IntentionallyMarkedAsDirtyElement = boolean;
|
|
|
|
export function syncLexicalUpdateToYjs(
|
|
binding: Binding,
|
|
provider: Provider,
|
|
prevEditorState: EditorState,
|
|
currEditorState: EditorState,
|
|
dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
|
|
dirtyLeaves: Set<NodeKey>,
|
|
normalizedNodes: Set<NodeKey>,
|
|
tags: Set<string>,
|
|
): void {
|
|
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') || tags.has('historic')) {
|
|
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);
|
|
});
|
|
});
|
|
}
|