/**
 * 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 {EditorState} from './LexicalEditorState';
import type {ElementNode} from './nodes/LexicalElementNode';

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

import {
  $caretFromPoint,
  $caretRangeFromSelection,
  $comparePointCaretNext,
  $createLineBreakNode,
  $createParagraphNode,
  $createTextNode,
  $extendCaretToRange,
  $getAdjacentChildCaret,
  $getCaretRange,
  $getCaretRangeInDirection,
  $getChildCaret,
  $getSiblingCaret,
  $getTextNodeOffset,
  $insertNodeToNearestRootAtCaret,
  $isChildCaret,
  $isDecoratorNode,
  $isElementNode,
  $isExtendableTextPointCaret,
  $isLineBreakNode,
  $isParagraphNode,
  $isRootNode,
  $isSiblingCaret,
  $isTextNode,
  $isTextPointCaret,
  $normalizeCaret,
  $removeTextFromCaretRange,
  $rewindSiblingCaret,
  $setPointFromCaret,
  $setSelection,
  $setSelectionFromCaretRange,
  $updateRangeSelectionFromCaretRange,
  type CaretRange,
  type ChildCaret,
  COLLABORATION_TAG,
  type LineBreakNode,
  type NodeCaret,
  type PointCaret,
  SKIP_SCROLL_INTO_VIEW_TAG,
  type TextNode,
} from '.';
import {IS_FIREFOX} from './environment';
import {TEXT_TYPE_TO_FORMAT} from './LexicalConstants';
import {
  markCollapsedSelectionFormat,
  markSelectionChangeFromDOMUpdate,
} from './LexicalEvents';
import {getIsProcessingMutations} from './LexicalMutations';
import {insertRangeAfter, type LexicalNode, type NodeKey} from './LexicalNode';
import {$normalizeSelection} from './LexicalNormalization';
import {
  $getSlot,
  $getSlotFrame,
  $getSlotHost,
  $getSlotHostKey,
  $getSlotMap,
  $getSlotNames,
} from './LexicalSlot';
import {
  getActiveEditor,
  getActiveEditorState,
  isCurrentlyReadOnlyMode,
} from './LexicalUpdates';
import {SKIP_SELECTION_FOCUS_TAG} from './LexicalUpdateTags';
import {
  $findMatchingParent,
  $getCompositionKey,
  $getDOMSlot,
  $getDOMTextNode,
  $getNearestRootOrShadowRoot,
  $getNodeByKey,
  $getNodeFromDOM,
  $getRoot,
  $hasAncestor,
  $isInlineElementOrDecoratorNode,
  $isRootOrShadowRoot,
  $isSelectionCapturedInDecoratorInput,
  $isTokenOrSegmented,
  $isTokenOrTab,
  $setCompositionKey,
  doesContainSurrogatePair,
  getActiveElement,
  getActiveElementDeep,
  getComposedStaticRange,
  getDOMSelection,
  getDOMSelectionPoints,
  getDOMSelectionRange,
  getElementByKeyOrThrow,
  getNearestEditorFromDOMNode,
  getNodeKeyFromDOMNode,
  getWindow,
  INTERNAL_$isBlock,
  isDOMCapturingSelection,
  isDOMDocumentNode,
  isDOMShadowRoot,
  isDOMTextNode,
  isHTMLElement,
  isSelectionWithinEditor,
  removeDOMBlockCursorElement,
  scrollIntoViewIfNeeded,
  toggleTextFormatType,
} from './LexicalUtils';
import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
import {
  $isInlineFormattable,
  type TextFormatType,
} from './nodes/LexicalTextNode';

const __DEV__ = process.env.NODE_ENV !== 'production';

export type TextPointType = {
  _selection: BaseSelection;
  getNode: () => TextNode;
  is: (point: PointType) => boolean;
  isBefore: (point: PointType) => boolean;
  key: NodeKey;
  offset: number;
  set: (
    key: NodeKey,
    offset: number,
    type: 'text' | 'element',
    onlyIfChanged?: boolean,
  ) => void;
  type: 'text';
};

export type ElementPointType = {
  _selection: BaseSelection;
  getNode: () => ElementNode;
  is: (point: PointType) => boolean;
  isBefore: (point: PointType) => boolean;
  key: NodeKey;
  offset: number;
  set: (
    key: NodeKey,
    offset: number,
    type: 'text' | 'element',
    onlyIfChanged?: boolean,
  ) => void;
  type: 'element';
};

export type PointType = TextPointType | ElementPointType;

export class Point {
  key: NodeKey;
  offset: number;
  type: 'text' | 'element';
  _selection: BaseSelection | null;

  constructor(key: NodeKey, offset: number, type: 'text' | 'element') {
    if (__DEV__) {
      // This prevents a circular reference error when serialized as JSON,
      // which happens on unit test failures
      Object.defineProperty(this, '_selection', {
        enumerable: false,
        writable: true,
      });
    }
    this._selection = null;
    this.key = key;
    this.offset = offset;
    this.type = type;
  }

  is(point: PointType): boolean {
    return (
      this.key === point.key &&
      this.offset === point.offset &&
      this.type === point.type
    );
  }

  isBefore(b: PointType): boolean {
    if (this.key === b.key) {
      return this.offset < b.offset;
    }
    const aCaret = $normalizeCaret($caretFromPoint(this, 'next'));
    const bCaret = $normalizeCaret($caretFromPoint(b, 'next'));
    return $comparePointCaretNext(aCaret, bCaret) < 0;
  }

  getNode(): LexicalNode {
    const key = this.key;
    const node = $getNodeByKey(key);
    if (node === null) {
      invariant(false, 'Point.getNode: node not found');
    }
    return node;
  }

  set(
    key: NodeKey,
    offset: number,
    type: 'text' | 'element',
    onlyIfChanged?: boolean,
  ): void {
    const selection = this._selection;
    const oldKey = this.key;
    if (
      onlyIfChanged &&
      this.key === key &&
      this.offset === offset &&
      this.type === type
    ) {
      return;
    }
    this.key = key;
    this.offset = offset;
    this.type = type;
    if (__DEV__) {
      const node = $getNodeByKey(key);
      invariant(
        type === 'text' ? $isTextNode(node) : $isElementNode(node),
        'PointType.set: node with key %s is %s and can not be used for a %s point',
        key,
        node ? node.__type : '[not found]',
        type,
      );
    }
    if (!isCurrentlyReadOnlyMode()) {
      if ($getCompositionKey() === oldKey) {
        $setCompositionKey(key);
      }
      if (selection !== null) {
        selection.setCachedNodes(null);
        if ($isRangeSelection(selection)) {
          selection._cachedIsBackward = null;
        }
        selection.dirty = true;
      }
    }
  }
}

export function $createPoint(
  key: NodeKey,
  offset: number,
  type: 'text' | 'element',
): PointType {
  // @ts-expect-error: intentionally cast as we use a class for perf reasons
  return new Point(key, offset, type);
}

function selectPointOnNode(point: PointType, node: LexicalNode): void {
  let key = node.__key;
  let offset = point.offset;
  let type: 'element' | 'text' = 'element';
  if ($isTextNode(node)) {
    type = 'text';
    const textContentLength = node.getTextContentSize();
    if (offset > textContentLength) {
      offset = textContentLength;
    }
  } else if (!$isElementNode(node)) {
    const nextSibling = node.getNextSibling();
    if ($isTextNode(nextSibling)) {
      key = nextSibling.__key;
      offset = 0;
      type = 'text';
    } else {
      const parentNode = node.getParent();
      if (parentNode) {
        key = parentNode.__key;
        offset = node.getIndexWithinParent() + 1;
      }
    }
  }
  point.set(key, offset, type);
}

export function $moveSelectionPointToEnd(
  point: PointType,
  node: LexicalNode,
): void {
  if ($isElementNode(node)) {
    const lastNode = node.getLastDescendant();
    if ($isElementNode(lastNode) || $isTextNode(lastNode)) {
      selectPointOnNode(point, lastNode);
    } else {
      selectPointOnNode(point, node);
    }
  } else {
    selectPointOnNode(point, node);
  }
}

function $transferStartingElementPointToTextPoint(
  start: ElementPointType,
  end: PointType,
  format: number,
  style: string,
): void {
  const element = start.getNode();
  const placementNode = element.getChildAtIndex(start.offset);
  const textNode = $createTextNode();
  textNode.setFormat(format);
  textNode.setStyle(style);
  if ($isParagraphNode(placementNode)) {
    placementNode.splice(0, 0, [textNode]);
  } else if (placementNode !== null) {
    // root or shadow-root + element-mode anchor before a non-paragraph
    // child (typically a sibling block decorator): wrap the new text in
    // a paragraph so it stays a valid block-level child of the root or
    // slot frame. The last-offset branch below already covers shadow
    // roots; the in-the-middle case used to drop a raw text node next
    // to the decorator, which leaves the text without a block ancestor
    // and breaks every downstream getTopLevelElement / $findMatchingParent
    // walk (Cmd+A, Enter, etc.).
    const target = $isRootOrShadowRoot(element)
      ? $createParagraphNode().append(textNode)
      : textNode;
    placementNode.insertBefore(target);
  } else if ($isRootOrShadowRoot(element)) {
    // root or shadow-root + last-offset typing: reuse the empty trailing
    // block when one exists (typical state after a sibling block decorator
    // was deleted) instead of appending a fresh paragraph. The old behavior
    // left a phantom empty paragraph above the user's input.
    const lastChild = element.getLastChild();
    if (
      $isElementNode(lastChild) &&
      !lastChild.isInline() &&
      lastChild.isEmpty()
    ) {
      lastChild.append(textNode);
    } else {
      element.append($createParagraphNode().append(textNode));
    }
  } else {
    element.append(textNode);
  }
  // Transfer the element point to a text point.
  if (start.is(end)) {
    end.set(textNode.__key, 0, 'text');
  }
  start.set(textNode.__key, 0, 'text');
}

export interface BaseSelection {
  _cachedNodes: LexicalNode[] | null;
  dirty: boolean;

  clone(): BaseSelection;
  extract(): LexicalNode[];
  getNodes(): LexicalNode[];
  getTextContent(): string;
  insertText(text: string): void;
  insertRawText(text: string): void;
  is(selection: null | BaseSelection): boolean;
  insertNodes(nodes: LexicalNode[]): void;
  getStartEndPoints(): null | [PointType, PointType];
  isCollapsed(): boolean;
  isBackward(): boolean;
  getCachedNodes(): LexicalNode[] | null;
  setCachedNodes(nodes: LexicalNode[] | null): void;
}

export class NodeSelection implements BaseSelection {
  _nodes: Set<NodeKey>;
  _cachedNodes: LexicalNode[] | null;
  dirty: boolean;

  constructor(objects: Set<NodeKey>) {
    this._cachedNodes = null;
    this._nodes = objects;
    this.dirty = false;
  }

  getCachedNodes(): LexicalNode[] | null {
    return this._cachedNodes;
  }

  setCachedNodes(nodes: LexicalNode[] | null): void {
    this._cachedNodes = nodes;
  }

  is(selection: null | BaseSelection): boolean {
    if (!$isNodeSelection(selection)) {
      return false;
    }
    const a: Set<NodeKey> = this._nodes;
    const b: Set<NodeKey> = selection._nodes;
    return a.size === b.size && Array.from(a).every(key => b.has(key));
  }

  isCollapsed(): boolean {
    return false;
  }

  isBackward(): boolean {
    return false;
  }

  getStartEndPoints(): null {
    return null;
  }

  add(key: NodeKey): void {
    this.dirty = true;
    this._nodes.add(key);
    this._cachedNodes = null;
  }

  delete(key: NodeKey): void {
    this.dirty = true;
    this._nodes.delete(key);
    this._cachedNodes = null;
  }

  clear(): void {
    this.dirty = true;
    this._nodes.clear();
    this._cachedNodes = null;
  }

  has(key: NodeKey): boolean {
    return this._nodes.has(key);
  }

  clone(): NodeSelection {
    return new NodeSelection(new Set(this._nodes));
  }

  extract(): LexicalNode[] {
    return this.getNodes();
  }

  insertRawText(text: string): void {
    // Do nothing?
  }

  insertText(): void {
    // Do nothing?
  }

  insertNodes(nodes: LexicalNode[]) {
    // Slotted nodes are fixed parts of their host with no parent, so they
    // can't be inserted around or removed (see $removeNode's slot guard).
    // Skip them; if nothing tree-resident is selected there's nowhere to
    // anchor the insertion.
    const selectedNodes = this.getNodes().filter(
      node => $getSlotHostKey(node) === null,
    );
    const selectedNodesLength = selectedNodes.length;
    if (selectedNodesLength === 0) {
      return;
    }
    const lastSelectedNode = selectedNodes[selectedNodesLength - 1];
    let selectionAtEnd: RangeSelection;
    // Insert nodes
    if ($isTextNode(lastSelectedNode)) {
      selectionAtEnd = lastSelectedNode.select();
    } else {
      const index = lastSelectedNode.getIndexWithinParent() + 1;
      selectionAtEnd = lastSelectedNode.getParentOrThrow().select(index, index);
    }
    selectionAtEnd.insertNodes(nodes);
    // Remove selected nodes
    for (let i = 0; i < selectedNodesLength; i++) {
      selectedNodes[i].remove();
    }
  }

  getNodes(): LexicalNode[] {
    const cachedNodes = this._cachedNodes;
    if (cachedNodes !== null) {
      return cachedNodes;
    }
    const objects = this._nodes;
    const nodes = [];
    for (const object of objects) {
      const node = $getNodeByKey(object);
      if (node !== null) {
        nodes.push(node);
      }
    }
    if (!isCurrentlyReadOnlyMode()) {
      this._cachedNodes = nodes;
    }
    return nodes;
  }

  getTextContent(): string {
    const nodes = this.getNodes();
    let textContent = '';
    for (let i = 0; i < nodes.length; i++) {
      textContent += nodes[i].getTextContent();
    }
    return textContent;
  }

  /**
   * Remove all nodes in the NodeSelection. If there were any nodes,
   * replace the selection with a new RangeSelection at the previous
   * location of the first node.
   */
  deleteNodes(): void {
    // Slotted nodes are fixed parts of their host; skip them so we neither
    // build a caret from a parentless node nor hit $removeNode's slot guard.
    const nodes = this.getNodes().filter(
      node => $getSlotHostKey(node) === null,
    );
    if (($getSelection() || $getPreviousSelection()) === this && nodes[0]) {
      const firstCaret = $getSiblingCaret(nodes[0], 'next');
      $setSelectionFromCaretRange($getCaretRange(firstCaret, firstCaret));
    }
    for (const node of nodes) {
      node.remove();
    }
    $ensureRootHasParagraph();
  }
}

function $ensureRootHasParagraph(): void {
  const root = $getRoot();
  if (root.isEmpty()) {
    const paragraph = $createParagraphNode();
    root.append(paragraph);
    paragraph.select();
  }
}

export function $isRangeSelection(x: unknown): x is RangeSelection {
  return x instanceof RangeSelection;
}

export class RangeSelection implements BaseSelection {
  format: number;
  style: string;
  anchor: PointType;
  focus: PointType;
  _cachedNodes: LexicalNode[] | null;
  /** @internal */
  _cachedIsBackward: boolean | null;
  dirty: boolean;

  constructor(
    anchor: PointType,
    focus: PointType,
    format: number,
    style: string,
  ) {
    this.anchor = anchor;
    this.focus = focus;
    anchor._selection = this;
    focus._selection = this;
    this._cachedNodes = null;
    this._cachedIsBackward = null;
    this.format = format;
    this.style = style;
    this.dirty = false;
  }

  getCachedNodes(): LexicalNode[] | null {
    return this._cachedNodes;
  }

  setCachedNodes(nodes: LexicalNode[] | null): void {
    this._cachedNodes = nodes;
  }

  /**
   * Used to check if the provided selections is equal to this one by value,
   * including anchor, focus, format, and style properties.
   * @param selection - the Selection to compare this one to.
   * @returns true if the Selections are equal, false otherwise.
   */
  is(selection: null | BaseSelection): boolean {
    if (!$isRangeSelection(selection)) {
      return false;
    }
    return (
      this.anchor.is(selection.anchor) &&
      this.focus.is(selection.focus) &&
      this.format === selection.format &&
      this.style === selection.style
    );
  }

  /**
   * Returns whether the Selection is "collapsed", meaning the anchor and focus are
   * the same node and have the same offset.
   *
   * @returns true if the Selection is collapsed, false otherwise.
   */
  isCollapsed(): boolean {
    return this.anchor.is(this.focus);
  }

  /**
   * Gets all the nodes in the Selection. Uses caching to make it generally suitable
   * for use in hot paths.
   *
   * See also the {@link CaretRange} APIs (starting with
   * {@link $caretRangeFromSelection}), which are likely to provide a better
   * foundation for any operation where partial selection is relevant
   * (e.g. the anchor or focus are inside an ElementNode and TextNode)
   *
   * @returns an Array containing all the nodes in the Selection
   */
  getNodes(): LexicalNode[] {
    const cachedNodes = this._cachedNodes;
    if (cachedNodes !== null) {
      return cachedNodes;
    }
    const range = $getCaretRangeInDirection(
      $caretRangeFromSelection(this),
      'next',
    );
    const nodes = $getNodesFromCaretRangeCompat(range);
    if (__DEV__) {
      if (this.isCollapsed() && nodes.length > 1) {
        invariant(
          false,
          'RangeSelection.getNodes() returned %s > 1 nodes in a collapsed selection',
          String(nodes.length),
        );
      }
    }
    if (!isCurrentlyReadOnlyMode()) {
      this._cachedNodes = nodes;
    }
    return nodes;
  }

  /**
   * Sets this Selection to be of type "text" at the provided anchor and focus values.
   *
   * @param anchorNode - the anchor node to set on the Selection
   * @param anchorOffset - the offset to set on the Selection
   * @param focusNode - the focus node to set on the Selection
   * @param focusOffset - the focus offset to set on the Selection
   */
  setTextNodeRange(
    anchorNode: TextNode,
    anchorOffset: number,
    focusNode: TextNode,
    focusOffset: number,
  ): this {
    this.anchor.set(anchorNode.__key, anchorOffset, 'text');
    this.focus.set(focusNode.__key, focusOffset, 'text');
    return this;
  }

