2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import type {TextNode} from '.';
10 import type {LexicalEditor} from './LexicalEditor';
11 import type {BaseSelection} from './LexicalSelection';
13 import {IS_FIREFOX} from 'lexical/shared/environment';
23 import {DOM_TEXT_TYPE} from './LexicalConstants';
24 import {updateEditor} from './LexicalUpdates';
26 $getNearestNodeFromDOMNode,
28 $updateTextNodeFromDOMContent,
32 isFirefoxClipboardEvents,
33 } from './LexicalUtils';
34 // The time between a text entry event and the mutation observer firing.
35 const TEXT_MUTATION_VARIANCE = 100;
37 let isProcessingMutations = false;
38 let lastTextEntryTimeStamp = 0;
40 export function getIsProcessingMutations(): boolean {
41 return isProcessingMutations;
44 function updateTimeStamp(event: Event) {
45 lastTextEntryTimeStamp = event.timeStamp;
48 function initTextEntryListener(editor: LexicalEditor): void {
49 if (lastTextEntryTimeStamp === 0) {
50 getWindow(editor).addEventListener('textInput', updateTimeStamp, true);
54 function isManagedLineBreak(
57 editor: LexicalEditor,
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
67 function getLastSelection(editor: LexicalEditor): null | BaseSelection {
68 return editor.getEditorState().read(() => {
69 const selection = $getSelection();
70 return selection !== null ? selection.clone() : null;
74 function $handleTextMutation(
77 editor: LexicalEditor,
79 const domSelection = getDOMSelection(editor._window);
80 let anchorOffset = null;
81 let focusOffset = null;
83 if (domSelection !== null && domSelection.anchorNode === target) {
84 anchorOffset = domSelection.anchorOffset;
85 focusOffset = domSelection.focusOffset;
88 const text = target.nodeValue;
90 $updateTextNodeFromDOMContent(node, text, anchorOffset, focusOffset, false);
94 function shouldUpdateTextNodeFromMutation(
95 selection: null | BaseSelection,
99 return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
102 export function $flushMutations(
103 editor: LexicalEditor,
104 mutations: Array<MutationRecord>,
105 observer: MutationObserver,
107 isProcessingMutations = true;
108 const shouldFlushTextMutations =
109 performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE;
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 = '';
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(
133 (targetNode === null && targetDOM !== rootElement) ||
134 $isDecoratorNode(targetNode)
139 if (type === 'characterData') {
140 // Text mutations are deferred and passed to mutation listeners to be
141 // processed outside of the Lexical engine.
143 shouldFlushTextMutations &&
144 $isTextNode(targetNode) &&
145 shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
148 // nodeType === DOM_TEXT_TYPE is a Text DOM node
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;
161 for (let s = 0; s < addedDOMs.length; s++) {
162 const addedDOM = addedDOMs[s];
163 const node = $getNodeFromDOMNode(addedDOM);
164 const parentDOM = addedDOM.parentNode;
168 addedDOM !== blockCursorElement &&
170 (addedDOM.nodeName !== 'BR' ||
171 !isManagedLineBreak(addedDOM, parentDOM, editor))
175 (addedDOM as HTMLElement).innerText || addedDOM.nodeValue;
178 possibleTextForFirefoxPaste += possibleText;
182 parentDOM.removeChild(addedDOM);
186 const removedDOMs = mutation.removedNodes;
187 const removedDOMsLength = removedDOMs.length;
189 if (removedDOMsLength > 0) {
190 let unremovedBRs = 0;
192 for (let s = 0; s < removedDOMsLength; s++) {
193 const removedDOM = removedDOMs[s];
196 (removedDOM.nodeName === 'BR' &&
197 isManagedLineBreak(removedDOM, targetDOM, editor)) ||
198 blockCursorElement === removedDOM
200 targetDOM.appendChild(removedDOM);
205 if (removedDOMsLength !== unremovedBRs) {
206 if (targetDOM === rootElement) {
207 targetNode = internalGetRoot(currentEditorState);
210 badDOMTargets.set(targetDOM, targetNode);
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;
226 for (let s = 0; s < childKeys.length; s++) {
227 const key = childKeys[s];
228 const correctDOM = editor.getElementByKey(key);
230 if (correctDOM === null) {
234 if (currentDOM == null) {
235 targetDOM.appendChild(correctDOM);
236 currentDOM = correctDOM;
237 } else if (currentDOM !== correctDOM) {
238 targetDOM.replaceChild(correctDOM, currentDOM);
241 currentDOM = currentDOM.nextSibling;
243 } else if ($isTextNode(targetNode)) {
244 targetNode.markDirty();
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();
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;
263 for (let s = 0; s < addedNodes.length; s++) {
264 const addedDOM = addedNodes[s];
265 const parentDOM = addedDOM.parentNode;
269 addedDOM.nodeName === 'BR' &&
270 !isManagedLineBreak(addedDOM, target, editor)
272 parentDOM.removeChild(addedDOM);
277 // Clear any of those removal mutations
278 observer.takeRecords();
281 if (selection !== null) {
282 if (shouldRevertSelection) {
283 selection.dirty = true;
284 $setSelection(selection);
287 if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
288 selection.insertRawText(possibleTextForFirefoxPaste);
293 isProcessingMutations = false;
297 export function $flushRootMutations(editor: LexicalEditor): void {
298 const observer = editor._observer;
300 if (observer !== null) {
301 const mutations = observer.takeRecords();
302 $flushMutations(editor, mutations, observer);
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);