313 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			313 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
/**
 | 
						|
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 | 
						|
 *
 | 
						|
 * This source code is licensed under the MIT license found in the
 | 
						|
 * LICENSE file in the root directory of this source tree.
 | 
						|
 *
 | 
						|
 */
 | 
						|
 | 
						|
import type {TextNode} from '.';
 | 
						|
import type {LexicalEditor} from './LexicalEditor';
 | 
						|
import type {BaseSelection} from './LexicalSelection';
 | 
						|
 | 
						|
import {IS_FIREFOX} from 'lexical/shared/environment';
 | 
						|
 | 
						|
import {
 | 
						|
  $getSelection,
 | 
						|
  $isDecoratorNode,
 | 
						|
  $isElementNode,
 | 
						|
  $isTextNode,
 | 
						|
  $setSelection,
 | 
						|
} from '.';
 | 
						|
import {DOM_TEXT_TYPE} from './LexicalConstants';
 | 
						|
import {updateEditor} from './LexicalUpdates';
 | 
						|
import {
 | 
						|
  $getNearestNodeFromDOMNode,
 | 
						|
  $getNodeFromDOMNode,
 | 
						|
  $updateTextNodeFromDOMContent,
 | 
						|
  getDOMSelection,
 | 
						|
  getWindow,
 | 
						|
  internalGetRoot,
 | 
						|
  isFirefoxClipboardEvents,
 | 
						|
} from './LexicalUtils';
 | 
						|
// The time between a text entry event and the mutation observer firing.
 | 
						|
const TEXT_MUTATION_VARIANCE = 100;
 | 
						|
 | 
						|
let isProcessingMutations = false;
 | 
						|
let lastTextEntryTimeStamp = 0;
 | 
						|
 | 
						|
export function getIsProcessingMutations(): boolean {
 | 
						|
  return isProcessingMutations;
 | 
						|
}
 | 
						|
 | 
						|
function updateTimeStamp(event: Event) {
 | 
						|
  lastTextEntryTimeStamp = event.timeStamp;
 | 
						|
}
 | 
						|
 | 
						|