  /**
   * Gets the (plain) text content of all the nodes in the selection.
   *
   * @returns a string representing the text content of all the nodes in the Selection
   */
  getTextContent(): string {
    const nodes = this.getNodes();
    if (nodes.length === 0) {
      return '';
    }
    const firstNode = nodes[0];
    const lastNode = nodes[nodes.length - 1];
    const anchor = this.anchor;
    const focus = this.focus;
    const isBefore = anchor.isBefore(focus);
    const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
    let textContent = '';
    let prevWasElement = true;
    for (let i = 0; i < nodes.length; i++) {
      const node = nodes[i];
      if ($isElementNode(node) && !node.isInline()) {
        if (!prevWasElement) {
          textContent += '\n';
        }
        // Slots are isolated shadow roots, so getNodes() never descends into
        // them; append their text here (slots-first, mirroring
        // ElementNode.getTextContent) so a selection enclosing the host
        // carries its slot content.
        let slotText = '';
        for (const slotName of $getSlotNames(node)) {
          const slot = $getSlot(node, slotName);
          if (slot !== null) {
            slotText += slot.getTextContent();
          }
        }
        if (slotText !== '') {
          textContent += slotText;
          prevWasElement = false;
        } else if (node.isEmpty()) {
          prevWasElement = false;
        } else {
          prevWasElement = true;
        }
      } else {
        prevWasElement = false;
        if ($isTextNode(node)) {
          let text = node.getTextContent();
          if (node === firstNode) {
            if (node === lastNode) {
              if (
                anchor.type !== 'element' ||
                focus.type !== 'element' ||
                focus.offset === anchor.offset
              ) {
                text =
                  anchorOffset < focusOffset
                    ? text.slice(anchorOffset, focusOffset)
                    : text.slice(focusOffset, anchorOffset);
              }
            } else {
              text = isBefore
                ? text.slice(anchorOffset)
                : text.slice(focusOffset);
            }
          } else if (node === lastNode) {
            text = isBefore
              ? text.slice(0, focusOffset)
              : text.slice(0, anchorOffset);
          }
          textContent += text;
        } else if (
          ($isDecoratorNode(node) || $isLineBreakNode(node)) &&
          (node !== lastNode || !this.isCollapsed())
        ) {
          textContent += node.getTextContent();
        }
      }
    }
    return textContent;
  }

