BookStackApp/BookStack

View on GitHub
resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts

Summary

Maintainability
F
1 mo
Test Coverage
/**
 * 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 {$createLinkNode} from '@lexical/link';
import {
  $getSelectionStyleValueForProperty,
  $patchStyleText,
} from '@lexical/selection';
import {
  $createLineBreakNode,
  $createParagraphNode,
  $createRangeSelection,
  $createTextNode,
  $getNodeByKey,
  $getRoot,
  $getSelection,
  $insertNodes,
  $isElementNode,
  $isParagraphNode,
  $isRangeSelection,
  $setSelection,
  ElementNode,
  LexicalEditor,
  LexicalNode,
  ParagraphNode,
  RangeSelection,
  TextModeType,
  TextNode,
} from 'lexical';
import {
  $createTestDecoratorNode,
  $createTestElementNode,
  $createTestShadowRootNode,
  createTestEditor,
  createTestHeadlessEditor,
  invariant,
  TestDecoratorNode,
} from 'lexical/__tests__/utils';

import {$setAnchorPoint, $setFocusPoint} from '../utils';
import {$createHeadingNode, $isHeadingNode} from "@lexical/rich-text/LexicalHeadingNode";

Range.prototype.getBoundingClientRect = function (): DOMRect {
  const rect = {
    bottom: 0,
    height: 0,
    left: 0,
    right: 0,
    top: 0,
    width: 0,
    x: 0,
    y: 0,
  };
  return {
    ...rect,
    toJSON() {
      return rect;
    },
  };
};

function $createParagraphWithNodes(
  editor: LexicalEditor,
  nodes: {text: string; key: string; mergeable?: boolean}[],
) {
  const paragraph = $createParagraphNode();
  const nodeMap = editor._pendingEditorState!._nodeMap;

  for (let i = 0; i < nodes.length; i++) {
    const {text, key, mergeable} = nodes[i];
    const textNode = new TextNode(text, key);
    nodeMap.set(key, textNode);

    if (!mergeable) {
      textNode.toggleUnmergeable();
    }

    paragraph.append(textNode);
  }

  return paragraph;
}

describe('LexicalSelectionHelpers tests', () => {
  describe('Collapsed', () => {
    test('Can handle a text point', () => {
      const setupTestCase = (
        cb: (selection: RangeSelection, node: ElementNode) => void,
      ) => {
        const editor = createTestEditor();

        editor.update(() => {
          const root = $getRoot();

          const element = $createParagraphWithNodes(editor, [
            {
              key: 'a',
              mergeable: false,
              text: 'a',
            },
            {
              key: 'b',
              mergeable: false,
              text: 'b',
            },
            {
              key: 'c',
              mergeable: false,
              text: 'c',
            },
          ]);

          root.append(element);

          $setAnchorPoint({
            key: 'a',
            offset: 0,
            type: 'text',
          });

          $setFocusPoint({
            key: 'a',
            offset: 0,
            type: 'text',
          });
          const selection = $getSelection();
          cb(selection as RangeSelection, element);
        });
      };

      // getNodes
      setupTestCase((selection, state) => {
        expect(selection.getNodes()).toEqual([$getNodeByKey('a')]);
      });

      // getTextContent
      setupTestCase((selection) => {
        expect(selection.getTextContent()).toEqual('');
      });

      // insertText
      setupTestCase((selection, state) => {
        selection.insertText('Test');

        expect($getNodeByKey('a')!.getTextContent()).toBe('Testa');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 4,
            type: 'text',
          }),
        );
      });

      // insertNodes
      setupTestCase((selection, element) => {
        selection.insertNodes([$createTextNode('foo')]);

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getFirstChild()!.getKey(),
            offset: 3,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getFirstChild()!.getKey(),
            offset: 3,
            type: 'text',
          }),
        );
      });

      // insertParagraph
      setupTestCase((selection) => {
        selection.insertParagraph();

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 0,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 0,
            type: 'text',
          }),
        );
      });

      // insertLineBreak
      setupTestCase((selection, element) => {
        selection.insertLineBreak(true);

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );
      });

      // Format text
      setupTestCase((selection, element) => {
        selection.formatText('bold');
        selection.insertText('Test');

        expect(element.getFirstChild()!.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getFirstChild()!.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getFirstChild()!.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(
          element.getFirstChild()!.getNextSibling()!.getTextContent(),
        ).toBe('a');
      });

      // Extract selection
      setupTestCase((selection, state) => {
        expect(selection.extract()).toEqual([$getNodeByKey('a')]);
      });
    });

    test('Has correct text point after removal after merge', async () => {
      const editor = createTestEditor();

      const domElement = document.createElement('div');
      let element;

      editor.setRootElement(domElement);

      editor.update(() => {
        const root = $getRoot();

        element = $createParagraphWithNodes(editor, [
          {
            key: 'a',
            mergeable: true,
            text: 'a',
          },
          {
            key: 'bb',
            mergeable: true,
            text: 'bb',
          },
          {
            key: 'empty',
            mergeable: true,
            text: '',
          },
          {
            key: 'cc',
            mergeable: true,
            text: 'cc',
          },
          {
            key: 'd',
            mergeable: true,
            text: 'd',
          },
        ]);

        root.append(element);

        $setAnchorPoint({
          key: 'bb',
          offset: 1,
          type: 'text',
        });

        $setFocusPoint({
          key: 'cc',
          offset: 1,
          type: 'text',
        });
      });

      await Promise.resolve().then();

      editor.getEditorState().read(() => {
        const selection = $getSelection();

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

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 2,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 4,
            type: 'text',
          }),
        );
      });
    });

    test('Has correct text point after removal after merge (2)', async () => {
      const editor = createTestEditor();

      const domElement = document.createElement('div');
      let element;

      editor.setRootElement(domElement);

      editor.update(() => {
        const root = $getRoot();

        element = $createParagraphWithNodes(editor, [
          {
            key: 'a',
            mergeable: true,
            text: 'a',
          },
          {
            key: 'empty',
            mergeable: true,
            text: '',
          },
          {
            key: 'b',
            mergeable: true,
            text: 'b',
          },
          {
            key: 'c',
            mergeable: true,
            text: 'c',
          },
        ]);

        root.append(element);

        $setAnchorPoint({
          key: 'a',
          offset: 0,
          type: 'text',
        });

        $setFocusPoint({
          key: 'c',
          offset: 1,
          type: 'text',
        });
      });

      await Promise.resolve().then();

      editor.getEditorState().read(() => {
        const selection = $getSelection();

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

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 0,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 3,
            type: 'text',
          }),
        );
      });
    });

    test('Has correct text point adjust to element point after removal of a single empty text node', async () => {
      const editor = createTestEditor();

      const domElement = document.createElement('div');
      let element: ParagraphNode;

      editor.setRootElement(domElement);

      editor.update(() => {
        const root = $getRoot();

        element = $createParagraphWithNodes(editor, [
          {
            key: 'a',
            mergeable: true,
            text: '',
          },
        ]);

        root.append(element);

        $setAnchorPoint({
          key: 'a',
          offset: 0,
          type: 'text',
        });

        $setFocusPoint({
          key: 'a',
          offset: 0,
          type: 'text',
        });
      });

      await Promise.resolve().then();

      editor.getEditorState().read(() => {
        const selection = $getSelection();

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

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );
      });
    });

    test('Has correct element point after removal of an empty text node in a group #1', async () => {
      const editor = createTestEditor();

      const domElement = document.createElement('div');
      let element;

      editor.setRootElement(domElement);

      editor.update(() => {
        const root = $getRoot();

        element = $createParagraphWithNodes(editor, [
          {
            key: 'a',
            mergeable: true,
            text: '',
          },
          {
            key: 'b',
            mergeable: false,
            text: 'b',
          },
        ]);

        root.append(element);

        $setAnchorPoint({
          key: element.getKey(),
          offset: 2,
          type: 'element',
        });

        $setFocusPoint({
          key: element.getKey(),
          offset: 2,
          type: 'element',
        });
      });

      await Promise.resolve().then();

      editor.getEditorState().read(() => {
        const selection = $getSelection();

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

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'b',
            offset: 1,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'b',
            offset: 1,
            type: 'text',
          }),
        );
      });
    });

    test('Has correct element point after removal of an empty text node in a group #2', async () => {
      const editor = createTestEditor();

      const domElement = document.createElement('div');
      let element;

      editor.setRootElement(domElement);

      editor.update(() => {
        const root = $getRoot();

        element = $createParagraphWithNodes(editor, [
          {
            key: 'a',
            mergeable: true,
            text: '',
          },
          {
            key: 'b',
            mergeable: false,
            text: 'b',
          },
          {
            key: 'c',
            mergeable: true,
            text: 'c',
          },
          {
            key: 'd',
            mergeable: true,
            text: 'd',
          },
        ]);

        root.append(element);

        $setAnchorPoint({
          key: element.getKey(),
          offset: 4,
          type: 'element',
        });

        $setFocusPoint({
          key: element.getKey(),
          offset: 4,
          type: 'element',
        });
      });

      await Promise.resolve().then();

      editor.getEditorState().read(() => {
        const selection = $getSelection();

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

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'c',
            offset: 2,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'c',
            offset: 2,
            type: 'text',
          }),
        );
      });
    });

    test('Has correct text point after removal of an empty text node in a group #3', async () => {
      const editor = createTestEditor();

      const domElement = document.createElement('div');
      let element;

      editor.setRootElement(domElement);

      editor.update(() => {
        const root = $getRoot();

        element = $createParagraphWithNodes(editor, [
          {
            key: 'a',
            mergeable: true,
            text: '',
          },
          {
            key: 'b',
            mergeable: false,
            text: 'b',
          },
          {
            key: 'c',
            mergeable: true,
            text: 'c',
          },
          {
            key: 'd',
            mergeable: true,
            text: 'd',
          },
        ]);

        root.append(element);

        $setAnchorPoint({
          key: 'd',
          offset: 1,
          type: 'text',
        });

        $setFocusPoint({
          key: 'd',
          offset: 1,
          type: 'text',
        });
      });

      await Promise.resolve().then();

      editor.getEditorState().read(() => {
        const selection = $getSelection();

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

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'c',
            offset: 2,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'c',
            offset: 2,
            type: 'text',
          }),
        );
      });
    });

    test('Can handle an element point on empty element', () => {
      const setupTestCase = (
        cb: (selection: RangeSelection, el: ElementNode) => void,
      ) => {
        const editor = createTestEditor();

        editor.update(() => {
          const root = $getRoot();

          const element = $createParagraphWithNodes(editor, []);

          root.append(element);

          $setAnchorPoint({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          });

          $setFocusPoint({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          });
          const selection = $getSelection();
          cb(selection as RangeSelection, element);
        });
      };

      // getNodes
      setupTestCase((selection, element) => {
        expect(selection.getNodes()).toEqual([element]);
      });

      // getTextContent
      setupTestCase((selection) => {
        expect(selection.getTextContent()).toEqual('');
      });

      // insertText
      setupTestCase((selection, element) => {
        selection.insertText('Test');
        const firstChild = element.getFirstChild()!;

        expect(firstChild.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // insertParagraph
      setupTestCase((selection, element) => {
        selection.insertParagraph();
        const nextElement = element.getNextSibling()!;

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: nextElement.getKey(),
            offset: 0,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: nextElement.getKey(),
            offset: 0,
            type: 'element',
          }),
        );
      });

      // insertLineBreak
      setupTestCase((selection, element) => {
        selection.insertLineBreak(true);

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );
      });

      // Format text
      setupTestCase((selection, element) => {
        selection.formatText('bold');
        selection.insertText('Test');
        const firstChild = element.getFirstChild()!;

        expect(firstChild.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // Extract selection
      setupTestCase((selection, element) => {
        expect(selection.extract()).toEqual([element]);
      });
    });

    test('Can handle a start element point', () => {
      const setupTestCase = (
        cb: (selection: RangeSelection, el: ElementNode) => void,
      ) => {
        const editor = createTestEditor();

        editor.update(() => {
          const root = $getRoot();

          const element = $createParagraphWithNodes(editor, [
            {
              key: 'a',
              mergeable: false,
              text: 'a',
            },
            {
              key: 'b',
              mergeable: false,
              text: 'b',
            },
            {
              key: 'c',
              mergeable: false,
              text: 'c',
            },
          ]);

          root.append(element);

          $setAnchorPoint({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          });

          $setFocusPoint({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          });
          const selection = $getSelection();
          cb(selection as RangeSelection, element);
        });
      };

      // getNodes
      setupTestCase((selection, state) => {
        expect(selection.getNodes()).toEqual([$getNodeByKey('a')]);
      });

      // getTextContent
      setupTestCase((selection) => {
        expect(selection.getTextContent()).toEqual('');
      });

      // insertText
      setupTestCase((selection, element) => {
        selection.insertText('Test');
        const firstChild = element.getFirstChild()!;

        expect(firstChild.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // insertParagraph
      setupTestCase((selection, element) => {
        selection.insertParagraph();

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 0,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 0,
            type: 'text',
          }),
        );
      });

      // insertLineBreak
      setupTestCase((selection, element) => {
        selection.insertLineBreak(true);

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );
      });

      // Format text
      setupTestCase((selection, element) => {
        selection.formatText('bold');
        selection.insertText('Test');

        const firstChild = element.getFirstChild()!;

        expect(firstChild.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // Extract selection
      setupTestCase((selection, element) => {
        expect(selection.extract()).toEqual([$getNodeByKey('a')]);
      });
    });

    test('Can handle an end element point', () => {
      const setupTestCase = (
        cb: (selection: RangeSelection, el: ElementNode) => void,
      ) => {
        const editor = createTestEditor();

        editor.update(() => {
          const root = $getRoot();

          const element = $createParagraphWithNodes(editor, [
            {
              key: 'a',
              mergeable: false,
              text: 'a',
            },
            {
              key: 'b',
              mergeable: false,
              text: 'b',
            },
            {
              key: 'c',
              mergeable: false,
              text: 'c',
            },
          ]);

          root.append(element);

          $setAnchorPoint({
            key: element.getKey(),
            offset: 3,
            type: 'element',
          });

          $setFocusPoint({
            key: element.getKey(),
            offset: 3,
            type: 'element',
          });
          const selection = $getSelection();
          cb(selection as RangeSelection, element);
        });
      };

      // getNodes
      setupTestCase((selection, state) => {
        expect(selection.getNodes()).toEqual([$getNodeByKey('c')]);
      });

      // getTextContent
      setupTestCase((selection) => {
        expect(selection.getTextContent()).toEqual('');
      });

      // insertText
      setupTestCase((selection, element) => {
        selection.insertText('Test');
        const lastChild = element.getLastChild()!;

        expect(lastChild.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: lastChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: lastChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // insertParagraph
      setupTestCase((selection, element) => {
        selection.insertParagraph();
        const nextSibling = element.getNextSibling()!;

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: nextSibling.getKey(),
            offset: 0,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: nextSibling.getKey(),
            offset: 0,
            type: 'element',
          }),
        );
      });

      // insertLineBreak
      setupTestCase((selection, element) => {
        selection.insertLineBreak();

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 4,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 4,
            type: 'element',
          }),
        );
      });

      // Format text
      setupTestCase((selection, element) => {
        selection.formatText('bold');
        selection.insertText('Test');
        const lastChild = element.getLastChild()!;

        expect(lastChild.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: lastChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: lastChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // Extract selection
      setupTestCase((selection, element) => {
        expect(selection.extract()).toEqual([$getNodeByKey('c')]);
      });
    });

    test('Has correct element point after merge from middle', async () => {
      const editor = createTestEditor();

      const domElement = document.createElement('div');
      let element;

      editor.setRootElement(domElement);

      editor.update(() => {
        const root = $getRoot();

        element = $createParagraphWithNodes(editor, [
          {
            key: 'a',
            mergeable: true,
            text: 'a',
          },
          {
            key: 'b',
            mergeable: true,
            text: 'b',
          },
          {
            key: 'c',
            mergeable: true,
            text: 'c',
          },
        ]);

        root.append(element);

        $setAnchorPoint({
          key: element.getKey(),
          offset: 2,
          type: 'element',
        });

        $setFocusPoint({
          key: element.getKey(),
          offset: 2,
          type: 'element',
        });
      });

      await Promise.resolve().then();

      editor.getEditorState().read(() => {
        const selection = $getSelection();

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

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 2,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 2,
            type: 'text',
          }),
        );
      });
    });

    test('Has correct element point after merge from end', async () => {
      const editor = createTestEditor();

      const domElement = document.createElement('div');
      let element;

      editor.setRootElement(domElement);

      editor.update(() => {
        const root = $getRoot();

        element = $createParagraphWithNodes(editor, [
          {
            key: 'a',
            mergeable: true,
            text: 'a',
          },
          {
            key: 'b',
            mergeable: true,
            text: 'b',
          },
          {
            key: 'c',
            mergeable: true,
            text: 'c',
          },
        ]);

        root.append(element);

        $setAnchorPoint({
          key: element.getKey(),
          offset: 3,
          type: 'element',
        });

        $setFocusPoint({
          key: element.getKey(),
          offset: 3,
          type: 'element',
        });
      });

      await Promise.resolve().then();

      editor.getEditorState().read(() => {
        const selection = $getSelection();

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

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 3,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 3,
            type: 'text',
          }),
        );
      });
    });
  });

  describe('Simple range', () => {
    test('Can handle multiple text points', () => {
      const setupTestCase = (
        cb: (selection: RangeSelection, el: ElementNode) => void,
      ) => {
        const editor = createTestEditor();

        editor.update(() => {
          const root = $getRoot();

          const element = $createParagraphWithNodes(editor, [
            {
              key: 'a',
              mergeable: false,
              text: 'a',
            },
            {
              key: 'b',
              mergeable: false,
              text: 'b',
            },
            {
              key: 'c',
              mergeable: false,
              text: 'c',
            },
          ]);

          root.append(element);

          $setAnchorPoint({
            key: 'a',
            offset: 0,
            type: 'text',
          });

          $setFocusPoint({
            key: 'b',
            offset: 0,
            type: 'text',
          });
          const selection = $getSelection();
          if (!$isRangeSelection(selection)) {
            return;
          }
          cb(selection, element);
        });
      };

      // getNodes
      setupTestCase((selection, state) => {
        expect(selection.getNodes()).toEqual([
          $getNodeByKey('a'),
          $getNodeByKey('b'),
        ]);
      });

      // getTextContent
      setupTestCase((selection) => {
        expect(selection.getTextContent()).toEqual('a');
      });

      // insertText
      setupTestCase((selection, state) => {
        selection.insertText('Test');

        expect($getNodeByKey('a')!.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'a',
            offset: 4,
            type: 'text',
          }),
        );
      });

      // insertNodes
      setupTestCase((selection, element) => {
        selection.insertNodes([$createTextNode('foo')]);

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getFirstChild()!.getKey(),
            offset: 3,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getFirstChild()!.getKey(),
            offset: 3,
            type: 'text',
          }),
        );
      });

      // insertParagraph
      setupTestCase((selection) => {
        selection.insertParagraph();

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'b',
            offset: 0,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'b',
            offset: 0,
            type: 'text',
          }),
        );
      });

      // insertLineBreak
      setupTestCase((selection, element) => {
        selection.insertLineBreak(true);

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );
      });

      // Format text
      setupTestCase((selection, element) => {
        selection.formatText('bold');
        selection.insertText('Test');

        expect(element.getFirstChild()!.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getFirstChild()!.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getFirstChild()!.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // Extract selection
      setupTestCase((selection, state) => {
        expect(selection.extract()).toEqual([{...$getNodeByKey('a')}]);
      });
    });

    test('Can handle multiple element points', () => {
      const setupTestCase = (
        cb: (selection: RangeSelection, el: ElementNode) => void,
      ) => {
        const editor = createTestEditor();

        editor.update(() => {
          const root = $getRoot();

          const element = $createParagraphWithNodes(editor, [
            {
              key: 'a',
              mergeable: false,
              text: 'a',
            },
            {
              key: 'b',
              mergeable: false,
              text: 'b',
            },
            {
              key: 'c',
              mergeable: false,
              text: 'c',
            },
          ]);

          root.append(element);

          $setAnchorPoint({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          });

          $setFocusPoint({
            key: element.getKey(),
            offset: 1,
            type: 'element',
          });
          const selection = $getSelection();
          if (!$isRangeSelection(selection)) {
            return;
          }
          cb(selection, element);
        });
      };

      // getNodes
      setupTestCase((selection) => {
        expect(selection.getNodes()).toEqual([$getNodeByKey('a')]);
      });

      // getTextContent
      setupTestCase((selection) => {
        expect(selection.getTextContent()).toEqual('a');
      });

      // insertText
      setupTestCase((selection, element) => {
        selection.insertText('Test');
        const firstChild = element.getFirstChild()!;

        expect(firstChild.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // insertParagraph
      setupTestCase((selection, element) => {
        selection.insertParagraph();

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: 'b',
            offset: 0,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: 'b',
            offset: 0,
            type: 'text',
          }),
        );
      });

      // insertLineBreak
      setupTestCase((selection, element) => {
        selection.insertLineBreak(true);

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );
      });

      // Format text
      setupTestCase((selection, element) => {
        selection.formatText('bold');
        selection.insertText('Test');
        const firstChild = element.getFirstChild()!;

        expect(firstChild.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // Extract selection
      setupTestCase((selection, element) => {
        const firstChild = element.getFirstChild();

        expect(selection.extract()).toEqual([firstChild]);
      });
    });

    test('Can handle a mix of text and element points', () => {
      const setupTestCase = (
        cb: (selection: RangeSelection, el: ElementNode) => void,
      ) => {
        const editor = createTestEditor();

        editor.update(() => {
          const root = $getRoot();

          const element = $createParagraphWithNodes(editor, [
            {
              key: 'a',
              mergeable: false,
              text: 'a',
            },
            {
              key: 'b',
              mergeable: false,
              text: 'b',
            },
            {
              key: 'c',
              mergeable: false,
              text: 'c',
            },
          ]);

          root.append(element);

          $setAnchorPoint({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          });

          $setFocusPoint({
            key: 'c',
            offset: 1,
            type: 'text',
          });
          const selection = $getSelection();
          if (!$isRangeSelection(selection)) {
            return;
          }
          cb(selection, element);
        });
      };

      // isBefore
      setupTestCase((selection, state) => {
        expect(selection.anchor.isBefore(selection.focus)).toEqual(true);
      });

      // getNodes
      setupTestCase((selection, state) => {
        expect(selection.getNodes()).toEqual([
          $getNodeByKey('a'),
          $getNodeByKey('b'),
          $getNodeByKey('c'),
        ]);
      });

      // getTextContent
      setupTestCase((selection) => {
        expect(selection.getTextContent()).toEqual('abc');
      });

      // insertText
      setupTestCase((selection, element) => {
        selection.insertText('Test');
        const firstChild = element.getFirstChild()!;

        expect(firstChild.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // insertParagraph
      setupTestCase((selection, element) => {
        selection.insertParagraph();
        const nextElement = element.getNextSibling()!;

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: nextElement.getKey(),
            offset: 0,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: nextElement.getKey(),
            offset: 0,
            type: 'element',
          }),
        );
      });

      // insertLineBreak
      setupTestCase((selection, element) => {
        selection.insertLineBreak(true);

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: element.getKey(),
            offset: 0,
            type: 'element',
          }),
        );
      });

      // Format text
      setupTestCase((selection, element) => {
        selection.formatText('bold');
        selection.insertText('Test');
        const firstChild = element.getFirstChild()!;

        expect(firstChild.getTextContent()).toBe('Test');

        expect(selection.anchor).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );

        expect(selection.focus).toEqual(
          expect.objectContaining({
            key: firstChild.getKey(),
            offset: 4,
            type: 'text',
          }),
        );
      });

      // Extract selection
      setupTestCase((selection, element) => {
        expect(selection.extract()).toEqual([
          $getNodeByKey('a'),
          $getNodeByKey('b'),
          $getNodeByKey('c'),
        ]);
      });
    });
  });

  describe('can insert non-element nodes correctly', () => {
    describe('with an empty paragraph node selected', () => {
      test('a single text node', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          root.append(paragraph);

          $setAnchorPoint({
            key: paragraph.getKey(),
            offset: 0,
            type: 'element',
          });

          $setFocusPoint({
            key: paragraph.getKey(),
            offset: 0,
            type: 'element',
          });

          const selection = $getSelection();

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

          selection.insertNodes([$createTextNode('foo')]);
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">foo</span></p>',
        );
      });

      test('two text nodes', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          root.append(paragraph);

          $setAnchorPoint({
            key: paragraph.getKey(),
            offset: 0,
            type: 'element',
          });

          $setFocusPoint({
            key: paragraph.getKey(),
            offset: 0,
            type: 'element',
          });
          const selection = $getSelection();

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

          selection.insertNodes([
            $createTextNode('foo'),
            $createTextNode('bar'),
          ]);
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">foobar</span></p>',
        );
      });

      test('link insertion without parent element', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          root.append(paragraph);

          $setAnchorPoint({
            key: paragraph.getKey(),
            offset: 0,
            type: 'element',
          });

          $setFocusPoint({
            key: paragraph.getKey(),
            offset: 0,
            type: 'element',
          });
          const link = $createLinkNode('https://');
          link.append($createTextNode('ello worl'));

          const selection = $getSelection();

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

          selection.insertNodes([
            $createTextNode('h'),
            link,
            $createTextNode('d'),
          ]);
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">h</span><a href="https://"><span data-lexical-text="true">ello worl</span></a><span data-lexical-text="true">d</span></p>',
        );
      });

      test('a single heading node with a child text node', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          root.append(paragraph);

          $setAnchorPoint({
            key: paragraph.getKey(),
            offset: 0,
            type: 'element',
          });

          $setFocusPoint({
            key: paragraph.getKey(),
            offset: 0,
            type: 'element',
          });

          const heading = $createHeadingNode('h1');
          const child = $createTextNode('foo');

          heading.append(child);

          const selection = $getSelection();

          if (!$isRangeSelection(selection)) {
            return;
          }
          selection.insertNodes([heading]);
        });

        expect(element.innerHTML).toBe(
          '<h1><span data-lexical-text="true">foo</span></h1>',
        );
      });
    });

    describe('with a paragraph node selected on some existing text', () => {
      test('a single text node', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          const text = $createTextNode('Existing text...');

          paragraph.append(text);
          root.append(paragraph);

          $setAnchorPoint({
            key: text.getKey(),
            offset: 16,
            type: 'text',
          });

          $setFocusPoint({
            key: text.getKey(),
            offset: 16,
            type: 'text',
          });

          const selection = $getSelection();

          if (!$isRangeSelection(selection)) {
            return;
          }
          selection.insertNodes([$createTextNode('foo')]);
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">Existing text...foo</span></p>',
        );
      });

      test('two text nodes', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          const text = $createTextNode('Existing text...');

          paragraph.append(text);
          root.append(paragraph);

          $setAnchorPoint({
            key: text.getKey(),
            offset: 16,
            type: 'text',
          });

          $setFocusPoint({
            key: text.getKey(),
            offset: 16,
            type: 'text',
          });

          const selection = $getSelection();

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

          selection.insertNodes([
            $createTextNode('foo'),
            $createTextNode('bar'),
          ]);
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">Existing text...foobar</span></p>',
        );
      });

      test('a single heading node with a child text node', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          const text = $createTextNode('Existing text...');

          paragraph.append(text);
          root.append(paragraph);

          $setAnchorPoint({
            key: text.getKey(),
            offset: 16,
            type: 'text',
          });

          $setFocusPoint({
            key: text.getKey(),
            offset: 16,
            type: 'text',
          });

          const heading = $createHeadingNode('h1');
          const child = $createTextNode('foo');

          heading.append(child);

          const selection = $getSelection();

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

          selection.insertNodes([heading]);
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">Existing text...foo</span></p>',
        );
      });

      test('a paragraph with a child text and a child italic text and a child text', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          const text = $createTextNode('AE');

          paragraph.append(text);
          root.append(paragraph);

          $setAnchorPoint({
            key: text.getKey(),
            offset: 1,
            type: 'text',
          });

          $setFocusPoint({
            key: text.getKey(),
            offset: 1,
            type: 'text',
          });

          const insertedParagraph = $createParagraphNode();
          const insertedTextB = $createTextNode('B');
          const insertedTextC = $createTextNode('C');
          const insertedTextD = $createTextNode('D');

          insertedTextC.toggleFormat('italic');

          insertedParagraph.append(insertedTextB, insertedTextC, insertedTextD);

          const selection = $getSelection();

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

          selection.insertNodes([insertedParagraph]);

          expect(selection.anchor).toEqual(
            expect.objectContaining({
              key: paragraph
                .getChildAtIndex(paragraph.getChildrenSize() - 2)!
                .getKey(),
              offset: 1,
              type: 'text',
            }),
          );

          expect(selection.focus).toEqual(
            expect.objectContaining({
              key: paragraph
                .getChildAtIndex(paragraph.getChildrenSize() - 2)!
                .getKey(),
              offset: 1,
              type: 'text',
            }),
          );
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">AB</span><em data-lexical-text="true">C</em><span data-lexical-text="true">DE</span></p>',
        );
      });
    });

    describe('with a fully-selected text node', () => {
      test('a single text node', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          root.append(paragraph);

          const text = $createTextNode('Existing text...');
          paragraph.append(text);

          $setAnchorPoint({
            key: text.getKey(),
            offset: 0,
            type: 'text',
          });

          $setFocusPoint({
            key: text.getKey(),
            offset: 'Existing text...'.length,
            type: 'text',
          });

          const selection = $getSelection();

          if (!$isRangeSelection(selection)) {
            return;
          }
          selection.insertNodes([$createTextNode('foo')]);
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">foo</span></p>',
        );
      });
    });

    describe('with a fully-selected text node followed by an inline element', () => {
      test('a single text node', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          root.append(paragraph);

          const text = $createTextNode('Existing text...');
          paragraph.append(text);

          const link = $createLinkNode('https://');
          link.append($createTextNode('link'));
          paragraph.append(link);

          $setAnchorPoint({
            key: text.getKey(),
            offset: 0,
            type: 'text',
          });

          $setFocusPoint({
            key: text.getKey(),
            offset: 'Existing text...'.length,
            type: 'text',
          });

          const selection = $getSelection();

          if (!$isRangeSelection(selection)) {
            return;
          }
          selection.insertNodes([$createTextNode('foo')]);
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">foo</span><a href="https://"><span data-lexical-text="true">link</span></a></p>',
        );
      });
    });

    describe('with a fully-selected text node preceded by an inline element', () => {
      test('a single text node', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          root.append(paragraph);

          const link = $createLinkNode('https://');
          link.append($createTextNode('link'));
          paragraph.append(link);

          const text = $createTextNode('Existing text...');
          paragraph.append(text);

          $setAnchorPoint({
            key: text.getKey(),
            offset: 0,
            type: 'text',
          });

          $setFocusPoint({
            key: text.getKey(),
            offset: 'Existing text...'.length,
            type: 'text',
          });

          const selection = $getSelection();

          if (!$isRangeSelection(selection)) {
            return;
          }
          selection.insertNodes([$createTextNode('foo')]);
        });

        expect(element.innerHTML).toBe(
          '<p><a href="https://"><span data-lexical-text="true">link</span></a><span data-lexical-text="true">foo</span></p>',
        );
      });
    });

    test.skip('can insert a linebreak node before an inline element node', async () => {
      const editor = createTestEditor();
      const element = document.createElement('div');
      editor.setRootElement(element);

      await editor.update(() => {
        const root = $getRoot();
        const paragraph = $createParagraphNode();
        root.append(paragraph);
        const link = $createLinkNode('https://lexical.dev/');
        paragraph.append(link);
        const text = $createTextNode('Lexical');
        link.append(text);
        text.select(0, 0);

        $insertNodes([$createLineBreakNode()]);
      });

      // TODO #5109 ElementNode should have a way to control when other nodes can be inserted inside
      expect(element.innerHTML).toBe(
        '<p><a href="https://lexical.dev/"><br><span data-lexical-text="true">Lexical</span></a></p>',
      );
    });
  });

  describe('can insert block element nodes correctly', () => {
    describe('with a fully-selected text node', () => {
      test('a paragraph node', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          root.append(paragraph);

          const text = $createTextNode('Existing text...');
          paragraph.append(text);

          $setAnchorPoint({
            key: text.getKey(),
            offset: 0,
            type: 'text',
          });

          $setFocusPoint({
            key: text.getKey(),
            offset: 'Existing text...'.length,
            type: 'text',
          });

          const paragraphToInsert = $createParagraphNode();
          paragraphToInsert.append($createTextNode('foo'));

          const selection = $getSelection();

          if (!$isRangeSelection(selection)) {
            return;
          }
          selection.insertNodes([paragraphToInsert]);
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">foo</span></p>',
        );
      });
    });

    describe('with a fully-selected text node followed by an inline element', () => {
      test('a paragraph node', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          root.append(paragraph);

          const text = $createTextNode('Existing text...');
          paragraph.append(text);

          const link = $createLinkNode('https://');
          link.append($createTextNode('link'));
          paragraph.append(link);

          $setAnchorPoint({
            key: text.getKey(),
            offset: 0,
            type: 'text',
          });

          $setFocusPoint({
            key: text.getKey(),
            offset: 'Existing text...'.length,
            type: 'text',
          });

          const paragraphToInsert = $createParagraphNode();
          paragraphToInsert.append($createTextNode('foo'));

          const selection = $getSelection();

          if (!$isRangeSelection(selection)) {
            return;
          }
          selection.insertNodes([paragraphToInsert]);
        });

        expect(element.innerHTML).toBe(
          '<p><span data-lexical-text="true">foo</span><a href="https://"><span data-lexical-text="true">link</span></a></p>',
        );
      });
    });

    describe('with a fully-selected text node preceded by an inline element', () => {
      test('a paragraph node', async () => {
        const editor = createTestEditor();

        const element = document.createElement('div');

        editor.setRootElement(element);

        await editor.update(() => {
          const root = $getRoot();

          const paragraph = $createParagraphNode();
          root.append(paragraph);

          const link = $createLinkNode('https://');
          link.append($createTextNode('link'));
          paragraph.append(link);

          const text = $createTextNode('Existing text...');
          paragraph.append(text);

          $setAnchorPoint({
            key: text.getKey(),
            offset: 0,
            type: 'text',
          });

          $setFocusPoint({
            key: text.getKey(),
            offset: 'Existing text...'.length,
            type: 'text',
          });

          const paragraphToInsert = $createParagraphNode();
          paragraphToInsert.append($createTextNode('foo'));

          const selection = $getSelection();

          if (!$isRangeSelection(selection)) {
            return;
          }
          selection.insertNodes([paragraphToInsert]);
        });

        expect(element.innerHTML).toBe(
          '<p><a href="https://"><span data-lexical-text="true">link</span></a><span data-lexical-text="true">foo</span></p>',
        );
      });
    });

    test('Can insert link into empty paragraph', async () => {
      const editor = createTestEditor();
      const element = document.createElement('div');
      editor.setRootElement(element);

      await editor.update(() => {
        const root = $getRoot();
        const paragraph = $createParagraphNode();
        root.append(paragraph);
        const linkNode = $createLinkNode('https://lexical.dev');
        const linkTextNode = $createTextNode('Lexical');
        linkNode.append(linkTextNode);
        $insertNodes([linkNode]);
      });
      expect(element.innerHTML).toBe(
        '<p><a href="https://lexical.dev"><span data-lexical-text="true">Lexical</span></a></p>',
      );
    });

    test('Can insert link into empty paragraph (2)', async () => {
      const editor = createTestEditor();
      const element = document.createElement('div');
      editor.setRootElement(element);

      await editor.update(() => {
        const root = $getRoot();
        const paragraph = $createParagraphNode();
        root.append(paragraph);
        const linkNode = $createLinkNode('https://lexical.dev');
        const linkTextNode = $createTextNode('Lexical');
        linkNode.append(linkTextNode);
        const textNode2 = $createTextNode('...');
        $insertNodes([linkNode, textNode2]);
      });
      expect(element.innerHTML).toBe(
        '<p><a href="https://lexical.dev"><span data-lexical-text="true">Lexical</span></a><span data-lexical-text="true">...</span></p>',
      );
    });

    test('Can insert an ElementNode after ShadowRoot', async () => {
      const editor = createTestEditor();
      const element = document.createElement('div');
      editor.setRootElement(element);

      await editor.update(() => {
        const root = $getRoot();
        const paragraph = $createParagraphNode();
        root.append(paragraph);
        paragraph.selectStart();
        const element1 = $createTestShadowRootNode();
        const element2 = $createTestElementNode();
        $insertNodes([element1, element2]);
      });
      expect([
        '<div><br></div><div><br></div>',
        '<div><br></div><p><br></p>',
      ]).toContain(element.innerHTML);
    });
  });
});

describe('extract', () => {
  test('Should return the selected node when collapsed on a TextNode', async () => {
    const editor = createTestEditor();

    const element = document.createElement('div');

    editor.setRootElement(element);

    await editor.update(() => {
      const root = $getRoot();

      const paragraph = $createParagraphNode();
      const text = $createTextNode('Existing text...');

      paragraph.append(text);
      root.append(paragraph);

      $setAnchorPoint({
        key: text.getKey(),
        offset: 16,
        type: 'text',
      });

      $setFocusPoint({
        key: text.getKey(),
        offset: 16,
        type: 'text',
      });

      const selection = $getSelection();
      expect($isRangeSelection(selection)).toBeTruthy();

      expect(selection!.extract()).toEqual([text]);
    });
  });
});

describe('insertNodes', () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  it('can insert element next to top level decorator node', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    jest.spyOn(TestDecoratorNode.prototype, 'isInline').mockReturnValue(false);

    await editor.update(() => {
      $getRoot().append(
        $createParagraphNode(),
        $createTestDecoratorNode(),
        $createParagraphNode().append($createTextNode('Text after')),
      );
    });

    await editor.update(() => {
      const selectionNode = $getRoot().getFirstChild();
      invariant($isElementNode(selectionNode));
      const selection = selectionNode.select();
      selection.insertNodes([
        $createParagraphNode().append($createTextNode('Text before')),
      ]);
    });

    expect(element.innerHTML).toBe(
      '<p><span data-lexical-text="true">Text before</span></p>' +
        '<span data-lexical-decorator="true" contenteditable="false"></span>' +
        '<p><span data-lexical-text="true">Text after</span></p>',
    );
  });

  it('can insert when previous selection was null', async () => {
    const editor = createTestHeadlessEditor();
    await editor.update(() => {
      const selection = $createRangeSelection();
      selection.anchor.set('root', 0, 'element');
      selection.focus.set('root', 0, 'element');

      selection.insertNodes([
        $createParagraphNode().append($createTextNode('Text')),
      ]);

      expect($getRoot().getTextContent()).toBe('Text');

      $setSelection(null);
    });
    await editor.update(() => {
      const selection = $createRangeSelection();
      const text = $getRoot().getLastDescendant()!;
      selection.anchor.set(text.getKey(), 0, 'text');
      selection.focus.set(text.getKey(), 0, 'text');

      selection.insertNodes([
        $createParagraphNode().append($createTextNode('Before ')),
      ]);

      expect($getRoot().getTextContent()).toBe('Before Text');
    });
  });

  it('can insert when before empty text node', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    await editor.update(() => {
      // Empty text node to test empty text split
      const emptyTextNode = $createTextNode('');
      $getRoot().append(
        $createParagraphNode().append(emptyTextNode, $createTextNode('text')),
      );
      emptyTextNode.select(0, 0);
      const selection = $getSelection()!;
      expect($isRangeSelection(selection)).toBeTruthy();
      selection.insertNodes([$createTextNode('foo')]);

      expect($getRoot().getTextContent()).toBe('footext');
    });
  });

  it('last node is LineBreakNode', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    await editor.update(() => {
      // Empty text node to test empty text split
      const paragraph = $createParagraphNode();
      $getRoot().append(paragraph);
      const selection = paragraph.select();
      expect($isRangeSelection(selection)).toBeTruthy();

      const newHeading = $createHeadingNode('h1').append(
        $createTextNode('heading'),
      );
      selection.insertNodes([newHeading, $createLineBreakNode()]);
    });
    editor.getEditorState().read(() => {
      expect(element.innerHTML).toBe(
        '<h1><span data-lexical-text="true">heading</span></h1><p><br></p>',
      );
      const selectedNode = ($getSelection() as RangeSelection).anchor.getNode();
      expect($isParagraphNode(selectedNode)).toBeTruthy();
      expect($isHeadingNode(selectedNode.getPreviousSibling())).toBeTruthy();
    });
  });
});

describe('$patchStyleText', () => {
  test('can patch a selection anchored to the end of a TextNode before an inline element', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    await editor.update(() => {
      const root = $getRoot();

      const paragraph = $createParagraphWithNodes(editor, [
        {
          key: 'a',
          mergeable: false,
          text: 'a',
        },
        {
          key: 'b',
          mergeable: false,
          text: 'b',
        },
      ]);

      root.append(paragraph);

      const link = $createLinkNode('https://');
      link.append($createTextNode('link'));

      const a = $getNodeByKey('a')!;
      a.insertAfter(link);

      $setAnchorPoint({
        key: 'a',
        offset: 1,
        type: 'text',
      });
      $setFocusPoint({
        key: 'b',
        offset: 1,
        type: 'text',
      });

      const selection = $getSelection();
      if (!$isRangeSelection(selection)) {
        return;
      }
      $patchStyleText(selection, {'text-emphasis': 'filled'});
    });

    expect(element.innerHTML).toBe(
      '<p><span data-lexical-text="true">a</span>' +
        '<a href="https://">' +
        '<span style="text-emphasis: filled;" data-lexical-text="true">link</span>' +
        '</a>' +
        '<span style="text-emphasis: filled;" data-lexical-text="true">b</span></p>',
    );
  });

  test('can patch a selection anchored to the end of a TextNode at the end of a paragraph', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    await editor.update(() => {
      const root = $getRoot();

      const paragraph1 = $createParagraphWithNodes(editor, [
        {
          key: 'a',
          mergeable: false,
          text: 'a',
        },
      ]);
      const paragraph2 = $createParagraphWithNodes(editor, [
        {
          key: 'b',
          mergeable: false,
          text: 'b',
        },
      ]);

      root.append(paragraph1);
      root.append(paragraph2);

      $setAnchorPoint({
        key: 'a',
        offset: 1,
        type: 'text',
      });
      $setFocusPoint({
        key: 'b',
        offset: 1,
        type: 'text',
      });

      const selection = $getSelection();
      if (!$isRangeSelection(selection)) {
        return;
      }
      $patchStyleText(selection, {'text-emphasis': 'filled'});
    });

    expect(element.innerHTML).toBe(
      '<p><span data-lexical-text="true">a</span></p>' +
        '<p><span style="text-emphasis: filled;" data-lexical-text="true">b</span></p>',
    );
  });

  test('can patch a selection that ends on an element', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    await editor.update(() => {
      const root = $getRoot();

      const paragraph = $createParagraphWithNodes(editor, [
        {
          key: 'a',
          mergeable: false,
          text: 'a',
        },
      ]);

      root.append(paragraph);

      const link = $createLinkNode('https://');
      link.append($createTextNode('link'));

      const a = $getNodeByKey('a')!;
      a.insertAfter(link);

      $setAnchorPoint({
        key: 'a',
        offset: 0,
        type: 'text',
      });
      // Select to end of the link _element_
      $setFocusPoint({
        key: link.getKey(),
        offset: 1,
        type: 'element',
      });

      const selection = $getSelection();
      if (!$isRangeSelection(selection)) {
        return;
      }
      $patchStyleText(selection, {'text-emphasis': 'filled'});
    });

    expect(element.innerHTML).toBe(
      '<p>' +
        '<span style="text-emphasis: filled;" data-lexical-text="true">a</span>' +
        '<a href="https://">' +
        '<span style="text-emphasis: filled;" data-lexical-text="true">link</span>' +
        '</a>' +
        '</p>',
    );
  });

  test('can patch a reversed selection that ends on an element', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    await editor.update(() => {
      const root = $getRoot();

      const paragraph = $createParagraphWithNodes(editor, [
        {
          key: 'a',
          mergeable: false,
          text: 'a',
        },
      ]);

      root.append(paragraph);

      const link = $createLinkNode('https://');
      link.append($createTextNode('link'));

      const a = $getNodeByKey('a')!;
      a.insertAfter(link);

      // Select from the end of the link _element_
      $setAnchorPoint({
        key: link.getKey(),
        offset: 1,
        type: 'element',
      });
      $setFocusPoint({
        key: 'a',
        offset: 0,
        type: 'text',
      });

      const selection = $getSelection();
      if (!$isRangeSelection(selection)) {
        return;
      }
      $patchStyleText(selection, {'text-emphasis': 'filled'});
    });

    expect(element.innerHTML).toBe(
      '<p>' +
        '<span style="text-emphasis: filled;" data-lexical-text="true">a</span>' +
        '<a href="https://">' +
        '<span style="text-emphasis: filled;" data-lexical-text="true">link</span>' +
        '</a>' +
        '</p>',
    );
  });

  test('can patch a selection that starts and ends on an element', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    await editor.update(() => {
      const root = $getRoot();

      const paragraph = $createParagraphNode();
      root.append(paragraph);

      const link = $createLinkNode('https://');
      link.append($createTextNode('link'));
      paragraph.append(link);

      $setAnchorPoint({
        key: link.getKey(),
        offset: 0,
        type: 'element',
      });
      $setFocusPoint({
        key: link.getKey(),
        offset: 1,
        type: 'element',
      });

      const selection = $getSelection();
      if (!$isRangeSelection(selection)) {
        return;
      }
      $patchStyleText(selection, {'text-emphasis': 'filled'});
    });

    expect(element.innerHTML).toBe(
      '<p>' +
        '<a href="https://">' +
        '<span style="text-emphasis: filled;" data-lexical-text="true">link</span>' +
        '</a>' +
        '</p>',
    );
  });

  test('can clear a style', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    await editor.update(() => {
      const root = $getRoot();

      const paragraph = $createParagraphNode();
      root.append(paragraph);

      const text = $createTextNode('text');
      paragraph.append(text);

      $setAnchorPoint({
        key: text.getKey(),
        offset: 0,
        type: 'text',
      });
      $setFocusPoint({
        key: text.getKey(),
        offset: text.getTextContentSize(),
        type: 'text',
      });

      const selection = $getSelection();
      if (!$isRangeSelection(selection)) {
        return;
      }
      $patchStyleText(selection, {'text-emphasis': 'filled'});
      $patchStyleText(selection, {'text-emphasis': null});
    });

    expect(element.innerHTML).toBe(
      '<p><span data-lexical-text="true">text</span></p>',
    );
  });

  test('can toggle a style on a collapsed selection', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    await editor.update(() => {
      const root = $getRoot();

      const paragraph = $createParagraphNode();
      root.append(paragraph);

      const text = $createTextNode('text');
      paragraph.append(text);

      $setAnchorPoint({
        key: text.getKey(),
        offset: 0,
        type: 'text',
      });
      $setFocusPoint({
        key: text.getKey(),
        offset: 0,
        type: 'text',
      });

      const selection = $getSelection();
      if (!$isRangeSelection(selection)) {
        return;
      }
      $patchStyleText(selection, {'text-emphasis': 'filled'});

      expect(
        $getSelectionStyleValueForProperty(selection, 'text-emphasis', ''),
      ).toEqual('filled');

      $patchStyleText(selection, {'text-emphasis': null});

      expect(
        $getSelectionStyleValueForProperty(selection, 'text-emphasis', ''),
      ).toEqual('');

      $patchStyleText(selection, {'text-emphasis': 'filled'});

      expect(
        $getSelectionStyleValueForProperty(selection, 'text-emphasis', ''),
      ).toEqual('filled');
    });
  });

  test('updates cached styles when setting on a collapsed selection', async () => {
    const editor = createTestEditor();
    const element = document.createElement('div');
    editor.setRootElement(element);

    await editor.update(() => {
      const root = $getRoot();

      const paragraph = $createParagraphNode();
      root.append(paragraph);

      const text = $createTextNode('text');
      paragraph.append(text);

      $setAnchorPoint({
        key: text.getKey(),
        offset: 0,
        type: 'text',
      });
      $setFocusPoint({
        key: text.getKey(),
        offset: 0,
        type: 'text',
      });

      // First fetch the initial style -- this will cause the CSS cache to be
      // populated with an empty string pointing to an empty style object.
      const selection = $getSelection();
      if (!$isRangeSelection(selection)) {
        return;
      }
      $getSelectionStyleValueForProperty(selection, 'color', '');

      // Now when we set the style, we should _not_ touch the previously created
      // empty style object, but create a new one instead.
      $patchStyleText(selection, {color: 'red'});

      // We can check that result by clearing the style and re-querying it.
      ($getSelection() as RangeSelection).setStyle('');

      const color = $getSelectionStyleValueForProperty(
        $getSelection() as RangeSelection,
        'color',
        '',
      );
      expect(color).toEqual('');
    });
  });

  test.each<TextModeType>(['token', 'segmented'])(
    'can update style of text node that is in %s mode',
    async (mode) => {
      const editor = createTestEditor();

      const element = document.createElement('div');
      editor.setRootElement(element);

      await editor.update(() => {
        const root = $getRoot();

        const paragraph = $createParagraphNode();
        root.append(paragraph);

        const text = $createTextNode('first').setFormat('bold');
        paragraph.append(text);

        const textInMode = $createTextNode('second').setMode(mode);
        paragraph.append(textInMode);

        $setAnchorPoint({
          key: text.getKey(),
          offset: 'fir'.length,
          type: 'text',
        });

        $setFocusPoint({
          key: textInMode.getKey(),
          offset: 'sec'.length,
          type: 'text',
        });

        const selection = $getSelection();
        $patchStyleText(selection!, {'font-size': '15px'});
      });

      expect(element.innerHTML).toBe(
        '<p>' +
          '<strong data-lexical-text="true">fir</strong>' +
          '<strong style="font-size: 15px;" data-lexical-text="true">st</strong>' +
          '<span style="font-size: 15px;" data-lexical-text="true">second</span>' +
          '</p>',
      );
    },
  );

  test('preserve backward selection when changing style of 2 different text nodes', async () => {
    const editor = createTestEditor();

    const element = document.createElement('div');

    editor.setRootElement(element);

    editor.update(() => {
      const root = $getRoot();

      const paragraph = $createParagraphNode();
      root.append(paragraph);

      const firstText = $createTextNode('first ').setFormat('bold');
      paragraph.append(firstText);

      const secondText = $createTextNode('second').setFormat('italic');
      paragraph.append(secondText);

      $setAnchorPoint({
        key: secondText.getKey(),
        offset: 'sec'.length,
        type: 'text',
      });

      $setFocusPoint({
        key: firstText.getKey(),
        offset: 'fir'.length,
        type: 'text',
      });

      const selection = $getSelection();

      $patchStyleText(selection!, {'font-size': '11px'});

      const [newAnchor, newFocus] = selection!.getStartEndPoints()!;

      const newAnchorNode: LexicalNode = newAnchor.getNode();
      expect(newAnchorNode.getTextContent()).toBe('sec');
      expect(newAnchor.offset).toBe('sec'.length);

      const newFocusNode: LexicalNode = newFocus.getNode();
      expect(newFocusNode.getTextContent()).toBe('st ');
      expect(newFocus.offset).toBe(0);
    });
  });
});