]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/LexicalMutations.ts
56f364501ee7c7e6b9233fa0c96ef7569f5fec09
[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   if ($isRangeSelection(selection)) {
100     const anchorNode = selection.anchor.getNode();
101     if (
102       anchorNode.is(targetNode) &&
103       selection.format !== anchorNode.getFormat()
104     ) {
105       return false;
106     }
107   }
108   return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
109 }
110
111 export function $flushMutations(
112   editor: LexicalEditor,
113   mutations: Array<MutationRecord>,
114   observer: MutationObserver,
115 ): void {
116   isProcessingMutations = true;
117   const shouldFlushTextMutations =
118     performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE;
119
120   try {
121     updateEditor(editor, () => {
122       const selection = $getSelection() || getLastSelection(editor);
123       const badDOMTargets = new Map();
124       const rootElement = editor.getRootElement();
125       // We use the current editor state, as that reflects what is
126       // actually "on screen".
127       const currentEditorState = editor._editorState;
128       const blockCursorElement = editor._blockCursorElement;
129       let shouldRevertSelection = false;
130       let possibleTextForFirefoxPaste = '';
131
132       for (let i = 0; i < mutations.length; i++) {
133         const mutation = mutations[i];
134         const type = mutation.type;
135         const targetDOM = mutation.target;
136         let targetNode = $getNearestNodeFromDOMNode(
137           targetDOM,
138           currentEditorState,
139         );
140
141         if (
142           (targetNode === null && targetDOM !== rootElement) ||
143           $isDecoratorNode(targetNode)
144         ) {
145           continue;
146         }
147
148         if (type === 'characterData') {
149           // Text mutations are deferred and passed to mutation listeners to be
150           // processed outside of the Lexical engine.
151           if (
152             shouldFlushTextMutations &&
153             $isTextNode(targetNode) &&
154             shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
155           ) {
156             $handleTextMutation(
157               // nodeType === DOM_TEXT_TYPE is a Text DOM node
158               targetDOM as Text,
159               targetNode,
160               editor,
161             );
162           }
163         } else if (type === 'childList') {
164           shouldRevertSelection = true;
165           // We attempt to "undo" any changes that have occurred outside
166           // of Lexical. We want Lexical's editor state to be source of truth.
167           // To the user, these will look like no-ops.
168           const addedDOMs = mutation.addedNodes;
169
170           for (let s = 0; s < addedDOMs.length; s++) {
171             const addedDOM = addedDOMs[s];
172             const node = $getNodeFromDOMNode(addedDOM);
173             const parentDOM = addedDOM.parentNode;
174
175             if (
176               parentDOM != null &&
177               addedDOM !== blockCursorElement &&
178               node === null &&
179               (addedDOM.nodeName !== 'BR' ||
180                 !isManagedLineBreak(addedDOM, parentDOM, editor))
181             ) {
182               if (IS_FIREFOX) {
183                 const possibleText =
184                   (addedDOM as HTMLElement).innerText || addedDOM.nodeValue;
185
186                 if (possibleText) {
187                   possibleTextForFirefoxPaste += possibleText;
188                 }
189               }
190
191               parentDOM.removeChild(addedDOM);
192             }
193           }
194
195           const removedDOMs = mutation.removedNodes;
196           const removedDOMsLength = removedDOMs.length;
197
198           if (removedDOMsLength > 0) {
199             let unremovedBRs = 0;
200
201             for (let s = 0; s < removedDOMsLength; s++) {
202               const removedDOM = removedDOMs[s];
203
204               if (
205                 (removedDOM.nodeName === 'BR' &&
206                   isManagedLineBreak(removedDOM, targetDOM, editor)) ||
207                 blockCursorElement === removedDOM
208               ) {
209                 targetDOM.appendChild(removedDOM);
210                 unremovedBRs++;
211               }
212             }
213
214             if (removedDOMsLength !== unremovedBRs) {
215               if (targetDOM === rootElement) {
216                 targetNode = internalGetRoot(currentEditorState);
217               }
218
219               badDOMTargets.set(targetDOM, targetNode);
220             }
221           }
222         }
223       }
224
225       // Now we process each of the unique target nodes, attempting
226       // to restore their contents back to the source of truth, which
227       // is Lexical's "current" editor state. This is basically like
228       // an internal revert on the DOM.
229       if (badDOMTargets.size > 0) {
230         for (const [targetDOM, targetNode] of badDOMTargets) {
231           if ($isElementNode(targetNode)) {
232             const childKeys = targetNode.getChildrenKeys();
233             let currentDOM = targetDOM.firstChild;
234
235             for (let s = 0; s < childKeys.length; s++) {
236               const key = childKeys[s];
237               const correctDOM = editor.getElementByKey(key);
238
239               if (correctDOM === null) {
240                 continue;
241               }
242
243               if (currentDOM == null) {
244                 targetDOM.appendChild(correctDOM);
245                 currentDOM = correctDOM;
246               } else if (currentDOM !== correctDOM) {
247                 targetDOM.replaceChild(correctDOM, currentDOM);
248               }
249
250               currentDOM = currentDOM.nextSibling;
251             }
252           } else if ($isTextNode(targetNode)) {
253             targetNode.markDirty();
254           }
255         }
256       }
257
258       // Capture all the mutations made during this function. This
259       // also prevents us having to process them on the next cycle
260       // of onMutation, as these mutations were made by us.
261       const records = observer.takeRecords();
262
263       // Check for any random auto-added <br> elements, and remove them.
264       // These get added by the browser when we undo the above mutations
265       // and this can lead to a broken UI.
266       if (records.length > 0) {
267         for (let i = 0; i < records.length; i++) {
268           const record = records[i];
269           const addedNodes = record.addedNodes;
270           const target = record.target;
271
272           for (let s = 0; s < addedNodes.length; s++) {
273             const addedDOM = addedNodes[s];
274             const parentDOM = addedDOM.parentNode;
275
276             if (
277               parentDOM != null &&
278               addedDOM.nodeName === 'BR' &&
279               !isManagedLineBreak(addedDOM, target, editor)
280             ) {
281               parentDOM.removeChild(addedDOM);
282             }
283           }
284         }
285
286         // Clear any of those removal mutations
287         observer.takeRecords();
288       }
289
290       if (selection !== null) {
291         if (shouldRevertSelection) {
292           selection.dirty = true;
293           $setSelection(selection);
294         }
295
296         if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
297           selection.insertRawText(possibleTextForFirefoxPaste);
298         }
299       }
300     });
301   } finally {
302     isProcessingMutations = false;
303   }
304 }
305
306 export function $flushRootMutations(editor: LexicalEditor): void {
307   const observer = editor._observer;
308
309   if (observer !== null) {
310     const mutations = observer.takeRecords();
311     $flushMutations(editor, mutations, observer);
312   }
313 }
314
315 export function initMutationObserver(editor: LexicalEditor): void {
316   initTextEntryListener(editor);
317   editor._observer = new MutationObserver(
318     (mutations: Array<MutationRecord>, observer: MutationObserver) => {
319       $flushMutations(editor, mutations, observer);
320     },
321   );
322 }