  /**
   * Attempts to map a DOM selection range onto this Lexical Selection,
   * setting the anchor, focus, and type accordingly
   *
   * @param range a DOM Selection range conforming to the StaticRange interface.
   */
  applyDOMRange(range: StaticRange): void {
    const editor = getActiveEditor();
    const currentEditorState = editor.getEditorState();
    const lastSelection = currentEditorState._selection;
    const resolvedSelectionPoints = $internalResolveSelectionPoints(
      range.startContainer,
      range.startOffset,
      range.endContainer,
      range.endOffset,
      editor,
      lastSelection,
    );
    if (resolvedSelectionPoints === null) {
      return;
    }
    const [anchorPoint, focusPoint, dirty] = resolvedSelectionPoints;
    this.anchor.set(
      anchorPoint.key,
      anchorPoint.offset,
      anchorPoint.type,
      true,
    );
    this.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type, true);
    if (dirty) {
      this.dirty = true;
    }
    // Firefox will use an element point rather than a text point in some cases,
    // so we normalize for that
    $normalizeSelection(this);
  }

  /**
   * Creates a new RangeSelection, copying over all the property values from this one.
   *
   * @returns a new RangeSelection with the same property values as this one.
   */
  clone(): RangeSelection {
    const anchor = this.anchor;
    const focus = this.focus;
    const selection = new RangeSelection(
      $createPoint(anchor.key, anchor.offset, anchor.type),
      $createPoint(focus.key, focus.offset, focus.type),
      this.format,
      this.style,
    );
    return selection;
  }

  /**
   * Toggles the provided format on all the TextNodes in the Selection.
   *
   * @param format a string TextFormatType to toggle on the TextNodes in the selection
   */
  toggleFormat(format: TextFormatType): void {
    this.format = toggleTextFormatType(this.format, format, null);
    this.dirty = true;
  }

  /**
   * Sets the value of the format property on the Selection
   *
   * @param format - the format to set at the value of the format property.
   */
  setFormat(format: number): void {
    this.format = format;
    this.dirty = true;
  }

  /**
   * Sets the value of the style property on the Selection
   *
   * @param style - the style to set at the value of the style property.
   */
  setStyle(style: string): void {
    this.style = style;
    this.dirty = true;
  }

  /**
   * Returns whether the provided TextFormatType is present on the Selection. This will be true if all text nodes in the Selection
   * have the specified format.
   *
   * @param type the TextFormatType to check for.
   * @returns true if the provided format is currently toggled on the Selection, false otherwise.
   */
  hasFormat(type: TextFormatType): boolean {
    const formatFlag = TEXT_TYPE_TO_FORMAT[type];
    return (this.format & formatFlag) !== 0;
  }

  /**
   * Attempts to insert the provided text into the EditorState at the current Selection.
   * converts tabs, newlines, and carriage returns into LexicalNodes.
   *
   * @param text the text to insert into the Selection
   */
  insertRawText(text: string): void {
    this.insertNodes($generateNodesFromRawText(text));
  }

  /**
   * Insert the provided text into the EditorState at the current Selection.
   *
   * @param text the text to insert into the Selection
   */
  insertText(text: string): void {
    // Now that "removeText" has been improved and does not depend on
    // insertText, insertText can be greatly simplified. The next
    // commented version is a WIP (about 5 tests fail).
    //
    // this.removeText();
    // if (text === '') {
    //   return;
    // }
    // const anchorNode = this.anchor.getNode();
    // const textNode = $createTextNode(text);
    // textNode.setFormat(this.format);
    // textNode.setStyle(this.style);
    // if ($isTextNode(anchorNode)) {
    //   const parent = anchorNode.getParentOrThrow();
    //   if (this.anchor.offset === 0) {
    //     if (parent.isInline() && !anchorNode.__prev) {
    //       parent.insertBefore(textNode);
    //     } else {
    //       anchorNode.insertBefore(textNode);
    //     }
    //   } else if (this.anchor.offset === anchorNode.getTextContentSize()) {
    //     if (parent.isInline() && !anchorNode.__next) {
    //       parent.insertAfter(textNode);
    //     } else {
    //       anchorNode.insertAfter(textNode);
    //     }
    //   } else {
    //     const [before] = anchorNode.splitText(this.anchor.offset);
    //     before.insertAfter(textNode);
    //   }
    // } else {
    //   anchorNode.splice(this.anchor.offset, 0, [textNode]);
    // }
    // const nodeToSelect = textNode.isAttached() ? textNode : anchorNode;
    // nodeToSelect.selectEnd();
    // // When composing, we need to adjust the anchor offset so that
    // // we correctly replace that right range.
    // if (
    //   textNode.isComposing() &&
    //   this.anchor.type === 'text' &&
    //   anchorNode.getTextContent() !== ''
    // ) {
    //   this.anchor.offset -= text.length;
    // }

    const anchor = this.anchor;
    const focus = this.focus;
    const format = this.format;
    const style = this.style;
    let firstPoint = anchor;
    let endPoint = focus;
    if (!this.isCollapsed() && focus.isBefore(anchor)) {
      firstPoint = focus;
      endPoint = anchor;
    }
    if (firstPoint.type === 'element') {
      $transferStartingElementPointToTextPoint(
        firstPoint,
        endPoint,
        format,
        style,
      );
    }
    if (endPoint.type === 'element') {
      $setPointFromCaret(
        endPoint,
        $normalizeCaret($caretFromPoint(endPoint, 'next')),
      );
    }
    const startOffset = firstPoint.offset;
    let endOffset = endPoint.offset;
    const selectedNodes = this.getNodes();
    const selectedNodesLength = selectedNodes.length;
    let firstNode: TextNode = selectedNodes[0] as TextNode;

    invariant(
      $isTextNode(firstNode),
      'insertText: first node is not a text node',
    );
    const firstNodeText = firstNode.getTextContent();
    const firstNodeTextLength = firstNodeText.length;
    const firstNodeParent = firstNode.getParentOrThrow();
    const lastIndex = selectedNodesLength - 1;
    let lastNode = selectedNodes[lastIndex];

    if (selectedNodesLength === 1 && endPoint.type === 'element') {
      endOffset = firstNodeTextLength;
      endPoint.set(firstPoint.key, endOffset, 'text');
    }

    if (
      this.isCollapsed() &&
      startOffset === firstNodeTextLength &&
      ($isTokenOrSegmented(firstNode) ||
        !firstNode.canInsertTextAfter() ||
        (!firstNodeParent.canInsertTextAfter() &&
          firstNode.getNextSibling() === null))
    ) {
      const candidateNextSibling = firstNode.getNextSibling();
      let nextSibling: TextNode;
      if (
        !$isTextNode(candidateNextSibling) ||
        !candidateNextSibling.canInsertTextBefore() ||
        $isTokenOrSegmented(candidateNextSibling)
      ) {
        nextSibling = $createTextNode();
        nextSibling.setFormat(format);
        nextSibling.setStyle(style);
        if (!firstNodeParent.canInsertTextAfter()) {
          firstNodeParent.insertAfter(nextSibling);
        } else {
          firstNode.insertAfter(nextSibling);
        }
      } else {
        nextSibling = candidateNextSibling;
      }
      nextSibling.select(0, 0);
      firstNode = nextSibling;
      if (text !== '') {
        this.insertText(text);
        return;
      }
    } else if (
      this.isCollapsed() &&
      startOffset === 0 &&
      ($isTokenOrSegmented(firstNode) ||
        !firstNode.canInsertTextBefore() ||
        (!firstNodeParent.canInsertTextBefore() &&
          firstNode.getPreviousSibling() === null))
    ) {
      const candidatePrevSibling = firstNode.getPreviousSibling();
      let prevSibling: TextNode;
      if (
        !$isTextNode(candidatePrevSibling) ||
        $isTokenOrSegmented(candidatePrevSibling)
      ) {
        prevSibling = $createTextNode();
        prevSibling.setFormat(format);
        if (!firstNodeParent.canInsertTextBefore()) {
          firstNodeParent.insertBefore(prevSibling);
        } else {
          firstNode.insertBefore(prevSibling);
        }
      } else {
        prevSibling = candidatePrevSibling;
      }
      prevSibling.select();
      firstNode = prevSibling;
      if (text !== '') {
        this.insertText(text);
        return;
      }
    } else if (firstNode.isSegmented() && startOffset !== firstNodeTextLength) {
      if ($getCompositionKey() !== null) {
        // Preserve the DOM element for the browser's composition tracker.
        // The subclass instance stays in normal mode until composition ends,
        // when $cleanupComposedSubclass replaces it with a plain TextNode.
        firstNode = firstNode
          .setMode('normal')
          .setFormat(format)
          .setStyle(style);
      } else {
        const textNode = $createTextNode(firstNode.getTextContent());
        textNode.setFormat(format);
        firstNode.replace(textNode);
        firstNode = textNode;
      }
    } else if (!this.isCollapsed() && text !== '') {
      // When the firstNode or lastNode parents are elements that
      // do not allow text to be inserted before or after, we first
      // clear the content. Then we normalize selection, then insert
      // the new content.
      const lastNodeParent = lastNode.getParent();

      if (
        !firstNodeParent.canInsertTextBefore() ||
        !firstNodeParent.canInsertTextAfter() ||
        ($isElementNode(lastNodeParent) &&
          (!lastNodeParent.canInsertTextBefore() ||
            !lastNodeParent.canInsertTextAfter()))
      ) {
        this.insertText('');
        $normalizeSelectionPointsForBoundaries(this.anchor, this.focus, null);
        this.insertText(text);
        return;
      }
    }

    if (selectedNodesLength === 1) {
      if ($isTokenOrTab(firstNode)) {
        const textNode = $createTextNode(text);
        textNode.select();
        firstNode.replace(textNode);
        return;
      }
      const firstNodeFormat = firstNode.getFormat();
      const firstNodeStyle = firstNode.getStyle();

      if (
        startOffset === endOffset &&
        (firstNodeFormat !== format || firstNodeStyle !== style)
      ) {
        if (firstNode.getTextContent() === '') {
          firstNode.setFormat(format);
          firstNode.setStyle(style);
        } else {
          const textNode = $createTextNode(text);
          textNode.setFormat(format);
          textNode.setStyle(style);
          textNode.select();
          if (startOffset === 0) {
            firstNode.insertBefore(textNode, false);
          } else {
            const [targetNode] = firstNode.splitText(startOffset);
            targetNode.insertAfter(textNode, false);
          }
          // When composing, we need to adjust the anchor offset so that
          // we correctly replace that right range.
          if (textNode.isComposing() && this.anchor.type === 'text') {
            this.anchor.offset -= text.length;
            this._cachedNodes = null;
            this._cachedIsBackward = null;
          }
          return;
        }
      } else if ($isTabNode(firstNode)) {
        // We don't need to check for delCount because there is only the entire selected node case
        // that can hit here for content size 1 and with canInsertTextBeforeAfter false
        const textNode = $createTextNode(text);
        textNode.setFormat(format);
        textNode.setStyle(style);
        textNode.select();
        firstNode.replace(textNode);
        return;
      }
      const delCount = endOffset - startOffset;

      firstNode = firstNode.spliceText(startOffset, delCount, text, true);
      if (firstNode.getTextContent() === '') {
        firstNode.remove();
      } else if (this.anchor.type === 'text') {
        this.format = firstNodeFormat;
        this.style = firstNodeStyle;
        if (firstNode.isComposing()) {
          // When composing, we need to adjust the anchor offset so that
          // we correctly replace that right range.
          this.anchor.offset -= text.length;
          this._cachedNodes = null;
          this._cachedIsBackward = null;
        }
      }
    } else {
      const markedNodeKeysForKeep = new Set([
        ...firstNode.getParentKeys(),
        ...lastNode.getParentKeys(),
      ]);

      // We have to get the parent elements before the next section,
      // as in that section we might mutate the lastNode.
      const firstElement = $isElementNode(firstNode)
        ? firstNode
        : firstNode.getParentOrThrow();
      let lastElement = $isElementNode(lastNode)
        ? lastNode
        : lastNode.getParentOrThrow();
      let lastElementChild = lastNode;

      // If the last element is inline, we should instead look at getting
      // the nodes of its parent, rather than itself. This behavior will
      // then better match how text node insertions work. We will need to
      // also update the last element's child accordingly as we do this.
      if (!firstElement.is(lastElement) && lastElement.isInline()) {
        // Keep traversing till we have a non-inline element parent.
        do {
          lastElementChild = lastElement;
          lastElement = lastElement.getParentOrThrow();
        } while (lastElement.isInline());
      }

      // Handle mutations to the last node.
      if (
        (endPoint.type === 'text' &&
          (endOffset !== 0 || lastNode.getTextContent() === '')) ||
        (endPoint.type === 'element' &&
          lastNode.getIndexWithinParent() < endOffset)
      ) {
        if (
          $isTextNode(lastNode) &&
          !$isTokenOrTab(lastNode) &&
          endOffset !== lastNode.getTextContentSize()
        ) {
          if (lastNode.isSegmented()) {
            const textNode = $createTextNode(lastNode.getTextContent());
            lastNode.replace(textNode);
            lastNode = textNode;
          }
          // root node selections only select whole nodes, so no text splice is necessary
          if (!$isRootNode(endPoint.getNode()) && endPoint.type === 'text') {
            invariant(
              $isTextNode(lastNode),
              'insertText: lastNode is not a TextNode',
            );
            lastNode = lastNode.spliceText(0, endOffset, '');
          }
          markedNodeKeysForKeep.add(lastNode.__key);
        } else {
          const lastNodeParent = lastNode.getParentOrThrow();
          if (
            !lastNodeParent.canBeEmpty() &&
            lastNodeParent.getChildrenSize() === 1
          ) {
            lastNodeParent.remove();
          } else {
            lastNode.remove();
          }
        }
      } else {
        markedNodeKeysForKeep.add(lastNode.__key);
      }

      // Either move the remaining nodes of the last parent to after
      // the first child, or remove them entirely. If the last parent
      // is the same as the first parent, this logic also works.
      const lastNodeChildren = lastElement.getChildren();
      const selectedNodesSet = new Set(selectedNodes);
      const firstAndLastElementsAreEqual = firstElement.is(lastElement);

      // We choose a target to insert all nodes after. In the case of having
      // and inline starting parent element with a starting node that has no
      // siblings, we should insert after the starting parent element, otherwise
      // we will incorrectly merge into the starting parent element.
      // TODO: should we keep on traversing parents if we're inside another
      // nested inline element?
      const insertionTarget =
        firstElement.isInline() && firstNode.getNextSibling() === null
          ? firstElement
          : firstNode;

      for (let i = lastNodeChildren.length - 1; i >= 0; i--) {
        const lastNodeChild = lastNodeChildren[i];

        if (
          lastNodeChild.is(firstNode) ||
          ($isElementNode(lastNodeChild) && lastNodeChild.isParentOf(firstNode))
        ) {
          break;
        }

        if (lastNodeChild.isAttached()) {
          if (
            !selectedNodesSet.has(lastNodeChild) ||
            lastNodeChild.is(lastElementChild)
          ) {
            if (!firstAndLastElementsAreEqual) {
              insertionTarget.insertAfter(lastNodeChild, false);
            }
          } else {
            lastNodeChild.remove();
          }
        }
      }

      if (!firstAndLastElementsAreEqual) {
        // Check if we have already moved out all the nodes of the
        // last parent, and if so, traverse the parent tree and mark
        // them all as being able to deleted too.
        let parent: ElementNode | null = lastElement;
        let lastRemovedParent = null;

        while (parent !== null) {
          const children = parent.getChildren();
          const childrenLength = children.length;
          if (
            childrenLength === 0 ||
            children[childrenLength - 1].is(lastRemovedParent)
          ) {
            markedNodeKeysForKeep.delete(parent.__key);
            lastRemovedParent = parent;
          }
          parent = parent.getParent();
        }
      }

      // Ensure we do splicing after moving of nodes, as splicing
      // can have side-effects (in the case of hashtags).
      if (!$isTokenOrTab(firstNode)) {
        firstNode = firstNode.spliceText(
          startOffset,
          firstNodeTextLength - startOffset,
          text,
          true,
        );
        if (firstNode.getTextContent() === '') {
          firstNode.remove();
        } else if (this.anchor.type === 'text') {
          this.format = firstNode.getFormat();
          this.style = firstNode.getStyle();
          if (firstNode.isComposing()) {
            // When composing, we need to adjust the anchor offset so that
            // we correctly replace that right range.
            this.anchor.offset -= text.length;
            this._cachedNodes = null;
            this._cachedIsBackward = null;
          }
        }
      } else if (startOffset === firstNodeTextLength) {
        firstNode.select();
      } else {
        const textNode = $createTextNode(text);
        textNode.select();
        firstNode.replace(textNode);
      }

      // Remove all selected nodes that haven't already been removed.
      for (let i = 1; i < selectedNodesLength; i++) {
        const selectedNode = selectedNodes[i];
        const key = selectedNode.__key;
        if (!markedNodeKeysForKeep.has(key)) {
          selectedNode.remove();
        }
      }
    }
  }

  /**
   * Removes the text in the Selection, adjusting the EditorState accordingly.
   */
  removeText(): void {
    const isCurrentSelection = $getSelection() === this;
    const newRange = $removeTextFromCaretRange($caretRangeFromSelection(this));
    $updateRangeSelectionFromCaretRange(this, newRange);
    if (isCurrentSelection && $getSelection() !== this) {
      $setSelection(this);
    }
  }

  // TO-DO: Migrate this method to the new utility function $forEachSelectedTextNode (share similar logic)
  /**
   * Applies the provided format to the TextNodes in the Selection, splitting or
   * merging nodes as necessary.
   *
   * @param formatType the format type to apply to the nodes in the Selection.
   * @param alignWithFormat a 32-bit integer representing formatting flags to align with.
   */
  formatText(
    formatType: TextFormatType,
    alignWithFormat: number | null = null,
  ): void {
    $formatText(this, formatType, alignWithFormat);
  }

  /**
   * Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the
   * current Selection according to a set of heuristics that determine how surrounding nodes
   * should be changed, replaced, or moved to accommodate the incoming ones.
   *
   * @param nodes - the nodes to insert
   */
  insertNodes(nodes: LexicalNode[]): void {
    if (nodes.length === 0) {
      return;
    }
    if (!this.isCollapsed()) {
      this.removeText();
    }
    // @experimental named-slots. Anchor on a slot value root (e.g. after a
    // slot-scoped Cmd+A leaves the selection on the slot's element point)
    // has __parent === null, so the block-finding walk below would throw.
    // Redirect into the slot subtree by collapsing the selection at the
    // slot's first child and re-running insertNodes.
    const anchorNode = this.anchor.getNode();
    if (
      this.anchor.type === 'element' &&
      $isElementNode(anchorNode) &&
      $getSlotHostKey(anchorNode) !== null
    ) {
      // A container (shadow-root) value redirects into its first child; an
      // empty one has no child to redirect into (its caret target is the
      // reconciler's terminating <br>), so seed a paragraph first —
      // insertNodes removes the seed again when block content replaces it. A
      // block-shaped value (virtual shadow root around a single block) needs
      // no seeding: it IS the block, so the block-finding walk below lands
      // on it directly.
      let firstChild = anchorNode.isShadowRoot()
        ? (anchorNode.getFirstChild() ??
          anchorNode.append($createParagraphNode()).getFirstChild())
        : anchorNode.getFirstChild();
      // A shadow-root slot whose first child is a non-element (typically a
      // decorator like HorizontalRuleNode) would re-enter this same branch
      // forever: `firstChild.selectStart()` resolves back to the slot value's
      // own element-mode caret (no sibling, parent = the slot value root),
      // which matches the entry condition above. Seed a paragraph before the
      // non-element first child so the redirected selection lands in a block
      // and the recursion terminates.
      //
      // The seed paragraph is the redirect target only — if `nodes` carries
      // inline content the recursion fills the paragraph in place, and if it
      // carries block content the recursion's root/shadow-root branch
      // (`splice` after `$wrapInlineNodes`) inserts the new blocks before the
      // existing non-element first child while the seed sits at offset 0 as
      // the new shadow-root first child. In either case the seed ends up
      // hosting either the inserted content or an empty leading line, never
      // a stranded paragraph next to the original non-element child.
      if (
        anchorNode.isShadowRoot() &&
        firstChild !== null &&
        !$isElementNode(firstChild)
      ) {
        const seed = $createParagraphNode();
        firstChild.insertBefore(seed);
        firstChild = seed;
      }
      if (firstChild !== null) {
        firstChild.selectStart();
        const redirected = $getSelection();
        invariant(
          $isRangeSelection(redirected),
          'Expected RangeSelection after redirecting into slot subtree',
        );
        return redirected.insertNodes(nodes);
      }
    }

    // The anchor is an element point directly on a root or shadow root that is
    // not a named-slot host (handled above). This includes the document root
    // (e.g. an empty editor) and shadow roots that hold block-level children
    // directly — for instance the block cursor between or after the children of
    // a decorator-only container or the playground CollapsibleContentNode.
    // Roots and shadow roots hold blocks (and shadow roots) directly, so splice
    // the nodes in at the anchor offset: a block node (such as a pasted
    // DecoratorNode) goes in as-is, while inline runs are wrapped in a block
    // first since a root/shadow root cannot contain inline children.
    if (this.anchor.type === 'element' && $isRootOrShadowRoot(anchorNode)) {
      const blocksParent = $wrapInlineNodes(nodes);
      const nodeToSelect = blocksParent.getLastDescendant();
      anchorNode.splice(this.anchor.offset, 0, blocksParent.getChildren());
      if (nodeToSelect !== null) {
        nodeToSelect.selectEnd();
      }
      return;
    }

    const firstPoint = this.isBackward() ? this.focus : this.anchor;
    let firstNode = firstPoint.getNode();
    let firstBlock = $findMatchingParent(firstNode, INTERNAL_$isBlock);

    const last = nodes[nodes.length - 1]!;

    // CASE 1: insert inside a code block
    if ($isElementNode(firstBlock) && '__language' in firstBlock) {
      if ('__language' in nodes[0]) {
        this.insertText(nodes[0].getTextContent());
      } else {
        const index = $removeTextAndSplitBlock(this);
        firstBlock.splice(index, 0, nodes);
        last.selectEnd();
      }
      return;
    }

    // CASE 2: All elements of the array are inline
    const notInline = (node: LexicalNode) =>
      ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline();

    if (!nodes.some(notInline)) {
      invariant(
        $isElementNode(firstBlock),
        'Expected node %s of type %s to have a block ElementNode ancestor',
        firstNode.constructor.name,
        firstNode.getType(),
      );
      const index = $removeTextAndSplitBlock(this);
      firstBlock.splice(index, 0, nodes);
      last.selectEnd();
      return;
    }

    // CASE 3a: the target block IS a slot value. Its virtual shadow root
    // holds exactly one block, so block-level content cannot become its
    // sibling; mirror pasting into an <input> instead — block structure
    // flattens to its inline content on the single line (line breaks are
    // stripped like the input value sanitization strips newlines, and
    // block-only decorators are dropped, having no single-line form).
    if ($isElementNode(firstBlock) && $getSlotHostKey(firstBlock) !== null) {
      const index = $removeTextAndSplitBlock(this);
      const inlineNodes = $extractInlineFromBlocks(nodes);
      firstBlock.splice(index, 0, inlineNodes);
      const lastInserted = inlineNodes[inlineNodes.length - 1];
      if (lastInserted !== undefined) {
        lastInserted.selectEnd();
      } else {
        firstBlock.select(index, index);
      }
      return;
    }

    // CASE 3b: there is non-inline content but no block ancestor to insert it
    // relative to. The element point on a root/shadow root is handled above, so
    // this is a malformed document where an inline-only element directly holds
    // a block child (e.g. a HorizontalRuleNode inside a CollapsibleTitleNode,
    // see #8713) or a non-inline element that reports canBeEmpty() === false.
    // A non-inline node must never become the child of an inline-only element,
    // so enforce the document structure rules with
    // $insertNodeToNearestRootAtCaret, which splits the ancestor chain up to
    // the nearest node that may contain non-inline children (a root or shadow
    // root) and inserts the blocks there. Lists are unaffected: a list item is
    // always a block ancestor, so they fall through to CASE 3 and keep their
    // existing (ListItemNode-aware) paste behavior.
    if (firstBlock === null) {
      const blocksParent = $wrapInlineNodes(nodes);
      const nodeToSelect = blocksParent.getLastDescendant();
      // Split the ancestor chain up to the nearest root or shadow root and
      // insert each block there.
      let caret: PointCaret<'next'> = $caretFromPoint(this.anchor, 'next');
      for (const block of blocksParent.getChildren()) {
        caret = $insertNodeToNearestRootAtCaret(block, caret);
      }
      if (nodeToSelect !== null) {
        nodeToSelect.selectEnd();
      }
      return;
    }

    // CASE 3c: the target block exists but its parent is not a root or shadow
    // root — the only elements that may contain non-inline children — and the
    // block does not relocate itself to a valid parent (it is not
    // parent-required, unlike a ListItemNode, whose insertAfter escapes the
    // list). Inserting the blocks as siblings here would nest them in an
    // inline-only element, e.g. a HorizontalRuleNode pasted into the
    // ParagraphNode of a CollapsibleTitleNode (see #8724). Mirror CASE 3a and
    // flatten the incoming nodes to their inline content, dropping the
    // block-level parts that have no inline form.
    if (
      $isElementNode(firstBlock) &&
      !firstBlock.isParentRequired() &&
      !$isRootOrShadowRoot(firstBlock.getParentOrThrow())
    ) {
      const index = $removeTextAndSplitBlock(this);
      const inlineNodes = $extractInlineFromBlocks(nodes);
      firstBlock.splice(index, 0, inlineNodes);
      const lastInserted = inlineNodes[inlineNodes.length - 1];
      if (lastInserted !== undefined) {
        lastInserted.selectEnd();
      } else {
        firstBlock.select(index, index);
      }
      return;
    }

    // CASE 3: At least 1 element of the array is not inline
    const blocksParent = $wrapInlineNodes(nodes);
    const nodeToSelect = blocksParent.getLastDescendant()!;
    const blocks = blocksParent.getChildren();
    const isMergeable = (node: LexicalNode): node is ElementNode =>
      $isElementNode(node) &&
      INTERNAL_$isBlock(node) &&
      !node.isEmpty() &&
      $isElementNode(firstBlock) &&
      (!firstBlock.isEmpty() || firstBlock.canMergeWhenEmpty());

    const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty();
    const insertedParagraph = shouldInsert ? this.insertParagraph() : null;
    if (insertedParagraph && !firstBlock.isAttached()) {
      firstNode = this.anchor.getNode();
      firstBlock = $findMatchingParent(firstNode, INTERNAL_$isBlock);
    }
    const lastToInsert: LexicalNode | undefined = blocks[blocks.length - 1];
    let firstToInsert: LexicalNode | undefined = blocks[0];
    if (isMergeable(firstToInsert)) {
      invariant(
        $isElementNode(firstBlock),
        'Expected node %s of type %s to have a block ElementNode ancestor',
        firstNode.constructor.name,
        firstNode.getType(),
      );
      firstBlock.append(...firstToInsert.getChildren());
      firstToInsert = blocks[1];
    }
    if (firstToInsert) {
      invariant(
        firstBlock !== null,
        'Expected node %s of type %s to have a block ancestor',
        firstNode.constructor.name,
        firstNode.getType(),
      );
      insertRangeAfter(firstBlock, firstToInsert);
    }
    const lastInsertedBlock = $findMatchingParent(
      nodeToSelect,
      INTERNAL_$isBlock,
    );

    if (
      insertedParagraph &&
      $isElementNode(lastInsertedBlock) &&
      (insertedParagraph.canMergeWhenEmpty() || INTERNAL_$isBlock(lastToInsert))
    ) {
      lastInsertedBlock.append(...insertedParagraph.getChildren());
      insertedParagraph.remove();
    }
    if ($isElementNode(firstBlock) && firstBlock.isEmpty()) {
      firstBlock.remove();
    }

    nodeToSelect.selectEnd();

    // To understand this take a look at the test "can wrap post-linebreak nodes into new element"
    const lastChild = $isElementNode(firstBlock)
      ? firstBlock.getLastChild()
      : null;
    if ($isLineBreakNode(lastChild) && lastInsertedBlock !== firstBlock) {
      lastChild.remove();
    }
  }

  /**
   * Inserts a new ParagraphNode into the EditorState at the current Selection
   *
   * @returns the newly inserted node.
   */
  insertParagraph(): ElementNode | null {
    const anchorNode = this.anchor.getNode();
    if (this.anchor.type === 'element' && $isRootOrShadowRoot(anchorNode)) {
      const paragraph = $createParagraphNode();
      anchorNode.splice(this.anchor.offset, 0, [paragraph]);
      paragraph.select();
      return paragraph;
    }
    const index = $removeTextAndSplitBlock(this);
    const block = $findMatchingParent(this.anchor.getNode(), INTERNAL_$isBlock);
    if (block !== null && $getSlotHostKey(block) !== null) {
      // The block IS the slot value: its virtual shadow root holds exactly
      // one block, so there is no position for a sibling paragraph. Mirrors
      // Enter in a single-line input — a no-op (hosts may map it to focus
      // movement).
      return null;
    }
    invariant(
      $isElementNode(block),
      'Expected ancestor to be a block ElementNode',
    );
    const firstToAppend = block.getChildAtIndex(index);
    const nodesToInsert = firstToAppend
      ? [firstToAppend, ...firstToAppend.getNextSiblings()]
      : [];
    const newBlock = block.insertNewAfter(this, false) as ElementNode | null;
    if (newBlock) {
      newBlock.append(...nodesToInsert);
      newBlock.selectStart();
      return newBlock;
    }
    // if newBlock is null, it means that block is of type CodeNode.
    return null;
  }

  /**
   * Inserts a logical linebreak, which may be a new LineBreakNode or a new ParagraphNode, into the EditorState at the
   * current Selection.
   */
  insertLineBreak(selectStart?: boolean): void {
    const lineBreak = $createLineBreakNode();
    this.insertNodes([lineBreak]);
    // this is used in MacOS with the command 'ctrl-O' (openLineBreak)
    if (selectStart) {
      const parent = lineBreak.getParentOrThrow();
      const index = lineBreak.getIndexWithinParent();
      parent.select(index, index);
    }
  }

  /**
   * Extracts the nodes in the Selection, splitting nodes where necessary
   * to get offset-level precision.
   *
   * @returns The nodes in the Selection
   */
  extract(): LexicalNode[] {
    const selectedNodes = [...this.getNodes()];
    const selectedNodesLength = selectedNodes.length;
    let firstNode = selectedNodes[0];
    let lastNode = selectedNodes[selectedNodesLength - 1];
    const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
    const isBackward = this.isBackward();
    const [startPoint, endPoint] = isBackward
      ? [this.focus, this.anchor]
      : [this.anchor, this.focus];
    const [startOffset, endOffset] = isBackward
      ? [focusOffset, anchorOffset]
      : [anchorOffset, focusOffset];

    if (selectedNodesLength === 0) {
      return [];
    } else if (selectedNodesLength === 1) {
      if ($isTextNode(firstNode) && !this.isCollapsed()) {
        const splitNodes = firstNode.splitText(startOffset, endOffset);
        const node = startOffset === 0 ? splitNodes[0] : splitNodes[1];
        if (node) {
          startPoint.set(node.getKey(), 0, 'text');
          endPoint.set(node.getKey(), node.getTextContentSize(), 'text');
          return [node];
        }
        return [];
      }
      return [firstNode];
    }

    if ($isTextNode(firstNode)) {
      if (startOffset === firstNode.getTextContentSize()) {
        selectedNodes.shift();
      } else if (startOffset !== 0) {
        [, firstNode] = firstNode.splitText(startOffset);
        selectedNodes[0] = firstNode;
        startPoint.set(firstNode.getKey(), 0, 'text');
      }
    }
    if ($isTextNode(lastNode)) {
      const lastNodeText = lastNode.getTextContent();
      const lastNodeTextLength = lastNodeText.length;
      if (endOffset === 0) {
        selectedNodes.pop();
      } else if (endOffset !== lastNodeTextLength) {
        [lastNode] = lastNode.splitText(endOffset);
        selectedNodes[selectedNodes.length - 1] = lastNode;
        endPoint.set(lastNode.getKey(), lastNode.getTextContentSize(), 'text');
      }
    }
    return selectedNodes;
  }

  /**
   * Modifies the Selection according to the parameters and a set of heuristics that account for
   * various node types. Can be used to safely move or extend selection by one logical "unit" without
   * dealing explicitly with all the possible node types.
   *
   * @param alter the type of modification to perform
   * @param isBackward whether or not selection is backwards
   * @param granularity the granularity at which to apply the modification
   */
  modify(
    alter: 'move' | 'extend',
    isBackward: boolean,
    granularity: 'character' | 'word' | 'lineboundary',
  ): void {
    if (
      $modifySelectionAroundDecoratorsAndBlocks(
        this,
        alter,
        isBackward,
        granularity,
      )
    ) {
      return;
    }
    const collapse = alter === 'move';

    const editor = getActiveEditor();
    const domSelection = getDOMSelection(getWindow(editor));

    if (!domSelection) {
      return;
    }
    const blockCursorElement = editor._blockCursorElement;
    const rootElement = editor._rootElement;
    const focusNode = this.focus.getNode();
    // Remove the block cursor element if it exists. This will ensure selection
    // works as intended. If we leave it in the DOM all sorts of strange bugs
    // occur. :/
    if (
      rootElement !== null &&
      blockCursorElement !== null &&
      $isElementNode(focusNode) &&
      !focusNode.isInline() &&
      !focusNode.canBeEmpty()
    ) {
      removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
    }
    const focusKeyedDOM = getElementByKeyOrThrow(editor, this.focus.key);
    let nextFocusDOM: HTMLElement | Text | null = focusKeyedDOM;
    if (this.focus.type === 'text') {
      nextFocusDOM = $isTextNode(focusNode)
        ? $getDOMTextNode(focusNode, focusKeyedDOM, editor)
        : null;
    }
    if (this.dirty) {
      const anchorKeyedDOM = getElementByKeyOrThrow(editor, this.anchor.key);
      let nextAnchorDOM: HTMLElement | Text | null = anchorKeyedDOM;
      if (this.anchor.type === 'text') {
        const node = this.anchor.getNode();
        nextAnchorDOM = $isTextNode(node)
          ? $getDOMTextNode(node, anchorKeyedDOM, editor)
          : null;
      }
      if (nextAnchorDOM && nextFocusDOM) {
        setDOMSelectionBaseAndExtent(
          domSelection,
          nextAnchorDOM,
          this.anchor.offset,
          nextFocusDOM,
          this.focus.offset,
        );
      }
    }
    // When focus sits at a TextNode boundary, pre-normalize the DOM
    // selection into the adjacent sibling's Text node so that the
    // native Selection.modify can cross inline-grid/flex spans (#7301).
    if (
      granularity === 'character' &&
      $isTextNode(focusNode) &&
      focusNode.isUnmergeable()
    ) {
      const atBoundary = isBackward
        ? this.focus.offset === 0
        : this.focus.offset === focusNode.getTextContentSize();
      if (atBoundary) {
        const sibling = $getSiblingCaret(
          focusNode,
          isBackward ? 'previous' : 'next',
        ).getNodeAtCaret();
        if ($isTextNode(sibling)) {
          if (collapse) {
            const sibKeyedDOM = editor.getElementByKey(sibling.getKey());
            const sibDOM = sibKeyedDOM
              ? $getDOMTextNode(sibling, sibKeyedDOM, editor)
              : null;
            if (sibDOM) {
              const sibOffset = isBackward ? sibDOM.length : 0;
              setDOMSelectionBaseAndExtent(
                domSelection,
                sibDOM,
                sibOffset,
                sibDOM,
                sibOffset,
              );
            }
          } else {
            // For extend (used by deleteCharacter), native Selection.modify
            // cannot cross inline-grid span boundaries even after
            // pre-normalization. Set the Lexical selection directly and
            // return early to skip the native moveNativeSelection call.
            const sibLen = sibling.getTextContentSize();
            if (isBackward) {
              this.focus.set(sibling.__key, sibLen - 1, 'text');
            } else {
              this.focus.set(sibling.__key, 1, 'text');
            }
            this.dirty = true;
            return;
          }
        }
      }
    }
    // We use the DOM selection.modify API here to "tell" us what the selection
    // will be. We then use it to update the Lexical selection accordingly. This
    // is much more reliable than waiting for a beforeinput and using the ranges
    // from getTargetRanges(), and is also better than trying to do it ourselves
    // using Intl.Segmenter or other workarounds that struggle with word segments
    // and line segments (especially with word wrapping and non-Roman languages).
    moveNativeSelection(
      domSelection,
      alter,
      isBackward ? 'backward' : 'forward',
      granularity,
    );
    // Guard against no ranges
    if (domSelection.rangeCount > 0) {
      // Inside a DOM shadow root getRangeAt(0) is retargeted to the host;
      // read the composed StaticRange (real nodes) where available.
      const composedRange = getComposedStaticRange(
        domSelection,
        editor._rootElement,
      );
      const range = composedRange || domSelection.getRangeAt(0);
      // Apply the DOM selection to our Lexical selection.
      const anchorNode = this.anchor.getNode();
      const root = $isRootNode(anchorNode)
        ? anchorNode
        : $getNearestRootOrShadowRoot(anchorNode);
      this.applyDOMRange(range);
      this.dirty = true;
      if (!collapse) {
        $shrinkSelectionToRoot(this, isBackward, root);

        // Because a range works on start and end, we might need to flip
        // the anchor and focus points to match what the DOM has, not what
        // the range has specifically. Inside a shadow root anchorNode is
        // retargeted to the host, so use the standard Selection.direction.
        // If a future engine ships getComposedRanges without direction
        // this falls through to forward; backward fidelity is the known
        // limitation documented on getDOMSelectionPoints.
        const anchorIsAtRangeStart = composedRange
          ? domSelection.direction !== 'backward'
          : domSelection.anchorNode === range.startContainer &&
            domSelection.anchorOffset === range.startOffset;
        if (!anchorIsAtRangeStart) {
          $swapPoints(this);
        }
      }
    }
    if (granularity === 'lineboundary') {
      $modifySelectionAroundDecoratorsAndBlocks(
        this,
        alter,
        isBackward,
        granularity,
        'decorators',
      );
    }
  }
  /**
   * Helper for handling forward character and word deletion that prevents element nodes
   * like a table, columns layout being destroyed
   *
   * @param anchor the anchor
   * @param anchorNode the anchor node in the selection
   * @param isBackward whether or not selection is backwards
   */
  forwardDeletion(
    anchor: PointType,
    anchorNode: TextNode | ElementNode,
    isBackward: boolean,
  ): boolean {
    if (
      !isBackward &&
      // Delete forward handle case
      ((anchor.type === 'element' &&
        $isElementNode(anchorNode) &&
        anchor.offset === anchorNode.getChildrenSize()) ||
        (anchor.type === 'text' &&
          anchor.offset === anchorNode.getTextContentSize()))
    ) {
      const parent = anchorNode.getParent();
      const nextSibling =
        anchorNode.getNextSibling() ||
        (parent === null ? null : parent.getNextSibling());

      if ($isElementNode(nextSibling) && nextSibling.isShadowRoot()) {
        return true;
      }
    }
    return false;
  }

  /**
   * Performs one logical character deletion operation on the EditorState based on the current Selection.
   * Handles different node types.
   *
   * @param isBackward whether or not the selection is backwards.
   */
  deleteCharacter(isBackward: boolean): void {
    const wasCollapsed = this.isCollapsed();
    if (this.isCollapsed()) {
      const anchor = this.anchor;
      let anchorNode: TextNode | ElementNode | null = anchor.getNode();
      if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
        return;
      }
      const direction = isBackward ? 'previous' : 'next';
      const initialCaret = $caretFromPoint(anchor, direction);
      const initialRange = $extendCaretToRange(initialCaret);
      if (
        initialRange
          .getTextSlices()
          .every(slice => slice === null || slice.distance === 0)
      ) {
        // There's no text in the direction of the deletion so we can explore our options
        let state:
          | {type: 'initial'}
          | {
              type: 'merge-next-block';
              block: ElementNode;
            }
          | {
              type: 'merge-block';
              caret: ChildCaret<ElementNode, typeof direction>;
              block: ElementNode;
            } = {type: 'initial'};
        for (const caret of initialRange.iterNodeCarets('shadowRoot')) {
          if ($isChildCaret(caret)) {
            if (caret.origin.isInline()) {
              // fall through when descending an inline
            } else if (caret.origin.isShadowRoot()) {
              if (state.type === 'merge-block') {
                break;
              }
              // Don't merge with a shadow root block
              if (
                $isElementNode(initialRange.anchor.origin) &&
                initialRange.anchor.origin.isEmpty()
              ) {
                // delete an empty paragraph like the DecoratorNode case
                const normCaret = $normalizeCaret(caret);
                $updateRangeSelectionFromCaretRange(
                  this,
                  $getCaretRange(normCaret, normCaret),
                );
                initialRange.anchor.origin.remove();
              }
              return;
            } else if (
              state.type === 'merge-next-block' ||
              state.type === 'merge-block'
            ) {
              // Keep descending ChildCaret to find which block to merge with
              state = {block: state.block, caret, type: 'merge-block'};
            }
          } else if (state.type === 'merge-block') {
            break;
          } else if ($isSiblingCaret(caret)) {
            if ($isElementNode(caret.origin)) {
              if (!caret.origin.isInline()) {
                state = {block: caret.origin, type: 'merge-next-block'};
              } else if (!caret.origin.isParentOf(initialRange.anchor.origin)) {
                break;
              }
              continue;
            } else if ($isDecoratorNode(caret.origin)) {
              if (caret.origin.isIsolated()) {
                // do nothing, shouldn't delete an isolated decorator
              } else if ($getSlotNames(caret.origin).length > 0) {
                // A slot-bearing decorator is removed only as a unit by an
                // explicit host deletion, never silently via backspace —
                // same policy as the merge-block branch below for
                // ElementNode-as-host. When the anchor is an empty
                // paragraph next to the host, drop the paragraph and
                // select the host (matches the shadow-root ElementNode
                // path at line 1951–1962 above); otherwise leave both in
                // place.
                if (
                  $isElementNode(initialRange.anchor.origin) &&
                  initialRange.anchor.origin.isEmpty()
                ) {
                  initialRange.anchor.origin.remove();
                  const nodeSelection = $createNodeSelection();
                  nodeSelection.add(caret.origin.getKey());
                  $setSelection(nodeSelection);
                }
              } else if (
                state.type === 'merge-next-block' &&
                (caret.origin.isKeyboardSelectable() ||
                  !caret.origin.isInline()) &&
                $isElementNode(initialRange.anchor.origin) &&
                initialRange.anchor.origin.isEmpty()
              ) {
                // If the anchor is an empty element that is adjacent to a
                // decorator then we remove the paragraph and select the
                // decorator
                initialRange.anchor.origin.remove();
                const nodeSelection = $createNodeSelection();
                nodeSelection.add(caret.origin.getKey());
                $setSelection(nodeSelection);
              } else {
                // When the anchor is not an empty element then the
                // adjacent decorator is removed
                caret.origin.remove();
              }
              // always stop when a decorator is encountered
              return;
            }
            break;
          }
        }
        if (state.type === 'merge-block') {
          // `block` is the anchor-side block; `caret.origin` is the
          // adjacent (previous-direction) block we descended into.
          const {caret, block} = state;
          // The cross-block merge below removes `block` (it merges into the
          // adjacent block). If `block` owns slots, that removal would discard
          // them, since slots are not children and are not carried over. Leave
          // the caret in place instead: a slot-bearing host is removed only as
          // a unit by an explicit host deletion, never silently via backspace.
          if ($getSlotNames(block).length > 0) {
            return;
          }
          // Empty adjacent block at the same nesting level: remove it
          // instead of merging, so the current block's type (e.g.
          // heading) survives. Limiting to a shared parent leaves
          // structural wrappers like a ListNode containing an empty
          // ListItemNode to the default cross-block merge — the
          // ListNode is not considered empty just because its only
          // child is.
          if (
            caret.origin.isEmpty() &&
            !block.isEmpty() &&
            caret.origin.getParent() === block.getParent()
          ) {
            caret.origin.remove(true);
            return;
          }
          $updateRangeSelectionFromCaretRange(
            this,
            $getCaretRange(
              !caret.origin.isEmpty() && block.isEmpty()
                ? $rewindSiblingCaret($getSiblingCaret(block, caret.direction))
                : initialRange.anchor,
              caret,
            ),
          );
          return this.removeText();
        }
        // No text lies in the deletion direction and nothing in scope was
        // found to delete, so the caret sits at a slot edge. A slot value is
        // nested within its host's DOM, so the boundary lives only in the
        // model: the native caret measurement below would cross it and
        // select into the host. Stop when that edge is a slot value — the
        // slot link is a virtual shadow root, so this applies whether or not
        // the value is itself a shadow root; an ordinary (non-slotted)
        // shadow root keeps the native behavior.
        for (let node: LexicalNode | null = anchor.getNode(); node !== null; ) {
          if ($getSlotHostKey(node) !== null) {
            return;
          }
          if ($isElementNode(node) && node.isShadowRoot()) {
            break;
          }
          node = node.getParent();
        }
      }

      // Handle the deletion around decorators.
      const focus = this.focus;
      // Extend the selection in the model rather than with the native
      // modify('extend'), which would clobber the X11 PRIMARY selection.
      // See https://github.com/facebook/lexical/issues/8766
      $extendSelectionForDeletion(this, isBackward, 'character');

      if (!this.isCollapsed()) {
        const focusNode = focus.type === 'text' ? focus.getNode() : null;
        anchorNode = anchor.type === 'text' ? anchor.getNode() : null;

        if (focusNode !== null && focusNode.isSegmented()) {
          const offset = focus.offset;
          const textContentSize = focusNode.getTextContentSize();
          if (
            focusNode.is(anchorNode) ||
            (isBackward && offset !== textContentSize) ||
            (!isBackward && offset !== 0)
          ) {
            $removeSegment(focusNode, isBackward, offset);
            return;
          }
        } else if (anchorNode !== null && anchorNode.isSegmented()) {
          const offset = anchor.offset;
          const textContentSize = anchorNode.getTextContentSize();
          if (
            anchorNode.is(focusNode) ||
            (isBackward && offset !== 0) ||
            (!isBackward && offset !== textContentSize)
          ) {
            $removeSegment(anchorNode, isBackward, offset);
            return;
          }
        }
        $updateCaretSelectionForUnicodeCharacter(this, isBackward);
      } else if (isBackward && anchor.offset === 0) {
        // Special handling around rich text nodes
        if ($collapseAtStart(this, anchor.getNode())) {
          return;
        }
      }
    }
    this.removeText();
    if (
      isBackward &&
      !wasCollapsed &&
      this.isCollapsed() &&
      this.anchor.type === 'element' &&
      this.anchor.offset === 0
    ) {
      const anchorNode = this.anchor.getNode();
      if (
        anchorNode.isEmpty() &&
        $isRootNode(anchorNode.getParent()) &&
        anchorNode.getPreviousSibling() === null
      ) {
        $collapseAtStart(this, anchorNode);
      }
      $ensureRootHasParagraph();
    }
  }

  /**
   * Performs one logical line deletion operation on the EditorState based on the current Selection.
   * Handles different node types.
   *
   * @param isBackward whether or not the selection is backwards.
   */
  deleteLine(isBackward: boolean): void {
    // A decorator-host slot's DOM is relocated out of document order (the
    // host's React decorate() mounts the slot container wherever it wants),
    // so a deletion that starts inside one cannot be expressed by the
    // native range the browser hands us: forward delete at a slot edge
    // extends backward over the whole line, and the lineboundary extend
    // below resolves in the wrong direction too. Element-host slots
    // (e.g. a Card title) keep document order so the native path is
    // fine there. Narrow to the decorator-host case and defer to
    // deleteCharacter, which clamps at the slot boundary while still
    // handling in-slot character and paragraph deletion.
    const anchorSlotFrame = $getPointSlotFrame(this.anchor);
    if (
      anchorSlotFrame !== null &&
      $isDecoratorNode($getSlotHost(anchorSlotFrame))
    ) {
      if (!this.isCollapsed()) {
        this.focus.set(this.anchor.key, this.anchor.offset, this.anchor.type);
      }
      this.deleteCharacter(isBackward);
      return;
    }
    if (this.isCollapsed()) {
      $extendSelectionForDeletion(this, isBackward, 'lineboundary');
    }
    if (this.isCollapsed()) {
      // If the selection was already collapsed at the lineboundary,
      // use the deleteCharacter operation to handle all of the logic associated
      // with navigating through the parent element
      this.deleteCharacter(isBackward);
    } else {
      const anchorBlock = $findMatchingParent(
        this.anchor.getNode(),
        INTERNAL_$isBlock,
      );
      const focusBlock = $findMatchingParent(
        this.focus.getNode(),
        INTERNAL_$isBlock,
      );
      if (anchorBlock !== focusBlock) {
        this.focus.set(this.anchor.key, this.anchor.offset, this.anchor.type);
        this.deleteCharacter(isBackward);
      } else {
        this.removeText();
      }
    }
  }

  /**
   * Performs one logical word deletion operation on the EditorState based on the current Selection.
   * Handles different node types.
   *
   * @param isBackward whether or not the selection is backwards.
   */
  deleteWord(isBackward: boolean): void {
    if (this.isCollapsed()) {
      const anchor = this.anchor;
      const anchorNode: TextNode | ElementNode | null = anchor.getNode();
      if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
        return;
      }
      $extendSelectionForDeletion(this, isBackward, 'word');
    }
    if (this.isCollapsed()) {
      // If the selection was already collapsed at the lineboundary,
      // use the deleteCharacter operation to handle all of the logic associated
      // with navigating through the parent element
      this.deleteCharacter(isBackward);
    } else {
      this.removeText();
    }
  }

  /**
   * Returns whether the Selection is "backwards", meaning the focus
   * logically precedes the anchor in the EditorState.
   * @returns true if the Selection is backwards, false otherwise.
   */
  isBackward(): boolean {
    const cached = this._cachedIsBackward;
    if (cached !== null) {
      return cached;
    }
    const isBackward = this.focus.isBefore(this.anchor);
    if (!isCurrentlyReadOnlyMode()) {
      this._cachedIsBackward = isBackward;
    }
    return isBackward;
  }

  getStartEndPoints(): [PointType, PointType] {
    return [this.anchor, this.focus];
  }
}

