resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts
/**
* 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 {$createListItemNode, $createListNode} from '@lexical/list';
import {$createHeadingNode, registerRichText} from '@lexical/rich-text';
import {
$addNodeStyle,
$getSelectionStyleValueForProperty,
$patchStyleText,
$setBlocksType,
} from '@lexical/selection';
import {$createTableNodeWithDimensions} from '@lexical/table';
import {
$createLineBreakNode,
$createParagraphNode,
$createRangeSelection,
$createTextNode,
$getRoot,
$getSelection, $insertNodes,
$isElementNode,
$isRangeSelection,
$isTextNode,
$setSelection,
DecoratorNode,
ElementNode,
LexicalEditor,
LexicalNode,
ParagraphNode,
PointType,
type RangeSelection,
TextNode,
} from 'lexical';
import {
$assertRangeSelection,
$createTestDecoratorNode,
$createTestElementNode,
createTestEditor,
initializeClipboard,
invariant,
} from 'lexical/__tests__/utils';
import {
$setAnchorPoint,
$setFocusPoint,
applySelectionInputs,
convertToSegmentedNode,
convertToTokenNode,
deleteBackward,
deleteWordBackward,
deleteWordForward,
formatBold,
formatItalic,
formatStrikeThrough,
formatUnderline,
getNodeFromPath,
insertParagraph,
insertSegmentedNode,
insertText,
insertTokenNode,
moveBackward,
moveEnd,
moveNativeSelection,
pastePlain,
printWhitespace,
redo,
setNativeSelectionWithPaths,
undo,
} from '../utils';
import {createEmptyHistoryState, registerHistory} from "@lexical/history";
import {mergeRegister} from "@lexical/utils";
interface ExpectedSelection {
anchorPath: number[];
anchorOffset: number;
focusPath: number[];
focusOffset: number;
}
initializeClipboard();
jest.mock('lexical/shared/environment', () => {
const originalModule = jest.requireActual('lexical/shared/environment');
return {...originalModule, IS_FIREFOX: true};
});
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;
},
};
};
describe('LexicalSelection tests', () => {
let container: HTMLElement;
let root: HTMLDivElement;
let editor: LexicalEditor | null = null;
beforeEach(async () => {
container = document.createElement('div');
document.body.appendChild(container);
root = document.createElement('div');
root.setAttribute('contenteditable', 'true');
container.append(root);
await init();
});
afterEach(async () => {
document.body.removeChild(container);
});
async function init() {
editor = createTestEditor({
nodes: [],
theme: {
code: 'editor-code',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
h4: 'editor-heading-h4',
h5: 'editor-heading-h5',
h6: 'editor-heading-h6',
},
image: 'editor-image',
list: {
ol: 'editor-list-ol',
ul: 'editor-list-ul',
},
listitem: 'editor-listitem',
paragraph: 'editor-paragraph',
quote: 'editor-quote',
text: {
bold: 'editor-text-bold',
code: 'editor-text-code',
hashtag: 'editor-text-hashtag',
italic: 'editor-text-italic',
link: 'editor-text-link',
strikethrough: 'editor-text-strikethrough',
underline: 'editor-text-underline',
underlineStrikethrough: 'editor-text-underlineStrikethrough',
},
}
});
mergeRegister(
registerHistory(editor, createEmptyHistoryState(), 300),
registerRichText(editor),
);
editor.setRootElement(root);
editor.update(() => {
const p = $createParagraphNode();
$insertNodes([p]);
});
editor.commitUpdates();
editor.focus();
// Focus first element
setNativeSelectionWithPaths(
editor!.getRootElement()!,
[0, 0],
0,
[0, 0],
0,
);
}
async function update(fn: () => void) {
editor!.update(fn);
editor!.commitUpdates();
}
test('Expect initial output to be a block with no text.', () => {
expect(container!.innerHTML).toBe(
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><br></p></div>',
);
});
function assertSelection(
rootElement: HTMLElement,
expectedSelection: ExpectedSelection,
) {
const actualSelection = window.getSelection()!;
expect(actualSelection.anchorNode).toBe(
getNodeFromPath(expectedSelection.anchorPath, rootElement),
);
expect(actualSelection.anchorOffset).toBe(expectedSelection.anchorOffset);
expect(actualSelection.focusNode).toBe(
getNodeFromPath(expectedSelection.focusPath, rootElement),
);
expect(actualSelection.focusOffset).toBe(expectedSelection.focusOffset);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const GRAPHEME_SCENARIOS = [
{
description: 'grapheme cluster',
// Hangul grapheme cluster.
// https://www.compart.com/en/unicode/U+AC01
grapheme: '\u1100\u1161\u11A8',
},
{
description: 'extended grapheme cluster',
// Tamil 'ni' grapheme cluster.
// http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters
grapheme: '\u0BA8\u0BBF',
},
{
description: 'tailored grapheme cluster',
// Devangari 'kshi' tailored grapheme cluster.
// http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters
grapheme: '\u0915\u094D\u0937\u093F',
},
{
description: 'Emoji sequence combined using zero-width joiners',
// https://emojipedia.org/family-woman-woman-girl-boy/
grapheme:
'\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66',
},
{
description: 'Emoji sequence with skin-tone modifier',
// https://emojipedia.org/clapping-hands-medium-skin-tone/
grapheme: '\uD83D\uDC4F\uD83C\uDFFD',
},
];
const suite = [
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true">Hello</span></p></div>',
expectedSelection: {
anchorOffset: 5,
anchorPath: [0, 0, 0],
focusOffset: 5,
focusPath: [0, 0, 0],
},
inputs: [
insertText('H'),
insertText('e'),
insertText('l'),
insertText('l'),
insertText('o'),
],
name: 'Simple typing',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<strong class="editor-text-bold" data-lexical-text="true">Hello</strong></p></div>',
expectedSelection: {
anchorOffset: 5,
anchorPath: [0, 0, 0],
focusOffset: 5,
focusPath: [0, 0, 0],
},
inputs: [
formatBold(),
insertText('H'),
insertText('e'),
insertText('l'),
insertText('l'),
insertText('o'),
],
name: 'Simple typing in bold',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<em class="editor-text-italic" data-lexical-text="true">Hello</em></p></div>',
expectedSelection: {
anchorOffset: 5,
anchorPath: [0, 0, 0],
focusOffset: 5,
focusPath: [0, 0, 0],
},
inputs: [
formatItalic(),
insertText('H'),
insertText('e'),
insertText('l'),
insertText('l'),
insertText('o'),
],
name: 'Simple typing in italic',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<strong class="editor-text-bold editor-text-italic" data-lexical-text="true">Hello</strong></p></div>',
expectedSelection: {
anchorOffset: 5,
anchorPath: [0, 0, 0],
focusOffset: 5,
focusPath: [0, 0, 0],
},
inputs: [
formatItalic(),
formatBold(),
insertText('H'),
insertText('e'),
insertText('l'),
insertText('l'),
insertText('o'),
],
name: 'Simple typing in italic + bold',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<span class="editor-text-underline" data-lexical-text="true">Hello</span></p></div>',
expectedSelection: {
anchorOffset: 5,
anchorPath: [0, 0, 0],
focusOffset: 5,
focusPath: [0, 0, 0],
},
inputs: [
formatUnderline(),
insertText('H'),
insertText('e'),
insertText('l'),
insertText('l'),
insertText('o'),
],
name: 'Simple typing in underline',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<span class="editor-text-strikethrough" data-lexical-text="true">Hello</span></p></div>',
expectedSelection: {
anchorOffset: 5,
anchorPath: [0, 0, 0],
focusOffset: 5,
focusPath: [0, 0, 0],
},
inputs: [
formatStrikeThrough(),
insertText('H'),
insertText('e'),
insertText('l'),
insertText('l'),
insertText('o'),
],
name: 'Simple typing in strikethrough',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<span class="editor-text-underlineStrikethrough" data-lexical-text="true">Hello</span></p></div>',
expectedSelection: {
anchorOffset: 5,
anchorPath: [0, 0, 0],
focusOffset: 5,
focusPath: [0, 0, 0],
},
inputs: [
formatUnderline(),
formatStrikeThrough(),
insertText('H'),
insertText('e'),
insertText('l'),
insertText('l'),
insertText('o'),
],
name: 'Simple typing in underline + strikethrough',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true">1246</span></p></div>',
expectedSelection: {
anchorOffset: 4,
anchorPath: [0, 0, 0],
focusOffset: 4,
focusPath: [0, 0, 0],
},
inputs: [
insertText('1'),
insertText('2'),
insertText('3'),
deleteBackward(1),
insertText('4'),
insertText('5'),
deleteBackward(1),
insertText('6'),
],
name: 'Deletion',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<span data-lexical-text="true">Dominic Gannaway</span>' +
'</p></div>',
expectedSelection: {
anchorOffset: 16,
anchorPath: [0, 0, 0],
focusOffset: 16,
focusPath: [0, 0, 0],
},
inputs: [insertTokenNode('Dominic Gannaway')],
name: 'Creation of an token node',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<span data-lexical-text="true">Dominic Gannaway</span>' +
'</p></div>',
expectedSelection: {
anchorOffset: 1,
anchorPath: [0],
focusOffset: 1,
focusPath: [0],
},
inputs: [
insertText('Dominic Gannaway'),
moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16),
convertToTokenNode(),
],
name: 'Convert text to an token node',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<span data-lexical-text="true">Dominic Gannaway</span>' +
'</p></div>',
expectedSelection: {
anchorOffset: 1,
anchorPath: [0],
focusOffset: 1,
focusPath: [0],
},
inputs: [insertSegmentedNode('Dominic Gannaway')],
name: 'Creation of a segmented node',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<span data-lexical-text="true">Dominic Gannaway</span>' +
'</p></div>',
expectedSelection: {
anchorOffset: 1,
anchorPath: [0],
focusOffset: 1,
focusPath: [0],
},
inputs: [
insertText('Dominic Gannaway'),
moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16),
convertToSegmentedNode(),
],
name: 'Convert text to a segmented node',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
'<p class="editor-paragraph"><br></p>' +
'<p class="editor-paragraph">' +
'<strong class="editor-text-bold" data-lexical-text="true">Hello world</strong>' +
'</p>' +
'<p class="editor-paragraph"><br></p>' +
'</div>',
expectedSelection: {
anchorOffset: 0,
anchorPath: [0],
focusOffset: 0,
focusPath: [2],
},
inputs: [
insertParagraph(),
insertText('Hello world'),
insertParagraph(),
moveNativeSelection([0], 0, [2], 0),
formatBold(),
],
name: 'Format selection that starts and ends on element and retain selection',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
'<p class="editor-paragraph"><br></p>' +
'<p class="editor-paragraph">' +
'<strong class="editor-text-bold" data-lexical-text="true">Hello</strong>' +
'</p>' +
'<p class="editor-paragraph">' +
'<strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
'</p>' +
'<p class="editor-paragraph"><br></p>' +
'</div>',
expectedSelection: {
anchorOffset: 0,
anchorPath: [0],
focusOffset: 0,
focusPath: [3],
},
inputs: [
insertParagraph(),
insertText('Hello'),
insertParagraph(),
insertText('world'),
insertParagraph(),
moveNativeSelection([0], 0, [3], 0),
formatBold(),
],
name: 'Format multiline text selection that starts and ends on element and retain selection',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
'<p class="editor-paragraph">' +
'<span data-lexical-text="true">He</span>' +
'<strong class="editor-text-bold" data-lexical-text="true">llo</strong>' +
'</p>' +
'<p class="editor-paragraph">' +
'<strong class="editor-text-bold" data-lexical-text="true">wo</strong>' +
'<span data-lexical-text="true">rld</span>' +
'</p>' +
'</div>',
expectedSelection: {
anchorOffset: 0,
anchorPath: [0, 1, 0],
focusOffset: 2,
focusPath: [1, 0, 0],
},
inputs: [
insertText('Hello'),
insertParagraph(),
insertText('world'),
moveNativeSelection([0, 0, 0], 2, [1, 0, 0], 2),
formatBold(),
],
name: 'Format multiline text selection that starts and ends within text',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
'<p class="editor-paragraph"><br></p>' +
'<p class="editor-paragraph">' +
'<span data-lexical-text="true">Hello </span>' +
'<strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
'</p>' +
'<p class="editor-paragraph"><br></p>' +
'</div>',
expectedSelection: {
anchorOffset: 0,
anchorPath: [1, 1, 0],
focusOffset: 0,
focusPath: [2],
},
inputs: [
insertParagraph(),
insertText('Hello world'),
insertParagraph(),
moveNativeSelection([1, 0, 0], 6, [2], 0),
formatBold(),
],
name: 'Format selection that starts on text and ends on element and retain selection',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
'<p class="editor-paragraph"><br></p>' +
'<p class="editor-paragraph">' +
'<strong class="editor-text-bold" data-lexical-text="true">Hello</strong>' +
'<span data-lexical-text="true"> world</span>' +
'</p>' +
'<p class="editor-paragraph"><br></p>' +
'</div>',
expectedSelection: {
anchorOffset: 0,
anchorPath: [0],
focusOffset: 5,
focusPath: [1, 0, 0],
},
inputs: [
insertParagraph(),
insertText('Hello world'),
insertParagraph(),
moveNativeSelection([0], 0, [1, 0, 0], 5),
formatBold(),
],
name: 'Format selection that starts on element and ends on text and retain selection',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
'<p class="editor-paragraph"><br></p>' +
'<p class="editor-paragraph">' +
'<strong class="editor-text-bold" data-lexical-text="true">Hello</strong><strong class="editor-text-bold" data-lexical-text="true"> world</strong>' +
'</p>' +
'<p class="editor-paragraph"><br></p>' +
'</div>',
expectedSelection: {
anchorOffset: 2,
anchorPath: [1, 0, 0],
focusOffset: 0,
focusPath: [2],
},
inputs: [
insertParagraph(),
insertTokenNode('Hello'),
insertText(' world'),
insertParagraph(),
moveNativeSelection([1, 0, 0], 2, [2], 0),
formatBold(),
],
name: 'Format selection that starts on middle of token node should format complete node',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
'<p class="editor-paragraph"><br></p>' +
'<p class="editor-paragraph">' +
'<strong class="editor-text-bold" data-lexical-text="true">Hello </strong><strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
'</p>' +
'<p class="editor-paragraph"><br></p>' +
'</div>',
expectedSelection: {
anchorOffset: 0,
anchorPath: [0],
focusOffset: 2,
focusPath: [1, 1, 0],
},
inputs: [
insertParagraph(),
insertText('Hello '),
insertTokenNode('world'),
insertParagraph(),
moveNativeSelection([0], 0, [1, 1, 0], 2),
formatBold(),
],
name: 'Format selection that ends on middle of token node should format complete node',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
'<p class="editor-paragraph"><br></p>' +
'<p class="editor-paragraph">' +
'<strong class="editor-text-bold" data-lexical-text="true">Hello</strong><span data-lexical-text="true"> world</span>' +
'</p>' +
'<p class="editor-paragraph"><br></p>' +
'</div>',
expectedSelection: {
anchorOffset: 2,
anchorPath: [1, 0, 0],
focusOffset: 3,
focusPath: [1, 0, 0],
},
inputs: [
insertParagraph(),
insertTokenNode('Hello'),
insertText(' world'),
insertParagraph(),
moveNativeSelection([1, 0, 0], 2, [1, 0, 0], 3),
formatBold(),
],
name: 'Format token node if it is the single one selected',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
'<p class="editor-paragraph"><br></p>' +
'<p class="editor-paragraph">' +
'<strong class="editor-text-bold" data-lexical-text="true">Hello </strong><strong class="editor-text-bold" data-lexical-text="true">beautiful</strong><strong class="editor-text-bold" data-lexical-text="true"> world</strong>' +
'</p>' +
'<p class="editor-paragraph"><br></p>' +
'</div>',
expectedSelection: {
anchorOffset: 0,
anchorPath: [0],
focusOffset: 0,
focusPath: [2],
},
inputs: [
insertParagraph(),
insertText('Hello '),
insertTokenNode('beautiful'),
insertText(' world'),
insertParagraph(),
moveNativeSelection([0], 0, [2], 0),
formatBold(),
],
name: 'Format selection that contains a token node in the middle should format the token node',
},
...[
{
whitespaceCharacter: ' ',
whitespaceName: 'space',
},
{
whitespaceCharacter: '\u00a0',
whitespaceName: 'non-breaking space',
},
{
whitespaceCharacter: '\u2000',
whitespaceName: 'en quad',
},
{
whitespaceCharacter: '\u2001',
whitespaceName: 'em quad',
},
{
whitespaceCharacter: '\u2002',
whitespaceName: 'en space',
},
{
whitespaceCharacter: '\u2003',
whitespaceName: 'em space',
},
{
whitespaceCharacter: '\u2004',
whitespaceName: 'three-per-em space',
},
{
whitespaceCharacter: '\u2005',
whitespaceName: 'four-per-em space',
},
{
whitespaceCharacter: '\u2006',
whitespaceName: 'six-per-em space',
},
{
whitespaceCharacter: '\u2007',
whitespaceName: 'figure space',
},
{
whitespaceCharacter: '\u2008',
whitespaceName: 'punctuation space',
},
{
whitespaceCharacter: '\u2009',
whitespaceName: 'thin space',
},
{
whitespaceCharacter: '\u200A',
whitespaceName: 'hair space',
},
].flatMap(({whitespaceCharacter, whitespaceName}) => [
{
expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true">Hello${printWhitespace(
whitespaceCharacter,
)}</span></p></div>`,
expectedSelection: {
anchorOffset: 6,
anchorPath: [0, 0, 0],
focusOffset: 6,
focusPath: [0, 0, 0],
},
inputs: [
insertText(`Hello${whitespaceCharacter}world`),
deleteWordBackward(1),
],
name: `Type two words separated by a ${whitespaceName}, delete word backward from end`,
},
{
expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true">${printWhitespace(
whitespaceCharacter,
)}world</span></p></div>`,
expectedSelection: {
anchorOffset: 0,
anchorPath: [0, 0, 0],
focusOffset: 0,
focusPath: [0, 0, 0],
},
inputs: [
insertText(`Hello${whitespaceCharacter}world`),
moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
deleteWordForward(1),
],
name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning`,
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true">Hello</span></p></div>',
expectedSelection: {
anchorOffset: 5,
anchorPath: [0, 0, 0],
focusOffset: 5,
focusPath: [0, 0, 0],
},
inputs: [
insertText(`Hello${whitespaceCharacter}world`),
moveNativeSelection([0, 0, 0], 5, [0, 0, 0], 5),
deleteWordForward(1),
],
name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning of preceding whitespace`,
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true">world</span></p></div>',
expectedSelection: {
anchorOffset: 0,
anchorPath: [0, 0, 0],
focusOffset: 0,
focusPath: [0, 0, 0],
},
inputs: [
insertText(`Hello${whitespaceCharacter}world`),
moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 6),
deleteWordBackward(1),
],
name: `Type two words separated by a ${whitespaceName}, delete word backward from end of trailing whitespace`,
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true">Hello world</span></p></div>',
expectedSelection: {
anchorOffset: 11,
anchorPath: [0, 0, 0],
focusOffset: 11,
focusPath: [0, 0, 0],
},
inputs: [insertText('Hello world'), deleteWordBackward(1), undo(1)],
name: `Type a word, delete it and undo the deletion`,
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true">Hello </span></p></div>',
expectedSelection: {
anchorOffset: 6,
anchorPath: [0, 0, 0],
focusOffset: 6,
focusPath: [0, 0, 0],
},
inputs: [
insertText('Hello world'),
deleteWordBackward(1),
undo(1),
redo(1),
],
name: `Type a word, delete it and undo the deletion`,
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<span data-lexical-text="true">this is weird test</span></p></div>',
expectedSelection: {
anchorOffset: 0,
anchorPath: [0, 0, 0],
focusOffset: 0,
focusPath: [0, 0, 0],
},
inputs: [
insertText('this is weird test'),
moveNativeSelection([0, 0, 0], 14, [0, 0, 0], 14),
moveBackward(14),
],
name: 'Type a sentence, move the caret to the middle and move with the arrows to the start',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
'<span data-lexical-text="true">Hello </span>' +
'<span data-lexical-text="true">Bob</span>' +
'</p></div>',
expectedSelection: {
anchorOffset: 3,
anchorPath: [0, 1, 0],
focusOffset: 3,
focusPath: [0, 1, 0],
},
inputs: [
insertText('Hello '),
insertTokenNode('Bob'),
moveBackward(1),
moveBackward(1),
moveEnd(),
],
name: 'Type a text and token text, move the caret to the end of the first text',
},
{
expectedHTML:
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph"><span data-lexical-text="true">ABD</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">EFG</span></p></div>',
expectedSelection: {
anchorOffset: 3,
anchorPath: [0, 0, 0],
focusOffset: 3,
focusPath: [0, 0, 0],
},
inputs: [
pastePlain('ABD\tEFG'),
moveBackward(5),
insertText('C'),
moveBackward(1),
deleteWordForward(1),
],
name: 'Paste text, move selection and delete word forward',
},
]),
];
suite.forEach((testUnit, i) => {
const name = testUnit.name || 'Test case';
test(name + ` (#${i + 1})`, async () => {
await applySelectionInputs(testUnit.inputs, update, editor!);
// Validate HTML matches
expect(container.innerHTML).toBe(testUnit.expectedHTML);
// Validate selection matches
const rootElement = editor!.getRootElement()!;
const expectedSelection = testUnit.expectedSelection;
assertSelection(rootElement, expectedSelection);
});
});
test('insert text one selected node element selection', async () => {
await editor!.update(() => {
const root = $getRoot();
const paragraph = root.getFirstChild<ParagraphNode>()!;
const elementNode = $createTestElementNode();
const text = $createTextNode('foo');
paragraph.append(elementNode);
elementNode.append(text);
const selection = $createRangeSelection();
selection.anchor.set(text.__key, 0, 'text');
selection.focus.set(paragraph.__key, 1, 'element');
selection.insertText('');
expect(root.getTextContent()).toBe('');
});
});
test('getNodes resolves nested block nodes', async () => {
await editor!.update(() => {
const root = $getRoot();
const paragraph = root.getFirstChild<ParagraphNode>()!;
const elementNode = $createTestElementNode();
const text = $createTextNode();
paragraph.append(elementNode);
elementNode.append(text);
const selectedNodes = $getSelection()!.getNodes();
expect(selectedNodes.length).toBe(1);
expect(selectedNodes[0].getKey()).toBe(text.getKey());
});
});
describe('Block selection moves when new nodes are inserted', () => {
const baseCases: {
name: string;
anchorOffset: number;
focusOffset: number;
fn: (
paragraph: ElementNode,
text: TextNode,
) => {
expectedAnchor: LexicalNode;
expectedAnchorOffset: number;
expectedFocus: LexicalNode;
expectedFocusOffset: number;
};
fnBefore?: (paragraph: ElementNode, text: TextNode) => void;
invertSelection?: true;
only?: true;
}[] = [
// Collapsed selection on end; add/remove/replace beginning
{
anchorOffset: 2,
fn: (paragraph, text) => {
const newText = $createTextNode('2');
text.insertBefore(newText);
return {
expectedAnchor: paragraph,
expectedAnchorOffset: 3,
expectedFocus: paragraph,
expectedFocusOffset: 3,
};
},
focusOffset: 2,
name: 'insertBefore - Collapsed selection on end; add beginning',
},
{
anchorOffset: 2,
fn: (paragraph, text) => {
const newText = $createTextNode('2');
text.insertAfter(newText);
return {
expectedAnchor: paragraph,
expectedAnchorOffset: 3,
expectedFocus: paragraph,
expectedFocusOffset: 3,
};
},
focusOffset: 2,
name: 'insertAfter - Collapsed selection on end; add beginning',
},
{
anchorOffset: 2,
fn: (paragraph, text) => {
text.splitText(1);
return {
expectedAnchor: paragraph,
expectedAnchorOffset: 3,
expectedFocus: paragraph,
expectedFocusOffset: 3,
};
},
focusOffset: 2,
name: 'splitText - Collapsed selection on end; add beginning',
},
{
anchorOffset: 1,
fn: (paragraph, text) => {
text.remove();
return {
expectedAnchor: paragraph,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 0,
};
},
focusOffset: 1,
name: 'remove - Collapsed selection on end; add beginning',
},
{
anchorOffset: 1,
fn: (paragraph, text) => {
const newText = $createTextNode('replacement');
text.replace(newText);
return {
expectedAnchor: paragraph,
expectedAnchorOffset: 1,
expectedFocus: paragraph,
expectedFocusOffset: 1,
};
},
focusOffset: 1,
name: 'replace - Collapsed selection on end; replace beginning',
},
// All selected; add/remove/replace on beginning
{
anchorOffset: 0,
fn: (paragraph, text) => {
const newText = $createTextNode('2');
text.insertBefore(newText);
return {
expectedAnchor: text,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 3,
};
},
focusOffset: 2,
name: 'insertBefore - All selected; add on beginning',
},
{
anchorOffset: 0,
fn: (paragraph, originalText) => {
const [, text] = originalText.splitText(1);
return {
expectedAnchor: text,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 3,
};
},
focusOffset: 2,
name: 'splitNodes - All selected; add on beginning',
},
{
anchorOffset: 0,
fn: (paragraph, text) => {
text.remove();
return {
expectedAnchor: paragraph,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 0,
};
},
focusOffset: 1,
name: 'remove - All selected; remove on beginning',
},
{
anchorOffset: 0,
fn: (paragraph, text) => {
const newText = $createTextNode('replacement');
text.replace(newText);
return {
expectedAnchor: paragraph,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 1,
};
},
focusOffset: 1,
name: 'replace - All selected; replace on beginning',
},
// Selection beginning; add/remove/replace on end
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
const originalText2 = originalText1.getPreviousSibling()!;
const lastChild = paragraph.getLastChild()!;
const newText = $createTextNode('2');
lastChild.insertBefore(newText);
return {
expectedAnchor: originalText2,
expectedAnchorOffset: 0,
expectedFocus: originalText1,
expectedFocusOffset: 0,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
originalText1.insertBefore(originalText2);
},
focusOffset: 1,
name: 'insertBefore - Selection beginning; add on end',
},
{
anchorOffset: 0,
fn: (paragraph, text) => {
const lastChild = paragraph.getLastChild()!;
const newText = $createTextNode('2');
lastChild.insertAfter(newText);
return {
expectedAnchor: text,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 1,
};
},
focusOffset: 1,
name: 'insertAfter - Selection beginning; add on end',
},
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
const originalText2 = originalText1.getPreviousSibling()!;
const [, text] = originalText1.splitText(1);
return {
expectedAnchor: originalText2,
expectedAnchorOffset: 0,
expectedFocus: text,
expectedFocusOffset: 0,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
originalText1.insertBefore(originalText2);
},
focusOffset: 1,
name: 'splitText - Selection beginning; add on end',
},
{
anchorOffset: 0,
fn: (paragraph, text) => {
const lastChild = paragraph.getLastChild()!;
lastChild.remove();
return {
expectedAnchor: text,
expectedAnchorOffset: 0,
expectedFocus: text,
expectedFocusOffset: 3,
};
},
focusOffset: 1,
name: 'remove - Selection beginning; remove on end',
},
{
anchorOffset: 0,
fn: (paragraph, text) => {
const newText = $createTextNode('replacement');
const lastChild = paragraph.getLastChild()!;
lastChild.replace(newText);
return {
expectedAnchor: paragraph,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 1,
};
},
focusOffset: 1,
name: 'replace - Selection beginning; replace on end',
},
// All selected; add/remove/replace in end offset [1, 2] -> [1, N, 2]
{
anchorOffset: 0,
fn: (paragraph, text) => {
const lastChild = paragraph.getLastChild()!;
const newText = $createTextNode('2');
lastChild.insertBefore(newText);
return {
expectedAnchor: text,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 2,
};
},
focusOffset: 1,
name: 'insertBefore - All selected; add in end offset',
},
{
anchorOffset: 0,
fn: (paragraph, text) => {
const newText = $createTextNode('2');
text.insertAfter(newText);
return {
expectedAnchor: text,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 2,
};
},
focusOffset: 1,
name: 'insertAfter - All selected; add in end offset',
},
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
const originalText2 = originalText1.getPreviousSibling()!;
const [, text] = originalText1.splitText(1);
return {
expectedAnchor: originalText2,
expectedAnchorOffset: 0,
expectedFocus: text,
expectedFocusOffset: 0,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
originalText1.insertBefore(originalText2);
},
focusOffset: 1,
name: 'splitText - All selected; add in end offset',
},
{
anchorOffset: 1,
fn: (paragraph, originalText1) => {
const lastChild = paragraph.getLastChild()!;
lastChild.remove();
return {
expectedAnchor: originalText1,
expectedAnchorOffset: 0,
expectedFocus: originalText1,
expectedFocusOffset: 3,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
originalText1.insertBefore(originalText2);
},
focusOffset: 2,
name: 'remove - All selected; remove in end offset',
},
{
anchorOffset: 1,
fn: (paragraph, originalText1) => {
const newText = $createTextNode('replacement');
const lastChild = paragraph.getLastChild()!;
lastChild.replace(newText);
return {
expectedAnchor: paragraph,
expectedAnchorOffset: 1,
expectedFocus: paragraph,
expectedFocusOffset: 2,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
originalText1.insertBefore(originalText2);
},
focusOffset: 2,
name: 'replace - All selected; replace in end offset',
},
// All selected; add/remove/replace in middle [1, 2, 3] -> [1, 2, N, 3]
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
const originalText2 = originalText1.getPreviousSibling()!;
const lastChild = paragraph.getLastChild()!;
const newText = $createTextNode('2');
lastChild.insertBefore(newText);
return {
expectedAnchor: originalText2,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 3,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
originalText1.insertBefore(originalText2);
},
focusOffset: 2,
name: 'insertBefore - All selected; add in middle',
},
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
const originalText2 = originalText1.getPreviousSibling()!;
const newText = $createTextNode('2');
originalText1.insertAfter(newText);
return {
expectedAnchor: originalText2,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 3,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
originalText1.insertBefore(originalText2);
},
focusOffset: 2,
name: 'insertAfter - All selected; add in middle',
},
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
const originalText2 = originalText1.getPreviousSibling()!;
originalText1.splitText(1);
return {
expectedAnchor: originalText2,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 3,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
originalText1.insertBefore(originalText2);
},
focusOffset: 2,
name: 'splitText - All selected; add in middle',
},
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
const originalText2 = originalText1.getPreviousSibling()!;
originalText1.remove();
return {
expectedAnchor: originalText2,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 1,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
originalText1.insertBefore(originalText2);
},
focusOffset: 2,
name: 'remove - All selected; remove in middle',
},
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
const newText = $createTextNode('replacement');
originalText1.replace(newText);
return {
expectedAnchor: paragraph,
expectedAnchorOffset: 0,
expectedFocus: paragraph,
expectedFocusOffset: 2,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
originalText1.insertBefore(originalText2);
},
focusOffset: 2,
name: 'replace - All selected; replace in middle',
},
// Edge cases
{
anchorOffset: 3,
fn: (paragraph, originalText1) => {
const originalText2 = paragraph.getLastChild()!;
const newText = $createTextNode('new');
originalText1.insertBefore(newText);
return {
expectedAnchor: originalText2,
expectedAnchorOffset: 'bar'.length,
expectedFocus: originalText2,
expectedFocusOffset: 'bar'.length,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
paragraph.append(originalText2);
},
focusOffset: 3,
name: "Selection resolves to the end of text node when it's at the end (1)",
},
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
const originalText2 = paragraph.getLastChild()!;
const newText = $createTextNode('new');
originalText1.insertBefore(newText);
return {
expectedAnchor: originalText1,
expectedAnchorOffset: 0,
expectedFocus: originalText2,
expectedFocusOffset: 'bar'.length,
};
},
fnBefore: (paragraph, originalText1) => {
const originalText2 = $createTextNode('bar');
paragraph.append(originalText2);
},
focusOffset: 3,
name: "Selection resolves to the end of text node when it's at the end (2)",
},
{
anchorOffset: 1,
fn: (paragraph, originalText1) => {
originalText1.getNextSibling()!.remove();
return {
expectedAnchor: originalText1,
expectedAnchorOffset: 3,
expectedFocus: originalText1,
expectedFocusOffset: 3,
};
},
focusOffset: 1,
name: 'remove - Remove with collapsed selection at offset #4221',
},
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
originalText1.getNextSibling()!.remove();
return {
expectedAnchor: originalText1,
expectedAnchorOffset: 0,
expectedFocus: originalText1,
expectedFocusOffset: 3,
};
},
focusOffset: 1,
name: 'remove - Remove with non-collapsed selection at offset',
},
];
baseCases
.flatMap((testCase) => {
// Test inverse selection
const inverse = {
...testCase,
anchorOffset: testCase.focusOffset,
focusOffset: testCase.anchorOffset,
invertSelection: true,
name: testCase.name + ' (inverse selection)',
};
return [testCase, inverse];
})
.forEach(
({
name,
fn,
fnBefore = () => {
return;
},
anchorOffset,
focusOffset,
invertSelection,
only,
}) => {
// eslint-disable-next-line no-only-tests/no-only-tests
const test_ = only === true ? test.only : test;
test_(name, async () => {
await editor!.update(() => {
const root = $getRoot();
const paragraph = root.getFirstChild<ParagraphNode>()!;
const textNode = $createTextNode('foo');
// Note: line break can't be selected by the DOM
const linebreak = $createLineBreakNode();
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const anchor = selection.anchor;
const focus = selection.focus;
paragraph.append(textNode, linebreak);
fnBefore(paragraph, textNode);
anchor.set(paragraph.getKey(), anchorOffset, 'element');
focus.set(paragraph.getKey(), focusOffset, 'element');
const {
expectedAnchor,
expectedAnchorOffset,
expectedFocus,
expectedFocusOffset,
} = fn(paragraph, textNode);
if (invertSelection !== true) {
expect(selection.anchor.key).toBe(expectedAnchor.__key);
expect(selection.anchor.offset).toBe(expectedAnchorOffset);
expect(selection.focus.key).toBe(expectedFocus.__key);
expect(selection.focus.offset).toBe(expectedFocusOffset);
} else {
expect(selection.anchor.key).toBe(expectedFocus.__key);
expect(selection.anchor.offset).toBe(expectedFocusOffset);
expect(selection.focus.key).toBe(expectedAnchor.__key);
expect(selection.focus.offset).toBe(expectedAnchorOffset);
}
});
});
},
);
});
describe('Selection correctly resolves to a sibling ElementNode when a node is removed', () => {
test('', async () => {
await editor!.update(() => {
const root = $getRoot();
const listNode = $createListNode('bullet');
const listItemNode = $createListItemNode();
const paragraph = $createParagraphNode();
root.append(listNode);
listNode.append(listItemNode);
listItemNode.select();
listNode.insertAfter(paragraph);
listItemNode.remove();
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
expect(selection.anchor.getNode().__type).toBe('paragraph');
expect(selection.focus.getNode().__type).toBe('paragraph');
});
});
});
describe('Selection correctly resolves to a sibling ElementNode when a selected node child is removed', () => {
test('', async () => {
let paragraphNodeKey: string;
await editor!.update(() => {
const root = $getRoot();
const paragraphNode = $createParagraphNode();
paragraphNodeKey = paragraphNode.__key;
const listNode = $createListNode('number');
const listItemNode1 = $createListItemNode();
const textNode1 = $createTextNode('foo');
const listItemNode2 = $createListItemNode();
const listNode2 = $createListNode('number');
const listItemNode2x1 = $createListItemNode();
listNode.append(listItemNode1, listItemNode2);
listItemNode1.append(textNode1);
listItemNode2.append(listNode2);
listNode2.append(listItemNode2x1);
root.append(paragraphNode, listNode);
listItemNode2.select();
listNode.remove();
});
await editor!.getEditorState().read(() => {
const selection = $assertRangeSelection($getSelection());
expect(selection.anchor.key).toBe(paragraphNodeKey);
expect(selection.focus.key).toBe(paragraphNodeKey);
});
});
});
describe('Selection correctly resolves to a sibling ElementNode that has multiple children with the correct offset when a node is removed', () => {
test('', async () => {
await editor!.update(() => {
// Arrange
// Root
// |- Paragraph
// |- Link
// |- Text
// |- LineBreak
// |- Text
// |- Text
const root = $getRoot();
const paragraph = $createParagraphNode();
const link = $createLinkNode('bullet');
const textOne = $createTextNode('Hello');
const br = $createLineBreakNode();
const textTwo = $createTextNode('world');
const textThree = $createTextNode(' ');
root.append(paragraph);
link.append(textOne);
link.append(br);
link.append(textTwo);
paragraph.append(link);
paragraph.append(textThree);
textThree.select();
// Act
textThree.remove();
// Assert
const expectedKey = link.getKey();
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const {anchor, focus} = selection;
expect(anchor.getNode().getKey()).toBe(expectedKey);
expect(focus.getNode().getKey()).toBe(expectedKey);
expect(anchor.offset).toBe(3);
expect(focus.offset).toBe(3);
});
});
});
test('isBackward', async () => {
await editor!.update(() => {
const root = $getRoot();
const paragraph = root.getFirstChild<ParagraphNode>()!;
const paragraphKey = paragraph.getKey();
const textNode = $createTextNode('foo');
const textNodeKey = textNode.getKey();
// Note: line break can't be selected by the DOM
const linebreak = $createLineBreakNode();
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const anchor = selection.anchor;
const focus = selection.focus;
paragraph.append(textNode, linebreak);
anchor.set(textNodeKey, 0, 'text');
focus.set(textNodeKey, 0, 'text');
expect(selection.isBackward()).toBe(false);
anchor.set(paragraphKey, 1, 'element');
focus.set(paragraphKey, 1, 'element');
expect(selection.isBackward()).toBe(false);
anchor.set(paragraphKey, 0, 'element');
focus.set(paragraphKey, 1, 'element');
expect(selection.isBackward()).toBe(false);
anchor.set(paragraphKey, 1, 'element');
focus.set(paragraphKey, 0, 'element');
expect(selection.isBackward()).toBe(true);
});
});
describe('Decorator text content for selection', () => {
const baseCases: {
name: string;
fn: (opts: {
textNode1: TextNode;
textNode2: TextNode;
decorator: DecoratorNode<unknown>;
paragraph: ParagraphNode;
anchor: PointType;
focus: PointType;
}) => string;
invertSelection?: true;
}[] = [
{
fn: ({textNode1, anchor, focus}) => {
anchor.set(textNode1.getKey(), 1, 'text');
focus.set(textNode1.getKey(), 1, 'text');
return '';
},
name: 'Not included if cursor right before it',
},
{
fn: ({textNode2, anchor, focus}) => {
anchor.set(textNode2.getKey(), 0, 'text');
focus.set(textNode2.getKey(), 0, 'text');
return '';
},
name: 'Not included if cursor right after it',
},
{
fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
anchor.set(textNode1.getKey(), 1, 'text');
focus.set(textNode2.getKey(), 0, 'text');
return decorator.getTextContent();
},
name: 'Included if decorator is selected within text',
},
{
fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
anchor.set(textNode1.getKey(), 0, 'text');
focus.set(textNode2.getKey(), 0, 'text');
return textNode1.getTextContent() + decorator.getTextContent();
},
name: 'Included if decorator is selected with another node before it',
},
{
fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
anchor.set(textNode1.getKey(), 1, 'text');
focus.set(textNode2.getKey(), 1, 'text');
return decorator.getTextContent() + textNode2.getTextContent();
},
name: 'Included if decorator is selected with another node after it',
},
{
fn: ({paragraph, textNode1, textNode2, decorator, anchor, focus}) => {
textNode1.remove();
textNode2.remove();
anchor.set(paragraph.getKey(), 0, 'element');
focus.set(paragraph.getKey(), 1, 'element');
return decorator.getTextContent();
},
name: 'Included if decorator is selected as the only node',
},
];
baseCases
.flatMap((testCase) => {
const inverse = {
...testCase,
invertSelection: true,
name: testCase.name + ' (inverse selection)',
};
return [testCase, inverse];
})
.forEach(({name, fn, invertSelection}) => {
it(name, async () => {
await editor!.update(() => {
const root = $getRoot();
const paragraph = root.getFirstChild<ParagraphNode>()!;
const textNode1 = $createTextNode('1');
const textNode2 = $createTextNode('2');
const decorator = $createTestDecoratorNode();
paragraph.append(textNode1, decorator, textNode2);
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const expectedTextContent = fn({
anchor: invertSelection ? selection.focus : selection.anchor,
decorator,
focus: invertSelection ? selection.anchor : selection.focus,
paragraph,
textNode1,
textNode2,
});
expect(selection.getTextContent()).toBe(expectedTextContent);
});
});
});
});
describe('insertParagraph', () => {
test('three text nodes at offset 0 on third node', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode('Hello ');
const text2 = $createTextNode('awesome');
text2.toggleFormat('bold');
const text3 = $createTextNode(' world');
paragraph.append(text, text2, text3);
root.append(paragraph);
$setAnchorPoint({
key: text3.getKey(),
offset: 0,
type: 'text',
});
$setFocusPoint({
key: text3.getKey(),
offset: 0,
type: 'text',
});
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
selection.insertParagraph();
});
expect(element.innerHTML).toBe(
'<p><span data-lexical-text="true">Hello </span><strong data-lexical-text="true">awesome</strong></p><p><span data-lexical-text="true"> world</span></p>',
);
});
test('four text nodes at offset 0 on third node', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode('Hello ');
const text2 = $createTextNode('awesome ');
text2.toggleFormat('bold');
const text3 = $createTextNode('beautiful');
const text4 = $createTextNode(' world');
text4.toggleFormat('bold');
paragraph.append(text, text2, text3, text4);
root.append(paragraph);
$setAnchorPoint({
key: text3.getKey(),
offset: 0,
type: 'text',
});
$setFocusPoint({
key: text3.getKey(),
offset: 0,
type: 'text',
});
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
selection.insertParagraph();
});
expect(element.innerHTML).toBe(
'<p><span data-lexical-text="true">Hello </span><strong data-lexical-text="true">awesome </strong></p><p><span data-lexical-text="true">beautiful</span><strong data-lexical-text="true"> world</strong></p>',
);
});
it('adjust offset for inline elements text formatting', async () => {
await init();
await editor!.update(() => {
const root = $getRoot();
const text1 = $createTextNode('--');
const text2 = $createTextNode('abc');
const text3 = $createTextNode('--');
root.append(
$createParagraphNode().append(
text1,
$createLinkNode('https://lexical.dev').append(text2),
text3,
),
);
$setAnchorPoint({
key: text1.getKey(),
offset: 2,
type: 'text',
});
$setFocusPoint({
key: text3.getKey(),
offset: 0,
type: 'text',
});
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
selection.formatText('bold');
expect(text2.hasFormat('bold')).toBe(true);
});
});
});
describe('Node.replace', () => {
let text1: TextNode,
text2: TextNode,
text3: TextNode,
paragraph: ParagraphNode,
testEditor: LexicalEditor;
beforeEach(async () => {
testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
paragraph = $createParagraphNode();
text1 = $createTextNode('Hello ');
text2 = $createTextNode('awesome');
text2.toggleFormat('bold');
text3 = $createTextNode(' world');
paragraph.append(text1, text2, text3);
root.append(paragraph);
});
});
[
{
fn: () => {
text2.select(1, 1);
text2.replace($createTestDecoratorNode());
return {
key: text3.__key,
offset: 0,
};
},
name: 'moves selection to to next text node if replacing with decorator',
},
{
fn: () => {
text3.replace($createTestDecoratorNode());
text2.select(1, 1);
text2.replace($createTestDecoratorNode());
return {
key: paragraph.__key,
offset: 2,
};
},
name: 'moves selection to parent if next sibling is not a text node',
},
].forEach((testCase) => {
test(testCase.name, async () => {
await testEditor.update(() => {
const {key, offset} = testCase.fn();
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
expect(selection.anchor.key).toBe(key);
expect(selection.anchor.offset).toBe(offset);
expect(selection.focus.key).toBe(key);
expect(selection.focus.offset).toBe(offset);
});
});
});
});
describe('Testing that $getStyleObjectFromRawCSS handles unformatted css text ', () => {
test('', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const textNode = $createTextNode('Hello, World!');
textNode.setStyle(
' font-family : Arial ; color : red ;top : 50px',
);
$addNodeStyle(textNode);
paragraph.append(textNode);
root.append(paragraph);
const selection = $createRangeSelection();
$setSelection(selection);
selection.insertParagraph();
$setAnchorPoint({
key: textNode.getKey(),
offset: 0,
type: 'text',
});
$setFocusPoint({
key: textNode.getKey(),
offset: 10,
type: 'text',
});
const cssFontFamilyValue = $getSelectionStyleValueForProperty(
selection,
'font-family',
'',
);
expect(cssFontFamilyValue).toBe('Arial');
const cssColorValue = $getSelectionStyleValueForProperty(
selection,
'color',
'',
);
expect(cssColorValue).toBe('red');
const cssTopValue = $getSelectionStyleValueForProperty(
selection,
'top',
'',
);
expect(cssTopValue).toBe('50px');
});
});
});
describe('Testing that getStyleObjectFromRawCSS handles values with colons', () => {
test('', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const textNode = $createTextNode('Hello, World!');
textNode.setStyle(
'font-family: double:prefix:Arial; color: color:white; font-size: 30px',
);
$addNodeStyle(textNode);
paragraph.append(textNode);
root.append(paragraph);
const selection = $createRangeSelection();
$setSelection(selection);
selection.insertParagraph();
$setAnchorPoint({
key: textNode.getKey(),
offset: 0,
type: 'text',
});
$setFocusPoint({
key: textNode.getKey(),
offset: 10,
type: 'text',
});
const cssFontFamilyValue = $getSelectionStyleValueForProperty(
selection,
'font-family',
'',
);
expect(cssFontFamilyValue).toBe('double:prefix:Arial');
const cssColorValue = $getSelectionStyleValueForProperty(
selection,
'color',
'',
);
expect(cssColorValue).toBe('color:white');
const cssFontSizeValue = $getSelectionStyleValueForProperty(
selection,
'font-size',
'',
);
expect(cssFontSizeValue).toBe('30px');
});
});
});
describe('$patchStyle', () => {
it('should patch the style with the new style object', async () => {
await editor!.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const textNode = $createTextNode('Hello, World!');
textNode.setStyle('font-family: serif; color: red;');
$addNodeStyle(textNode);
paragraph.append(textNode);
root.append(paragraph);
const selection = $createRangeSelection();
$setSelection(selection);
selection.insertParagraph();
$setAnchorPoint({
key: textNode.getKey(),
offset: 0,
type: 'text',
});
$setFocusPoint({
key: textNode.getKey(),
offset: 10,
type: 'text',
});
const newStyle = {
color: 'blue',
'font-family': 'Arial',
};
$patchStyleText(selection, newStyle);
const cssFontFamilyValue = $getSelectionStyleValueForProperty(
selection,
'font-family',
'',
);
expect(cssFontFamilyValue).toBe('Arial');
const cssColorValue = $getSelectionStyleValueForProperty(
selection,
'color',
'',
);
expect(cssColorValue).toBe('blue');
});
});
it('should patch the style with property function', async () => {
await editor!.update(() => {
const currentColor = 'red';
const nextColor = 'blue';
const root = $getRoot();
const paragraph = $createParagraphNode();
const textNode = $createTextNode('Hello, World!');
textNode.setStyle(`color: ${currentColor};`);
$addNodeStyle(textNode);
paragraph.append(textNode);
root.append(paragraph);
const selection = $createRangeSelection();
$setSelection(selection);
selection.insertParagraph();
$setAnchorPoint({
key: textNode.getKey(),
offset: 0,
type: 'text',
});
$setFocusPoint({
key: textNode.getKey(),
offset: 10,
type: 'text',
});
const newStyle = {
color: jest.fn(
(current: string | null, target: LexicalNode | RangeSelection) =>
nextColor,
),
};
$patchStyleText(selection, newStyle);
const cssColorValue = $getSelectionStyleValueForProperty(
selection,
'color',
'',
);
expect(cssColorValue).toBe(nextColor);
expect(newStyle.color).toHaveBeenCalledTimes(1);
const lastCall = newStyle.color.mock.lastCall!;
expect(lastCall[0]).toBe(currentColor);
// @ts-ignore - It expected to be a LexicalNode
expect($isTextNode(lastCall[1])).toBeTruthy();
});
});
});
describe('$setBlocksType', () => {
test('Collapsed selection in text', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph1 = $createParagraphNode();
const text1 = $createTextNode('text 1');
const paragraph2 = $createParagraphNode();
const text2 = $createTextNode('text 2');
root.append(paragraph1, paragraph2);
paragraph1.append(text1);
paragraph2.append(text2);
const selection = $createRangeSelection();
$setSelection(selection);
$setAnchorPoint({
key: text1.__key,
offset: text1.__text.length,
type: 'text',
});
$setFocusPoint({
key: text1.__key,
offset: text1.__text.length,
type: 'text',
});
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
const rootChildren = root.getChildren();
expect(rootChildren[0].__type).toBe('heading');
expect(rootChildren[1].__type).toBe('paragraph');
expect(rootChildren.length).toBe(2);
});
});
test('Collapsed selection in element', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph1 = $createParagraphNode();
const paragraph2 = $createParagraphNode();
root.append(paragraph1, paragraph2);
const selection = $createRangeSelection();
$setSelection(selection);
$setAnchorPoint({
key: 'root',
offset: 0,
type: 'element',
});
$setFocusPoint({
key: 'root',
offset: 0,
type: 'element',
});
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
const rootChildren = root.getChildren();
expect(rootChildren[0].__type).toBe('heading');
expect(rootChildren[1].__type).toBe('paragraph');
expect(rootChildren.length).toBe(2);
});
});
test('Two elements, same top-element', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph1 = $createParagraphNode();
const text1 = $createTextNode('text 1');
const paragraph2 = $createParagraphNode();
const text2 = $createTextNode('text 2');
root.append(paragraph1, paragraph2);
paragraph1.append(text1);
paragraph2.append(text2);
const selection = $createRangeSelection();
$setSelection(selection);
$setAnchorPoint({
key: text1.__key,
offset: 0,
type: 'text',
});
$setFocusPoint({
key: text2.__key,
offset: text1.__text.length,
type: 'text',
});
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
const rootChildren = root.getChildren();
expect(rootChildren[0].__type).toBe('heading');
expect(rootChildren[1].__type).toBe('heading');
expect(rootChildren.length).toBe(2);
});
});
test('Two empty elements, same top-element', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph1 = $createParagraphNode();
const paragraph2 = $createParagraphNode();
root.append(paragraph1, paragraph2);
const selection = $createRangeSelection();
$setSelection(selection);
$setAnchorPoint({
key: paragraph1.__key,
offset: 0,
type: 'element',
});
$setFocusPoint({
key: paragraph2.__key,
offset: 0,
type: 'element',
});
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
const rootChildren = root.getChildren();
expect(rootChildren[0].__type).toBe('heading');
expect(rootChildren[1].__type).toBe('heading');
expect(rootChildren.length).toBe(2);
const sel = $getSelection()!;
expect(sel.getNodes().length).toBe(2);
});
});
test('Two elements, same top-element', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph1 = $createParagraphNode();
const text1 = $createTextNode('text 1');
const paragraph2 = $createParagraphNode();
const text2 = $createTextNode('text 2');
root.append(paragraph1, paragraph2);
paragraph1.append(text1);
paragraph2.append(text2);
const selection = $createRangeSelection();
$setSelection(selection);
$setAnchorPoint({
key: text1.__key,
offset: 0,
type: 'text',
});
$setFocusPoint({
key: text2.__key,
offset: text1.__text.length,
type: 'text',
});
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
const rootChildren = root.getChildren();
expect(rootChildren[0].__type).toBe('heading');
expect(rootChildren[1].__type).toBe('heading');
expect(rootChildren.length).toBe(2);
});
});
test('Collapsed in element inside top-element', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const table = $createTableNodeWithDimensions(1, 1);
const row = table.getFirstChild();
invariant($isElementNode(row));
const column = row.getFirstChild();
invariant($isElementNode(column));
const paragraph = column.getFirstChild();
invariant($isElementNode(paragraph));
if (paragraph.getFirstChild()) {
paragraph.getFirstChild()!.remove();
}
root.append(table);
const selection = $createRangeSelection();
$setSelection(selection);
$setAnchorPoint({
key: paragraph.__key,
offset: 0,
type: 'element',
});
$setFocusPoint({
key: paragraph.__key,
offset: 0,
type: 'element',
});
const columnChildrenPrev = column.getChildren();
expect(columnChildrenPrev[0].__type).toBe('paragraph');
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
const columnChildrenAfter = column.getChildren();
expect(columnChildrenAfter[0].__type).toBe('heading');
expect(columnChildrenAfter.length).toBe(1);
});
});
test('Collapsed in text inside top-element', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const table = $createTableNodeWithDimensions(1, 1);
const row = table.getFirstChild();
invariant($isElementNode(row));
const column = row.getFirstChild();
invariant($isElementNode(column));
const paragraph = column.getFirstChild();
invariant($isElementNode(paragraph));
const text = $createTextNode('foo');
root.append(table);
paragraph.append(text);
const selectionz = $createRangeSelection();
$setSelection(selectionz);
$setAnchorPoint({
key: text.__key,
offset: text.__text.length,
type: 'text',
});
$setFocusPoint({
key: text.__key,
offset: text.__text.length,
type: 'text',
});
const selection = $getSelection() as RangeSelection;
const columnChildrenPrev = column.getChildren();
expect(columnChildrenPrev[0].__type).toBe('paragraph');
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
const columnChildrenAfter = column.getChildren();
expect(columnChildrenAfter[0].__type).toBe('heading');
expect(columnChildrenAfter.length).toBe(1);
});
});
test('Full editor selection with a mix of top-elements', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph1 = $createParagraphNode();
const paragraph2 = $createParagraphNode();
const text1 = $createTextNode();
const text2 = $createTextNode();
paragraph1.append(text1);
paragraph2.append(text2);
root.append(paragraph1, paragraph2);
const table = $createTableNodeWithDimensions(1, 2);
const row = table.getFirstChild();
invariant($isElementNode(row));
const columns = row.getChildren();
root.append(table);
const column1 = columns[0];
const paragraph3 = $createParagraphNode();
const paragraph4 = $createParagraphNode();
const text3 = $createTextNode();
const text4 = $createTextNode();
paragraph1.append(text3);
paragraph2.append(text4);
invariant($isElementNode(column1));
column1.append(paragraph3, paragraph4);
const column2 = columns[1];
const paragraph5 = $createParagraphNode();
const paragraph6 = $createParagraphNode();
invariant($isElementNode(column2));
column2.append(paragraph5, paragraph6);
const paragraph7 = $createParagraphNode();
root.append(paragraph7);
const selectionz = $createRangeSelection();
$setSelection(selectionz);
$setAnchorPoint({
key: paragraph1.__key,
offset: 0,
type: 'element',
});
$setFocusPoint({
key: paragraph7.__key,
offset: 0,
type: 'element',
});
const selection = $getSelection() as RangeSelection;
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
expect(JSON.stringify(testEditor._pendingEditorState?.toJSON())).toBe(
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"children":[{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"root","version":1}}',
);
});
});
test('Paragraph with links to heading with links', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text1 = $createTextNode('Links: ');
const text2 = $createTextNode('link1');
const text3 = $createTextNode('link2');
root.append(
paragraph.append(
text1,
$createLinkNode('https://lexical.dev').append(text2),
$createTextNode(' '),
$createLinkNode('https://playground.lexical.dev').append(text3),
),
);
const paragraphChildrenKeys = [...paragraph.getChildrenKeys()];
const selection = $createRangeSelection();
$setSelection(selection);
$setAnchorPoint({
key: text1.getKey(),
offset: 1,
type: 'text',
});
$setFocusPoint({
key: text3.getKey(),
offset: 1,
type: 'text',
});
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
const rootChildren = root.getChildren();
expect(rootChildren.length).toBe(1);
invariant($isElementNode(rootChildren[0]));
expect(rootChildren[0].getType()).toBe('heading');
expect(rootChildren[0].getChildrenKeys()).toEqual(
paragraphChildrenKeys,
);
});
});
test('Nested list', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const ul1 = $createListNode('bullet');
const text1 = $createTextNode('1');
const li1 = $createListItemNode().append(text1);
const li1_wrapper = $createListItemNode();
const ul2 = $createListNode('bullet');
const text1_1 = $createTextNode('1.1');
const li1_1 = $createListItemNode().append(text1_1);
ul1.append(li1, li1_wrapper);
li1_wrapper.append(ul2);
ul2.append(li1_1);
root.append(ul1);
const selection = $createRangeSelection();
$setSelection(selection);
$setAnchorPoint({
key: text1.getKey(),
offset: 1,
type: 'text',
});
$setFocusPoint({
key: text1_1.getKey(),
offset: 1,
type: 'text',
});
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
});
expect(element.innerHTML).toStrictEqual(
`<h1><span data-lexical-text="true">1</span></h1><h1 style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">1.1</span></h1>`,
);
});
test('Nested list with listItem twice indented from his father', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
await testEditor.update(() => {
const root = $getRoot();
const ul1 = $createListNode('bullet');
const li1_wrapper = $createListItemNode();
const ul2 = $createListNode('bullet');
const text1_1 = $createTextNode('1.1');
const li1_1 = $createListItemNode().append(text1_1);
ul1.append(li1_wrapper);
li1_wrapper.append(ul2);
ul2.append(li1_1);
root.append(ul1);
const selection = $createRangeSelection();
$setSelection(selection);
$setAnchorPoint({
key: text1_1.getKey(),
offset: 1,
type: 'text',
});
$setFocusPoint({
key: text1_1.getKey(),
offset: 1,
type: 'text',
});
$setBlocksType(selection, () => {
return $createHeadingNode('h1');
});
});
expect(element.innerHTML).toStrictEqual(
`<h1 style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">1.1</span></h1>`,
);
});
});
});