BookStackApp_BookStack/resources/js/wysiwyg/lexical/yjs/SyncEditorStates.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

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);
});
});
}