export function $isNodeSelection(x: unknown): x is NodeSelection {
  return x instanceof NodeSelection;
}

/**
 * Applies a pure bitmask transform to every formattable node in the selection
 * in a single traversal, splitting the first and last TextNodes as necessary
 * so that only the selected text is affected. Each node receives exactly one
 * `setFormat(applyFormat(getFormat()))` (ElementNodes use their textFormat).
 *
 * @param selection - the selection whose nodes should be formatted.
 * @param applyFormat - maps a node's current 32-bit format to its new format.
 */
function $updateTextFormat(
  selection: RangeSelection | NodeSelection,
  applyFormat: (format: number) => number,
): void {
  if ($isNodeSelection(selection)) {
    for (const node of selection.getNodes()) {
      if ($isInlineFormattable(node)) {
        node.setFormat(applyFormat(node.getFormat()));
      }
    }
    return;
  }

  if (selection.isCollapsed()) {
    selection.setFormat(applyFormat(selection.format));
    // When changing format, we should stop composition
    $setCompositionKey(null);
    return;
  }

  const selectedTextNodes: TextNode[] = [];
  for (const node of selection.getNodes()) {
    if ($isTextNode(node)) {
      selectedTextNodes.push(node);
    } else if ($isElementNode(node)) {
      node.setTextFormat(applyFormat(node.getTextFormat()));
    } else if ($isInlineFormattable(node)) {
      node.setFormat(applyFormat(node.getFormat()));
    }
  }

  const selectedTextNodesLength = selectedTextNodes.length;
  if (selectedTextNodesLength === 0) {
    selection.setFormat(applyFormat(selection.format));
    // When changing format, we should stop composition
    $setCompositionKey(null);
    return;
  }

  const anchor = selection.anchor;
  const focus = selection.focus;
  const isBackward = selection.isBackward();
  const startPoint = isBackward ? focus : anchor;
  const endPoint = isBackward ? anchor : focus;

  let firstIndex = 0;
  let firstNode = selectedTextNodes[0];
  let startOffset = startPoint.type === 'element' ? 0 : startPoint.offset;

  // In case selection started at the end of text node use next text node
  if (
    startPoint.type === 'text' &&
    startOffset === firstNode.getTextContentSize()
  ) {
    firstIndex = 1;
    firstNode = selectedTextNodes[1];
    startOffset = 0;
  }

  if (firstNode == null) {
    return;
  }

  const lastIndex = selectedTextNodesLength - 1;
  let lastNode = selectedTextNodes[lastIndex];
  const endOffset =
    endPoint.type === 'text' ? endPoint.offset : lastNode.getTextContentSize();

  // Single node selected
  if (firstNode.is(lastNode)) {
    // No actual text is selected, so do nothing.
    if (startOffset === endOffset) {
      return;
    }
    const newFormat = applyFormat(firstNode.getFormat());
    // The entire node is selected or it is token, so just format it
    if (
      $isTokenOrSegmented(firstNode) ||
      (startOffset === 0 && endOffset === firstNode.getTextContentSize())
    ) {
      firstNode.setFormat(newFormat);
    } else {
      // Node is partially selected, so split it into two nodes
      // and style the selected one.
      const splitNodes = firstNode.splitText(startOffset, endOffset);
      const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
      replacement.setFormat(newFormat);

      // Update selection only if starts/ends on text node
      if (startPoint.type === 'text') {
        startPoint.set(replacement.__key, 0, 'text');
      }
      if (endPoint.type === 'text') {
        endPoint.set(replacement.__key, endOffset - startOffset, 'text');
      }
    }

    selection.format = newFormat;
    return;
  }

  // Multiple nodes selected
  // The entire first node isn't selected, so split it
  if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
    [, firstNode] = firstNode.splitText(startOffset);
    startOffset = 0;
  }
  const firstNextFormat = applyFormat(firstNode.getFormat());
  firstNode.setFormat(firstNextFormat);

  const lastNextFormat = applyFormat(lastNode.getFormat());
  // If the offset is 0, it means no actual characters are selected,
  // so we skip formatting the last node altogether.
  if (endOffset > 0) {
    if (
      endOffset !== lastNode.getTextContentSize() &&
      !$isTokenOrSegmented(lastNode)
    ) {
      [lastNode] = lastNode.splitText(endOffset);
    }
    lastNode.setFormat(lastNextFormat);
  }

  // Process all text nodes in between
  for (let i = firstIndex + 1; i < lastIndex; i++) {
    const textNode = selectedTextNodes[i];
    textNode.setFormat(applyFormat(textNode.getFormat()));
  }

  // Update selection only if starts/ends on text node
  if (startPoint.type === 'text') {
    startPoint.set(firstNode.__key, startOffset, 'text');
  }
  if (endPoint.type === 'text') {
    endPoint.set(lastNode.__key, endOffset, 'text');
  }

  selection.format = firstNextFormat | lastNextFormat;
}

