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';
22 import {DOM_TEXT_TYPE} from './LexicalConstants';
23 import {updateEditor} from './LexicalUpdates';
25 $getNearestNodeFromDOMNode,
27 $updateTextNodeFromDOMContent,
31 isFirefoxClipboardEvents,
32 } from './LexicalUtils';
33 // The time between a text entry event and the mutation observer firing.
34 const TEXT_MUTATION_VARIANCE = 100;
36 let isProcessingMutations = false;
37 let lastTextEntryTimeStamp = 0;
39 export function getIsProcessingMutations(): boolean {
40 return isProcessingMutations;
43 function updateTimeStamp(event: Event) {
44 lastTextEntryTimeStamp = event.timeStamp;
47 function initTextEntryListener(editor: LexicalEditor): void {
48 if (lastTextEntryTimeStamp === 0) {
49 getWindow(editor).addEventListener('textInput', updateTimeStamp, true);
53 function isManagedLineBreak(
56 editor: LexicalEditor,
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
66 function getLastSelection(editor: LexicalEditor): null | BaseSelection {
67 return editor.getEditorState().read(() => {
68 const selection = $getSelection();
69 return selection !== null ? selection.clone() : null;
73 function $handleTextMutation(
76 editor: LexicalEditor,
78 const domSelection = getDOMSelection(editor._window);
79 let anchorOffset = null;
80 let focusOffset = null;
82 if (domSelection !== null && domSelection.anchorNode === target) {
83 anchorOffset = domSelection.anchorOffset;
84 focusOffset = domSelection.focusOffset;
87 const text = target.nodeValue;
89 $updateTextNodeFromDOMContent(node, text, anchorOffset, focusOffset, false);
93 function shouldUpdateTextNodeFromMutation(
94 selection: null | BaseSelection,
98 return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
101 export function $flushMutations(
102 editor: LexicalEditor,
103 mutations: Array<MutationRecord>,
104 observer: MutationObserver,
106 isProcessingMutations = true;
107 const shouldFlushTextMutations =
108 performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE;
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 = '';
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(
132 (targetNode === null && targetDOM !== rootElement) ||
133 $isDecoratorNode(targetNode)
138 if (type === 'characterData') {
139 // Text mutations are deferred and passed to mutation listeners to be
140 // processed outside of the Lexical engine.
142 shouldFlushTextMutations &&
143 $isTextNode(targetNode) &&
144 shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
147 // nodeType === DOM_TEXT_TYPE is a Text DOM node
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;
160 for (let s = 0; s < addedDOMs.length; s++) {
161 const addedDOM = addedDOMs[s];
162 const node = $getNodeFromDOMNode(addedDOM);
163 const parentDOM = addedDOM.parentNode;
167 addedDOM !== blockCursorElement &&
169 (addedDOM.nodeName !== 'BR' ||
170 !isManagedLineBreak(addedDOM, parentDOM, editor))
174 (addedDOM as HTMLElement).innerText || addedDOM.nodeValue;
177 possibleTextForFirefoxPaste += possibleText;
181 parentDOM.removeChild(addedDOM);
185 const removedDOMs = mutation.removedNodes;
186 const removedDOMsLength = removedDOMs.length;
188 if (removedDOMsLength > 0) {
189 let unremovedBRs = 0;
191 for (let s = 0; s < removedDOMsLength; s++) {
192 const removedDOM = removedDOMs[s];
195 (removedDOM.nodeName === 'BR' &&
196 isManagedLineBreak(removedDOM, targetDOM, editor)) ||
197 blockCursorElement === removedDOM
199 targetDOM.appendChild(removedDOM);
204 if (removedDOMsLength !== unremovedBRs) {
205 if (targetDOM === rootElement) {
206 targetNode = internalGetRoot(currentEditorState);
209 badDOMTargets.set(targetDOM, targetNode);
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;
225 for (let s = 0; s < childKeys.length; s++) {
226 const key = childKeys[s];
227 const correctDOM = editor.getElementByKey(key);
229 if (correctDOM === null) {
233 if (currentDOM == null) {
234 targetDOM.appendChild(correctDOM);
235 currentDOM = correctDOM;
236 } else if (currentDOM !== correctDOM) {
237 targetDOM.replaceChild(correctDOM, currentDOM);
240 currentDOM = currentDOM.nextSibling;
242 } else if ($isTextNode(targetNode)) {
243 targetNode.markDirty();
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();
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;
262 for (let s = 0; s < addedNodes.length; s++) {
263 const addedDOM = addedNodes[s];
264 const parentDOM = addedDOM.parentNode;
268 addedDOM.nodeName === 'BR' &&
269 !isManagedLineBreak(addedDOM, target, editor)
271 parentDOM.removeChild(addedDOM);
276 // Clear any of those removal mutations
277 observer.takeRecords();
280 if (selection !== null) {
281 if (shouldRevertSelection) {
282 selection.dirty = true;
283 $setSelection(selection);
286 if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
287 selection.insertRawText(possibleTextForFirefoxPaste);
292 isProcessingMutations = false;
296 export function $flushRootMutations(editor: LexicalEditor): void {
297 const observer = editor._observer;
299 if (observer !== null) {
300 const mutations = observer.takeRecords();
301 $flushMutations(editor, mutations, observer);
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);