/**
 * 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 {LexicalEditor} from './LexicalEditor';
import type {NodeKey} from './LexicalNode';
import type {ElementNode} from './nodes/LexicalElementNode';
import type {TextNode} from './nodes/LexicalTextNode';

import invariant from '@lexical/internal/invariant';
import warnOnlyOnce from '@lexical/internal/warnOnlyOnce';

import {
  $getPreviousSelection,
  $getRoot,
  $getSelection,
  $isBlockElementNode,
  $isDecoratorNode,
  $isElementNode,
  $isLineBreakNode,
  $isNodeSelection,
  $isRangeSelection,
  $isRootNode,
  $isTabNode,
  $isTextNode,
  $setCompositionKey,
  BLUR_COMMAND,
  CLICK_COMMAND,
  COMMAND_PRIORITY_EDITOR,
  COMPOSITION_END_TAG,
  COMPOSITION_START_TAG,
  CONTROLLED_TEXT_INSERTION_COMMAND,
  COPY_COMMAND,
  CUT_COMMAND,
  DELETE_CHARACTER_COMMAND,
  DELETE_LINE_COMMAND,
  DELETE_WORD_COMMAND,
  DRAGEND_COMMAND,
  DRAGOVER_COMMAND,
  DRAGSTART_COMMAND,
  DROP_COMMAND,
  FOCUS_COMMAND,
  FORMAT_TEXT_COMMAND,
  INSERT_LINE_BREAK_COMMAND,
  INSERT_PARAGRAPH_COMMAND,
  KEY_ARROW_DOWN_COMMAND,
  KEY_ARROW_LEFT_COMMAND,
  KEY_ARROW_RIGHT_COMMAND,
  KEY_ARROW_UP_COMMAND,
  KEY_BACKSPACE_COMMAND,
  KEY_DELETE_COMMAND,
  KEY_DOWN_COMMAND,
  KEY_ENTER_COMMAND,
  KEY_ESCAPE_COMMAND,
  KEY_SPACE_COMMAND,
  KEY_TAB_COMMAND,
  MOVE_TO_END,
  MOVE_TO_START,
  PASTE_COMMAND,
  REDO_COMMAND,
  REMOVE_TEXT_COMMAND,
  SELECTION_CHANGE_COMMAND,
  SKIP_SELECTION_FOCUS_TAG,
  UNDO_COMMAND,
} from '.';
import {
  CAN_USE_BEFORE_INPUT,
  IS_ANDROID_CHROME,
  IS_APPLE_WEBKIT,
  IS_FIREFOX,
  IS_IOS,
  IS_SAFARI,
} from './environment';
import {
  BEFORE_INPUT_COMMAND,
  COMPOSITION_END_COMMAND,
  COMPOSITION_START_COMMAND,
  INPUT_COMMAND,
  KEY_MODIFIER_COMMAND,
  SELECT_ALL_COMMAND,
} from './LexicalCommands';
import {
  COMPOSITION_START_CHAR,
  DOUBLE_LINE_BREAK,
  IS_ALL_FORMATTING,
} from './LexicalConstants';
import {
  $internalCreateRangeSelection,
  RangeSelection,
} from './LexicalSelection';
import {getActiveEditor, updateEditorSync} from './LexicalUpdates';
import {
  $addUpdateTag,
  $findMatchingParent,
  $flushMutations,
  $getAdjacentNode,
  $getDOMTextNode,
  $getNodeByKey,
  $isSelectionCapturedInDecorator,
  $isTokenOrSegmented,
  $isTokenOrTab,
  $setSelection,
  $shouldInsertTextAfterOrBeforeTextNode,
  $updateSelectedTextFromDOM,
  $updateTextNodeFromDOMContent,
  dispatchCommand,
  doesContainSurrogatePair,
  getAnchorTextFromDOM,
  getDOMSelection,
  getDOMSelectionFromTarget,
  getEditorPropertyFromDOMNode,
  getEditorsToPropagate,
  getNearestEditorFromDOMNode,
  getWindow,
  isBackspace,
  isBold,
  isCopy,
  isCut,
  isDelete,
  isDeleteBackward,
  isDeleteForward,
  isDeleteLineBackward,
  isDeleteLineForward,
  isDeleteWordBackward,
  isDeleteWordForward,
  isDOMNode,
  isDOMTextNode,
  isEscape,
  isFirefoxClipboardEvents,
  isHTMLElement,
  isItalic,
  isLexicalEditor,
  isLineBreak,
  isModifier,
  isMoveBackward,
  isMoveDown,
  isMoveForward,
  isMoveToEnd,
  isMoveToStart,
  isMoveUp,
  isOpenLineBreak,
  isParagraph,
  isRedo,
  isSelectAll,
  isSelectionWithinEditor,
  isSpace,
  isTab,
  isUnderline,
  isUndo,
} from './LexicalUtils';

type RootElementRemoveHandles = Array<() => void>;
type RootElementEvents = Array<
  [
    string,
    Record<string, unknown> | ((event: Event, editor: LexicalEditor) => void),
  ]
>;
const PASS_THROUGH_COMMAND = Object.freeze({});
const ANDROID_COMPOSITION_LATENCY = 30;
const rootElementEvents: RootElementEvents = [
  ['keydown', onKeyDown],
  ['pointerdown', onPointerDown],
  ['compositionstart', onCompositionStart],
  ['compositionend', onCompositionEnd],
  ['input', onInput],
  ['click', onClick],
  ['cut', PASS_THROUGH_COMMAND],
  ['copy', PASS_THROUGH_COMMAND],
  ['dragstart', PASS_THROUGH_COMMAND],
  ['dragover', PASS_THROUGH_COMMAND],
  ['dragend', PASS_THROUGH_COMMAND],
  ['paste', PASS_THROUGH_COMMAND],
  ['focus', PASS_THROUGH_COMMAND],
  ['blur', PASS_THROUGH_COMMAND],
  ['drop', PASS_THROUGH_COMMAND],
];

if (CAN_USE_BEFORE_INPUT) {
  rootElementEvents.push([
    'beforeinput',
    (event, editor) => onBeforeInput(event as InputEvent, editor),
  ]);
}

let lastKeyDownTimeStamp = 0;
let lastKeyCode: null | string = null;
let lastBeforeInputInsertTextTimeStamp = 0;
let unprocessedBeforeInputData: null | string = null;
let isInsertTextAfterHandledSelectionCommand = false;
let handledSelectionCommandTimeoutId: null | ReturnType<typeof setTimeout> =
  null;
// Node can be moved between documents (for example using createPortal), so we
// need to track the document each root element was originally registered on.
const rootElementToDocument = new WeakMap<HTMLElement, Document>();
const rootElementsRegistered = new WeakMap<Document, number>();
let isSelectionChangeFromDOMUpdate = false;
let isSelectionChangeFromMouseDown = false;
let isInsertLineBreak = false;
let isFirefoxEndingComposition = false;
let isSafariEndingComposition = false;
let safariEndCompositionEventData = '';
let postDeleteSelectionToRestore: RangeSelection | null = null;
let collapsedSelectionFormat: [number, string, number, NodeKey, number] = [
  0,
  '',
  0,
  'root',
  0,
];

// This function is used to determine if Lexical should attempt to override
// the default browser behavior for insertion of text and use its own internal
// heuristics. This is an extremely important function, and makes much of Lexical
// work as intended between different browsers and across word, line and character
// boundary/formats. It also is important for text replacement, node schemas and
// composition mechanics.
function $shouldPreventDefaultAndInsertText(
  selection: RangeSelection,
  domTargetRange: null | StaticRange,
  text: string,
  timeStamp: number,
  isBeforeInput: boolean,
): boolean {
  const anchor = selection.anchor;
  const focus = selection.focus;
  const anchorNode = anchor.getNode();
  const editor = getActiveEditor();
  const domSelection = getDOMSelection(getWindow(editor));
  const domAnchorNode = domSelection !== null ? domSelection.anchorNode : null;
  const anchorKey = anchor.key;
  const backingAnchorElement = editor.getElementByKey(anchorKey);
  const textLength = text.length;

  return (
    anchorKey !== focus.key ||
    // If we're working with a non-text node.
    !$isTextNode(anchorNode) ||
    // If we are replacing a range with a single character or grapheme, and not composing.
    (((!isBeforeInput &&
      (!CAN_USE_BEFORE_INPUT ||
        // We check to see if there has been
        // a recent beforeinput event for "textInput". If there has been one in the last
        // 50ms then we proceed as normal. However, if there is not, then this is likely
        // a dangling `input` event caused by execCommand('insertText').
        lastBeforeInputInsertTextTimeStamp < timeStamp + 50)) ||
      (anchorNode.isDirty() && textLength < 2) ||
      // TODO consider if there are other scenarios when multiple code units
      //      should be addressed here
      doesContainSurrogatePair(text)) &&
      anchor.offset !== focus.offset &&
      !anchorNode.isComposing()) ||
    // Any non standard text node.
    $isTokenOrSegmented(anchorNode) ||
    // If the text length is more than a single character and we're either
    // dealing with this in "beforeinput" or where the node has already recently
    // been changed (thus is dirty).
    (anchorNode.isDirty() && textLength > 1) ||
    // If the DOM selection element is not the same as the backing node during beforeinput.
    ((isBeforeInput || !CAN_USE_BEFORE_INPUT) &&
      backingAnchorElement !== null &&
      !anchorNode.isComposing() &&
      domAnchorNode !==
        $getDOMTextNode(anchorNode, backingAnchorElement, editor)) ||
    // If TargetRange is not the same as the DOM selection; browser trying to edit random parts
    // of the editor.
    (domSelection !== null &&
      domTargetRange !== null &&
      (!domTargetRange.collapsed ||
        domTargetRange.startContainer !== domSelection.anchorNode ||
        domTargetRange.startOffset !== domSelection.anchorOffset)) ||
    // Check if we're changing from bold to italics, or some other format.
    (!anchorNode.isComposing() &&
      (anchorNode.getFormat() !== selection.format ||
        anchorNode.getStyle() !== selection.style)) ||
    // One last set of heuristics to check against.
    $shouldInsertTextAfterOrBeforeTextNode(selection, anchorNode)
  );
}

function shouldSkipSelectionChange(
  domNode: null | Node,
  offset: number,
): boolean {
  return (
    isDOMTextNode(domNode) &&
    domNode.nodeValue !== null &&
    offset !== 0 &&
    offset !== domNode.nodeValue.length
  );
}

function onSelectionChange(
  domSelection: Selection,
  editor: LexicalEditor,
  isActive: boolean,
): void {
  const {
    anchorNode: anchorDOM,
    anchorOffset,
    focusNode: focusDOM,
    focusOffset,
  } = domSelection;
  if (isSelectionChangeFromDOMUpdate) {
    isSelectionChangeFromDOMUpdate = false;

    // If native DOM selection is on a DOM element, then
    // we should continue as usual, as Lexical's selection
    // may have normalized to a better child. If the DOM
    // element is a text node, we can safely apply this
    // optimization and skip the selection change entirely.
    // We also need to check if the offset is at the boundary,
    // because in this case, we might need to normalize to a
    // sibling instead.
    if (
      shouldSkipSelectionChange(anchorDOM, anchorOffset) &&
      shouldSkipSelectionChange(focusDOM, focusOffset) &&
      !postDeleteSelectionToRestore
    ) {
      return;
    }
  }
  updateEditorSync(editor, () => {
    // Non-active editor don't need any extra logic for selection, it only needs update
    // to reconcile selection (set it to null) to ensure that only one editor has non-null selection.
    if (!isActive) {
      $setSelection(null);
      return;
    }

    if (!isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
      return;
    }

    let selection = $getSelection();

    // Restore selection in the event of incorrect rightward shift after deletion
    if (
      postDeleteSelectionToRestore &&
      $isRangeSelection(selection) &&
      selection.isCollapsed()
    ) {
      const curAnchor = selection.anchor;
      const prevAnchor = postDeleteSelectionToRestore.anchor;
      if (
        // Rightward shift in same node
        (curAnchor.key === prevAnchor.key &&
          curAnchor.offset === prevAnchor.offset + 1) ||
        // Or rightward shift into sibling node
        (curAnchor.offset === 1 &&
          prevAnchor.getNode().is(curAnchor.getNode().getPreviousSibling()))
      ) {
        // Restore selection
        selection = postDeleteSelectionToRestore.clone();
        $setSelection(selection);
      }
    }
    postDeleteSelectionToRestore = null;

    // Update the selection format
    if ($isRangeSelection(selection)) {
      const anchor = selection.anchor;
      const anchorNode = anchor.getNode();

      if (selection.isCollapsed()) {
        // Badly interpreted range selection when collapsed - #1482
        if (
          domSelection.type === 'Range' &&
          domSelection.anchorNode === domSelection.focusNode
        ) {
          selection.dirty = true;
        }

        // If we have marked a collapsed selection format, and we're
        // within the given time range – then attempt to use that format
        // instead of getting the format from the anchor node.
        const windowEvent = getWindow(editor).event;
        const currentTimeStamp = windowEvent
          ? windowEvent.timeStamp
          : performance.now();
        const [lastFormat, lastStyle, lastOffset, lastKey, timeStamp] =
          collapsedSelectionFormat;

        const root = $getRoot();
        const isRootTextContentEmpty =
          editor.isComposing() === false && root.getTextContent() === '';

        if (
          currentTimeStamp < timeStamp + 200 &&
          anchor.offset === lastOffset &&
          anchor.key === lastKey
        ) {
          $updateSelectionFormatStyle(selection, lastFormat, lastStyle);
        } else {
          if (anchor.type === 'text') {
            invariant(
              $isTextNode(anchorNode),
              'Point.getNode() must return TextNode when type is text',
            );
            $updateSelectionFormatStyleFromTextNode(selection, anchorNode);
          } else if (anchor.type === 'element' && !isRootTextContentEmpty) {
            invariant(
              $isElementNode(anchorNode),
              'Point.getNode() must return ElementNode when type is element',
            );
            const lastNode = anchor.getNode();
            if (
              // This previously applied to all ParagraphNode
              lastNode.isEmpty()
            ) {
              $updateSelectionFormatStyleFromElementNode(selection, lastNode);
            } else {
              $updateSelectionFormatStyle(selection, selection.format, '');
            }
          }
        }
      } else {
        const anchorKey = anchor.key;
        const focus = selection.focus;
        const focusKey = focus.key;
        const nodes = selection.getNodes();
        const nodesLength = nodes.length;
        const isBackward = selection.isBackward();
        const startOffset = isBackward ? focusOffset : anchorOffset;
        const endOffset = isBackward ? anchorOffset : focusOffset;
        const startKey = isBackward ? focusKey : anchorKey;
        const endKey = isBackward ? anchorKey : focusKey;
        let combinedFormat = IS_ALL_FORMATTING;
        let hasTextNodes = false;
        for (let i = 0; i < nodesLength; i++) {
          const node = nodes[i];
          const textContentSize = node.getTextContentSize();
          if (
            $isTextNode(node) &&
            textContentSize !== 0 &&
            // Exclude empty text nodes at boundaries resulting from user's selection
            !(
              (i === 0 &&
                node.__key === startKey &&
                startOffset === textContentSize) ||
              (i === nodesLength - 1 &&
                node.__key === endKey &&
                endOffset === 0)
            )
          ) {
            // TODO: what about style?
            hasTextNodes = true;
            combinedFormat &= node.getFormat();
            if (combinedFormat === 0) {
              break;
            }
          }
        }

        selection.format = hasTextNodes ? combinedFormat : 0;
      }
    }

    dispatchCommand(editor, SELECTION_CHANGE_COMMAND, undefined);
  });
}

function $updateSelectionFormatStyle(
  selection: RangeSelection,
  format: number,
  style: string,
) {
  if (selection.format !== format || selection.style !== style) {
    selection.format = format;
    selection.style = style;
    selection.dirty = true;
  }
}

function $updateSelectionFormatStyleFromTextNode(
  selection: RangeSelection,
  node: TextNode,
) {
  const format = node.getFormat();
  const style = node.getStyle();
  $updateSelectionFormatStyle(selection, format, style);
}

function $updateSelectionFormatStyleFromElementNode(
  selection: RangeSelection,
  node: ElementNode,
) {
  const format = node.getTextFormat();
  const style = node.getTextStyle();
  $updateSelectionFormatStyle(selection, format, style);
}

// This is a work-around is mainly Chrome specific bug where if you select
// the contents of an empty block, you cannot easily unselect anything.
// This results in a tiny selection box that looks buggy/broken. This can
// also help other browsers when selection might "appear" lost, when it
// really isn't.
function onClick(event: PointerEvent, editor: LexicalEditor): void {
  updateEditorSync(editor, () => {
    const selection = $getSelection();
    const domSelection = getDOMSelection(getWindow(editor));
    const lastSelection = $getPreviousSelection();

    if (domSelection) {
      if ($isRangeSelection(selection)) {
        const anchor = selection.anchor;
        const anchorNode = anchor.getNode();

        if (
          anchor.type === 'element' &&
          anchor.offset === 0 &&
          selection.isCollapsed() &&
          !$isRootNode(anchorNode) &&
          $getRoot().getChildrenSize() === 1 &&
          anchorNode.getTopLevelElementOrThrow().isEmpty() &&
          lastSelection !== null &&
          selection.is(lastSelection)
        ) {
          domSelection.removeAllRanges();
          selection.dirty = true;
        }
      } else if (event.pointerType === 'touch' || event.pointerType === 'pen') {
        // This is used to update the selection on touch devices (including Apple Pencil) when the user clicks on text after a
        // node selection. See isSelectionChangeFromMouseDown for the inverse
        const domAnchorNode = domSelection.anchorNode;
        // If the user is attempting to click selection back onto text, then
        // we should attempt create a range selection.
        // When we click on an empty paragraph node or the end of a paragraph that ends
        // with an image/poll, the nodeType will be ELEMENT_NODE
        if (isHTMLElement(domAnchorNode) || isDOMTextNode(domAnchorNode)) {
          const newSelection = $internalCreateRangeSelection(
            lastSelection,
            domSelection,
            editor,
            event,
          );
          $setSelection(newSelection);
        }
      }
    }

    dispatchCommand(editor, CLICK_COMMAND, event);
  });
}

function onPointerDown(event: PointerEvent, editor: LexicalEditor) {
  // TODO implement text drag & drop
  const target = event.target;
  const pointerType = event.pointerType;
  if (
    isDOMNode(target) &&
    pointerType !== 'touch' &&
    pointerType !== 'pen' &&
    event.button === 0
  ) {
    updateEditorSync(editor, () => {
      // Drag & drop should not recompute selection until mouse up; otherwise the initially
      // selected content is lost.
      if (!$isSelectionCapturedInDecorator(target)) {
        isSelectionChangeFromMouseDown = true;
      }
    });
  }
}

function getTargetRange(event: InputEvent): null | StaticRange {
  if (!event.getTargetRanges) {
    return null;
  }
  const targetRanges = event.getTargetRanges();
  if (targetRanges.length === 0) {
    return null;
  }
  return targetRanges[0];
}

// When a macOS text replacement is accepted, Chrome and Firefox fire input events for the key press that
// triggered the acceptance *before* the one for the replacement text. This causes the caret to be placed
// before the acceptance boundary. This function moves the caret past the acceptance boundary.
function $maybeMoveSelectionPastTrailingAcceptanceBoundary(
  insertedText: string | null | undefined,
): void {
  if (insertedText == null || insertedText.length <= 1 || lastKeyCode == null) {
    return;
  }

  const characterToSearchFor =
    lastKeyCode.length === 1
      ? lastKeyCode
      : lastKeyCode === 'Enter'
        ? '\n'
        : lastKeyCode === 'Tab'
          ? '\t'
          : null;

  if (!characterToSearchFor) {
    return;
  }

  const selection = $getSelection();
  if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
    return;
  }

  const anchorNode = selection.anchor.getNode();
  if (!$isTextNode(anchorNode)) {
    return;
  }

  const {offset} = selection.anchor;
  if (anchorNode.getTextContentSize() === offset) {
    const nextSibling = anchorNode.getNextSibling();
    if (characterToSearchFor === '\n') {
      if ($isLineBreakNode(nextSibling)) {
        nextSibling.selectEnd();
      } else if (!nextSibling) {
        const block = $findMatchingParent(anchorNode, $isBlockElementNode);
        const nextBlock = block && block.getNextSibling();
        if ($isElementNode(nextBlock)) {
          nextBlock.selectStart();
        }
      }
    } else if (characterToSearchFor === '\t') {
      if ($isTabNode(nextSibling)) {
        nextSibling.selectEnd();
      }
    } else if (
      $isTextNode(nextSibling) &&
      nextSibling.getTextContent()[0] === characterToSearchFor
    ) {
      nextSibling.select(1, 1);
    }
  } else if (anchorNode.getTextContent()[offset] === characterToSearchFor) {
    anchorNode.select(offset + 1, offset + 1);
  }
}

function $canRemoveText(
  anchorNode: TextNode | ElementNode,
  focusNode: TextNode | ElementNode,
): boolean {
  return (
    anchorNode !== focusNode ||
    $isElementNode(anchorNode) ||
    $isElementNode(focusNode) ||
    !$isTokenOrTab(anchorNode) ||
    !$isTokenOrTab(focusNode)
  );
}

function isPossiblyAndroidKeyPress(timeStamp: number): boolean {
  return (
    lastKeyCode === 'MediaLast' &&
    timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY
  );
}

function clearHandledSelectionCommandInsertText(): void {
  isInsertTextAfterHandledSelectionCommand = false;
  if (handledSelectionCommandTimeoutId !== null) {
    clearTimeout(handledSelectionCommandTimeoutId);
    handledSelectionCommandTimeoutId = null;
  }
}

function markHandledSelectionCommandInsertText(): void {
  clearHandledSelectionCommandInsertText();
  isInsertTextAfterHandledSelectionCommand = true;
  handledSelectionCommandTimeoutId = setTimeout(
    clearHandledSelectionCommandInsertText,
    0,
  );
}

export function registerDefaultCommandHandlers(editor: LexicalEditor) {
  editor.registerCommand(
    BEFORE_INPUT_COMMAND,
    $handleBeforeInput,
    COMMAND_PRIORITY_EDITOR,
  );
  editor.registerCommand(INPUT_COMMAND, $handleInput, COMMAND_PRIORITY_EDITOR);
  editor.registerCommand(
    COMPOSITION_START_COMMAND,
    $handleCompositionStart,
    COMMAND_PRIORITY_EDITOR,
  );
  editor.registerCommand(
    COMPOSITION_END_COMMAND,
    $handleCompositionEnd,
    COMMAND_PRIORITY_EDITOR,
  );
  editor.registerCommand(
    KEY_DOWN_COMMAND,
    $handleKeyDown,
    COMMAND_PRIORITY_EDITOR,
  );
}

function onBeforeInput(event: InputEvent, editor: LexicalEditor): void {
  const inputType = event.inputType;

  // We let the browser do its own thing for composition.
  if (
    inputType === 'deleteCompositionText' ||
    // If we're pasting in FF, we shouldn't get this event
    // as the `paste` event should have triggered, unless the
    // user has dom.event.clipboardevents.enabled disabled in
    // about:config. In that case, we need to process the
    // pasted content in the DOM mutation phase.
    (IS_FIREFOX && isFirefoxClipboardEvents(editor))
  ) {
    return;
  } else if (inputType === 'insertCompositionText') {
    return;
  }

  dispatchCommand(editor, BEFORE_INPUT_COMMAND, event);
}

function $handleBeforeInput(event: InputEvent): boolean {
  const inputType = event.inputType;
  const targetRange = getTargetRange(event);
  const editor = getActiveEditor();

  const selection = $getSelection();

  // On Chrome on macOS, some handled selection commands may accept a pending text replacement. This behavior
  // is not desirable, so we check for this case and prevent bogus text replacements from happening.
  if (
    inputType === 'insertText' &&
    event.data &&
    isInsertTextAfterHandledSelectionCommand
  ) {
    clearHandledSelectionCommandInsertText();
    event.preventDefault();
    if ($isRangeSelection(selection) && !selection.isCollapsed()) {
      const point = selection.isBackward() ? selection.anchor : selection.focus;
      selection.anchor.set(point.key, point.offset, point.type);
      selection.focus.set(point.key, point.offset, point.type);
    }
    return true;
  }

  if (inputType === 'deleteContentBackward') {
    if (selection === null) {
      // Use previous selection
      const prevSelection = $getPreviousSelection();

      if (!$isRangeSelection(prevSelection)) {
        return true;
      }

      $setSelection(prevSelection.clone());
    }

    if ($isRangeSelection(selection)) {
      const isSelectionAnchorSameAsFocus =
        selection.anchor.key === selection.focus.key;

      if (
        isPossiblyAndroidKeyPress(event.timeStamp) &&
        editor.isComposing() &&
        isSelectionAnchorSameAsFocus
      ) {
        $setCompositionKey(null);
        lastKeyDownTimeStamp = 0;
        // Fixes an Android bug where selection flickers when backspacing
        setTimeout(() => {
          updateEditorSync(editor, () => {
            $setCompositionKey(null);
          });
        }, ANDROID_COMPOSITION_LATENCY);
        if ($isRangeSelection(selection)) {
          const anchorNode = selection.anchor.getNode();
          anchorNode.markDirty();
          invariant($isTextNode(anchorNode), 'Anchor node must be a TextNode');
          $updateSelectionFormatStyleFromTextNode(selection, anchorNode);
        }
      } else {
        $setCompositionKey(null);

        // iOS 10-key Korean IME (천지인/Chunjiin) does not fire compositionstart /
        // compositionend events. Instead it sends a deleteContentBackward with a
        // non-collapsed targetRange to delete the current composing jamo, immediately
        // followed by insertText with the updated syllable.
        //
        // Because editor.isComposing() is always false for this keyboard type, Lexical
        // would otherwise dispatch DELETE_CHARACTER_COMMAND, which ignores the
        // targetRange entirely and deletes only one character before the cursor. This
        // leaves orphaned jamo in the editor state that accumulate and corrupt output
        // (e.g. typing "안녕하세요" produces "안녕하ᄉ세ᄋᄋ요").
        //
        // Fix: when on iOS with a non-collapsed targetRange, apply the range directly
        // to the Lexical selection and delete the matched text. If applyDOMRange cannot
        // resolve the range (returns a collapsed selection), fall through to the default
        // Lexical deletion path.
        if (IS_IOS && targetRange !== null && !targetRange.collapsed) {
          selection.applyDOMRange(targetRange);
          if (!selection.isCollapsed()) {
            event.preventDefault();
            selection.removeText();
            return true;
          }
        }

        event.preventDefault();
        // Chromium Android at the moment seems to ignore the preventDefault
        // on 'deleteContentBackward' and still deletes the content. Which leads
        // to multiple deletions. So we let the browser handle the deletion in this case.
        const selectedNode = selection.anchor.getNode();
        const selectedNodeText = selectedNode.getTextContent();
        // When the target node has `canInsertTextAfter` set to false, the first deletion
        // doesn't have an effect, so we need to handle it with Lexical.
        const selectedNodeCanInsertTextAfter =
          selectedNode.canInsertTextAfter();
        const hasSelectedAllTextInNode =
          selection.anchor.offset === 0 &&
          selection.focus.offset === selectedNodeText.length;
        let shouldLetBrowserHandleDelete =
          IS_ANDROID_CHROME &&
          isSelectionAnchorSameAsFocus &&
          !hasSelectedAllTextInNode &&
          selectedNodeCanInsertTextAfter;
        // Check if selection is collapsed and if the previous node is a decorator node
        // If so, the browser will not be able to handle the deletion
        if (shouldLetBrowserHandleDelete && selection.isCollapsed()) {
          shouldLetBrowserHandleDelete = !$isDecoratorNode(
            $getAdjacentNode(selection.anchor, true),
          );
        }
        if (!shouldLetBrowserHandleDelete) {
          dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
          // When deleting across paragraphs, Chrome on Android incorrectly shifts the selection rightwards
          // We save the correct selection to restore later during handling of selectionchange event
          const selectionAfterDelete = $getSelection();
          if (
            IS_ANDROID_CHROME &&
            $isRangeSelection(selectionAfterDelete) &&
            selectionAfterDelete.isCollapsed()
          ) {
            postDeleteSelectionToRestore = selectionAfterDelete;
            // Cleanup in case selectionchange does not fire
            setTimeout(() => (postDeleteSelectionToRestore = null));
          }
        }
      }
      return true;
    }
  }

  if (!$isRangeSelection(selection)) {
    return true;
  }

  const data = event.data;

  // This represents the case when two beforeinput events are triggered at the same time (without a
  // full event loop ending at input). This happens with MacOS with the default keyboard settings,
  // a combination of autocorrection + autocapitalization.
  // Having Lexical run everything in controlled mode would fix the issue without additional code
  // but this would kill the massive performance win from the most common typing event.
  // Alternatively, when this happens we can prematurely update our EditorState based on the DOM
  // content, a job that would usually be the input event's responsibility.
  if (unprocessedBeforeInputData !== null) {
    $updateSelectedTextFromDOM(false, editor, unprocessedBeforeInputData);
  }

  if (
    (!selection.dirty || unprocessedBeforeInputData !== null) &&
    selection.isCollapsed() &&
    !$isRootNode(selection.anchor.getNode()) &&
    targetRange !== null
  ) {
    selection.applyDOMRange(targetRange);
  }

  unprocessedBeforeInputData = null;

  const anchor = selection.anchor;
  const focus = selection.focus;
  const anchorNode = anchor.getNode();
  const focusNode = focus.getNode();

  if (inputType === 'insertText' || inputType === 'insertTranspose') {
    if (data === '\n') {
      event.preventDefault();
      dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
    } else if (data === DOUBLE_LINE_BREAK) {
      event.preventDefault();
      dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
    } else if (data == null && event.dataTransfer) {
      // Gets around a Safari text replacement bug.
      const text = event.dataTransfer.getData('text/plain');
      event.preventDefault();
      selection.insertRawText(text);
    } else if (
      data != null &&
      $shouldPreventDefaultAndInsertText(
        selection,
        targetRange,
        data,
        event.timeStamp,
        true,
      )
    ) {
      event.preventDefault();
      dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
      $maybeMoveSelectionPastTrailingAcceptanceBoundary(data);
    } else {
      unprocessedBeforeInputData = data;
    }
    lastBeforeInputInsertTextTimeStamp = event.timeStamp;
    return true;
  }

  // Prevent the browser from carrying out
  // the input event, so we can control the
  // output.
  event.preventDefault();

  switch (inputType) {
    case 'insertFromYank':
    case 'insertFromDrop':
    case 'insertReplacementText': {
      dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
      const textFromDataTransfer = event.dataTransfer
        ? event.dataTransfer.getData('text/plain')
        : null;
      $maybeMoveSelectionPastTrailingAcceptanceBoundary(
        textFromDataTransfer ?? event.data,
      );
      break;
    }

    case 'insertFromComposition': {
      // This is the end of composition
      $setCompositionKey(null);
      dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
      break;
    }

    case 'insertLineBreak': {
      // Used for Android
      $setCompositionKey(null);
      dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
      break;
    }

    case 'insertParagraph': {
      // Used for Android
      $setCompositionKey(null);

      // Safari does not provide the type "insertLineBreak".
      // So instead, we need to infer it from the keyboard event.
      // We do not apply this logic to iOS to allow newline auto-capitalization
      // work without creating linebreaks when pressing Enter
      if (isInsertLineBreak && !IS_IOS) {
        isInsertLineBreak = false;
        dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
      } else {
        dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
      }

      break;
    }

    case 'insertFromPaste':
    case 'insertFromPasteAsQuotation': {
      dispatchCommand(editor, PASTE_COMMAND, event);
      break;
    }

    case 'deleteByComposition': {
      if ($canRemoveText(anchorNode, focusNode)) {
        dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
      }

      break;
    }

    case 'deleteByDrag': {
      // The drop target is taking over focus and the document selection;
      // suppress this editor's own attempt to focus its root or move the DOM
      // selection back to the post-removal point during reconciliation.
      $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
      dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
      break;
    }

    case 'deleteByCut': {
      dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
      break;
    }

    case 'deleteContent': {
      dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
      break;
    }

    case 'deleteWordBackward': {
      dispatchCommand(editor, DELETE_WORD_COMMAND, true);
      break;
    }

    case 'deleteWordForward': {
      dispatchCommand(editor, DELETE_WORD_COMMAND, false);
      break;
    }

    case 'deleteHardLineBackward':
    case 'deleteSoftLineBackward': {
      dispatchCommand(editor, DELETE_LINE_COMMAND, true);
      break;
    }

    case 'deleteContentForward':
    case 'deleteHardLineForward':
    case 'deleteSoftLineForward': {
      dispatchCommand(editor, DELETE_LINE_COMMAND, false);
      break;
    }

    case 'formatStrikeThrough': {
      dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'strikethrough');
      break;
    }

    case 'formatBold': {
      dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
      break;
    }

    case 'formatItalic': {
      dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
      break;
    }

    case 'formatUnderline': {
      dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
      break;
    }

    case 'historyUndo': {
      dispatchCommand(editor, UNDO_COMMAND, undefined);
      break;
    }

    case 'historyRedo': {
      dispatchCommand(editor, REDO_COMMAND, undefined);
      break;
    }

    default:
    // NO-OP
  }

  return true;
}

function onInput(event: InputEvent, editor: LexicalEditor): void {
  // Note that the MutationObserver may or may not have already fired,
  // but the the DOM and selection may have already changed.
  // See also:
  // - https://github.com/facebook/lexical/issues/7028
  // - https://github.com/facebook/lexical/pull/794

  // We don't want the onInput to bubble, in the case of nested editors.
  event.stopPropagation();
  clearHandledSelectionCommandInsertText();
  updateEditorSync(
    editor,
    () => {
      editor.dispatchCommand(INPUT_COMMAND, event);
    },
    {event},
  );
  unprocessedBeforeInputData = null;
}

function $handleInput(event: InputEvent): boolean {
  if (
    isHTMLElement(event.target) &&
    $isSelectionCapturedInDecorator(event.target)
  ) {
    return true;
  }

  const editor = getActiveEditor();
  const selection = $getSelection();
  const data = event.data;
  const targetRange = getTargetRange(event);

  if (
    data != null &&
    $isRangeSelection(selection) &&
    $shouldPreventDefaultAndInsertText(
      selection,
      targetRange,
      data,
      event.timeStamp,
      false,
    )
  ) {
    // Given we're over-riding the default behavior, we will need
    // to ensure to disable composition before dispatching the
    // insertText command for when changing the sequence for FF.
    if (isFirefoxEndingComposition) {
      $onCompositionEndImpl(editor, data);
      isFirefoxEndingComposition = false;
    }
    const anchor = selection.anchor;
    const anchorNode = anchor.getNode();
    const domSelection = getDOMSelection(getWindow(editor));
    if (domSelection === null) {
      return true;
    }
    const isBackward = selection.isBackward();
    const startOffset = isBackward
      ? selection.anchor.offset
      : selection.focus.offset;
    const endOffset = isBackward
      ? selection.focus.offset
      : selection.anchor.offset;
    // If the content is the same as inserted, then don't dispatch an insertion.
    // Given onInput doesn't take the current selection (it uses the previous)
    // we can compare that against what the DOM currently says.
    if (
      !CAN_USE_BEFORE_INPUT ||
      selection.isCollapsed() ||
      !$isTextNode(anchorNode) ||
      domSelection.anchorNode === null ||
      anchorNode.getTextContent().slice(0, startOffset) +
        data +
        anchorNode.getTextContent().slice(startOffset + endOffset) !==
        getAnchorTextFromDOM(domSelection.anchorNode)
    ) {
      dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
    }

    const textLength = data.length;

    // Another hack for FF, as it's possible that the IME is still
    // open, even though compositionend has already fired (sigh).
    if (
      IS_FIREFOX &&
      textLength > 1 &&
      event.inputType === 'insertCompositionText' &&
      !editor.isComposing()
    ) {
      selection.anchor.offset -= textLength;
      selection._cachedNodes = null;
      selection._cachedIsBackward = null;
    }

    // This ensures consistency on Android.
    if (IS_ANDROID_CHROME && editor.isComposing()) {
      lastKeyDownTimeStamp = 0;
      $setCompositionKey(null);
    }
  } else {
    const characterData = data !== null ? data : undefined;
    $updateSelectedTextFromDOM(false, editor, characterData);

    // onInput always fires after onCompositionEnd for FF.
    if (isFirefoxEndingComposition) {
      $onCompositionEndImpl(editor, data || undefined);
      isFirefoxEndingComposition = false;
    }
  }

  // Also flush any other mutations that might have occurred
  // since the change.
  $flushMutations();

  return true;
}

function onCompositionStart(
  event: CompositionEvent,
  editor: LexicalEditor,
): void {
  dispatchCommand(editor, COMPOSITION_START_COMMAND, event);
}

function $handleCompositionStart(event: CompositionEvent): boolean {
  const editor = getActiveEditor();
  const selection = $getSelection();

  if ($isRangeSelection(selection) && !editor.isComposing()) {
    const anchor = selection.anchor;
    const node = selection.anchor.getNode();
    $setCompositionKey(anchor.key);
    $addUpdateTag(COMPOSITION_START_TAG);

    if (
      // If it has been 30ms since the last keydown, then we should
      // apply the empty space heuristic. We can't do this for Safari,
      // as the keydown fires after composition start.
      event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ||
      // FF has issues around composing multibyte characters, so we also
      // need to invoke the empty space heuristic below.
      anchor.type === 'element' ||
      !selection.isCollapsed() ||
      node.getFormat() !== selection.format ||
      ($isTextNode(node) && node.getStyle() !== selection.style)
    ) {
      // We insert a zero width character, ready for the composition
      // to get inserted into the new node we create. If
      // we don't do this, Safari will fail on us because
      // there is no text node matching the selection.
      dispatchCommand(
        editor,
        CONTROLLED_TEXT_INSERTION_COMMAND,
        COMPOSITION_START_CHAR,
      );
    }
  }

  return true;
}

function $handleCompositionEnd(event: CompositionEvent): boolean {
  const editor = getActiveEditor();
  $onCompositionEndImpl(editor, event.data);
  $addUpdateTag(COMPOSITION_END_TAG);
  return true;
}

function $onCompositionEndImpl(editor: LexicalEditor, data?: string): void {
  const compositionKey = editor._compositionKey;
  $setCompositionKey(null);

  // Handle termination of composition.
  if (compositionKey !== null && data != null) {
    // Composition can sometimes move to an adjacent DOM node when backspacing.
    // So check for the empty case.
    if (data === '') {
      const node = $getNodeByKey(compositionKey);
      const domElement = editor.getElementByKey(compositionKey);
      const textNode =
        domElement !== null && $isTextNode(node)
          ? $getDOMTextNode(node, domElement, editor)
          : null;

      if (
        textNode !== null &&
        textNode.nodeValue !== null &&
        $isTextNode(node)
      ) {
        const domSelection = getDOMSelection(getWindow(editor));
        let anchorOffset = null;
        let focusOffset = null;

        if (domSelection !== null && domSelection.anchorNode === textNode) {
          anchorOffset = domSelection.anchorOffset;
          focusOffset = domSelection.focusOffset;
        }

        $updateTextNodeFromDOMContent(
          node,
          textNode.nodeValue,
          anchorOffset,
          focusOffset,
          true,
        );
      }
      return;
    } else if (data[data.length - 1] === '\n') {
      const selection = $getSelection();

      if ($isRangeSelection(selection) || $isNodeSelection(selection)) {
        // If the last character is a line break, we also need to insert
        // a line break.
        if ($isRangeSelection(selection)) {
          const focus = selection.focus;
          selection.anchor.set(focus.key, focus.offset, focus.type);
        }
        dispatchCommand(editor, KEY_ENTER_COMMAND, null);
        return;
      }
    }
  }

  $updateSelectedTextFromDOM(true, editor, data);
}

function onCompositionEnd(
  event: CompositionEvent,
  editor: LexicalEditor,
): void {
  // Firefox fires onCompositionEnd before onInput, but Chrome/Webkit,
  // fire onInput before onCompositionEnd. To ensure the sequence works
  // like Chrome/Webkit we use the isFirefoxEndingComposition flag to
  // defer handling of onCompositionEnd in Firefox till we have processed
  // the logic in onInput.
  if (IS_FIREFOX) {
    isFirefoxEndingComposition = true;
  } else if (!IS_IOS && (IS_SAFARI || IS_APPLE_WEBKIT)) {
    // Fix：https://github.com/facebook/lexical/pull/7061
    // In safari, onCompositionEnd triggers before keydown
    // This will cause an extra character to be deleted when exiting the IME
    // Therefore, a flag is used to mark that the keydown event is triggered after onCompositionEnd
    // Ensure that an extra character is not deleted due to the backspace event being triggered in the keydown event.
    isSafariEndingComposition = true;
    safariEndCompositionEventData = event.data;
  } else {
    dispatchCommand(editor, COMPOSITION_END_COMMAND, event);
  }
}

function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
  lastKeyDownTimeStamp = event.timeStamp;
  lastKeyCode = event.key;
  if (event.key !== 'Backspace') {
    clearHandledSelectionCommandInsertText();
  }
  if (editor.isComposing()) {
    return;
  }
  dispatchCommand(editor, KEY_DOWN_COMMAND, event);
}

function $handleKeyDown(event: KeyboardEvent): boolean {
  const editor = getActiveEditor();
  if (event.key == null) {
    return true;
  }
  if (isSafariEndingComposition) {
    if (isBackspace(event)) {
      updateEditorSync(editor, () => {
        $onCompositionEndImpl(editor, safariEndCompositionEventData);
      });
      isSafariEndingComposition = false;
      safariEndCompositionEventData = '';
      return true;
    }
    isSafariEndingComposition = false;
    safariEndCompositionEventData = '';
  }

  if (isMoveForward(event)) {
    dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event);
  } else if (isMoveToEnd(event)) {
    dispatchCommand(editor, MOVE_TO_END, event);
  } else if (isMoveBackward(event)) {
    dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event);
  } else if (isMoveToStart(event)) {
    dispatchCommand(editor, MOVE_TO_START, event);
  } else if (isMoveUp(event)) {
    dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event);
  } else if (isMoveDown(event)) {
    dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event);
  } else if (isLineBreak(event)) {
    isInsertLineBreak = true;
    dispatchCommand(editor, KEY_ENTER_COMMAND, event);
  } else if (isSpace(event)) {
    dispatchCommand(editor, KEY_SPACE_COMMAND, event);
  } else if (isOpenLineBreak(event)) {
    event.preventDefault();
    isInsertLineBreak = true;
    dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true);
  } else if (isParagraph(event)) {
    isInsertLineBreak = false;
    dispatchCommand(editor, KEY_ENTER_COMMAND, event);
  } else if (isDeleteBackward(event)) {
    if (isBackspace(event)) {
      if (dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event)) {
        markHandledSelectionCommandInsertText();
      }
    } else {
      event.preventDefault();
      dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
    }
  } else if (isEscape(event)) {
    dispatchCommand(editor, KEY_ESCAPE_COMMAND, event);
  } else if (isDeleteForward(event)) {
    if (isDelete(event)) {
      dispatchCommand(editor, KEY_DELETE_COMMAND, event);
    } else {
      event.preventDefault();
      dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
    }
  } else if (isDeleteWordBackward(event)) {
    event.preventDefault();
    dispatchCommand(editor, DELETE_WORD_COMMAND, true);
  } else if (isDeleteWordForward(event)) {
    event.preventDefault();
    dispatchCommand(editor, DELETE_WORD_COMMAND, false);
  } else if (isDeleteLineBackward(event)) {
    event.preventDefault();
    dispatchCommand(editor, DELETE_LINE_COMMAND, true);
  } else if (isDeleteLineForward(event)) {
    event.preventDefault();
    dispatchCommand(editor, DELETE_LINE_COMMAND, false);
  } else if (isBold(event)) {
    event.preventDefault();
    dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
  } else if (isUnderline(event)) {
    event.preventDefault();
    dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
  } else if (isItalic(event)) {
    event.preventDefault();
    dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
  } else if (isTab(event)) {
    dispatchCommand(editor, KEY_TAB_COMMAND, event);
  } else if (isUndo(event)) {
    event.preventDefault();
    dispatchCommand(editor, UNDO_COMMAND, undefined);
  } else if (isRedo(event)) {
    event.preventDefault();
    dispatchCommand(editor, REDO_COMMAND, undefined);
  } else {
    const prevSelection = editor._editorState._selection;
    if (isSelectAll(event)) {
      event.preventDefault();
      if (dispatchCommand(editor, SELECT_ALL_COMMAND, event)) {
        markHandledSelectionCommandInsertText();
      }
    } else if (prevSelection !== null && !$isRangeSelection(prevSelection)) {
      // Only RangeSelection can use the native cut/copy/select all
      if (isCopy(event)) {
        event.preventDefault();
        dispatchCommand(editor, COPY_COMMAND, event);
      } else if (isCut(event)) {
        event.preventDefault();
        dispatchCommand(editor, CUT_COMMAND, event);
      }
    }
  }

  if (isModifier(event)) {
    editor.dispatchCommand(KEY_MODIFIER_COMMAND, event);
  }

  return true;
}

function getRootElementRemoveHandles(
  rootElement: HTMLElement,
): RootElementRemoveHandles {
  // @ts-expect-error: internal field
  let eventHandles = rootElement.__lexicalEventHandles;

  if (eventHandles === undefined) {
    eventHandles = [];
    // @ts-expect-error: internal field
    rootElement.__lexicalEventHandles = eventHandles;
  }

  return eventHandles;
}

// Mapping root editors to their active nested editors, contains nested editors
// mapping only, so if root editor is selected map will have no reference to free up memory
const activeNestedEditorsMap: Map<string, LexicalEditor> = new Map();

function onDocumentSelectionChange(event: Event): void {
  const domSelection = getDOMSelectionFromTarget(event.target);
  if (domSelection === null) {
    return;
  }
  const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode);
  if (nextActiveEditor === null) {
    return;
  }

  if (isSelectionChangeFromMouseDown) {
    isSelectionChangeFromMouseDown = false;
    updateEditorSync(nextActiveEditor, () => {
      const lastSelection = $getPreviousSelection();
      const domAnchorNode = domSelection.anchorNode;
      if (isHTMLElement(domAnchorNode) || isDOMTextNode(domAnchorNode)) {
        // If the user is attempting to click selection back onto text, then
        // we should attempt create a range selection.
        // When we click on an empty paragraph node or the end of a paragraph that ends
        // with an image/poll, the nodeType will be ELEMENT_NODE
        const newSelection = $internalCreateRangeSelection(
          lastSelection,
          domSelection,
          nextActiveEditor,
          event,
        );
        $setSelection(newSelection);
      }
    });
  }

  // When editor receives selection change event, we're checking if
  // it has any sibling editors (within same parent editor) that were active
  // before, and trigger selection change on it to nullify selection.
  const editors = getEditorsToPropagate(nextActiveEditor);
  const rootEditor = editors[editors.length - 1];
  const rootEditorKey = rootEditor._key;
  const activeNestedEditor = activeNestedEditorsMap.get(rootEditorKey);
  const prevActiveEditor = activeNestedEditor || rootEditor;

  if (prevActiveEditor !== nextActiveEditor) {
    onSelectionChange(domSelection, prevActiveEditor, false);
  }

  onSelectionChange(domSelection, nextActiveEditor, true);

  // If newly selected editor is nested, then add it to the map, clean map otherwise
  if (nextActiveEditor !== rootEditor) {
    activeNestedEditorsMap.set(rootEditorKey, nextActiveEditor);
  } else if (activeNestedEditor) {
    activeNestedEditorsMap.delete(rootEditorKey);
  }
}

/** @internal */
export function stopLexicalPropagation(event: Event): void {
  // We attach a special property to ensure the same event doesn't re-fire
  // for parent editors.
  // @ts-ignore
  event._lexicalHandled = true;
}