/**
 * Explicitly sets or unsets text formats on the selection. Unlike $formatText
 * which toggles based on the current selection state, this function sets each
 * specified format to the exact boolean value provided. Mutually exclusive
 * formats (subscript/superscript, lowercase/uppercase/capitalize) are
 * reconciled by {@link toggleTextFormatType}, with later entries winning when
 * the requested formats conflict.
 *
 * @param selection - the selection whose nodes should be formatted.
 * @param formats - a partial record mapping TextFormatType to boolean.
 */
export function $setTextFormat(
  selection: RangeSelection | NodeSelection,
  formats: Partial<Record<TextFormatType, boolean>>,
): void {
  const entries: [TextFormatType, boolean][] = [];
  for (const [type, value] of Object.entries(formats) as [
    TextFormatType,
    boolean | undefined,
  ][]) {
    if (typeof value === 'boolean') {
      entries.push([type, value]);
    }
  }
  if (entries.length === 0) {
    return;
  }
  $updateTextFormat(selection, format => {
    for (const [type, value] of entries) {
      format = toggleTextFormatType(
        format,
        type,
        value ? TEXT_TYPE_TO_FORMAT[type] : 0,
      );
    }
    return format;
  });
}

/**
 * Applies the provided format to TextNodes and inline formattable nodes
 * (e.g. DecoratorTextNode) in the selection, splitting or merging TextNodes
 * as necessary and aligning all formattable nodes to the same target format.
 *
 * For RangeSelection the toggle direction is determined by the selection's
 * computed format (intersection of all text nodes) when no explicit alignment
 * is given. For NodeSelection each node is toggled independently when no
 * explicit alignment is given, since there is no TextNode to use as an
 * alignment reference.
 *
 * @param selection - the selection whose nodes should be formatted.
 * @param formatType - the format type to apply.
 * @param alignWithFormat - optional 32-bit bitmask to align with.
 */
export function $formatText(
  selection: RangeSelection | NodeSelection,
  formatType: TextFormatType,
  alignWithFormat: number | null = null,
): void {
  const effectiveAlign =
    alignWithFormat === null && $isRangeSelection(selection)
      ? toggleTextFormatType(selection.format, formatType, null)
      : alignWithFormat;
  $updateTextFormat(selection, format =>
    toggleTextFormatType(format, formatType, effectiveAlign),
  );
}

function getCharacterOffset(point: PointType): number {
  const offset = point.offset;
  if (point.type === 'text') {
    return offset;
  }

  const parent = point.getNode();
  return offset === parent.getChildrenSize()
    ? parent.getTextContent().length
    : 0;
}

export function $getCharacterOffsets(
  selection: BaseSelection,
): [number, number] {
  const anchorAndFocus = selection.getStartEndPoints();
  if (anchorAndFocus === null) {
    return [0, 0];
  }
  const [anchor, focus] = anchorAndFocus;
  if (
    anchor.type === 'element' &&
    focus.type === 'element' &&
    anchor.key === focus.key &&
    anchor.offset === focus.offset
  ) {
    return [0, 0];
  }
  return [getCharacterOffset(anchor), getCharacterOffset(focus)];
}

function $collapseAtStart(
  selection: RangeSelection,
  startNode: LexicalNode,
): boolean {
  for (
    let node: null | LexicalNode = startNode;
    node;
    node = node.getParent()
  ) {
    if ($isElementNode(node)) {
      if (node.collapseAtStart(selection)) {
        return true;
      }
      if ($isRootOrShadowRoot(node)) {
        break;
      }
    }
    if (node.getPreviousSibling()) {
      break;
    }
  }
  return false;
}

function $swapPoints(selection: RangeSelection): void {
  const focus = selection.focus;
  const anchor = selection.anchor;
  const anchorKey = anchor.key;
  const anchorOffset = anchor.offset;
  const anchorType = anchor.type;

  anchor.set(focus.key, focus.offset, focus.type, true);
  focus.set(anchorKey, anchorOffset, anchorType, true);
}

function moveNativeSelection(
  domSelection: Selection,
  alter: 'move' | 'extend',
  direction: 'backward' | 'forward' | 'left' | 'right',
  granularity: 'character' | 'word' | 'lineboundary',
): void {
  // Selection.modify() method applies a change to the current selection or cursor position,
  // but is still non-standard in some browsers.
  domSelection.modify(alter, direction, granularity);
}

/**
 * Validate that the selection respects `root` (the nearest root or shadow
 * root): if any selected node lies outside of it, shrink the selection to the
 * valid edge in the given direction. The valid node check is a safeguard
 * against an invalid selection, for which getNodes() returns an empty array.
 *
 * @returns true if the selection was shrunk
 */
function $shrinkSelectionToRoot(
  selection: RangeSelection,
  isBackward: boolean,
  root: LexicalNode,
): boolean {
  const nodes = selection.getNodes();
  const validNodes = nodes.filter(node => $hasAncestor(node, root));
  if (validNodes.length === 0 || validNodes.length === nodes.length) {
    return false;
  }
  const edgeNode = isBackward
    ? validNodes[0]
    : validNodes[validNodes.length - 1];
  const edgeElement = $isElementNode(edgeNode)
    ? edgeNode
    : edgeNode.getParentOrThrow();
  if (isBackward) {
    edgeElement.selectStart();
  } else {
    edgeElement.selectEnd();
  }
  return true;
}

/**
 * Extend a collapsed selection by one unit (`character`, `word` or
 * `lineboundary`) in the deletion direction without ever creating a
 * non-collapsed DOM selection.
 *
 * On Linux/X11, browsers propagate any non-collapsed DOM selection made
 * during a user gesture to the PRIMARY selection (the middle-click paste
 * buffer), so a deletion must never pass through a transient non-collapsed
 * DOM selection or every Backspace/Delete overwrites the user's paste
 * buffer (https://github.com/facebook/lexical/issues/8766). A collapsed
 * caret never takes PRIMARY ownership, so the DOM caret is moved with the
 * native `modify('move')` to measure where the engine places the unit
 * boundary, and the `[original .. landed]` range is constructed in the
 * model only. `applyDOMRange` reads just the range's boundary points (it
 * never touches the DOM selection), giving the same point resolution,
 * decorator pre/post handling, shadow-root shrink validation and
 * anchor/focus orientation as a native selection extension would, while
 * the DOM selection is only ever collapsed.
 *
 * When the measurement is not possible — no DOM selection or no
 * `Selection.modify` (headless environments can polyfill it), or an
 * unresolvable anchor — the selection is left collapsed, so the deletion
 * becomes a no-op for that keystroke.
 */
function $extendSelectionForDeletion(
  selection: RangeSelection,
  isBackward: boolean,
  granularity: 'character' | 'word' | 'lineboundary',
): void {
  // Decorator/block handling, resolved in the model exactly as modify() does.
  if (
    $modifySelectionAroundDecoratorsAndBlocks(
      selection,
      'extend',
      isBackward,
      granularity,
    )
  ) {
    return;
  }
  const editor = getActiveEditor();
  const domSelection = getDOMSelection(getWindow(editor));
  if (!domSelection || typeof domSelection.modify !== 'function') {
    return;
  }
  const blockCursorElement = editor._blockCursorElement;
  const rootElement = editor._rootElement;
  const anchor = selection.anchor;
  const focusNode = selection.focus.getNode();
  // A block cursor element left in the DOM would corrupt element offsets.
  if (
    rootElement !== null &&
    blockCursorElement !== null &&
    $isElementNode(focusNode) &&
    !focusNode.isInline() &&
    !focusNode.canBeEmpty()
  ) {
    removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
  }
  const $resolvePointDOM = (point: PointType): HTMLElement | Text | null => {
    const pointNode = point.getNode();
    const keyedDOM = editor.getElementByKey(point.key);
    return keyedDOM !== null && point.type === 'text' && $isTextNode(pointNode)
      ? $getDOMTextNode(pointNode, keyedDOM, editor)
      : keyedDOM;
  };
  // Resolve the model anchor to a DOM position (one end of the final range).
  const anchorNode = anchor.getNode();
  const anchorDOM = $resolvePointDOM(anchor);
  if (anchorDOM === null) {
    return;
  }
  const anchorOffset = anchor.offset;
  // Measure from the FOCUS: the decorator/block pre-pass above may have
  // hopped the model focus past an inline decorator and returned false — the
  // shape where native caret movement is unreliable (it can refuse to move a
  // caret adjacent to contenteditable=false content). modify() runs the
  // native extend from the extent (the hopped focus); do the same here. For
  // an untouched collapsed selection the focus is the anchor.
  const wasCollapsed = selection.isCollapsed();
  const focus = selection.focus;
  const focusDOM = wasCollapsed ? anchorDOM : $resolvePointDOM(focus);
  if (focusDOM === null) {
    return;
  }
  const focusOffset = focus.offset;
  // Sync a COLLAPSED DOM caret at the measurement origin (base === extent),
  // then move it by one unit to measure the engine's boundary. Neither
  // operation touches PRIMARY.
  setDOMSelectionBaseAndExtent(
    domSelection,
    focusDOM,
    focusOffset,
    focusDOM,
    focusOffset,
  );
  moveNativeSelection(
    domSelection,
    'move',
    isBackward ? 'backward' : 'forward',
    granularity,
  );
  if (domSelection.rangeCount === 0) {
    return;
  }
  // Inside a DOM shadow root getRangeAt(0) is retargeted to the host; read the
  // composed StaticRange (real nodes) where available. After a 'move' the DOM
  // selection is collapsed, so start === end === the landed caret.
  const landedRange =
    getComposedStaticRange(domSelection, rootElement) ||
    domSelection.getRangeAt(0);
  const landedContainer = landedRange.startContainer;
  const landedOffset = landedRange.startOffset;
  // Native 'move' cannot cross inline-grid/flex span boundaries (#7301).
  // When at the deletion-side edge of an unmergeable TextNode, extend into
  // the adjacent sibling directly instead of relying on the native result.
  if (
    wasCollapsed &&
    granularity === 'character' &&
    anchor.type === 'text' &&
    $isTextNode(anchorNode) &&
    anchorNode.isUnmergeable()
  ) {
    const boundaryOffset = isBackward ? 0 : anchorNode.getTextContentSize();
    if (anchorOffset === boundaryOffset) {
      const sibling = $getSiblingCaret(
        anchorNode,
        isBackward ? 'previous' : 'next',
      ).getNodeAtCaret();
      if ($isTextNode(sibling)) {
        const sibOffset = isBackward ? sibling.getTextContentSize() - 1 : 1;
        selection.focus.set(sibling.__key, sibOffset, 'text');
        selection.dirty = true;
        return;
      }
    }
  }
  // In-node character deletion (the common case): keep the focus in the
  // anchor's own text node. A native 'move' reports a caret that lands on a
  // text-node boundary as a position in the *adjacent* node, and that
  // representation changes how a later insertion (e.g. at a format or
  // keyword boundary) resolves. When the move lands inside the anchor node
  // its offset is used directly; landing on the node's edge is clamped to
  // that edge. Word/line deletions legitimately span nodes, so they use the
  // general path below.
  if (wasCollapsed && granularity === 'character' && anchor.type === 'text') {
    // The deletion-side edge of the anchor's text.
    const edgeOffset = isBackward ? 0 : anchorNode.getTextContentSize();
    const clampedOffset =
      landedContainer === anchorDOM
        ? landedOffset
        : anchorOffset !== edgeOffset
          ? edgeOffset
          : -1;
    if (clampedOffset >= 0) {
      if (clampedOffset !== anchorOffset) {
        selection.focus.set(anchor.key, clampedOffset, 'text');
        selection.dirty = true;
      }
      return;
    }
  }
  // General path: reconstruct, in document order, the [original .. landed]
  // range a native extend would have produced. applyDOMRange only reads these
  // four boundary properties into the model, so a plain StaticRange-shaped
  // object is sufficient (and avoids a StaticRange constructor dependency).
  const [startContainer, startOffset, endContainer, endOffset] = isBackward
    ? [landedContainer, landedOffset, anchorDOM, anchorOffset]
    : [anchorDOM, anchorOffset, landedContainer, landedOffset];
  const root = $isRootNode(anchorNode)
    ? anchorNode
    : $getNearestRootOrShadowRoot(anchorNode);
  selection.applyDOMRange({
    collapsed: false,
    endContainer,
    endOffset,
    startContainer,
    startOffset,
  } as unknown as StaticRange);
  selection.dirty = true;
  if (!$shrinkSelectionToRoot(selection, isBackward, root) && isBackward) {
    // applyDOMRange set anchor = range start (the landed point); the deletion
    // anchor must stay at the original caret, so restore that orientation.
    $swapPoints(selection);
  }
  if (granularity === 'lineboundary') {
    $modifySelectionAroundDecoratorsAndBlocks(
      selection,
      'extend',
      isBackward,
      granularity,
      'decorators',
    );
  }
}

/**
 * Called by `RangeSelection.deleteCharacter` to determine if
 * `$extendSelectionForDeletion` extended the selection further
 * than a user would expect for that operation.
 *
 * A short(?) JavaScript string vs. Unicode primer:
 *
 * Strings in JavaScript use an UTF-16 encoding, and the offsets into a
 * string are based on those UTF-16 *code units*. This is basically a
 * historical mistake (though logical at that time, decades ago), but
 * can never really be fixed for compatibility reasons.
 *
 * In Unicode, a *code point* is the combination of one or more *code units*.
 * and the range of a *code point* can fit into 21 bits.
 *
 * Every valid *code point* can be represented with one or two
 * *UTF-16 code units*. One unit is used when the code point is in the
 * Basic Multilingual Plane (BMP) and is `< 0xFFFF`. Anything outside
 * of that plane is encoded with a *surrogate pair* of *code units* and
 * `/[\uD800-\uDBFF][\uDC00-\uDFFF]/` is a regex that you could use to
 * find any valid *surrogate pair*. As far as Unicode is concerned, these
 * pairs represent a single *code point*, but in JavaScript, these pairs
 * have a length of 2 (`pair.charCodeAt(n)` is really returning a
 * UTF-16 *code unit*, not a unicode *code point*). It is possible to request
 * a *code point* with `pair.codePointAt(0)` and enumerate code points
 * in a string with `[...string]` but the offsets we work with, and
 * the string length, are based in *code units* so that functionality
 * is unfortunately not very useful here.
 *
 * This only gets us as far as *code points*. We now know that we must
 * consider that each *code point* can have a length of 1 or 2 in JavaScript
 * string distance. It gets even trickier because the visual representation
 * of a character is a *grapheme* (approximately what the user thinks of
 * as a character). A *grapheme* is one or more *code points*, and can
 * essentially be arbitrarily long, as there are many ways to combine
 * them.
 *
 * The native caret measurement has already extended our selection by one
 * *grapheme* in the direction we want to delete. Sounds great, it's done
 * a lot of awfully tricky work for us because this functionality has only
 * recently become available in JavaScript via `Intl.Segmenter`. The
 * problem is that in many cases the expected behavior of backspace or
 * delete is *not always to delete a whole grapheme*. In some languages
 * it's always expected that backspace ought to delete one code point, not the
 * whole grapheme. In other situations such as emoji that use variation
 * selectors you *do* want to delete the whole *grapheme*.
 *
 * In a few situations the behavior is even application dependent, such as
 * with latin languages where you have multiple ways to represent the same
 * character visually (e.g. a letter with an accent in one code point, or a
 * letter followed by a combining mark in a second code point); some apps will
 * delete the whole grapheme and others will delete only the combining mark,
 * probably based on whether they perform some sort of *normalization* on their
 * input to ensure that only one form is used when two sequences of code points
 * can represent the same visual character. Lexical currently chooses not
 * to perform any normalization so this type of combining marks will be
 * deleted as a *code point* without deleting the whole *grapheme*.
 *
 * See also:
 * https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-2/#G25564
 * https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-3/#G30602
 * https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-3/#G49537
 * https://mathiasbynens.be/notes/javascript-unicode
 */
function $updateCaretSelectionForUnicodeCharacter(
  selection: RangeSelection,
  isBackward: boolean,
): void {
  const anchor = selection.anchor;
  const focus = selection.focus;
  const anchorNode = anchor.getNode();
  const focusNode = focus.getNode();

  if (
    anchorNode === focusNode &&
    anchor.type === 'text' &&
    focus.type === 'text'
  ) {
    // Handling of multibyte characters
    const anchorOffset = anchor.offset;
    const focusOffset = focus.offset;
    const isBefore = anchorOffset < focusOffset;
    const startOffset = isBefore ? anchorOffset : focusOffset;
    const endOffset = isBefore ? focusOffset : anchorOffset;
    const characterOffset = endOffset - 1;

    if (startOffset !== characterOffset) {
      const text = anchorNode.getTextContent().slice(startOffset, endOffset);
      if (shouldDeleteExactlyOneCodeUnit(text)) {
        if (isBackward) {
          focus.set(focus.key, characterOffset, focus.type);
        } else {
          anchor.set(anchor.key, characterOffset, anchor.type);
        }
      }
    }
  }
}

function shouldDeleteExactlyOneCodeUnit(text: string) {
  if (__DEV__) {
    invariant(
      text.length > 1,
      'shouldDeleteExactlyOneCodeUnit: expecting to be called only with sequences of two or more code units',
    );
  }
  return !(doesContainSurrogatePair(text) || doesContainEmoji(text));
}

/**
 * Given the wall of text in $updateCaretSelectionForUnicodeCharacter, you'd
 * think that the solution might be complex, but the only currently known
 * cases given the above constraints where we want to delete a whole grapheme
 * are when emoji is involved. Since ES6 we can use unicode character classes
 * in regexp which makes this simple.
 *
 * It may make sense to add to this heuristic in the future if other
 * edge cases are discovered, which is why detailed notes remain.
 *
 * This is implemented with runtime feature detection and will always
 * return false on pre-2020 platforms that do not have unicode character
 * class support.
 */
const doesContainEmoji: (text: string) => boolean = (() => {
  try {
    const re = new RegExp('\\p{Emoji}', 'u');
    const test = re.test.bind(re);
    // Sanity check a few emoji to make sure the regexp was parsed
    // and works correctly. Any one of these should be sufficient,
    // but they're cheap and it only runs once.
    if (
      // Emoji in the BMP (heart) with variation selector
      test('\u2764\ufe0f') &&
      // Emoji in the BMP (#) with variation selector
      test('#\ufe0f\u20e3') &&
      // Emoji outside the BMP (thumbs up) that is encoded with a surrogate pair
      test('\ud83d\udc4d')
    ) {
      return test;
    }
  } catch (_e) {
    // SyntaxError
  }
  // fallback, surrogate pair already checked
  return () => false;
})();

