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