function hasStoppedLexicalPropagation(event: Event): boolean {
  // @ts-ignore
  const stopped = event._lexicalHandled === true;
  return stopped;
}

export type EventHandler = (event: Event, editor: LexicalEditor) => void;

export function addRootElementEvents(
  rootElement: HTMLElement,
  editor: LexicalEditor,
): void {
  // We only want to have a single global selectionchange event handler, shared
  // between all editor instances.
  const doc = rootElement.ownerDocument;
  rootElementToDocument.set(rootElement, doc);
  const documentRootElementsCount = rootElementsRegistered.get(doc) ?? 0;
  if (documentRootElementsCount < 1) {
    doc.addEventListener('selectionchange', onDocumentSelectionChange);
  }
  rootElementsRegistered.set(doc, documentRootElementsCount + 1);

  // @ts-expect-error: internal field
  rootElement.__lexicalEditor = editor;
  const removeHandles = getRootElementRemoveHandles(rootElement);

  for (let i = 0; i < rootElementEvents.length; i++) {
    const [eventName, onEvent] = rootElementEvents[i];
    const eventHandler =
      typeof onEvent === 'function'
        ? (event: Event) => {
            if (hasStoppedLexicalPropagation(event)) {
              return;
            }
            stopLexicalPropagation(event);
            if (editor.isEditable() || eventName === 'click') {
              onEvent(event, editor);
            }
          }
        : (event: Event) => {
            if (hasStoppedLexicalPropagation(event)) {
              return;
            }
            stopLexicalPropagation(event);
            const isEditable = editor.isEditable();
            switch (eventName) {
              case 'cut':
                return (
                  isEditable &&
                  dispatchCommand(editor, CUT_COMMAND, event as ClipboardEvent)
                );

              case 'copy':
                return dispatchCommand(
                  editor,
                  COPY_COMMAND,
                  event as ClipboardEvent,
                );

              case 'paste':
                return (
                  isEditable &&
                  dispatchCommand(
                    editor,
                    PASTE_COMMAND,
                    event as ClipboardEvent,
                  )
                );

              case 'dragstart':
                return (
                  isEditable &&
                  dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent)
                );

              case 'dragover':
                return (
                  isEditable &&
                  dispatchCommand(editor, DRAGOVER_COMMAND, event as DragEvent)
                );

              case 'dragend':
                return (
                  isEditable &&
                  dispatchCommand(editor, DRAGEND_COMMAND, event as DragEvent)
                );

              case 'focus':
                return (
                  isEditable &&
                  dispatchCommand(editor, FOCUS_COMMAND, event as FocusEvent)
                );

              case 'blur': {
                return (
                  isEditable &&
                  dispatchCommand(editor, BLUR_COMMAND, event as FocusEvent)
                );
              }

              case 'drop':
                return (
                  isEditable &&
                  dispatchCommand(editor, DROP_COMMAND, event as DragEvent)
                );
            }
          };
    rootElement.addEventListener(eventName, eventHandler);
    removeHandles.push(() => {
      rootElement.removeEventListener(eventName, eventHandler);
    });
  }
}

