]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/LexicalMutations.ts
c24dc9ebb3c55a520a6b7a2fa7a3f3e7bfade934
[bookstack] / resources / js / wysiwyg / lexical / core / LexicalMutations.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 {TextNode} from '.';
10 import type {LexicalEditor} from './LexicalEditor';
11 import type {BaseSelection} from './LexicalSelection';
12
13 import {IS_FIREFOX} from 'lexical/shared/environment';
14
15 import {
16   $getSelection,
17   $isDecoratorNode,
18   $isElementNode,
19   $isRangeSelection,
20   $isTextNode,
21   $setSelection,
22 } from '.';
23 import {DOM_TEXT_TYPE} from './LexicalConstants';
24 import {updateEditor} from './LexicalUpdates';
25 import {
26   $getNearestNodeFromDOMNode,
27   $getNodeFromDOMNode,
28   $updateTextNodeFromDOMContent,
29   getDOMSelection,
30   getWindow,
31   internalGetRoot,
32   isFirefoxClipboardEvents,
33 } from './LexicalUtils';
34 // The time between a text entry event and the mutation observer firing.
35 const TEXT_MUTATION_VARIANCE = 100;
36
37 let isProcessingMutations = false;
38 let lastTextEntryTimeStamp = 0;
39
40 export function getIsProcessingMutations(): boolean {
41   return isProcessingMutations;
42 }
43
44 function updateTimeStamp(event: Event) {
45   lastTextEntryTimeStamp = event.timeStamp;
46 }
47
48 function initTextEntryListener(editor: LexicalEditor): void {
49   if (lastTextEntryTimeStamp === 0) {
50     getWindow(editor).addEventListener('textInput', updateTimeStamp, true);
51   }
52 }
53
54 function isManagedLineBreak(
55   dom: Node,
56   target: Node,
57   editor: LexicalEditor,
58 ): boolean {
59   return (
60     // @ts-expect-error: internal field
61     target.__lexicalLineBreak === dom ||
62     // @ts-ignore We intentionally add this to the Node.
63     dom[`__lexicalKey_${editor._key}`] !== undefined
64   );
65 }
66
67 function getLastSelection(editor: LexicalEditor): null | BaseSelection {
68   return editor.getEditorState().read(() => {
69     const selection = $getSelection();
70     return selection !== null ? selection.clone() : null;
71   });
72 }
73
74 function $handleTextMutation(
75   target: Text,
76   node: TextNode,
77   editor: LexicalEditor,
78 ): void {
79   const domSelection = getDOMSelection(editor._window);
80   let anchorOffset = null;
81   let focusOffset = null;
82
83   if (domSelection !== null && domSelection.anchorNode === target) {
84     anchorOffset = domSelection.anchorOffset;
85     focusOffset = domSelection.focusOffset;
86   }
87
88   const text = target.nodeValue;
89   if (text !== null) {
90     $updateTextNodeFromDOMContent(node, text, anchorOffset, focusOffset, false);
91   }
92 }
93
94 function shouldUpdateTextNodeFromMutation(
95   selection: null | BaseSelection,
96   targetDOM: Node,
97   targetNode: TextNode,
98 ): boolean {
99   return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
100 }
101
102 export function $flushMutations(
103   editor: LexicalEditor,
104   mutations: Array<MutationRecord>,
105   observer: MutationObserver,
106 ): void {
107   isProcessingMutations = true;
108   const shouldFlushTextMutations =
109     performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE;
110
111   try {
112     updateEditor(editor, () => {
113       const selection = $getSelection() || getLastSelection(editor);
114       const badDOMTargets = new Map();
115       const rootElement = editor.getRootElement();
116       // We use the current editor state, as that reflects what is
117       // actually "on screen".
118       const currentEditorState = editor._editorState;
119       const blockCursorElement = editor._blockCursorElement;
120       let shouldRevertSelection = false;
121       let possibleTextForFirefoxPaste = '';
122
123       for (let i = 0; i < mutations.length; i++) {
124         const mutation = mutations[i];
125         const type = mutation.type;
126         const targetDOM = mutation.target;
127         let targetNode = $getNearestNodeFromDOMNode(
128           targetDOM,
129           currentEditorState,
130         );
131
132         if (
133           (targetNode === null && targetDOM !== rootElement) ||
134           $isDecoratorNode(targetNode)
135         ) {
136           continue;
137         }
138
139         if (type === 'characterData') {
140           // Text mutations are deferred and passed to mutation listeners to be
141           // processed outside of the Lexical engine.
142           if (
143             shouldFlushTextMutations &&
144             $isTextNode(targetNode) &&
145             shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
146           ) {
147             $handleTextMutation(
148               // nodeType === DOM_TEXT_TYPE is a Text DOM node
149               targetDOM as Text,
150               targetNode,
151               editor,
152             );
153           }
154         } else if (type === 'childList') {
155           shouldRevertSelection = true;
156           // We attempt to "undo" any changes that have occurred outside
157           // of Lexical. We want Lexical's editor state to be source of truth.
158           // To the user, these will look like no-ops.
159           const addedDOMs = mutation.addedNodes;
160
161           for (let s = 0; s < addedDOMs.length; s++) {
162             const addedDOM = addedDOMs[s];
163             const node = $getNodeFromDOMNode(addedDOM);
164             const parentDOM = addedDOM.parentNode;
165
166             if (
167               parentDOM != null &&
168               addedDOM !== blockCursorElement &&
169               node === null &&
170               (addedDOM.nodeName !== 'BR' ||
171                 !isManagedLineBreak(addedDOM, parentDOM, editor))
172             ) {
173               if (IS_FIREFOX) {
174                 const possibleText =
175                   (addedDOM as HTMLElement).innerText || addedDOM.nodeValue;
176
177                 if (possibleText) {
178                   possibleTextForFirefoxPaste += possibleText;
179                 }
180               }
181
182               parentDOM.removeChild(addedDOM);
183             }
184           }
185
186           const removedDOMs = mutation.removedNodes;
187           const removedDOMsLength = removedDOMs.length;
188
189           if (removedDOMsLength > 0) {
190             let unremovedBRs = 0;
191
192             for (let s = 0; s < removedDOMsLength; s++) {
193               const removedDOM = removedDOMs[s];
194
195               if (
196                 (removedDOM.nodeName === 'BR' &&
197                   isManagedLineBreak(removedDOM, targetDOM, editor)) ||
198                 blockCursorElement === removedDOM
199               ) {
200                 targetDOM.appendChild(removedDOM);
201                 unremovedBRs++;
202               }
203             }
204
205             if (removedDOMsLength !== unremovedBRs) {
206               if (targetDOM === rootElement) {
207                 targetNode = internalGetRoot(currentEditorState);
208               }
209
210               badDOMTargets.set(targetDOM, targetNode);
211             }
212           }
213         }
214       }
215
216       // Now we process each of the unique target nodes, attempting
217       // to restore their contents back to the source of truth, which
218       // is Lexical's "current" editor state. This is basically like
219       // an internal revert on the DOM.
220       if (badDOMTargets.size > 0) {
221         for (const [targetDOM, targetNode] of badDOMTargets) {
222           if ($isElementNode(targetNode)) {
223             const childKeys = targetNode.getChildrenKeys();
224             let currentDOM = targetDOM.firstChild;
225
226             for (let s = 0; s < childKeys.length; s++) {
227               const key = childKeys[s];
228               const correctDOM = editor.getElementByKey(key);
229
230               if (correctDOM === null) {
231                 continue;
232               }
233
234               if (currentDOM == null) {
235                 targetDOM.appendChild(correctDOM);
236                 currentDOM = correctDOM;
237               } else if (currentDOM !== correctDOM) {
238                 targetDOM.replaceChild(correctDOM, currentDOM);
239               }
240
241               currentDOM = currentDOM.nextSibling;
242             }
243           } else if ($isTextNode(targetNode)) {
244             targetNode.markDirty();
245           }
246         }
247       }
248
249       // Capture all the mutations made during this function. This
250       // also prevents us having to process them on the next cycle
251       // of onMutation, as these mutations were made by us.
252       const records = observer.takeRecords();
253
254       // Check for any random auto-added <br> elements, and remove them.
255       // These get added by the browser when we undo the above mutations
256       // and this can lead to a broken UI.
257       if (records.length > 0) {
258         for (let i = 0; i < records.length; i++) {
259           const record = records[i];
260           const addedNodes = record.addedNodes;
261           const target = record.target;
262
263           for (let s = 0; s < addedNodes.length; s++) {
264             const addedDOM = addedNodes[s];
265             const parentDOM = addedDOM.parentNode;
266
267             if (
268               parentDOM != null &&
269               addedDOM.nodeName === 'BR' &&
270               !isManagedLineBreak(addedDOM, target, editor)
271             ) {
272               parentDOM.removeChild(addedDOM);
273             }
274           }
275         }
276
277         // Clear any of those removal mutations
278         observer.takeRecords();
279       }
280
281       if (selection !== null) {
282         if (shouldRevertSelection) {
283           selection.dirty = true;
284           $setSelection(selection);
285         }
286
287         if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
288           selection.insertRawText(possibleTextForFirefoxPaste);
289         }
290       }
291     });
292   } finally {
293     isProcessingMutations = false;
294   }
295 }
296
297 export function $flushRootMutations(editor: LexicalEditor): void {
298   const observer = editor._observer;
299
300   if (observer !== null) {
301     const mutations = observer.takeRecords();
302     $flushMutations(editor, mutations, observer);
303   }
304 }
305
306 export function initMutationObserver(editor: LexicalEditor): void {
307   initTextEntryListener(editor);
308   editor._observer = new MutationObserver(
309     (mutations: Array<MutationRecord>, observer: MutationObserver) => {
310       $flushMutations(editor, mutations, observer);
311     },
312   );
313 }