]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/yjs/SyncEditorStates.ts
Opensearch: Fixed XML declaration when php short tags enabled
[bookstack] / resources / js / wysiwyg / lexical / yjs / SyncEditorStates.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import type {EditorState, NodeKey} from 'lexical';
10
11 import {
12   $createParagraphNode,
13   $getNodeByKey,
14   $getRoot,
15   $getSelection,
16   $isRangeSelection,
17   $isTextNode,
18 } from 'lexical';
19 import invariant from 'lexical/shared/invariant';
20 import {Text as YText, YEvent, YMapEvent, YTextEvent, YXmlEvent} from 'yjs';
21
22 import {Binding, Provider} from '.';
23 import {CollabDecoratorNode} from './CollabDecoratorNode';
24 import {CollabElementNode} from './CollabElementNode';
25 import {CollabTextNode} from './CollabTextNode';
26 import {
27   $syncLocalCursorPosition,
28   syncCursorPositions,
29   syncLexicalSelectionToYjs,
30 } from './SyncCursors';
31 import {
32   $getOrInitCollabNodeFromSharedType,
33   $moveSelectionToPreviousNode,
34   doesSelectionNeedRecovering,
35   syncWithTransaction,
36 } from './Utils';
37
38 // eslint-disable-next-line @typescript-eslint/no-explicit-any
39 function $syncEvent(binding: Binding, event: any): void {
40   const {target} = event;
41   const collabNode = $getOrInitCollabNodeFromSharedType(binding, target);
42
43   if (collabNode instanceof CollabElementNode && event instanceof YTextEvent) {
44     // @ts-expect-error We need to access the private property of the class
45     const {keysChanged, childListChanged, delta} = event;
46
47     // Update
48     if (keysChanged.size > 0) {
49       collabNode.syncPropertiesFromYjs(binding, keysChanged);
50     }
51
52     if (childListChanged) {
53       collabNode.applyChildrenYjsDelta(binding, delta);
54       collabNode.syncChildrenFromYjs(binding);
55     }
56   } else if (
57     collabNode instanceof CollabTextNode &&
58     event instanceof YMapEvent
59   ) {
60     const {keysChanged} = event;
61
62     // Update
63     if (keysChanged.size > 0) {
64       collabNode.syncPropertiesAndTextFromYjs(binding, keysChanged);
65     }
66   } else if (
67     collabNode instanceof CollabDecoratorNode &&
68     event instanceof YXmlEvent
69   ) {
70     const {attributesChanged} = event;
71
72     // Update
73     if (attributesChanged.size > 0) {
74       collabNode.syncPropertiesFromYjs(binding, attributesChanged);
75     }
76   } else {
77     invariant(false, 'Expected text, element, or decorator event');
78   }
79 }
80
81 export function syncYjsChangesToLexical(
82   binding: Binding,
83   provider: Provider,
84   events: Array<YEvent<YText>>,
85   isFromUndoManger: boolean,
86 ): void {
87   const editor = binding.editor;
88   const currentEditorState = editor._editorState;
89
90   // This line precompute the delta before editor update. The reason is
91   // delta is computed when it is accessed. Note that this can only be
92   // safely computed during the event call. If it is accessed after event
93   // call it might result in unexpected behavior.
94   // https://github.com/yjs/yjs/blob/00ef472d68545cb260abd35c2de4b3b78719c9e4/src/utils/YEvent.js#L132
95   events.forEach((event) => event.delta);
96
97   editor.update(
98     () => {
99       for (let i = 0; i < events.length; i++) {
100         const event = events[i];
101         $syncEvent(binding, event);
102       }
103
104       const selection = $getSelection();
105
106       if ($isRangeSelection(selection)) {
107         if (doesSelectionNeedRecovering(selection)) {
108           const prevSelection = currentEditorState._selection;
109
110           if ($isRangeSelection(prevSelection)) {
111             $syncLocalCursorPosition(binding, provider);
112             if (doesSelectionNeedRecovering(selection)) {
113               // If the selected node is deleted, move the selection to the previous or parent node.
114               const anchorNodeKey = selection.anchor.key;
115               $moveSelectionToPreviousNode(anchorNodeKey, currentEditorState);
116             }
117           }
118
119           syncLexicalSelectionToYjs(
120             binding,
121             provider,
122             prevSelection,
123             $getSelection(),
124           );
125         } else {
126           $syncLocalCursorPosition(binding, provider);
127         }
128       }
129     },
130     {
131       onUpdate: () => {
132         syncCursorPositions(binding, provider);
133         // If there was a collision on the top level paragraph
134         // we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
135         // it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
136         editor.update(() => {
137           if ($getRoot().getChildrenSize() === 0) {
138             $getRoot().append($createParagraphNode());
139           }
140         });
141       },
142       skipTransforms: true,
143       tag: isFromUndoManger ? 'historic' : 'collaboration',
144     },
145   );
146 }
147
148 function $handleNormalizationMergeConflicts(
149   binding: Binding,
150   normalizedNodes: Set<NodeKey>,
151 ): void {
152   // We handle the merge operations here
153   const normalizedNodesKeys = Array.from(normalizedNodes);
154   const collabNodeMap = binding.collabNodeMap;
155   const mergedNodes = [];
156
157   for (let i = 0; i < normalizedNodesKeys.length; i++) {
158     const nodeKey = normalizedNodesKeys[i];
159     const lexicalNode = $getNodeByKey(nodeKey);
160     const collabNode = collabNodeMap.get(nodeKey);
161
162     if (collabNode instanceof CollabTextNode) {
163       if ($isTextNode(lexicalNode)) {
164         // We mutate the text collab nodes after removing
165         // all the dead nodes first, otherwise offsets break.
166         mergedNodes.push([collabNode, lexicalNode.__text]);
167       } else {
168         const offset = collabNode.getOffset();
169
170         if (offset === -1) {
171           continue;
172         }
173
174         const parent = collabNode._parent;
175         collabNode._normalized = true;
176
177         parent._xmlText.delete(offset, 1);
178
179         collabNodeMap.delete(nodeKey);
180         const parentChildren = parent._children;
181         const index = parentChildren.indexOf(collabNode);
182         parentChildren.splice(index, 1);
183       }
184     }
185   }
186
187   for (let i = 0; i < mergedNodes.length; i++) {
188     const [collabNode, text] = mergedNodes[i];
189     if (collabNode instanceof CollabTextNode && typeof text === 'string') {
190       collabNode._text = text;
191     }
192   }
193 }
194
195 type IntentionallyMarkedAsDirtyElement = boolean;
196
197 export function syncLexicalUpdateToYjs(
198   binding: Binding,
199   provider: Provider,
200   prevEditorState: EditorState,
201   currEditorState: EditorState,
202   dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
203   dirtyLeaves: Set<NodeKey>,
204   normalizedNodes: Set<NodeKey>,
205   tags: Set<string>,
206 ): void {
207   syncWithTransaction(binding, () => {
208     currEditorState.read(() => {
209       // We check if the update has come from a origin where the origin
210       // was the collaboration binding previously. This can help us
211       // prevent unnecessarily re-diffing and possible re-applying
212       // the same change editor state again. For example, if a user
213       // types a character and we get it, we don't want to then insert
214       // the same character again. The exception to this heuristic is
215       // when we need to handle normalization merge conflicts.
216       if (tags.has('collaboration') || tags.has('historic')) {
217         if (normalizedNodes.size > 0) {
218           $handleNormalizationMergeConflicts(binding, normalizedNodes);
219         }
220
221         return;
222       }
223
224       if (dirtyElements.has('root')) {
225         const prevNodeMap = prevEditorState._nodeMap;
226         const nextLexicalRoot = $getRoot();
227         const collabRoot = binding.root;
228         collabRoot.syncPropertiesFromLexical(
229           binding,
230           nextLexicalRoot,
231           prevNodeMap,
232         );
233         collabRoot.syncChildrenFromLexical(
234           binding,
235           nextLexicalRoot,
236           prevNodeMap,
237           dirtyElements,
238           dirtyLeaves,
239         );
240       }
241
242       const selection = $getSelection();
243       const prevSelection = prevEditorState._selection;
244       syncLexicalSelectionToYjs(binding, provider, prevSelection, selection);
245     });
246   });
247 }