function $removeSegment(
  node: TextNode,
  isBackward: boolean,
  offset: number,
): void {
  const textNode = node;
  const textContent = textNode.getTextContent();
  const split = textContent.split(/(?=\s)/g);
  const splitLength = split.length;
  let segmentOffset = 0;
  let restoreOffset: number | undefined = 0;

  for (let i = 0; i < splitLength; i++) {
    const text = split[i];
    const isLast = i === splitLength - 1;
    restoreOffset = segmentOffset;
    segmentOffset += text.length;

    if (
      (isBackward && segmentOffset === offset) ||
      segmentOffset > offset ||
      isLast
    ) {
      split.splice(i, 1);
      if (isLast) {
        restoreOffset = undefined;
      }
      break;
    }
  }
  const nextTextContent = split.join('').trim();

  if (nextTextContent === '') {
    textNode.remove();
  } else {
    textNode.setTextContent(nextTextContent);
    textNode.select(restoreOffset, restoreOffset);
  }
}

function shouldResolveAncestor(
  resolvedElement: ElementNode,
  resolvedOffset: number,
  lastPoint: null | PointType,
): boolean {
  const parent = resolvedElement.getParent();
  return (
    lastPoint === null ||
    parent === null ||
    !parent.canBeEmpty() ||
    parent !== lastPoint.getNode()
  );
}

function $internalResolveSelectionPoint(
  dom: Node,
  offset: number,
  lastPoint: null | PointType,
  editor: LexicalEditor,
): null | [point: PointType, dirty: boolean] {
  let resolvedOffset = offset;
  let resolvedNode: TextNode | LexicalNode | null;
  // True when the DOM position is not directly representable in the
  // Lexical tree (e.g. the caret landed inside a void/empty element
  // such as <col> or in another unmanaged subtree) and the resolution
  // had to walk up to a Lexical ancestor. The caller marks the
  // resulting selection dirty so the reconciler writes a valid DOM
  // caret back instead of leaving the user's cursor "stuck" inside
  // unmanaged DOM.
  let dirty = false;
  // If we have selection on an element, we will
  // need to figure out (using the offset) what text
  // node should be selected.

  if (isHTMLElement(dom)) {
    // Resolve element to a ElementNode, or TextNode, or null
    let moveSelectionToEnd = false;
    // Given we're moving selection to another node, selection is
    // definitely dirty.
    // We use the anchor to find which child node to select
    const childNodes = dom.childNodes;
    const childNodesLength = childNodes.length;
    const blockCursorElement = editor._blockCursorElement;
    // If the anchor is the same as length, then this means we
    // need to select the very last text node.
    if (resolvedOffset === childNodesLength && childNodesLength > 0) {
      moveSelectionToEnd = true;
      resolvedOffset = childNodesLength - 1;
    }
    if (
      getNodeKeyFromDOMNode(dom, editor) === undefined &&
      !isDOMCapturingSelection(dom, editor)
    ) {
      // The DOM caret is sitting on a node that has no Lexical key
      // (e.g. <col> inside an unmanaged <colgroup>, or any unmanaged
      // scaffolding around a DOMSlot — wrap elements, contenteditable=false
      // labels, badges, etc.). Resolution will walk up to find a Lexical
      // ancestor below, so the resulting Lexical position will not
      // correspond to where the DOM caret currently is. Mark the
      // selection dirty so the reconciler writes a valid DOM caret back
      // at the resolved Lexical position.
      //
      // Exclusions split across the two guard clauses:
      //  - The first clause (`key !== undefined`) covers any DOM node
      //    with a `__lexicalKey_*` attribute — Lexical-managed elements
      //    and the editor root (stashed in `resetEditor`).
      //  - `isDOMCapturingSelection` covers DecoratorNode subtrees (which
      //    own their own DOM) and subtrees marked via
      //    `setDOMUnmanaged(dom, {captureSelection: true})` —
      //    extension-owned widgets that keep a native caret.
      //
      // Void elements that ARE Lexical nodes (LineBreakNode <br>,
      // empty decorator containers, etc.) have keys, so this check
      // leaves their existing resolution-to-parent behavior alone.
      dirty = true;
    }
    let childDOM = childNodes[resolvedOffset];
    let hasBlockCursor = false;
    if (childDOM === blockCursorElement) {
      childDOM = childNodes[resolvedOffset + 1];
      hasBlockCursor = true;
    } else if (blockCursorElement !== null) {
      const blockCursorElementParent = blockCursorElement.parentNode;
      if (dom === blockCursorElementParent) {
        const blockCursorOffset = Array.prototype.indexOf.call(
          blockCursorElementParent.children,
          blockCursorElement,
        );
        if (offset > blockCursorOffset) {
          resolvedOffset--;
        }
      }
    }
    resolvedNode = $getNodeFromDOM(childDOM);

    if ($isTextNode(resolvedNode)) {
      resolvedOffset = $getTextNodeOffset(
        resolvedNode,
        moveSelectionToEnd ? 'next' : 'previous',
      );
    } else {
      let resolvedElement = $getNodeFromDOM(dom);
      // Ensure resolvedElement is actually a element.
      if (resolvedElement === null) {
        return null;
      }
      if ($isElementNode(resolvedElement)) {
        const elementDOM = editor.getElementByKey(resolvedElement.getKey());
        invariant(
          elementDOM !== null,
          '$internalResolveSelectionPoint: node in DOM but not keyToDOMMap',
        );
        const slot = $getDOMSlot(resolvedElement, elementDOM, editor);
        [resolvedElement, resolvedOffset] = slot.resolveChildIndex(
          resolvedElement,
          elementDOM,
          dom,
          offset,
        );
        // This is just a typescript workaround, it is true but lost due to mutability
        invariant(
          $isElementNode(resolvedElement),
          '$internalResolveSelectionPoint: resolvedElement is not an ElementNode',
        );
        if (
          moveSelectionToEnd &&
          resolvedOffset >= resolvedElement.getChildrenSize()
        ) {
          resolvedOffset = Math.max(0, resolvedElement.getChildrenSize() - 1);
        }
        let child = resolvedElement.getChildAtIndex(resolvedOffset);
        if (
          $isElementNode(child) &&
          shouldResolveAncestor(child, resolvedOffset, lastPoint)
        ) {
          const descendant = moveSelectionToEnd
            ? child.getLastDescendant()
            : child.getFirstDescendant();
          if (descendant === null) {
            resolvedElement = child;
          } else {
            child = descendant;
            resolvedElement = $isElementNode(child)
              ? child
              : child.getParentOrThrow();
          }
          resolvedOffset = 0;
        }
        if ($isTextNode(child)) {
          resolvedNode = child;
          resolvedElement = null;
          resolvedOffset = $getTextNodeOffset(
            child,
            moveSelectionToEnd ? 'next' : 'previous',
          );
        } else if (
          child !== resolvedElement &&
          moveSelectionToEnd &&
          !hasBlockCursor
        ) {
          invariant($isElementNode(resolvedElement), 'invariant');
          resolvedOffset = Math.min(
            resolvedElement.getChildrenSize(),
            resolvedOffset + 1,
          );
        }
      } else {
        // A slot value is parentless — it links up to its host via
        // `__slotHost` and behaves like a shadow root. Anchor the caret
        // adjacent to the host (a normal child of its parent), since the slot
        // value itself has no parent to anchor in. Non-slotted leaves anchor
        // in their own parent as before.
        const slotHost = $getSlotHost(resolvedElement);
        const anchorNode = slotHost !== null ? slotHost : resolvedElement;
        const index = anchorNode.getIndexWithinParent();
        // For wrap patterns (slot exposes an inner content element via
        // `withElement`) defer to `slot.resolveLeafPosition` so the
        // wrap's structure determines "before vs after". For bare leaf
        // DOM we preserve the historical rule: only a DecoratorNode at
        // DOM offset 0 resolves to "before"; everything else (including
        // bare LineBreakNode) resolves to "after".
        const elementDOM = editor.getElementByKey(resolvedElement.getKey());
        let position: 'before' | 'after' = 'after';
        if (elementDOM !== null && $getNodeFromDOM(dom) === resolvedElement) {
          const slot = $getDOMSlot(resolvedElement, elementDOM, editor);
          if (slot.element !== elementDOM) {
            position = slot.resolveLeafPosition(elementDOM, dom, offset);
          } else if (offset === 0 && $isDecoratorNode(resolvedElement)) {
            position = 'before';
          }
        }
        resolvedOffset = position === 'before' ? index : index + 1;
        resolvedElement = anchorNode.getParentOrThrow();
      }
      if ($isElementNode(resolvedElement)) {
        return [
          $createPoint(resolvedElement.__key, resolvedOffset, 'element'),
          dirty,
        ];
      }
    }
  } else {
    // TextNode or null
    resolvedNode = $getNodeFromDOM(dom);
  }
  if (!$isTextNode(resolvedNode)) {
    return null;
  }
  return [
    $createPoint(
      resolvedNode.__key,
      $getTextNodeOffset(resolvedNode, resolvedOffset, 'clamp'),
      'text',
    ),
    dirty,
  ];
}

function resolveSelectionPointOnBoundary(
  point: TextPointType,
  isBackward: boolean,
  isCollapsed: boolean,
): void {
  const offset = point.offset;
  const node = point.getNode();

  if (offset === 0) {
    const prevSibling = node.getPreviousSibling();
    const parent = node.getParent();

    if (!isBackward) {
      if (
        $isElementNode(prevSibling) &&
        !isCollapsed &&
        prevSibling.isInline()
      ) {
        point.set(prevSibling.__key, prevSibling.getChildrenSize(), 'element');
      } else if ($isTextNode(prevSibling) && !node.isUnmergeable()) {
        point.set(
          prevSibling.__key,
          prevSibling.getTextContent().length,
          'text',
        );
      }
    } else if (
      (isCollapsed || !isBackward) &&
      prevSibling === null &&
      $isElementNode(parent) &&
      parent.isInline()
    ) {
      const parentSibling = parent.getPreviousSibling();
      if ($isTextNode(parentSibling)) {
        point.set(
          parentSibling.__key,
          parentSibling.getTextContent().length,
          'text',
        );
      }
    }
  } else if (offset === node.getTextContent().length) {
    const nextSibling = node.getNextSibling();
    const parent = node.getParent();

    if (isBackward && $isElementNode(nextSibling) && nextSibling.isInline()) {
      point.set(nextSibling.__key, 0, 'element');
    } else if (
      (isCollapsed || isBackward) &&
      nextSibling === null &&
      $isElementNode(parent) &&
      parent.isInline() &&
      !parent.canInsertTextAfter() &&
      parent.getTextContentSize() > 1
    ) {
      const parentSibling = parent.getNextSibling();
      if ($isTextNode(parentSibling)) {
        point.set(parentSibling.__key, 0, 'text');
      }
    }
  }
}

function $normalizeSelectionPointsForBoundaries(
  anchor: PointType,
  focus: PointType,
  lastSelection: null | BaseSelection,
): void {
  if (anchor.type === 'text' && focus.type === 'text') {
    const isBackward = anchor.isBefore(focus);
    const isCollapsed = anchor.is(focus);

    // Attempt to normalize the offset to the previous sibling if we're at the
    // start of a text node and the sibling is a text node or inline element.
    resolveSelectionPointOnBoundary(anchor, isBackward, isCollapsed);
    resolveSelectionPointOnBoundary(focus, !isBackward, isCollapsed);

    if (isCollapsed) {
      focus.set(anchor.key, anchor.offset, anchor.type);
    }
  }
}

// @experimental named-slots. The innermost slot-root ancestor of a point
// (a node whose up-pointer is __slotHost, not __parent), or null when the
// point is not inside any slot. Walking via getParent() naturally stops at a
// slot root because a slotted node's __parent is null. Non-slot trees have
// __slotHost === null everywhere, so this always returns null there.
function $getPointSlotFrame(point: PointType): LexicalNode | null {
  const node = $getNodeByKey(point.key);
  return node === null ? null : $getSlotFrame(node);
}

// @experimental named-slots. Content order (slots-first) of a slot-straddling
// pair, computed from the model alone. The caret comparison ($comparePoint…)
// throws across a slot boundary (a slotted node has no common ancestor through
// __parent), so each side that sits in a slot is reduced to its host — a
// main-tree node — and the hosts are compared with the linked-list isBefore.
// A slotted point sorts at its host's leading edge (slots-first). Only called
// for a confirmed straddle (the frames differ).
function $slotStraddleFocusAfterAnchor(
  anchorPoint: PointType,
  focusPoint: PointType,
  anchorFrame: LexicalNode | null,
  focusFrame: LexicalNode | null,
): boolean {
  if (anchorFrame !== null && focusFrame !== null) {
    const anchorHost = $getSlotHost(anchorFrame);
    const focusHost = $getSlotHost(focusFrame);
    if (anchorHost !== null && anchorHost.is(focusHost)) {
      // Two slots of the same host: slot-map iteration is insertion order,
      // which is the order the reconciler renders them (content order).
      for (const slotKey of $getSlotMap(anchorHost).values()) {
        if (slotKey === anchorFrame.getKey()) {
          return true;
        }
        if (slotKey === focusFrame.getKey()) {
          return false;
        }
      }
      return true;
    }
    return anchorHost !== null && focusHost !== null
      ? anchorHost.isBefore(focusHost)
      : true;
  }
  if (anchorFrame !== null) {
    const anchorHost = $getSlotHost(anchorFrame);
    const focusNode = $getNodeByKey(focusPoint.key);
    if (anchorHost === null || focusNode === null) {
      return true;
    }
    // Focus within the host's regular children sits after the slot content.
    if (anchorHost.is(focusNode) || anchorHost.isParentOf(focusNode)) {
      return true;
    }
    return anchorHost.isBefore(focusNode);
  }
  const focusHost = $getSlotHost(focusFrame as LexicalNode);
  const anchorNode = $getNodeByKey(anchorPoint.key);
  if (focusHost === null || anchorNode === null) {
    return false;
  }
  // Anchor within the host's regular children sits after the slot (focus).
  if (focusHost.is(anchorNode) || focusHost.isParentOf(anchorNode)) {
    return false;
  }
  return anchorNode.isBefore(focusHost);
}

// @experimental named-slots. Slots are shadow-root-isolated: a RangeSelection
// must not straddle a slot boundary. When the anchor and focus are in
// different frames, clamp the focus into the anchor's frame (anchor-frame
// rule), keeping keyboard/mouse/programmatic results consistent. The direction
// is resolved lazily (resolveFocusAfterAnchor) only on an actual straddle,
// because the DOM-read and programmatic callers determine it differently (DOM
// order vs the model comparator) and the model comparator is invalid until a
// straddle is confirmed. Returns true when it mutated the focus point. No-op
// (returns false) when both points share a frame — including the all-null case
// in non-slot trees, so behavior there is unchanged.
function $clampSelectionPointsToSlotFrame(
  anchorPoint: PointType,
  focusPoint: PointType,
  resolveFocusAfterAnchor: (
    anchorFrame: LexicalNode | null,
    focusFrame: LexicalNode | null,
  ) => boolean,
): boolean {
  const anchorFrame = $getPointSlotFrame(anchorPoint);
  const focusFrame = $getPointSlotFrame(focusPoint);
  if (
    anchorFrame === focusFrame ||
    (anchorFrame !== null && focusFrame !== null && anchorFrame.is(focusFrame))
  ) {
    return false;
  }
  const focusAfterAnchor = resolveFocusAfterAnchor(anchorFrame, focusFrame);
  if (anchorFrame !== null) {
    // Anchor sits inside a slot: pull the focus to that slot's edge (the far
    // edge in the drag direction), leaving a contained partial selection.
    // Slot→slot drags hit this same branch — no host escalation.
    if ($isElementNode(anchorFrame)) {
      focusPoint.set(
        anchorFrame.getKey(),
        focusAfterAnchor ? anchorFrame.getChildrenSize() : 0,
        'element',
      );
    } else {
      focusPoint.set(
        anchorFrame.getKey(),
        focusAfterAnchor ? anchorFrame.getTextContentSize() : 0,
        'text',
      );
    }
    return true;
  }
  // Anchor sits outside, focus inside a slot: push the focus past the host
  // that owns the slot so the host is wholly contained.
  const host = $getSlotHost(focusFrame as LexicalNode);
  if (host === null) {
    return false;
  }
  const hostParent = host.getParent();
  if (hostParent === null) {
    return false;
  }
  const hostIndex = host.getIndexWithinParent();
  focusPoint.set(
    hostParent.getKey(),
    focusAfterAnchor ? hostIndex + 1 : hostIndex,
    'element',
  );
  return true;
}

/**
 * Programmatic counterpart of the DOM-read clamp: applied when a
 * RangeSelection is committed via $setSelection so an API-built selection
 * cannot straddle a slot boundary either. Direction comes from the model
 * comparator (slots-first content order), not the caret system — a
 * straddling pair has no common ancestor through __parent, so the caret
 * comparison would throw (that integration is the deferred caret-slot work),
 * and not from the DOM either, since $setSelection also runs in headless
 * mode where there is no DOM. Marks the selection dirty when it mutates a
 * point. No-op for non-slot trees (both frames null), evaluated before any
 * direction work, so non-slot and headless callers are unaffected.
 *
 * @experimental named-slots
 * @internal
 */
export function $clampRangeSelectionToSlotFrame(
  selection: RangeSelection,
): boolean {
  const clamped = $clampSelectionPointsToSlotFrame(
    selection.anchor,
    selection.focus,
    (anchorFrame, focusFrame) =>
      $slotStraddleFocusAfterAnchor(
        selection.anchor,
        selection.focus,
        anchorFrame,
        focusFrame,
      ),
  );
  if (clamped) {
    selection.dirty = true;
  }
  return clamped;
}

