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);
							 | 
						||
| 
								 | 
							
								    },
							 | 
						||
| 
								 | 
							
								  );
							 | 
						||
| 
								 | 
							
								}
							 |