const rootElementNotRegisteredWarning = warnOnlyOnce(
  'Root element not registered',
);

export function removeRootElementEvents(rootElement: HTMLElement): void {
  const doc = rootElementToDocument.get(rootElement);
  if (doc === undefined) {
    rootElementNotRegisteredWarning();
    return;
  }

  const documentRootElementsCount = rootElementsRegistered.get(doc);
  if (documentRootElementsCount === undefined) {
    // This can happen if setRootElement() failed
    rootElementNotRegisteredWarning();
    return;
  }

  // We only want to have a single global selectionchange event handler, shared
  // between all editor instances.
  const newCount = documentRootElementsCount - 1;
  invariant(newCount >= 0, 'Root element count less than 0');
  rootElementToDocument.delete(rootElement);
  rootElementsRegistered.set(doc, newCount);
  if (newCount === 0) {
    doc.removeEventListener('selectionchange', onDocumentSelectionChange);
  }

  const editor = getEditorPropertyFromDOMNode(rootElement);

  if (isLexicalEditor(editor)) {
    cleanActiveNestedEditorsMap(editor);
    // @ts-expect-error: internal field
    rootElement.__lexicalEditor = null;
  } else if (editor) {
    invariant(
      false,
      'Attempted to remove event handlers from a node that does not belong to this build of Lexical',
    );
  }

  const removeHandles = getRootElementRemoveHandles(rootElement);

  for (let i = 0; i < removeHandles.length; i++) {
    removeHandles[i]();
  }

  // @ts-expect-error: internal field
  rootElement.__lexicalEventHandles = [];
}

function cleanActiveNestedEditorsMap(editor: LexicalEditor) {
  if (editor._parentEditor !== null) {
    // For nested editor cleanup map if this editor was marked as active
    const editors = getEditorsToPropagate(editor);
    const rootEditor = editors[editors.length - 1];
    const rootEditorKey = rootEditor._key;

    if (activeNestedEditorsMap.get(rootEditorKey) === editor) {
      activeNestedEditorsMap.delete(rootEditorKey);
    }
  } else {
    // For top-level editors cleanup map
    activeNestedEditorsMap.delete(editor._key);
  }
}

export function markSelectionChangeFromDOMUpdate(): void {
  isSelectionChangeFromDOMUpdate = true;
}

export function markCollapsedSelectionFormat(
  format: number,
  style: string,
  offset: number,
  key: NodeKey,
  timeStamp: number,
): void {
  collapsedSelectionFormat = [format, style, offset, key, timeStamp];
}