function $internalResolveSelectionPoints(
  anchorDOM: null | Node,
  anchorOffset: number,
  focusDOM: null | Node,
  focusOffset: number,
  editor: LexicalEditor,
  lastSelection: null | BaseSelection,
): null | [anchor: PointType, focus: PointType, dirty: boolean] {
  if (
    anchorDOM === null ||
    focusDOM === null ||
    !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
  ) {
    return null;
  }
  const resolvedAnchor = $internalResolveSelectionPoint(
    anchorDOM,
    anchorOffset,
    $isRangeSelection(lastSelection) ? lastSelection.anchor : null,
    editor,
  );
  if (resolvedAnchor === null) {
    return null;
  }
  const resolvedFocus = $internalResolveSelectionPoint(
    focusDOM,
    focusOffset,
    $isRangeSelection(lastSelection) ? lastSelection.focus : null,
    editor,
  );
  if (resolvedFocus === null) {
    return null;
  }
  const [resolvedAnchorPoint, anchorDirty] = resolvedAnchor;
  const [resolvedFocusPoint, focusDirty] = resolvedFocus;
  if (__DEV__) {
    $validatePoint('anchor', resolvedAnchorPoint);
    $validatePoint('focus', resolvedFocusPoint);
  }
  if (
    resolvedAnchorPoint.type === 'element' &&
    resolvedFocusPoint.type === 'element'
  ) {
    const anchorNode = $getNodeFromDOM(anchorDOM);
    const focusNode = $getNodeFromDOM(focusDOM);
    // Ensure if we're selecting the content of a decorator that we
    // return null for this point, as it's not in the controlled scope
    // of Lexical.
    if ($isDecoratorNode(anchorNode) && $isDecoratorNode(focusNode)) {
      return null;
    }
  }

  // @experimental named-slots. Clamp a slot-straddling drag into the
  // anchor's frame before normalization cleans up the resulting edge points.
  // The DOM order of the resolved nodes gives the drag direction (slot DOM is
  // slots-first, so DOM order matches content order). Gated on `_slotsUsed`
  // so editors that never slot anything skip the walk, mirroring the
  // commit-time and `$setSelection` clamps.
  const slotClamped =
    editor._slotsUsed &&
    $clampSelectionPointsToSlotFrame(
      resolvedAnchorPoint,
      resolvedFocusPoint,
      () =>
        (anchorDOM.compareDocumentPosition(focusDOM) &
          Node.DOCUMENT_POSITION_FOLLOWING) !==
        0,
    );

  // Handle normalization of selection when it is at the boundaries.
  $normalizeSelectionPointsForBoundaries(
    resolvedAnchorPoint,
    resolvedFocusPoint,
    lastSelection,
  );

  return [
    resolvedAnchorPoint,
    resolvedFocusPoint,
    anchorDirty || focusDirty || slotClamped,
  ];
}

export function $isBlockElementNode(
  node: LexicalNode | null | undefined,
): node is ElementNode {
  return $isElementNode(node) && !node.isInline();
}

// This is used to make a selection when the existing
// selection is null, i.e. forcing selection on the editor
// when it current exists outside the editor.

export function $internalMakeRangeSelection(
  anchorKey: NodeKey,
  anchorOffset: number,
  focusKey: NodeKey,
  focusOffset: number,
  anchorType: 'text' | 'element',
  focusType: 'text' | 'element',
): RangeSelection {
  const editorState = getActiveEditorState();
  const selection = new RangeSelection(
    $createPoint(anchorKey, anchorOffset, anchorType),
    $createPoint(focusKey, focusOffset, focusType),
    0,
    '',
  );
  selection.dirty = true;
  editorState._selection = selection;
  return selection;
}

export function $createRangeSelection(): RangeSelection {
  const anchor = $createPoint('root', 0, 'element');
  const focus = $createPoint('root', 0, 'element');
  return new RangeSelection(anchor, focus, 0, '');
}

export function $createNodeSelection(): NodeSelection {
  return new NodeSelection(new Set());
}

export function $internalCreateSelection(
  editor: LexicalEditor,
  event: UIEvent | Event | null,
): null | BaseSelection {
  const currentEditorState = editor.getEditorState();
  const lastSelection = currentEditorState._selection;
  const domSelection = getDOMSelection(getWindow(editor));

  if ($isRangeSelection(lastSelection) || lastSelection == null) {
    return $internalCreateRangeSelection(
      lastSelection,
      domSelection,
      editor,
      event,
    );
  }
  return lastSelection.clone();
}

export function $createRangeSelectionFromDom(
  domSelection: Selection | null,
  editor: LexicalEditor,
): null | RangeSelection {
  return $internalCreateRangeSelection(null, domSelection, editor, null);
}

export function $internalCreateRangeSelection(
  lastSelection: null | BaseSelection,
  domSelection: Selection | null,
  editor: LexicalEditor,
  event: UIEvent | Event | null,
): null | RangeSelection {
  const windowObj = editor._window;
  if (windowObj === null) {
    return null;
  }
  // When we create a selection, we try to use the previous
  // selection where possible, unless an actual user selection
  // change has occurred. When we do need to create a new selection
  // we validate we can have text nodes for both anchor and focus
  // nodes. If that holds true, we then return that selection
  // as a mutable object that we use for the editor state for this
  // update cycle. If a selection gets changed, and requires a
  // update to native DOM selection, it gets marked as "dirty".
  // If the selection changes, but matches with the existing
  // DOM selection, then we only need to sync it. Otherwise,
  // we generally bail out of doing an update to selection during
  // reconciliation unless there are dirty nodes that need
  // reconciling.

  const windowEvent = event || windowObj.event;
  const eventType = windowEvent ? windowEvent.type : undefined;
  const isSelectionChange = eventType === 'selectionchange';
  const useDOMSelection =
    !getIsProcessingMutations() &&
    (isSelectionChange ||
      eventType === 'beforeinput' ||
      eventType === 'compositionstart' ||
      eventType === 'compositionend' ||
      (eventType === 'click' &&
        windowEvent &&
        (windowEvent as InputEvent).detail === 3) ||
      eventType === 'drop' ||
      eventType === undefined);
  let anchorDOM, focusDOM, anchorOffset, focusOffset;

  if (!$isRangeSelection(lastSelection) || useDOMSelection) {
    if (domSelection === null) {
      return null;
    }
    const points = getDOMSelectionPoints(domSelection, editor._rootElement);
    anchorDOM = points.anchorNode;
    focusDOM = points.focusNode;
    anchorOffset = points.anchorOffset;
    focusOffset = points.focusOffset;
    if (
      (isSelectionChange || eventType === undefined) &&
      $isRangeSelection(lastSelection) &&
      !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
    ) {
      return lastSelection.clone();
    }
  } else {
    return lastSelection.clone();
  }
  // Let's resolve the text nodes from the offsets and DOM nodes we have from
  // native selection.
  const resolvedSelectionPoints = $internalResolveSelectionPoints(
    anchorDOM,
    anchorOffset,
    focusDOM,
    focusOffset,
    editor,
    lastSelection,
  );
  if (resolvedSelectionPoints === null) {
    return null;
  }
  const [resolvedAnchorPoint, resolvedFocusPoint, dirty] =
    resolvedSelectionPoints;
  let format = 0;
  let style = '';
  if ($isRangeSelection(lastSelection)) {
    const lastAnchor = lastSelection.anchor;
    if (resolvedAnchorPoint.key === lastAnchor.key) {
      format = lastSelection.format;
      style = lastSelection.style;
    } else {
      const anchorNode = resolvedAnchorPoint.getNode();
      if ($isTextNode(anchorNode)) {
        format = anchorNode.getFormat();
        style = anchorNode.getStyle();
      } else if ($isElementNode(anchorNode)) {
        format = anchorNode.getTextFormat();
        style = anchorNode.getTextStyle();
      }
    }
  }
  const newSelection = new RangeSelection(
    resolvedAnchorPoint,
    resolvedFocusPoint,
    format,
    style,
  );
  if (dirty) {
    newSelection.dirty = true;
  }
  return newSelection;
}

function $validatePoint(name: 'anchor' | 'focus', point: PointType): void {
  const node = $getNodeByKey(point.key);
  invariant(
    node !== undefined,
    '$validatePoint: %s key %s not found in current editorState',
    name,
    point.key,
  );
  if (point.type === 'text') {
    invariant(
      $isTextNode(node),
      '$validatePoint: %s key %s is not a TextNode',
      name,
      point.key,
    );
    const size = node.getTextContentSize();
    invariant(
      point.offset <= size,
      '$validatePoint: %s point.offset > node.getTextContentSize() (%s > %s)',
      name,
      String(point.offset),
      String(size),
    );
  } else {
    invariant(
      $isElementNode(node),
      '$validatePoint: %s key %s is not an ElementNode',
      name,
      point.key,
    );
    const size = node.getChildrenSize();
    invariant(
      point.offset <= size,
      '$validatePoint: %s point.offset > node.getChildrenSize() (%s > %s)',
      name,
      String(point.offset),
      String(size),
    );
  }
}

export function $getSelection(): null | BaseSelection {
  const editorState = getActiveEditorState();
  return editorState._selection;
}

export function $getPreviousSelection(): null | BaseSelection {
  const editor = getActiveEditor();
  return editor._editorState._selection;
}

export function $updateElementSelectionOnCreateDeleteNode(
  selection: RangeSelection,
  parentNode: LexicalNode,
  nodeOffset: number,
  times = 1,
): void {
  const anchor = selection.anchor;
  const focus = selection.focus;
  const anchorNode = anchor.getNode();
  const focusNode = focus.getNode();
  if (!parentNode.is(anchorNode) && !parentNode.is(focusNode)) {
    return;
  }
  const parentKey = parentNode.__key;
  // Single node. We shift selection but never redimension it
  if (selection.isCollapsed()) {
    const selectionOffset = anchor.offset;
    if (
      (nodeOffset <= selectionOffset && times > 0) ||
      (nodeOffset < selectionOffset && times < 0)
    ) {
      const newSelectionOffset = Math.max(0, selectionOffset + times);
      anchor.set(parentKey, newSelectionOffset, 'element');
      focus.set(parentKey, newSelectionOffset, 'element');
      // The new selection might point to text nodes, try to resolve them
      $updateSelectionResolveTextNodes(selection);
    }
  } else {
    // Multiple nodes selected. We shift or redimension selection
    const isBackward = selection.isBackward();
    const firstPoint = isBackward ? focus : anchor;
    const firstPointNode = firstPoint.getNode();
    const lastPoint = isBackward ? anchor : focus;
    const lastPointNode = lastPoint.getNode();
    if (parentNode.is(firstPointNode)) {
      const firstPointOffset = firstPoint.offset;
      if (
        (nodeOffset <= firstPointOffset && times > 0) ||
        (nodeOffset < firstPointOffset && times < 0)
      ) {
        firstPoint.set(
          parentKey,
          Math.max(0, firstPointOffset + times),
          'element',
        );
      }
    }
    if (parentNode.is(lastPointNode)) {
      const lastPointOffset = lastPoint.offset;
      if (
        (nodeOffset <= lastPointOffset && times > 0) ||
        (nodeOffset < lastPointOffset && times < 0)
      ) {
        lastPoint.set(
          parentKey,
          Math.max(0, lastPointOffset + times),
          'element',
        );
      }
    }
  }
  // The new selection might point to text nodes, try to resolve them
  $updateSelectionResolveTextNodes(selection);
}

function $updateSelectionResolveTextNodes(selection: RangeSelection): void {
  const anchor = selection.anchor;
  const anchorOffset = anchor.offset;
  const focus = selection.focus;
  const focusOffset = focus.offset;
  const anchorNode = anchor.getNode();
  const focusNode = focus.getNode();
  if (selection.isCollapsed()) {
    if (!$isElementNode(anchorNode)) {
      return;
    }
    const childSize = anchorNode.getChildrenSize();
    const anchorOffsetAtEnd = anchorOffset >= childSize;
    const child = anchorOffsetAtEnd
      ? anchorNode.getChildAtIndex(childSize - 1)
      : anchorNode.getChildAtIndex(anchorOffset);
    if ($isTextNode(child)) {
      let newOffset = 0;
      if (anchorOffsetAtEnd) {
        newOffset = child.getTextContentSize();
      }
      anchor.set(child.__key, newOffset, 'text');
      focus.set(child.__key, newOffset, 'text');
    }
    return;
  }
  if ($isElementNode(anchorNode)) {
    const childSize = anchorNode.getChildrenSize();
    const anchorOffsetAtEnd = anchorOffset >= childSize;
    const child = anchorOffsetAtEnd
      ? anchorNode.getChildAtIndex(childSize - 1)
      : anchorNode.getChildAtIndex(anchorOffset);
    if ($isTextNode(child)) {
      let newOffset = 0;
      if (anchorOffsetAtEnd) {
        newOffset = child.getTextContentSize();
      }
      anchor.set(child.__key, newOffset, 'text');
    }
  }
  if ($isElementNode(focusNode)) {
    const childSize = focusNode.getChildrenSize();
    const focusOffsetAtEnd = focusOffset >= childSize;
    const child = focusOffsetAtEnd
      ? focusNode.getChildAtIndex(childSize - 1)
      : focusNode.getChildAtIndex(focusOffset);
    if ($isTextNode(child)) {
      let newOffset = 0;
      if (focusOffsetAtEnd) {
        newOffset = child.getTextContentSize();
      }
      focus.set(child.__key, newOffset, 'text');
    }
  }
}

export function applySelectionTransforms(
  nextEditorState: EditorState,
  editor: LexicalEditor,
): void {
  const prevEditorState = editor.getEditorState();
  const prevSelection = prevEditorState._selection;
  const nextSelection = nextEditorState._selection;
  if ($isRangeSelection(nextSelection)) {
    const anchor = nextSelection.anchor;
    const focus = nextSelection.focus;
    let anchorNode;

    if (anchor.type === 'text') {
      anchorNode = anchor.getNode();
      anchorNode.selectionTransform(prevSelection, nextSelection);
    }
    if (focus.type === 'text') {
      const focusNode = focus.getNode();
      if (anchorNode !== focusNode) {
        focusNode.selectionTransform(prevSelection, nextSelection);
      }
    }
  }
}

export function moveSelectionPointToSibling(
  point: PointType,
  node: LexicalNode,
  parent: ElementNode,
  prevSibling: LexicalNode | null,
  nextSibling: LexicalNode | null,
): void {
  let siblingKey = null;
  let offset = 0;
  let type: 'text' | 'element' | null = null;
  if (prevSibling !== null) {
    siblingKey = prevSibling.__key;
    if ($isTextNode(prevSibling)) {
      offset = prevSibling.getTextContentSize();
      type = 'text';
    } else if ($isElementNode(prevSibling)) {
      offset = prevSibling.getChildrenSize();
      type = 'element';
    }
  } else {
    if (nextSibling !== null) {
      siblingKey = nextSibling.__key;
      if ($isTextNode(nextSibling)) {
        type = 'text';
      } else if ($isElementNode(nextSibling)) {
        type = 'element';
      }
    }
  }
  if (siblingKey !== null && type !== null) {
    point.set(siblingKey, offset, type);
  } else {
    offset = node.getIndexWithinParent();
    if (offset === -1) {
      // Move selection to end of parent
      offset = parent.getChildrenSize();
    }
    point.set(parent.__key, offset, 'element');
  }
}

export function adjustPointOffsetForMergedSibling(
  point: PointType,
  isBefore: boolean,
  key: NodeKey,
  target: TextNode,
  textLength: number,
): void {
  if (point.type === 'text') {
    point.set(key, point.offset + (isBefore ? 0 : textLength), 'text');
  } else if (point.offset > target.getIndexWithinParent()) {
    point.set(point.key, point.offset - 1, 'element');
  }
}

function setDOMSelectionBaseAndExtent(
  domSelection: Selection,
  nextAnchorDOM: HTMLElement | Text,
  nextAnchorOffset: number,
  nextFocusDOM: HTMLElement | Text,
  nextFocusOffset: number,
): void {
  // Apply the updated selection to the DOM. Note: this will trigger
  // a "selectionchange" event, although it will be asynchronous.
  try {
    domSelection.setBaseAndExtent(
      nextAnchorDOM,
      nextAnchorOffset,
      nextFocusDOM,
      nextFocusOffset,
    );
  } catch (error) {
    // If we encounter an error, continue. This can sometimes
    // occur with FF and there's no good reason as to why it
    // should happen.
    if (__DEV__) {
      console.warn(error);
    }
  }
}

function $getElementAndOffsetForPoint(
  editor: LexicalEditor,
  node: LexicalNode,
  offset: number,
): [HTMLElement, number] {
  const element = getElementByKeyOrThrow(editor, node.getKey());
  if ($isElementNode(node)) {
    const slot = $getDOMSlot(node, element, editor);
    return [slot.element, offset + slot.getFirstChildOffset()];
  }
  return [element, offset];
}

