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 if ($isRangeSelection(selection)) {
100 const anchorNode = selection.anchor.getNode();
102 anchorNode.is(targetNode) &&
103 selection.format !== anchorNode.getFormat()
108 return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
111 export function $flushMutations(
112 editor: LexicalEditor,
113 mutations: Array<MutationRecord>,
114 observer: MutationObserver,
116 isProcessingMutations = true;
117 const shouldFlushTextMutations =
118 performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE;
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 = '';
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(
142 (targetNode === null && targetDOM !== rootElement) ||
143 $isDecoratorNode(targetNode)
148 if (type === 'characterData') {
149 // Text mutations are deferred and passed to mutation listeners to be
150 // processed outside of the Lexical engine.
152 shouldFlushTextMutations &&
153 $isTextNode(targetNode) &&
154 shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
157 // nodeType === DOM_TEXT_TYPE is a Text DOM node
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;
170 for (let s = 0; s < addedDOMs.length; s++) {
171 const addedDOM = addedDOMs[s];
172 const node = $getNodeFromDOMNode(addedDOM);
173 const parentDOM = addedDOM.parentNode;
177 addedDOM !== blockCursorElement &&
179 (addedDOM.nodeName !== 'BR' ||
180 !isManagedLineBreak(addedDOM, parentDOM, editor))
184 (addedDOM as HTMLElement).innerText || addedDOM.nodeValue;
187 possibleTextForFirefoxPaste += possibleText;
191 parentDOM.removeChild(addedDOM);
195 const removedDOMs = mutation.removedNodes;
196 const removedDOMsLength = removedDOMs.length;
198 if (removedDOMsLength > 0) {
199 let unremovedBRs = 0;
201 for (let s = 0; s < removedDOMsLength; s++) {
202 const removedDOM = removedDOMs[s];
205 (removedDOM.nodeName === 'BR' &&
206 isManagedLineBreak(removedDOM, targetDOM, editor)) ||
207 blockCursorElement === removedDOM
209 targetDOM.appendChild(removedDOM);
214 if (removedDOMsLength !== unremovedBRs) {
215 if (targetDOM === rootElement) {
216 targetNode = internalGetRoot(currentEditorState);
219 badDOMTargets.set(targetDOM, targetNode);
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;
235 for (let s = 0; s < childKeys.length; s++) {
236 const key = childKeys[s];
237 const correctDOM = editor.getElementByKey(key);
239 if (correctDOM === null) {
243 if (currentDOM == null) {
244 targetDOM.appendChild(correctDOM);
245 currentDOM = correctDOM;
246 } else if (currentDOM !== correctDOM) {
247 targetDOM.replaceChild(correctDOM, currentDOM);
250 currentDOM = currentDOM.nextSibling;
252 } else if ($isTextNode(targetNode)) {
253 targetNode.markDirty();
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();
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;
272 for (let s = 0; s < addedNodes.length; s++) {
273 const addedDOM = addedNodes[s];
274 const parentDOM = addedDOM.parentNode;
278 addedDOM.nodeName === 'BR' &&
279 !isManagedLineBreak(addedDOM, target, editor)
281 parentDOM.removeChild(addedDOM);
286 // Clear any of those removal mutations
287 observer.takeRecords();
290 if (selection !== null) {
291 if (shouldRevertSelection) {
292 selection.dirty = true;
293 $setSelection(selection);
296 if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
297 selection.insertRawText(possibleTextForFirefoxPaste);
302 isProcessingMutations = false;
306 export function $flushRootMutations(editor: LexicalEditor): void {
307 const observer = editor._observer;
309 if (observer !== null) {
310 const mutations = observer.takeRecords();
311 $flushMutations(editor, mutations, observer);
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);