function initTextEntryListener(editor: LexicalEditor): void {
 | 
						|
  if (lastTextEntryTimeStamp === 0) {
 | 
						|
    getWindow(editor).addEventListener('textInput', updateTimeStamp, true);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
function isManagedLineBreak(
 | 
						|
  dom: Node,
 | 
						|
  target: Node,
 | 
						|
  editor: LexicalEditor,
 | 
						|
): boolean {
 | 
						|
  return (
 | 
						|
    // @ts-expect-error: internal field
 | 
						|
    target.__lexicalLineBreak === dom ||
 | 
						|
    // @ts-ignore We intentionally add this to the Node.
 | 
						|
    dom[`__lexicalKey_${editor._key}`] !== undefined
 | 
						|
  );
 | 
						|
}
 | 
						|
 | 
						|
function getLastSelection(editor: LexicalEditor): null | BaseSelection {
 | 
						|
  return editor.getEditorState().read(() => {
 | 
						|
    const selection = $getSelection();
 | 
						|
    return selection !== null ? selection.clone() : null;
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
function $handleTextMutation(
 | 
						|
  target: Text,
 | 
						|
  node: TextNode,
 | 
						|
  editor: LexicalEditor,
 | 
						|
): void {
 | 
						|
  const domSelection = getDOMSelection(editor._window);
 | 
						|
  let anchorOffset = null;
 | 
						|
  let focusOffset = null;
 | 
						|
 | 
						|
  if (domSelection !== null && domSelection.anchorNode === target) {
 | 
						|
    anchorOffset = domSelection.anchorOffset;
 | 
						|
    focusOffset = domSelection.focusOffset;
 | 
						|
  }
 | 
						|
 | 
						|
  const text = target.nodeValue;
 | 
						|
  if (text !== null) {
 | 
						|
    $updateTextNodeFromDOMContent(node, text, anchorOffset, focusOffset, false);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
function shouldUpdateTextNodeFromMutation(
 | 
						|
  selection: null | BaseSelection,
 | 
						|
  targetDOM: Node,
 | 
						|
  targetNode: TextNode,
 | 
						|
): boolean {
 | 
						|
  return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
 | 
						|
}
 | 
						|
 | 
						|
export function $flushMutations(
 | 
						|
  editor: LexicalEditor,
 | 
						|
  mutations: Array<MutationRecord>,
 | 
						|
  observer: MutationObserver,
 | 
						|
): void {
 | 
						|
  isProcessingMutations = true;
 | 
						|
  const shouldFlushTextMutations =
 | 
						|
    performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE;
 | 
						|
 | 
						|
  try {
 | 
						|
    updateEditor(editor, () => {
 | 
						|
      const selection = $getSelection() || getLastSelection(editor);
 | 
						|
      const badDOMTargets = new Map();
 | 
						|
      const rootElement = editor.getRootElement();
 | 
						|
      // We use the current editor state, as that reflects what is
 | 
						|
      // actually "on screen".
 | 
						|
      const currentEditorState = editor._editorState;
 | 
						|
      const blockCursorElement = editor._blockCursorElement;
 | 
						|
      let shouldRevertSelection = false;
 | 
						|
      let possibleTextForFirefoxPaste = '';
 | 
						|
 | 
						|
      for (let i = 0; i < mutations.length; i++) {
 | 
						|
        const mutation = mutations[i];
 | 
						|
        const type = mutation.type;
 | 
						|
        const targetDOM = mutation.target;
 | 
						|
        let targetNode = $getNearestNodeFromDOMNode(
 | 
						|
          targetDOM,
 | 
						|
          currentEditorState,
 | 
						|
        );
 | 
						|
 | 
						|
        if (
 | 
						|
          (targetNode === null && targetDOM !== rootElement) ||
 | 
						|
          $isDecoratorNode(targetNode)
 | 
						|
        ) {
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
 | 
						|
        if (type === 'characterData') {
 | 
						|
          // Text mutations are deferred and passed to mutation listeners to be
 | 
						|
          // processed outside of the Lexical engine.
 | 
						|
          if (
 | 
						|
            shouldFlushTextMutations &&
 | 
						|
            $isTextNode(targetNode) &&
 | 
						|
            shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
 | 
						|
          ) {
 | 
						|
            $handleTextMutation(
 | 
						|
              // nodeType === DOM_TEXT_TYPE is a Text DOM node
 | 
						|
              targetDOM as Text,
 | 
						|
              targetNode,
 | 
						|
              editor,
 | 
						|
            );
 | 
						|
          }
 | 
						|
        } else if (type === 'childList') {
 | 
						|
          shouldRevertSelection = true;
 | 
						|
          // We attempt to "undo" any changes that have occurred outside
 | 
						|
          // of Lexical. We want Lexical's editor state to be source of truth.
 | 
						|
          // To the user, these will look like no-ops.
 | 
						|
          const addedDOMs = mutation.addedNodes;
 | 
						|
 | 
						|
          for (let s = 0; s < addedDOMs.length; s++) {
 | 
						|
            const addedDOM = addedDOMs[s];
 | 
						|
            const node = $getNodeFromDOMNode(addedDOM);
 | 
						|
            const parentDOM = addedDOM.parentNode;
 | 
						|
 | 
						|
            if (
 | 
						|
              parentDOM != null &&
 | 
						|
              addedDOM !== blockCursorElement &&
 | 
						|
              node === null &&
 | 
						|
              (addedDOM.nodeName !== 'BR' ||
 | 
						|
                !isManagedLineBreak(addedDOM, parentDOM, editor))
 | 
						|
            ) {
 | 
						|
              if (IS_FIREFOX) {
 | 
						|
                const possibleText =
 | 
						|
                  (addedDOM as HTMLElement).innerText || addedDOM.nodeValue;
 | 
						|
 | 
						|
                if (possibleText) {
 | 
						|
                  possibleTextForFirefoxPaste += possibleText;
 | 
						|
                }
 | 
						|
              }
 | 
						|
 | 
						|
              parentDOM.removeChild(addedDOM);
 | 
						|
            }
 | 
						|
          }
 | 
						|
 | 
						|
          const removedDOMs = mutation.removedNodes;
 | 
						|
          const removedDOMsLength = removedDOMs.length;
 | 
						|
 | 
						|
          if (removedDOMsLength > 0) {
 | 
						|
            let unremovedBRs = 0;
 | 
						|
 | 
						|
            for (let s = 0; s < removedDOMsLength; s++) {
 | 
						|
              const removedDOM = removedDOMs[s];
 | 
						|
 | 
						|
              if (
 | 
						|
                (removedDOM.nodeName === 'BR' &&
 | 
						|
                  isManagedLineBreak(removedDOM, targetDOM, editor)) ||
 | 
						|
                blockCursorElement === removedDOM
 | 
						|
              ) {
 | 
						|
                targetDOM.appendChild(removedDOM);
 | 
						|
                unremovedBRs++;
 | 
						|
              }
 | 
						|
            }
 | 
						|
 | 
						|
            if (removedDOMsLength !== unremovedBRs) {
 | 
						|
              if (targetDOM === rootElement) {
 | 
						|
                targetNode = internalGetRoot(currentEditorState);
 | 
						|
              }
 | 
						|
 | 
						|
              badDOMTargets.set(targetDOM, targetNode);
 | 
						|
            }
 | 
						|
          }
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      // Now we process each of the unique target nodes, attempting
 | 
						|
      // to restore their contents back to the source of truth, which
 | 
						|
      // is Lexical's "current" editor state. This is basically like
 | 
						|
      // an internal revert on the DOM.
 | 
						|
      if (badDOMTargets.size > 0) {
 | 
						|
        for (const [targetDOM, targetNode] of badDOMTargets) {
 | 
						|
          if ($isElementNode(targetNode)) {
 | 
						|
            const childKeys = targetNode.getChildrenKeys();
 | 
						|
            let currentDOM = targetDOM.firstChild;
 | 
						|
 | 
						|
            for (let s = 0; s < childKeys.length; s++) {
 | 
						|
              const key = childKeys[s];
 | 
						|
              const correctDOM = editor.getElementByKey(key);
 | 
						|
 | 
						|
              if (correctDOM === null) {
 | 
						|
                continue;
 | 
						|
              }
 | 
						|
 | 
						|
              if (currentDOM == null) {
 | 
						|
                targetDOM.appendChild(correctDOM);
 | 
						|
                currentDOM = correctDOM;
 | 
						|
              } else if (currentDOM !== correctDOM) {
 | 
						|
                targetDOM.replaceChild(correctDOM, currentDOM);
 | 
						|
              }
 | 
						|
 | 
						|
              currentDOM = currentDOM.nextSibling;
 | 
						|
            }
 | 
						|
          } else if ($isTextNode(targetNode)) {
 | 
						|
            targetNode.markDirty();
 | 
						|
          }
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      // Capture all the mutations made during this function. This
 | 
						|
      // also prevents us having to process them on the next cycle
 | 
						|
      // of onMutation, as these mutations were made by us.
 | 
						|
      const records = observer.takeRecords();
 | 
						|
 | 
						|
      // Check for any random auto-added <br> elements, and remove them.
 | 
						|
      // These get added by the browser when we undo the above mutations
 | 
						|
      // and this can lead to a broken UI.
 | 
						|
      if (records.length > 0) {
 | 
						|
        for (let i = 0; i < records.length; i++) {
 | 
						|
          const record = records[i];
 | 
						|
          const addedNodes = record.addedNodes;
 | 
						|
          const target = record.target;
 | 
						|
 | 
						|
          for (let s = 0; s < addedNodes.length; s++) {
 | 
						|
            const addedDOM = addedNodes[s];
 | 
						|
            const parentDOM = addedDOM.parentNode;
 | 
						|
 | 
						|
            if (
 | 
						|
              parentDOM != null &&
 | 
						|
              addedDOM.nodeName === 'BR' &&
 | 
						|
              !isManagedLineBreak(addedDOM, target, editor)
 | 
						|
            ) {
 | 
						|
              parentDOM.removeChild(addedDOM);
 | 
						|
            }
 | 
						|
          }
 | 
						|
        }
 | 
						|
 | 
						|
        // Clear any of those removal mutations
 | 
						|
        observer.takeRecords();
 | 
						|
      }
 | 
						|
 | 
						|
      if (selection !== null) {
 | 
						|
        if (shouldRevertSelection) {
 | 
						|
          selection.dirty = true;
 | 
						|
          $setSelection(selection);
 | 
						|
        }
 | 
						|
 | 
						|
        if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
 | 
						|
          selection.insertRawText(possibleTextForFirefoxPaste);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    });
 | 
						|
  } finally {
 | 
						|
    isProcessingMutations = false;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
export function $flushRootMutations(editor: LexicalEditor): void {
 | 
						|
  const observer = editor._observer;
 | 
						|
 | 
						|
  if (observer !== null) {
 | 
						|
    const mutations = observer.takeRecords();
 | 
						|
    $flushMutations(editor, mutations, observer);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
export function initMutationObserver(editor: LexicalEditor): void {
 | 
						|
  initTextEntryListener(editor);
 | 
						|
  editor._observer = new MutationObserver(
 | 
						|
    (mutations: Array<MutationRecord>, observer: MutationObserver) => {
 | 
						|
      $flushMutations(editor, mutations, observer);
 | 
						|
    },
 | 
						|
  );
 | 
						|
}
 |