/** @internal */
export function $updateDOMSelection(
  prevSelection: BaseSelection | null,
  nextSelection: BaseSelection | null,
  editor: LexicalEditor,
  domSelection: Selection,
  tags: Set<string>,
  rootElement: HTMLElement,
): void {
  const rootForActive = rootElement.getRootNode();
  const activeElement =
    isDOMDocumentNode(rootForActive) || isDOMShadowRoot(rootForActive)
      ? getActiveElementDeep(rootForActive)
      : null;

  // TODO: make this not hard-coded, and add another config option
  // that makes this configurable.
  if (
    (tags.has(COLLABORATION_TAG) && activeElement !== rootElement) ||
    (activeElement !== null &&
      $isSelectionCapturedInDecoratorInput(activeElement, activeElement))
  ) {
    return;
  }

  // Resolve the live DOM selection's boundary points through any enclosing
  // DOM shadow roots; Selection.anchorNode/focusNode are retargeted to the
  // shadow host, so the comparisons below read composed points instead. In
  // the light DOM getDOMSelectionPoints returns `domSelection` itself (no
  // Selection property reads happen here), so `currentPoints` aliases it
  // and preserves the deferred reads described below. The matching live
  // Range is computed lazily in `getCurrentRange()` so the scroll-into-view
  // fallback below is the only path that pays `getRangeAt(0)`'s layout
  // flush — `getDOMSelectionRangeAndPoints()` (the public helper) still
  // returns both eagerly for external callers.
  const currentPoints = getDOMSelectionPoints(domSelection, rootElement);
  let currentRangeCache: Range | null | undefined;
  const getCurrentRange = (): Range | null => {
    if (currentRangeCache === undefined) {
      // Resolve through any enclosing shadow roots: getRangeAt(0) alone is
      // retargeted to the shadow host inside a shadow tree, so the
      // scroll-into-view rect below would measure the host instead of the
      // caret. getDOMSelectionRange falls back to getRangeAt(0) in the light
      // DOM.
      currentRangeCache = getDOMSelectionRange(domSelection, rootElement);
    }
    return currentRangeCache;
  };

  if (!$isRangeSelection(nextSelection)) {
    // We don't remove selection if the prevSelection is null because
    // of editor.setRootElement(). If this occurs on init when the
    // editor is already focused, then this can cause the editor to
    // lose focus.
    if (
      prevSelection !== null &&
      isSelectionWithinEditor(
        editor,
        currentPoints.anchorNode,
        currentPoints.focusNode,
      )
    ) {
      domSelection.removeAllRanges();
    }

    return;
  }

  // DOM Selection property reads (anchorNode, focusNode, anchorOffset,
  // focusOffset) are deferred to their single point of use in the diff
  // check below, and guarded by a cheap domSelection.type check first.
  // These reads force the browser to resolve the selection against the
  // current layout, triggering synchronous style/layout recalculation.

  const anchor = nextSelection.anchor;
  const focus = nextSelection.focus;
  const anchorNode = anchor.getNode();
  const focusNode = focus.getNode();
  const [anchorDOM, nextAnchorOffset] = $getElementAndOffsetForPoint(
    editor,
    anchorNode,
    anchor.offset,
  );
  const [focusDOM, nextFocusOffset] = $getElementAndOffsetForPoint(
    editor,
    focusNode,
    focus.offset,
  );
  const nextFormat = nextSelection.format;
  const nextStyle = nextSelection.style;
  const isCollapsed = nextSelection.isCollapsed();
  let nextAnchorNode: HTMLElement | Text | null = anchorDOM;
  let nextFocusNode: HTMLElement | Text | null = focusDOM;
  let anchorFormatOrStyleChanged = false;

  if (anchor.type === 'text') {
    nextAnchorNode = $isTextNode(anchorNode)
      ? $getDOMTextNode(anchorNode, anchorDOM, editor)
      : null;
    anchorFormatOrStyleChanged =
      anchorNode.getFormat() !== nextFormat ||
      anchorNode.getStyle() !== nextStyle;
  } else if (
    $isRangeSelection(prevSelection) &&
    prevSelection.anchor.type === 'text'
  ) {
    anchorFormatOrStyleChanged = true;
  }

  if (focus.type === 'text') {
    nextFocusNode = $isTextNode(focusNode)
      ? $getDOMTextNode(focusNode, focusDOM, editor)
      : null;
  }

  // If we can't get an underlying text node for selection, then
  // we should avoid setting selection to something incorrect.
  if (nextAnchorNode === null || nextFocusNode === null) {
    return;
  }

  if (
    isCollapsed &&
    (prevSelection === null ||
      anchorFormatOrStyleChanged ||
      ($isRangeSelection(prevSelection) &&
        (prevSelection.format !== nextFormat ||
          prevSelection.style !== nextStyle)))
  ) {
    markCollapsedSelectionFormat(
      editor,
      nextFormat,
      nextStyle,
      nextAnchorOffset,
      anchor.key,
      performance.now(),
    );
  }

  // Diff against the native DOM selection to ensure we don't do
  // an unnecessary selection update. We also skip this check if
  // we're moving selection to within an element, as this can
  // sometimes be problematic around scrolling.
  if (
    !(domSelection.type === 'Range' && isCollapsed) && // Badly interpreted range selection when collapsed - #1482
    currentPoints.anchorOffset === nextAnchorOffset &&
    currentPoints.focusOffset === nextFocusOffset &&
    currentPoints.anchorNode === nextAnchorNode &&
    currentPoints.focusNode === nextFocusNode
  ) {
    // If the root element does not have focus, ensure it has focus — but
    // not when the deep-focused element belongs to a different editor
    // (e.g. the inner editor of a coexisting outer-editor / shadow-editor
    // pair). Stealing focus there breaks the user's typing flow.
    if (activeElement === null || !rootElement.contains(activeElement)) {
      const focusEditor =
        activeElement !== null
          ? getNearestEditorFromDOMNode(activeElement)
          : null;
      if (
        (focusEditor === null || focusEditor === editor) &&
        !tags.has(SKIP_SELECTION_FOCUS_TAG)
      ) {
        rootElement.focus({
          preventScroll: true,
        });
      }
    }
    if (anchor.type !== 'element') {
      return;
    }
  }

  // Apply the updated selection to the DOM. Note: this will trigger
  // a "selectionchange" event, although it will be asynchronous.
  setDOMSelectionBaseAndExtent(
    domSelection,
    nextAnchorNode,
    nextAnchorOffset,
    nextFocusNode,
    nextFocusOffset,
  );

  // Firefox-specific fix: After setting DOM selection, ensure root element has focus
  // to maintain cursor visibility. Firefox requires focus to be on the root element
  // for the cursor to be visible, especially after operations like drag that may
  // cause focus loss. This is critical for collapsed selections (cursor).
  if (
    IS_FIREFOX &&
    nextSelection.isCollapsed() &&
    rootElement !== null &&
    !tags.has(SKIP_SELECTION_FOCUS_TAG)
  ) {
    // Shallow active element for the containment check: rootElement.contains()
    // does not cross shadow boundaries, so a host-retargeted result gives the
    // correct containment outcome (and avoids a false "outside" when focus is
    // in a nested decorator shadow inside this editor).
    const focusedElement = getActiveElement(rootElement);
    if (focusedElement === null || !rootElement.contains(focusedElement)) {
      // Don't steal focus when the active element belongs to a *different*
      // editor (e.g. the inner editor of a coexisting outer-editor /
      // shadow-editor pair). Resolve the *deep* focused element for this
      // attribution: a shallow read returns the other editor's shadow host,
      // which getNearestEditorFromDOMNode can't map back to that editor, so
      // the guard would otherwise wrongly steal focus from a shadow-mounted
      // sibling.
      const deepFocusedElement = getActiveElementDeep(
        rootElement.ownerDocument,
      );
      const focusEditor =
        deepFocusedElement !== null
          ? getNearestEditorFromDOMNode(deepFocusedElement)
          : null;
      if (focusEditor === null || focusEditor === editor) {
        // Restore focus immediately to ensure cursor visibility.
        // Note: We rely on the normal selection update mechanism to ensure the
        // cursor is visible. Using requestAnimationFrame here could cause race
        // conditions where another update changes the selection before the rAF
        // callback executes.
        rootElement.focus({preventScroll: true});
      }
    }
  }

  if (
    !tags.has(SKIP_SCROLL_INTO_VIEW_TAG) &&
    nextSelection.isCollapsed() &&
    rootElement !== null &&
    // Re-read the active element rather than a value cached before the focus
    // restore / selection mutation above, which can become stale (e.g. when
    // setting the DOM selection focuses the contentEditable as a side effect).
    // Shallow is sufficient here for the same reason as the Firefox branch
    // above: the equality check doesn't cross the shadow boundary.
    rootElement === getActiveElement(rootElement)
  ) {
    const selectionTarget: null | Range | HTMLElement | Text =
      $isRangeSelection(nextSelection) &&
      nextSelection.anchor.type === 'element'
        ? (nextAnchorNode.childNodes[nextAnchorOffset] as HTMLElement | Text) ||
          null
        : getCurrentRange();
    if (selectionTarget !== null) {
      let selectionRect: DOMRect;
      if (isDOMTextNode(selectionTarget)) {
        const range = selectionTarget.ownerDocument.createRange();
        range.selectNode(selectionTarget);
        selectionRect = range.getBoundingClientRect();
      } else {
        selectionRect = selectionTarget.getBoundingClientRect();
      }
      scrollIntoViewIfNeeded(editor, selectionRect, rootElement);
    }
  }

  markSelectionChangeFromDOMUpdate(editor);
}

export function $insertNodes(nodes: LexicalNode[]) {
  let selection = $getSelection() || $getPreviousSelection();

  if (selection === null) {
    selection = $getRoot().selectEnd();
  }
  selection.insertNodes(nodes);
}

/**
 * Push-lexer visitor passed to {@link tokenizeRawText}. The tokenizer
 * invokes one callback per token it emits; empty text runs are
 * suppressed, so `text` is only invoked with a non-empty string.
 */
export interface RawTextVisitor {
  readonly linebreak: () => void;
  readonly tab: () => void;
  readonly text: (text: string) => void;
}

/**
 * Push-lex a raw text string into `linebreak` (`\n` / `\r\n`), `tab`
 * (`\t`), and `text` (everything else) tokens, dispatching each to the
 * matching callback on `visitor` in source order.
 *
 * Shared by {@link $generateNodesFromRawText} (which builds
 * `LineBreakNode` / `TabNode` / `TextNode` siblings) and by
 * `@lexical/clipboard`'s default `text/plain` clipboard importer
 * (which maps `linebreak` to a real paragraph break via
 * `insertParagraph` so multi-line plain text becomes multi-paragraph
 * rich text). Empty text runs are dropped so callers don't need to
 * special-case them.
 */
export function tokenizeRawText(text: string, visitor: RawTextVisitor): void {
  for (const part of text.split(/(\r?\n|\t)/)) {
    if (part === '\n' || part === '\r\n') {
      visitor.linebreak();
    } else if (part === '\t') {
      visitor.tab();
    } else if (part !== '') {
      visitor.text(part);
    }
  }
}

/**
 * Convert a raw text string into a flat array of `TextNode`,
 * `LineBreakNode`, and `TabNode` siblings, splitting on `\n`, `\r\n`,
 * and `\t`. Use this when you need the same `\n` / `\t` → real-node
 * conversion that {@link RangeSelection.insertRawText} performs but
 * without a selection — e.g. when building a `CodeNode`'s children
 * inside a DOM-import rule.
 */
export function $generateNodesFromRawText(
  text: string,
): (TextNode | LineBreakNode)[] {
  const nodes: (TextNode | LineBreakNode)[] = [];
  tokenizeRawText(text, {
    linebreak: () => nodes.push($createLineBreakNode()),
    tab: () => nodes.push($createTabNode()),
    text: part => nodes.push($createTextNode(part)),
  });
  return nodes;
}

export function $getTextContent(): string {
  const selection = $getSelection();
  if (selection === null) {
    return '';
  }
  return selection.getTextContent();
}

// @experimental named-slots. Inline projection of a pasted node list for a
// block-shaped slot value (insertNodes CASE 3a): inline nodes pass through,
// non-inline elements contribute their inline content recursively, line
// breaks are stripped (the <input> value-sanitization analogy for newlines),
// and non-inline decorators are dropped.
function $extractInlineFromBlocks(nodes: LexicalNode[]): LexicalNode[] {
  const inlineNodes: LexicalNode[] = [];
  for (const node of nodes) {
    if ($isLineBreakNode(node)) {
      continue;
    }
    if (($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline()) {
      if ($isElementNode(node)) {
        inlineNodes.push(...$extractInlineFromBlocks(node.getChildren()));
      }
      continue;
    }
    inlineNodes.push(node);
  }
  return inlineNodes;
}

function $removeTextAndSplitBlock(selection: RangeSelection): number {
  let selection_ = selection;
  if (!selection.isCollapsed()) {
    selection_.removeText();
  }
  // A new selection can originate as a result of node replacement, in which case is registered via
  // $setSelection
  const newSelection = $getSelection();
  if ($isRangeSelection(newSelection)) {
    selection_ = newSelection;
  }

  invariant(
    $isRangeSelection(selection_),
    'Unexpected dirty selection to be null',
  );

  const anchor = selection_.anchor;
  let node = anchor.getNode();
  let offset = anchor.offset;

  // A slotted node is the virtual scope root (its parent is null), so the
  // split walk must stop there even when it is not INTERNAL_$isBlock itself
  // (e.g. a container-shaped slot value with element children) — otherwise
  // $splitNodeAtPoint's parentless fallback would append a stray paragraph
  // to the document root.
  while (!INTERNAL_$isBlock(node) && $getSlotHostKey(node) === null) {
    const prevNode = node;
    [node, offset] = $splitNodeAtPoint(node, offset);
    if (prevNode.is(node)) {
      break;
    }
  }

  return offset;
}

function $splitNodeAtPoint(
  node: LexicalNode,
  offset: number,
): [parent: ElementNode, offset: number] {
  const parent = node.getParent();
  if (!parent) {
    const paragraph = $createParagraphNode();
    $getRoot().append(paragraph);
    paragraph.select();
    return [$getRoot(), 0];
  }

  if ($isTextNode(node)) {
    const split = node.splitText(offset);
    if (split.length === 0) {
      return [parent, node.getIndexWithinParent()];
    }
    const x = offset === 0 ? 0 : 1;
    const index = split[0].getIndexWithinParent() + x;

    return [parent, index];
  }

  if (!$isElementNode(node) || offset === 0) {
    return [parent, node.getIndexWithinParent()];
  }

  const firstToAppend = node.getChildAtIndex(offset);
  if (firstToAppend) {
    const insertPoint = new RangeSelection(
      $createPoint(node.__key, offset, 'element'),
      $createPoint(node.__key, offset, 'element'),
      0,
      '',
    );
    const newElement = node.insertNewAfter(insertPoint) as ElementNode | null;
    if (newElement) {
      newElement.append(firstToAppend, ...firstToAppend.getNextSiblings());
    }
  }
  return [parent, node.getIndexWithinParent() + 1];
}

function $isInlineRunNode(node: LexicalNode): boolean {
  return (
    $isLineBreakNode(node) ||
    $isInlineElementOrDecoratorNode(node) ||
    $isTextNode(node) ||
    node.isParentRequired()
  );
}

function $wrapInlineNodes(nodes: LexicalNode[]) {
  // We temporarily insert the topLevelNodes into an arbitrary ElementNode,
  // since insertAfter does not work on nodes that have no parent (TO-DO: fix that).
  const virtualRoot = $createParagraphNode();

  let currentBlock: ElementNode | null = null;
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i];

    if ($isInlineRunNode(node)) {
      if (currentBlock === null) {
        currentBlock = node.createParentElementNode();
        virtualRoot.append(currentBlock);
        // A LineBreakNode that is an entire run by itself collapses to an
        // empty paragraph, since the block boundary already provides the
        // visual newline (the form that clipboard pastes ending in a
        // trailing <br> rely on, and the same policy as
        // $paragraphPackageRun in @lexical/html). A linebreak followed by
        // more inline content in the same run is preserved.
        const nextNode: LexicalNode | undefined = nodes[i + 1];
        if (
          $isLineBreakNode(node) &&
          (nextNode === undefined || !$isInlineRunNode(nextNode))
        ) {
          continue;
        }
      }
      currentBlock.append(node);
    } else {
      virtualRoot.append(node);
      currentBlock = null;
    }
  }

  return virtualRoot;
}

/**
 * Get all nodes in a CaretRange in a way that complies with all of the
 * quirks of the original RangeSelection.getNodes().
 *
 * @param range The CaretRange
 */
function $getNodesFromCaretRangeCompat(
  // getNodes returned nodes in document order
  range: CaretRange<'next'>,
): LexicalNode[] {
  const nodes: LexicalNode[] = [];
  const [beforeSlice, afterSlice] = range.getTextSlices();
  if (beforeSlice) {
    nodes.push(beforeSlice.caret.origin);
  }
  const seenAncestors = new Set<ElementNode>();
  const seenElements = new Set<ElementNode>();
  for (const caret of range) {
    if ($isChildCaret(caret)) {
      // Emulate the leading under-selection behavior of getNodes by
      // ignoring the 'enter' of any ElementNode until we've seen a
      // SiblingCaret
      const {origin} = caret;
      if (nodes.length === 0) {
        seenAncestors.add(origin);
      } else {
        seenElements.add(origin);
        nodes.push(origin);
      }
    } else {
      const {origin} = caret;
      if (!$isElementNode(origin) || !seenElements.has(origin)) {
        nodes.push(origin);
      }
    }
  }
  if (afterSlice) {
    nodes.push(afterSlice.caret.origin);
  }
  // Emulate the trailing underselection behavior when the last offset of
  // an element is selected
  if (
    $isSiblingCaret(range.focus) &&
    $isElementNode(range.focus.origin) &&
    range.focus.getNodeAtCaret() === null
  ) {
    for (
      let reverseCaret: null | NodeCaret<'previous'> = $getChildCaret(
        range.focus.origin,
        'previous',
      );
      $isChildCaret(reverseCaret) &&
      seenAncestors.has(reverseCaret.origin) &&
      !reverseCaret.origin.isEmpty() &&
      reverseCaret.origin.is(nodes[nodes.length - 1]);
      reverseCaret = $getAdjacentChildCaret(reverseCaret)
    ) {
      seenAncestors.delete(reverseCaret.origin);
      nodes.pop();
    }
  }
  while (nodes.length > 1) {
    const lastIncludedNode = nodes[nodes.length - 1];
    if ($isElementNode(lastIncludedNode)) {
      if (
        seenElements.has(lastIncludedNode) ||
        lastIncludedNode.isEmpty() ||
        seenAncestors.has(lastIncludedNode)
      ) {
        // fall through to break
      } else {
        nodes.pop();
        continue;
      }
    }
    break;
  }
  if (nodes.length === 0 && range.isCollapsed()) {
    // Emulate the collapsed behavior of getNodes by returning the descendant
    const normCaret = $normalizeCaret(range.anchor);
    const flippedNormCaret = $normalizeCaret(range.anchor.getFlipped());
    const $getCandidate = (caret: PointCaret): LexicalNode | null =>
      $isTextPointCaret(caret) ? caret.origin : caret.getNodeAtCaret();
    const node =
      $getCandidate(normCaret) ||
      $getCandidate(flippedNormCaret) ||
      (range.anchor.getNodeAtCaret()
        ? normCaret.origin
        : flippedNormCaret.origin);
    nodes.push(node);
  }
  return nodes;
}

/**
 * @internal
 *
 * Modify the focus of the focus around possible decorators and blocks and return true
 * if the movement is done.
 */
function $modifySelectionAroundDecoratorsAndBlocks(
  selection: RangeSelection,
  alter: 'move' | 'extend',
  isBackward: boolean,
  granularity: 'character' | 'word' | 'lineboundary',
  mode: 'decorators-and-blocks' | 'decorators' = 'decorators-and-blocks',
): boolean {
  if (
    alter === 'move' &&
    granularity === 'character' &&
    !selection.isCollapsed()
  ) {
    // moving left or right when the selection isn't collapsed will
    // just set the anchor to the focus or vice versa depending on
    // direction
    const [src, dst] =
      isBackward === selection.isBackward()
        ? [selection.focus, selection.anchor]
        : [selection.anchor, selection.focus];
    dst.set(src.key, src.offset, src.type);
    return true;
  }
  const initialFocus = $caretFromPoint(
    selection.focus,
    isBackward ? 'previous' : 'next',
  );
  const isLineBoundary = granularity === 'lineboundary';
  const collapse = alter === 'move';
  let focus = initialFocus;
  let checkForBlock = mode === 'decorators-and-blocks';
  if (!$isExtendableTextPointCaret(focus)) {
    for (const siblingCaret of focus) {
      checkForBlock = false;
      const {origin} = siblingCaret;
      if ($isDecoratorNode(origin) && !origin.isIsolated()) {
        focus = siblingCaret;
        if (isLineBoundary && origin.isInline()) {
          continue;
        }
      }
      break;
    }
    if (checkForBlock) {
      for (const nextCaret of $extendCaretToRange(initialFocus).iterNodeCarets(
        alter === 'extend' ? 'shadowRoot' : 'root',
      )) {
        if ($isChildCaret(nextCaret)) {
          if (!nextCaret.origin.isInline()) {
            focus = nextCaret;
          }
        } else if ($isElementNode(nextCaret.origin)) {
          continue;
        } else if (
          $isDecoratorNode(nextCaret.origin) &&
          !nextCaret.origin.isInline()
        ) {
          focus = nextCaret;
        }
        break;
      }
    }
  }
  if (focus === initialFocus) {
    return false;
  }
  // After this point checkForBlock is true if and only if we moved to a
  // different block, so we should stop regardless of the granularity
  if (
    collapse &&
    !isLineBoundary &&
    $isDecoratorNode(focus.origin) &&
    focus.origin.isKeyboardSelectable()
  ) {
    // Make it possible to move selection from range selection to
    // node selection on the node.
    const nodeSelection = $createNodeSelection();
    nodeSelection.add(focus.origin.getKey());
    $setSelection(nodeSelection);
    return true;
  }
  focus = $normalizeCaret(focus);
  if (collapse) {
    $setPointFromCaret(selection.anchor, focus);
  }
  $setPointFromCaret(selection.focus, focus);
  return checkForBlock || !isLineBoundary;
}
