2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import {$createLinkNode} from '@lexical/link';
10 import {$createListItemNode, $createListNode} from '@lexical/list';
11 import {$createHeadingNode, registerRichText} from '@lexical/rich-text';
14 $getSelectionStyleValueForProperty,
17 } from '@lexical/selection';
18 import {$createTableNodeWithDimensions} from '@lexical/table';
22 $createRangeSelection,
25 $getSelection, $insertNodes,
40 $assertRangeSelection,
41 $createTestDecoratorNode,
42 $createTestElementNode,
46 } from 'lexical/__tests__/utils';
52 convertToSegmentedNode,
72 setNativeSelectionWithPaths,
75 import {createEmptyHistoryState, registerHistory} from "@lexical/history";
76 import {mergeRegister} from "@lexical/utils";
78 interface ExpectedSelection {
85 initializeClipboard();
87 jest.mock('lexical/shared/environment', () => {
88 const originalModule = jest.requireActual('lexical/shared/environment');
90 return {...originalModule, IS_FIREFOX: true};
93 Range.prototype.getBoundingClientRect = function (): DOMRect {
112 describe('LexicalSelection tests', () => {
113 let container: HTMLElement;
114 let root: HTMLDivElement;
115 let editor: LexicalEditor | null = null;
117 beforeEach(async () => {
118 container = document.createElement('div');
119 document.body.appendChild(container);
121 root = document.createElement('div');
122 root.setAttribute('contenteditable', 'true');
123 container.append(root);
128 afterEach(async () => {
129 document.body.removeChild(container);
132 async function init() {
134 editor = createTestEditor({
139 h1: 'editor-heading-h1',
140 h2: 'editor-heading-h2',
141 h3: 'editor-heading-h3',
142 h4: 'editor-heading-h4',
143 h5: 'editor-heading-h5',
144 h6: 'editor-heading-h6',
146 image: 'editor-image',
148 ol: 'editor-list-ol',
149 ul: 'editor-list-ul',
151 listitem: 'editor-listitem',
152 paragraph: 'editor-paragraph',
153 quote: 'editor-quote',
155 bold: 'editor-text-bold',
156 code: 'editor-text-code',
157 hashtag: 'editor-text-hashtag',
158 italic: 'editor-text-italic',
159 link: 'editor-text-link',
160 strikethrough: 'editor-text-strikethrough',
161 underline: 'editor-text-underline',
162 underlineStrikethrough: 'editor-text-underlineStrikethrough',
168 registerHistory(editor, createEmptyHistoryState(), 300),
169 registerRichText(editor),
172 editor.setRootElement(root);
173 editor.update(() => {
174 const p = $createParagraphNode();
177 editor.commitUpdates();
180 // Focus first element
181 setNativeSelectionWithPaths(
182 editor!.getRootElement()!,
190 async function update(fn: () => void) {
192 editor!.commitUpdates();
195 test('Expect initial output to be a block with no text.', () => {
196 expect(container!.innerHTML).toBe(
197 '<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>',
201 function assertSelection(
202 rootElement: HTMLElement,
203 expectedSelection: ExpectedSelection,
205 const actualSelection = window.getSelection()!;
207 expect(actualSelection.anchorNode).toBe(
208 getNodeFromPath(expectedSelection.anchorPath, rootElement),
210 expect(actualSelection.anchorOffset).toBe(expectedSelection.anchorOffset);
211 expect(actualSelection.focusNode).toBe(
212 getNodeFromPath(expectedSelection.focusPath, rootElement),
214 expect(actualSelection.focusOffset).toBe(expectedSelection.focusOffset);
217 // eslint-disable-next-line @typescript-eslint/no-unused-vars
218 const GRAPHEME_SCENARIOS = [
220 description: 'grapheme cluster',
221 // Hangul grapheme cluster.
222 // https://www.compart.com/en/unicode/U+AC01
223 grapheme: '\u1100\u1161\u11A8',
226 description: 'extended grapheme cluster',
227 // Tamil 'ni' grapheme cluster.
228 // http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters
229 grapheme: '\u0BA8\u0BBF',
232 description: 'tailored grapheme cluster',
233 // Devangari 'kshi' tailored grapheme cluster.
234 // http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters
235 grapheme: '\u0915\u094D\u0937\u093F',
238 description: 'Emoji sequence combined using zero-width joiners',
239 // https://emojipedia.org/family-woman-woman-girl-boy/
241 '\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66',
244 description: 'Emoji sequence with skin-tone modifier',
245 // https://emojipedia.org/clapping-hands-medium-skin-tone/
246 grapheme: '\uD83D\uDC4F\uD83C\uDFFD',
253 '<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>',
256 anchorPath: [0, 0, 0],
258 focusPath: [0, 0, 0],
267 name: 'Simple typing',
271 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
272 '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong></p></div>',
275 anchorPath: [0, 0, 0],
277 focusPath: [0, 0, 0],
287 name: 'Simple typing in bold',
291 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
292 '<em class="editor-text-italic" data-lexical-text="true">Hello</em></p></div>',
295 anchorPath: [0, 0, 0],
297 focusPath: [0, 0, 0],
307 name: 'Simple typing in italic',
311 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
312 '<strong class="editor-text-bold editor-text-italic" data-lexical-text="true">Hello</strong></p></div>',
315 anchorPath: [0, 0, 0],
317 focusPath: [0, 0, 0],
328 name: 'Simple typing in italic + bold',
332 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
333 '<span class="editor-text-underline" data-lexical-text="true">Hello</span></p></div>',
336 anchorPath: [0, 0, 0],
338 focusPath: [0, 0, 0],
348 name: 'Simple typing in underline',
352 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
353 '<span class="editor-text-strikethrough" data-lexical-text="true">Hello</span></p></div>',
356 anchorPath: [0, 0, 0],
358 focusPath: [0, 0, 0],
361 formatStrikeThrough(),
368 name: 'Simple typing in strikethrough',
372 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
373 '<span class="editor-text-underlineStrikethrough" data-lexical-text="true">Hello</span></p></div>',
376 anchorPath: [0, 0, 0],
378 focusPath: [0, 0, 0],
382 formatStrikeThrough(),
389 name: 'Simple typing in underline + strikethrough',
393 '<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>',
396 anchorPath: [0, 0, 0],
398 focusPath: [0, 0, 0],
414 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
415 '<span data-lexical-text="true">Dominic Gannaway</span>' +
419 anchorPath: [0, 0, 0],
421 focusPath: [0, 0, 0],
423 inputs: [insertTokenNode('Dominic Gannaway')],
424 name: 'Creation of an token node',
428 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
429 '<span data-lexical-text="true">Dominic Gannaway</span>' +
438 insertText('Dominic Gannaway'),
439 moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16),
440 convertToTokenNode(),
442 name: 'Convert text to an token node',
446 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
447 '<span data-lexical-text="true">Dominic Gannaway</span>' +
455 inputs: [insertSegmentedNode('Dominic Gannaway')],
456 name: 'Creation of a segmented node',
460 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
461 '<span data-lexical-text="true">Dominic Gannaway</span>' +
470 insertText('Dominic Gannaway'),
471 moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16),
472 convertToSegmentedNode(),
474 name: 'Convert text to a segmented node',
478 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
479 '<p class="editor-paragraph"><br></p>' +
480 '<p class="editor-paragraph">' +
481 '<strong class="editor-text-bold" data-lexical-text="true">Hello world</strong>' +
483 '<p class="editor-paragraph"><br></p>' +
493 insertText('Hello world'),
495 moveNativeSelection([0], 0, [2], 0),
498 name: 'Format selection that starts and ends on element and retain selection',
502 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
503 '<p class="editor-paragraph"><br></p>' +
504 '<p class="editor-paragraph">' +
505 '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong>' +
507 '<p class="editor-paragraph">' +
508 '<strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
510 '<p class="editor-paragraph"><br></p>' +
524 moveNativeSelection([0], 0, [3], 0),
527 name: 'Format multiline text selection that starts and ends on element and retain selection',
531 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
532 '<p class="editor-paragraph">' +
533 '<span data-lexical-text="true">He</span>' +
534 '<strong class="editor-text-bold" data-lexical-text="true">llo</strong>' +
536 '<p class="editor-paragraph">' +
537 '<strong class="editor-text-bold" data-lexical-text="true">wo</strong>' +
538 '<span data-lexical-text="true">rld</span>' +
543 anchorPath: [0, 1, 0],
545 focusPath: [1, 0, 0],
551 moveNativeSelection([0, 0, 0], 2, [1, 0, 0], 2),
554 name: 'Format multiline text selection that starts and ends within text',
558 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
559 '<p class="editor-paragraph"><br></p>' +
560 '<p class="editor-paragraph">' +
561 '<span data-lexical-text="true">Hello </span>' +
562 '<strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
564 '<p class="editor-paragraph"><br></p>' +
568 anchorPath: [1, 1, 0],
574 insertText('Hello world'),
576 moveNativeSelection([1, 0, 0], 6, [2], 0),
579 name: 'Format selection that starts on text and ends on element and retain selection',
583 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
584 '<p class="editor-paragraph"><br></p>' +
585 '<p class="editor-paragraph">' +
586 '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong>' +
587 '<span data-lexical-text="true"> world</span>' +
589 '<p class="editor-paragraph"><br></p>' +
595 focusPath: [1, 0, 0],
599 insertText('Hello world'),
601 moveNativeSelection([0], 0, [1, 0, 0], 5),
604 name: 'Format selection that starts on element and ends on text and retain selection',
609 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
610 '<p class="editor-paragraph"><br></p>' +
611 '<p class="editor-paragraph">' +
612 '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong><strong class="editor-text-bold" data-lexical-text="true"> world</strong>' +
614 '<p class="editor-paragraph"><br></p>' +
618 anchorPath: [1, 0, 0],
624 insertTokenNode('Hello'),
625 insertText(' world'),
627 moveNativeSelection([1, 0, 0], 2, [2], 0),
630 name: 'Format selection that starts on middle of token node should format complete node',
635 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
636 '<p class="editor-paragraph"><br></p>' +
637 '<p class="editor-paragraph">' +
638 '<strong class="editor-text-bold" data-lexical-text="true">Hello </strong><strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
640 '<p class="editor-paragraph"><br></p>' +
646 focusPath: [1, 1, 0],
650 insertText('Hello '),
651 insertTokenNode('world'),
653 moveNativeSelection([0], 0, [1, 1, 0], 2),
656 name: 'Format selection that ends on middle of token node should format complete node',
661 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
662 '<p class="editor-paragraph"><br></p>' +
663 '<p class="editor-paragraph">' +
664 '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong><span data-lexical-text="true"> world</span>' +
666 '<p class="editor-paragraph"><br></p>' +
670 anchorPath: [1, 0, 0],
672 focusPath: [1, 0, 0],
676 insertTokenNode('Hello'),
677 insertText(' world'),
679 moveNativeSelection([1, 0, 0], 2, [1, 0, 0], 3),
682 name: 'Format token node if it is the single one selected',
687 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
688 '<p class="editor-paragraph"><br></p>' +
689 '<p class="editor-paragraph">' +
690 '<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>' +
692 '<p class="editor-paragraph"><br></p>' +
702 insertText('Hello '),
703 insertTokenNode('beautiful'),
704 insertText(' world'),
706 moveNativeSelection([0], 0, [2], 0),
709 name: 'Format selection that contains a token node in the middle should format the token node',
714 whitespaceCharacter: ' ',
715 whitespaceName: 'space',
718 whitespaceCharacter: '\u00a0',
719 whitespaceName: 'non-breaking space',
722 whitespaceCharacter: '\u2000',
723 whitespaceName: 'en quad',
726 whitespaceCharacter: '\u2001',
727 whitespaceName: 'em quad',
730 whitespaceCharacter: '\u2002',
731 whitespaceName: 'en space',
734 whitespaceCharacter: '\u2003',
735 whitespaceName: 'em space',
738 whitespaceCharacter: '\u2004',
739 whitespaceName: 'three-per-em space',
742 whitespaceCharacter: '\u2005',
743 whitespaceName: 'four-per-em space',
746 whitespaceCharacter: '\u2006',
747 whitespaceName: 'six-per-em space',
750 whitespaceCharacter: '\u2007',
751 whitespaceName: 'figure space',
754 whitespaceCharacter: '\u2008',
755 whitespaceName: 'punctuation space',
758 whitespaceCharacter: '\u2009',
759 whitespaceName: 'thin space',
762 whitespaceCharacter: '\u200A',
763 whitespaceName: 'hair space',
765 ].flatMap(({whitespaceCharacter, whitespaceName}) => [
767 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(
769 )}</span></p></div>`,
772 anchorPath: [0, 0, 0],
774 focusPath: [0, 0, 0],
777 insertText(`Hello${whitespaceCharacter}world`),
778 deleteWordBackward(1),
780 name: `Type two words separated by a ${whitespaceName}, delete word backward from end`,
783 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(
785 )}world</span></p></div>`,
788 anchorPath: [0, 0, 0],
790 focusPath: [0, 0, 0],
793 insertText(`Hello${whitespaceCharacter}world`),
794 moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
795 deleteWordForward(1),
797 name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning`,
801 '<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>',
804 anchorPath: [0, 0, 0],
806 focusPath: [0, 0, 0],
809 insertText(`Hello${whitespaceCharacter}world`),
810 moveNativeSelection([0, 0, 0], 5, [0, 0, 0], 5),
811 deleteWordForward(1),
813 name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning of preceding whitespace`,
817 '<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>',
820 anchorPath: [0, 0, 0],
822 focusPath: [0, 0, 0],
825 insertText(`Hello${whitespaceCharacter}world`),
826 moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 6),
827 deleteWordBackward(1),
829 name: `Type two words separated by a ${whitespaceName}, delete word backward from end of trailing whitespace`,
833 '<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>',
836 anchorPath: [0, 0, 0],
838 focusPath: [0, 0, 0],
840 inputs: [insertText('Hello world'), deleteWordBackward(1), undo(1)],
841 name: `Type a word, delete it and undo the deletion`,
845 '<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>',
848 anchorPath: [0, 0, 0],
850 focusPath: [0, 0, 0],
853 insertText('Hello world'),
854 deleteWordBackward(1),
858 name: `Type a word, delete it and undo the deletion`,
862 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
863 '<span data-lexical-text="true">this is weird test</span></p></div>',
866 anchorPath: [0, 0, 0],
868 focusPath: [0, 0, 0],
871 insertText('this is weird test'),
872 moveNativeSelection([0, 0, 0], 14, [0, 0, 0], 14),
875 name: 'Type a sentence, move the caret to the middle and move with the arrows to the start',
879 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
880 '<span data-lexical-text="true">Hello </span>' +
881 '<span data-lexical-text="true">Bob</span>' +
885 anchorPath: [0, 1, 0],
887 focusPath: [0, 1, 0],
890 insertText('Hello '),
891 insertTokenNode('Bob'),
896 name: 'Type a text and token text, move the caret to the end of the first text',
900 '<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>',
903 anchorPath: [0, 0, 0],
905 focusPath: [0, 0, 0],
908 pastePlain('ABD\tEFG'),
912 deleteWordForward(1),
914 name: 'Paste text, move selection and delete word forward',
919 suite.forEach((testUnit, i) => {
920 const name = testUnit.name || 'Test case';
922 test(name + ` (#${i + 1})`, async () => {
923 await applySelectionInputs(testUnit.inputs, update, editor!);
925 // Validate HTML matches
926 expect(container.innerHTML).toBe(testUnit.expectedHTML);
928 // Validate selection matches
929 const rootElement = editor!.getRootElement()!;
930 const expectedSelection = testUnit.expectedSelection;
932 assertSelection(rootElement, expectedSelection);
936 test('insert text one selected node element selection', async () => {
937 await editor!.update(() => {
938 const root = $getRoot();
940 const paragraph = root.getFirstChild<ParagraphNode>()!;
942 const elementNode = $createTestElementNode();
943 const text = $createTextNode('foo');
945 paragraph.append(elementNode);
946 elementNode.append(text);
948 const selection = $createRangeSelection();
949 selection.anchor.set(text.__key, 0, 'text');
950 selection.focus.set(paragraph.__key, 1, 'element');
952 selection.insertText('');
954 expect(root.getTextContent()).toBe('');
958 test('getNodes resolves nested block nodes', async () => {
959 await editor!.update(() => {
960 const root = $getRoot();
962 const paragraph = root.getFirstChild<ParagraphNode>()!;
964 const elementNode = $createTestElementNode();
965 const text = $createTextNode();
967 paragraph.append(elementNode);
968 elementNode.append(text);
970 const selectedNodes = $getSelection()!.getNodes();
972 expect(selectedNodes.length).toBe(1);
973 expect(selectedNodes[0].getKey()).toBe(text.getKey());
977 describe('Block selection moves when new nodes are inserted', () => {
980 anchorOffset: number;
983 paragraph: ElementNode,
986 expectedAnchor: LexicalNode;
987 expectedAnchorOffset: number;
988 expectedFocus: LexicalNode;
989 expectedFocusOffset: number;
991 fnBefore?: (paragraph: ElementNode, text: TextNode) => void;
992 invertSelection?: true;
995 // Collapsed selection on end; add/remove/replace beginning
998 fn: (paragraph, text) => {
999 const newText = $createTextNode('2');
1000 text.insertBefore(newText);
1003 expectedAnchor: paragraph,
1004 expectedAnchorOffset: 3,
1005 expectedFocus: paragraph,
1006 expectedFocusOffset: 3,
1010 name: 'insertBefore - Collapsed selection on end; add beginning',
1014 fn: (paragraph, text) => {
1015 const newText = $createTextNode('2');
1016 text.insertAfter(newText);
1019 expectedAnchor: paragraph,
1020 expectedAnchorOffset: 3,
1021 expectedFocus: paragraph,
1022 expectedFocusOffset: 3,
1026 name: 'insertAfter - Collapsed selection on end; add beginning',
1030 fn: (paragraph, text) => {
1034 expectedAnchor: paragraph,
1035 expectedAnchorOffset: 3,
1036 expectedFocus: paragraph,
1037 expectedFocusOffset: 3,
1041 name: 'splitText - Collapsed selection on end; add beginning',
1045 fn: (paragraph, text) => {
1049 expectedAnchor: paragraph,
1050 expectedAnchorOffset: 0,
1051 expectedFocus: paragraph,
1052 expectedFocusOffset: 0,
1056 name: 'remove - Collapsed selection on end; add beginning',
1060 fn: (paragraph, text) => {
1061 const newText = $createTextNode('replacement');
1062 text.replace(newText);
1065 expectedAnchor: paragraph,
1066 expectedAnchorOffset: 1,
1067 expectedFocus: paragraph,
1068 expectedFocusOffset: 1,
1072 name: 'replace - Collapsed selection on end; replace beginning',
1074 // All selected; add/remove/replace on beginning
1077 fn: (paragraph, text) => {
1078 const newText = $createTextNode('2');
1079 text.insertBefore(newText);
1082 expectedAnchor: text,
1083 expectedAnchorOffset: 0,
1084 expectedFocus: paragraph,
1085 expectedFocusOffset: 3,
1089 name: 'insertBefore - All selected; add on beginning',
1093 fn: (paragraph, originalText) => {
1094 const [, text] = originalText.splitText(1);
1097 expectedAnchor: text,
1098 expectedAnchorOffset: 0,
1099 expectedFocus: paragraph,
1100 expectedFocusOffset: 3,
1104 name: 'splitNodes - All selected; add on beginning',
1108 fn: (paragraph, text) => {
1112 expectedAnchor: paragraph,
1113 expectedAnchorOffset: 0,
1114 expectedFocus: paragraph,
1115 expectedFocusOffset: 0,
1119 name: 'remove - All selected; remove on beginning',
1123 fn: (paragraph, text) => {
1124 const newText = $createTextNode('replacement');
1125 text.replace(newText);
1128 expectedAnchor: paragraph,
1129 expectedAnchorOffset: 0,
1130 expectedFocus: paragraph,
1131 expectedFocusOffset: 1,
1135 name: 'replace - All selected; replace on beginning',
1137 // Selection beginning; add/remove/replace on end
1140 fn: (paragraph, originalText1) => {
1141 const originalText2 = originalText1.getPreviousSibling()!;
1142 const lastChild = paragraph.getLastChild()!;
1143 const newText = $createTextNode('2');
1144 lastChild.insertBefore(newText);
1147 expectedAnchor: originalText2,
1148 expectedAnchorOffset: 0,
1149 expectedFocus: originalText1,
1150 expectedFocusOffset: 0,
1153 fnBefore: (paragraph, originalText1) => {
1154 const originalText2 = $createTextNode('bar');
1155 originalText1.insertBefore(originalText2);
1158 name: 'insertBefore - Selection beginning; add on end',
1162 fn: (paragraph, text) => {
1163 const lastChild = paragraph.getLastChild()!;
1164 const newText = $createTextNode('2');
1165 lastChild.insertAfter(newText);
1168 expectedAnchor: text,
1169 expectedAnchorOffset: 0,
1170 expectedFocus: paragraph,
1171 expectedFocusOffset: 1,
1175 name: 'insertAfter - Selection beginning; add on end',
1179 fn: (paragraph, originalText1) => {
1180 const originalText2 = originalText1.getPreviousSibling()!;
1181 const [, text] = originalText1.splitText(1);
1184 expectedAnchor: originalText2,
1185 expectedAnchorOffset: 0,
1186 expectedFocus: text,
1187 expectedFocusOffset: 0,
1190 fnBefore: (paragraph, originalText1) => {
1191 const originalText2 = $createTextNode('bar');
1192 originalText1.insertBefore(originalText2);
1195 name: 'splitText - Selection beginning; add on end',
1199 fn: (paragraph, text) => {
1200 const lastChild = paragraph.getLastChild()!;
1204 expectedAnchor: text,
1205 expectedAnchorOffset: 0,
1206 expectedFocus: text,
1207 expectedFocusOffset: 3,
1211 name: 'remove - Selection beginning; remove on end',
1215 fn: (paragraph, text) => {
1216 const newText = $createTextNode('replacement');
1217 const lastChild = paragraph.getLastChild()!;
1218 lastChild.replace(newText);
1221 expectedAnchor: paragraph,
1222 expectedAnchorOffset: 0,
1223 expectedFocus: paragraph,
1224 expectedFocusOffset: 1,
1228 name: 'replace - Selection beginning; replace on end',
1230 // All selected; add/remove/replace in end offset [1, 2] -> [1, N, 2]
1233 fn: (paragraph, text) => {
1234 const lastChild = paragraph.getLastChild()!;
1235 const newText = $createTextNode('2');
1236 lastChild.insertBefore(newText);
1239 expectedAnchor: text,
1240 expectedAnchorOffset: 0,
1241 expectedFocus: paragraph,
1242 expectedFocusOffset: 2,
1246 name: 'insertBefore - All selected; add in end offset',
1250 fn: (paragraph, text) => {
1251 const newText = $createTextNode('2');
1252 text.insertAfter(newText);
1255 expectedAnchor: text,
1256 expectedAnchorOffset: 0,
1257 expectedFocus: paragraph,
1258 expectedFocusOffset: 2,
1262 name: 'insertAfter - All selected; add in end offset',
1266 fn: (paragraph, originalText1) => {
1267 const originalText2 = originalText1.getPreviousSibling()!;
1268 const [, text] = originalText1.splitText(1);
1271 expectedAnchor: originalText2,
1272 expectedAnchorOffset: 0,
1273 expectedFocus: text,
1274 expectedFocusOffset: 0,
1277 fnBefore: (paragraph, originalText1) => {
1278 const originalText2 = $createTextNode('bar');
1279 originalText1.insertBefore(originalText2);
1282 name: 'splitText - All selected; add in end offset',
1286 fn: (paragraph, originalText1) => {
1287 const lastChild = paragraph.getLastChild()!;
1291 expectedAnchor: originalText1,
1292 expectedAnchorOffset: 0,
1293 expectedFocus: originalText1,
1294 expectedFocusOffset: 3,
1297 fnBefore: (paragraph, originalText1) => {
1298 const originalText2 = $createTextNode('bar');
1299 originalText1.insertBefore(originalText2);
1302 name: 'remove - All selected; remove in end offset',
1306 fn: (paragraph, originalText1) => {
1307 const newText = $createTextNode('replacement');
1308 const lastChild = paragraph.getLastChild()!;
1309 lastChild.replace(newText);
1312 expectedAnchor: paragraph,
1313 expectedAnchorOffset: 1,
1314 expectedFocus: paragraph,
1315 expectedFocusOffset: 2,
1318 fnBefore: (paragraph, originalText1) => {
1319 const originalText2 = $createTextNode('bar');
1320 originalText1.insertBefore(originalText2);
1323 name: 'replace - All selected; replace in end offset',
1325 // All selected; add/remove/replace in middle [1, 2, 3] -> [1, 2, N, 3]
1328 fn: (paragraph, originalText1) => {
1329 const originalText2 = originalText1.getPreviousSibling()!;
1330 const lastChild = paragraph.getLastChild()!;
1331 const newText = $createTextNode('2');
1332 lastChild.insertBefore(newText);
1335 expectedAnchor: originalText2,
1336 expectedAnchorOffset: 0,
1337 expectedFocus: paragraph,
1338 expectedFocusOffset: 3,
1341 fnBefore: (paragraph, originalText1) => {
1342 const originalText2 = $createTextNode('bar');
1343 originalText1.insertBefore(originalText2);
1346 name: 'insertBefore - All selected; add in middle',
1350 fn: (paragraph, originalText1) => {
1351 const originalText2 = originalText1.getPreviousSibling()!;
1352 const newText = $createTextNode('2');
1353 originalText1.insertAfter(newText);
1356 expectedAnchor: originalText2,
1357 expectedAnchorOffset: 0,
1358 expectedFocus: paragraph,
1359 expectedFocusOffset: 3,
1362 fnBefore: (paragraph, originalText1) => {
1363 const originalText2 = $createTextNode('bar');
1364 originalText1.insertBefore(originalText2);
1367 name: 'insertAfter - All selected; add in middle',
1371 fn: (paragraph, originalText1) => {
1372 const originalText2 = originalText1.getPreviousSibling()!;
1373 originalText1.splitText(1);
1376 expectedAnchor: originalText2,
1377 expectedAnchorOffset: 0,
1378 expectedFocus: paragraph,
1379 expectedFocusOffset: 3,
1382 fnBefore: (paragraph, originalText1) => {
1383 const originalText2 = $createTextNode('bar');
1384 originalText1.insertBefore(originalText2);
1387 name: 'splitText - All selected; add in middle',
1391 fn: (paragraph, originalText1) => {
1392 const originalText2 = originalText1.getPreviousSibling()!;
1393 originalText1.remove();
1396 expectedAnchor: originalText2,
1397 expectedAnchorOffset: 0,
1398 expectedFocus: paragraph,
1399 expectedFocusOffset: 1,
1402 fnBefore: (paragraph, originalText1) => {
1403 const originalText2 = $createTextNode('bar');
1404 originalText1.insertBefore(originalText2);
1407 name: 'remove - All selected; remove in middle',
1411 fn: (paragraph, originalText1) => {
1412 const newText = $createTextNode('replacement');
1413 originalText1.replace(newText);
1416 expectedAnchor: paragraph,
1417 expectedAnchorOffset: 0,
1418 expectedFocus: paragraph,
1419 expectedFocusOffset: 2,
1422 fnBefore: (paragraph, originalText1) => {
1423 const originalText2 = $createTextNode('bar');
1424 originalText1.insertBefore(originalText2);
1427 name: 'replace - All selected; replace in middle',
1432 fn: (paragraph, originalText1) => {
1433 const originalText2 = paragraph.getLastChild()!;
1434 const newText = $createTextNode('new');
1435 originalText1.insertBefore(newText);
1438 expectedAnchor: originalText2,
1439 expectedAnchorOffset: 'bar'.length,
1440 expectedFocus: originalText2,
1441 expectedFocusOffset: 'bar'.length,
1444 fnBefore: (paragraph, originalText1) => {
1445 const originalText2 = $createTextNode('bar');
1446 paragraph.append(originalText2);
1449 name: "Selection resolves to the end of text node when it's at the end (1)",
1453 fn: (paragraph, originalText1) => {
1454 const originalText2 = paragraph.getLastChild()!;
1455 const newText = $createTextNode('new');
1456 originalText1.insertBefore(newText);
1459 expectedAnchor: originalText1,
1460 expectedAnchorOffset: 0,
1461 expectedFocus: originalText2,
1462 expectedFocusOffset: 'bar'.length,
1465 fnBefore: (paragraph, originalText1) => {
1466 const originalText2 = $createTextNode('bar');
1467 paragraph.append(originalText2);
1470 name: "Selection resolves to the end of text node when it's at the end (2)",
1474 fn: (paragraph, originalText1) => {
1475 originalText1.getNextSibling()!.remove();
1478 expectedAnchor: originalText1,
1479 expectedAnchorOffset: 3,
1480 expectedFocus: originalText1,
1481 expectedFocusOffset: 3,
1485 name: 'remove - Remove with collapsed selection at offset #4221',
1489 fn: (paragraph, originalText1) => {
1490 originalText1.getNextSibling()!.remove();
1493 expectedAnchor: originalText1,
1494 expectedAnchorOffset: 0,
1495 expectedFocus: originalText1,
1496 expectedFocusOffset: 3,
1500 name: 'remove - Remove with non-collapsed selection at offset',
1504 .flatMap((testCase) => {
1505 // Test inverse selection
1508 anchorOffset: testCase.focusOffset,
1509 focusOffset: testCase.anchorOffset,
1510 invertSelection: true,
1511 name: testCase.name + ' (inverse selection)',
1513 return [testCase, inverse];
1527 // eslint-disable-next-line no-only-tests/no-only-tests
1528 const test_ = only === true ? test.only : test;
1529 test_(name, async () => {
1530 await editor!.update(() => {
1531 const root = $getRoot();
1533 const paragraph = root.getFirstChild<ParagraphNode>()!;
1534 const textNode = $createTextNode('foo');
1535 // Note: line break can't be selected by the DOM
1536 const linebreak = $createLineBreakNode();
1538 const selection = $getSelection();
1540 if (!$isRangeSelection(selection)) {
1544 const anchor = selection.anchor;
1545 const focus = selection.focus;
1547 paragraph.append(textNode, linebreak);
1549 fnBefore(paragraph, textNode);
1551 anchor.set(paragraph.getKey(), anchorOffset, 'element');
1552 focus.set(paragraph.getKey(), focusOffset, 'element');
1556 expectedAnchorOffset,
1558 expectedFocusOffset,
1559 } = fn(paragraph, textNode);
1561 if (invertSelection !== true) {
1562 expect(selection.anchor.key).toBe(expectedAnchor.__key);
1563 expect(selection.anchor.offset).toBe(expectedAnchorOffset);
1564 expect(selection.focus.key).toBe(expectedFocus.__key);
1565 expect(selection.focus.offset).toBe(expectedFocusOffset);
1567 expect(selection.anchor.key).toBe(expectedFocus.__key);
1568 expect(selection.anchor.offset).toBe(expectedFocusOffset);
1569 expect(selection.focus.key).toBe(expectedAnchor.__key);
1570 expect(selection.focus.offset).toBe(expectedAnchorOffset);
1578 describe('Selection correctly resolves to a sibling ElementNode when a node is removed', () => {
1579 test('', async () => {
1580 await editor!.update(() => {
1581 const root = $getRoot();
1583 const listNode = $createListNode('bullet');
1584 const listItemNode = $createListItemNode();
1585 const paragraph = $createParagraphNode();
1587 root.append(listNode);
1589 listNode.append(listItemNode);
1590 listItemNode.select();
1591 listNode.insertAfter(paragraph);
1592 listItemNode.remove();
1594 const selection = $getSelection();
1596 if (!$isRangeSelection(selection)) {
1600 expect(selection.anchor.getNode().__type).toBe('paragraph');
1601 expect(selection.focus.getNode().__type).toBe('paragraph');
1606 describe('Selection correctly resolves to a sibling ElementNode when a selected node child is removed', () => {
1607 test('', async () => {
1608 let paragraphNodeKey: string;
1609 await editor!.update(() => {
1610 const root = $getRoot();
1612 const paragraphNode = $createParagraphNode();
1613 paragraphNodeKey = paragraphNode.__key;
1614 const listNode = $createListNode('number');
1615 const listItemNode1 = $createListItemNode();
1616 const textNode1 = $createTextNode('foo');
1617 const listItemNode2 = $createListItemNode();
1618 const listNode2 = $createListNode('number');
1619 const listItemNode2x1 = $createListItemNode();
1621 listNode.append(listItemNode1, listItemNode2);
1622 listItemNode1.append(textNode1);
1623 listItemNode2.append(listNode2);
1624 listNode2.append(listItemNode2x1);
1625 root.append(paragraphNode, listNode);
1627 listItemNode2.select();
1631 await editor!.getEditorState().read(() => {
1632 const selection = $assertRangeSelection($getSelection());
1633 expect(selection.anchor.key).toBe(paragraphNodeKey);
1634 expect(selection.focus.key).toBe(paragraphNodeKey);
1639 describe('Selection correctly resolves to a sibling ElementNode that has multiple children with the correct offset when a node is removed', () => {
1640 test('', async () => {
1641 await editor!.update(() => {
1650 const root = $getRoot();
1652 const paragraph = $createParagraphNode();
1653 const link = $createLinkNode('bullet');
1654 const textOne = $createTextNode('Hello');
1655 const br = $createLineBreakNode();
1656 const textTwo = $createTextNode('world');
1657 const textThree = $createTextNode(' ');
1659 root.append(paragraph);
1660 link.append(textOne);
1662 link.append(textTwo);
1664 paragraph.append(link);
1665 paragraph.append(textThree);
1671 const expectedKey = link.getKey();
1673 const selection = $getSelection();
1675 if (!$isRangeSelection(selection)) {
1679 const {anchor, focus} = selection;
1681 expect(anchor.getNode().getKey()).toBe(expectedKey);
1682 expect(focus.getNode().getKey()).toBe(expectedKey);
1683 expect(anchor.offset).toBe(3);
1684 expect(focus.offset).toBe(3);
1689 test('isBackward', async () => {
1690 await editor!.update(() => {
1691 const root = $getRoot();
1693 const paragraph = root.getFirstChild<ParagraphNode>()!;
1694 const paragraphKey = paragraph.getKey();
1695 const textNode = $createTextNode('foo');
1696 const textNodeKey = textNode.getKey();
1697 // Note: line break can't be selected by the DOM
1698 const linebreak = $createLineBreakNode();
1700 const selection = $getSelection();
1702 if (!$isRangeSelection(selection)) {
1706 const anchor = selection.anchor;
1707 const focus = selection.focus;
1708 paragraph.append(textNode, linebreak);
1709 anchor.set(textNodeKey, 0, 'text');
1710 focus.set(textNodeKey, 0, 'text');
1712 expect(selection.isBackward()).toBe(false);
1714 anchor.set(paragraphKey, 1, 'element');
1715 focus.set(paragraphKey, 1, 'element');
1717 expect(selection.isBackward()).toBe(false);
1719 anchor.set(paragraphKey, 0, 'element');
1720 focus.set(paragraphKey, 1, 'element');
1722 expect(selection.isBackward()).toBe(false);
1724 anchor.set(paragraphKey, 1, 'element');
1725 focus.set(paragraphKey, 0, 'element');
1727 expect(selection.isBackward()).toBe(true);
1731 describe('Decorator text content for selection', () => {
1735 textNode1: TextNode;
1736 textNode2: TextNode;
1737 decorator: DecoratorNode<unknown>;
1738 paragraph: ParagraphNode;
1742 invertSelection?: true;
1745 fn: ({textNode1, anchor, focus}) => {
1746 anchor.set(textNode1.getKey(), 1, 'text');
1747 focus.set(textNode1.getKey(), 1, 'text');
1751 name: 'Not included if cursor right before it',
1754 fn: ({textNode2, anchor, focus}) => {
1755 anchor.set(textNode2.getKey(), 0, 'text');
1756 focus.set(textNode2.getKey(), 0, 'text');
1760 name: 'Not included if cursor right after it',
1763 fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
1764 anchor.set(textNode1.getKey(), 1, 'text');
1765 focus.set(textNode2.getKey(), 0, 'text');
1767 return decorator.getTextContent();
1769 name: 'Included if decorator is selected within text',
1772 fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
1773 anchor.set(textNode1.getKey(), 0, 'text');
1774 focus.set(textNode2.getKey(), 0, 'text');
1776 return textNode1.getTextContent() + decorator.getTextContent();
1778 name: 'Included if decorator is selected with another node before it',
1781 fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
1782 anchor.set(textNode1.getKey(), 1, 'text');
1783 focus.set(textNode2.getKey(), 1, 'text');
1785 return decorator.getTextContent() + textNode2.getTextContent();
1787 name: 'Included if decorator is selected with another node after it',
1790 fn: ({paragraph, textNode1, textNode2, decorator, anchor, focus}) => {
1793 anchor.set(paragraph.getKey(), 0, 'element');
1794 focus.set(paragraph.getKey(), 1, 'element');
1796 return decorator.getTextContent();
1798 name: 'Included if decorator is selected as the only node',
1802 .flatMap((testCase) => {
1805 invertSelection: true,
1806 name: testCase.name + ' (inverse selection)',
1809 return [testCase, inverse];
1811 .forEach(({name, fn, invertSelection}) => {
1812 it(name, async () => {
1813 await editor!.update(() => {
1814 const root = $getRoot();
1816 const paragraph = root.getFirstChild<ParagraphNode>()!;
1817 const textNode1 = $createTextNode('1');
1818 const textNode2 = $createTextNode('2');
1819 const decorator = $createTestDecoratorNode();
1821 paragraph.append(textNode1, decorator, textNode2);
1823 const selection = $getSelection();
1825 if (!$isRangeSelection(selection)) {
1829 const expectedTextContent = fn({
1830 anchor: invertSelection ? selection.focus : selection.anchor,
1832 focus: invertSelection ? selection.anchor : selection.focus,
1838 expect(selection.getTextContent()).toBe(expectedTextContent);
1844 describe('insertParagraph', () => {
1845 test('three text nodes at offset 0 on third node', async () => {
1846 const testEditor = createTestEditor();
1847 const element = document.createElement('div');
1848 testEditor.setRootElement(element);
1850 await testEditor.update(() => {
1851 const root = $getRoot();
1853 const paragraph = $createParagraphNode();
1854 const text = $createTextNode('Hello ');
1855 const text2 = $createTextNode('awesome');
1857 text2.toggleFormat('bold');
1859 const text3 = $createTextNode(' world');
1861 paragraph.append(text, text2, text3);
1862 root.append(paragraph);
1865 key: text3.getKey(),
1871 key: text3.getKey(),
1876 const selection = $getSelection();
1878 if (!$isRangeSelection(selection)) {
1882 selection.insertParagraph();
1885 expect(element.innerHTML).toBe(
1886 '<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>',
1890 test('four text nodes at offset 0 on third node', async () => {
1891 const testEditor = createTestEditor();
1892 const element = document.createElement('div');
1893 testEditor.setRootElement(element);
1895 await testEditor.update(() => {
1896 const root = $getRoot();
1898 const paragraph = $createParagraphNode();
1899 const text = $createTextNode('Hello ');
1900 const text2 = $createTextNode('awesome ');
1902 text2.toggleFormat('bold');
1904 const text3 = $createTextNode('beautiful');
1905 const text4 = $createTextNode(' world');
1907 text4.toggleFormat('bold');
1909 paragraph.append(text, text2, text3, text4);
1910 root.append(paragraph);
1913 key: text3.getKey(),
1919 key: text3.getKey(),
1924 const selection = $getSelection();
1926 if (!$isRangeSelection(selection)) {
1930 selection.insertParagraph();
1933 expect(element.innerHTML).toBe(
1934 '<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>',
1938 it('adjust offset for inline elements text formatting', async () => {
1941 await editor!.update(() => {
1942 const root = $getRoot();
1944 const text1 = $createTextNode('--');
1945 const text2 = $createTextNode('abc');
1946 const text3 = $createTextNode('--');
1949 $createParagraphNode().append(
1951 $createLinkNode('https://lexical.dev').append(text2),
1957 key: text1.getKey(),
1963 key: text3.getKey(),
1968 const selection = $getSelection();
1970 if (!$isRangeSelection(selection)) {
1974 selection.formatText('bold');
1976 expect(text2.hasFormat('bold')).toBe(true);
1981 describe('Node.replace', () => {
1982 let text1: TextNode,
1985 paragraph: ParagraphNode,
1986 testEditor: LexicalEditor;
1988 beforeEach(async () => {
1989 testEditor = createTestEditor();
1991 const element = document.createElement('div');
1992 testEditor.setRootElement(element);
1994 await testEditor.update(() => {
1995 const root = $getRoot();
1997 paragraph = $createParagraphNode();
1998 text1 = $createTextNode('Hello ');
1999 text2 = $createTextNode('awesome');
2001 text2.toggleFormat('bold');
2003 text3 = $createTextNode(' world');
2005 paragraph.append(text1, text2, text3);
2006 root.append(paragraph);
2013 text2.replace($createTestDecoratorNode());
2020 name: 'moves selection to to next text node if replacing with decorator',
2024 text3.replace($createTestDecoratorNode());
2026 text2.replace($createTestDecoratorNode());
2029 key: paragraph.__key,
2033 name: 'moves selection to parent if next sibling is not a text node',
2035 ].forEach((testCase) => {
2036 test(testCase.name, async () => {
2037 await testEditor.update(() => {
2038 const {key, offset} = testCase.fn();
2040 const selection = $getSelection();
2042 if (!$isRangeSelection(selection)) {
2046 expect(selection.anchor.key).toBe(key);
2047 expect(selection.anchor.offset).toBe(offset);
2048 expect(selection.focus.key).toBe(key);
2049 expect(selection.focus.offset).toBe(offset);
2055 describe('Testing that $getStyleObjectFromRawCSS handles unformatted css text ', () => {
2056 test('', async () => {
2057 const testEditor = createTestEditor();
2058 const element = document.createElement('div');
2059 testEditor.setRootElement(element);
2061 await testEditor.update(() => {
2062 const root = $getRoot();
2063 const paragraph = $createParagraphNode();
2064 const textNode = $createTextNode('Hello, World!');
2066 ' font-family : Arial ; color : red ;top : 50px',
2068 $addNodeStyle(textNode);
2069 paragraph.append(textNode);
2070 root.append(paragraph);
2072 const selection = $createRangeSelection();
2073 $setSelection(selection);
2074 selection.insertParagraph();
2076 key: textNode.getKey(),
2082 key: textNode.getKey(),
2087 const cssFontFamilyValue = $getSelectionStyleValueForProperty(
2092 expect(cssFontFamilyValue).toBe('Arial');
2094 const cssColorValue = $getSelectionStyleValueForProperty(
2099 expect(cssColorValue).toBe('red');
2101 const cssTopValue = $getSelectionStyleValueForProperty(
2106 expect(cssTopValue).toBe('50px');
2111 describe('Testing that getStyleObjectFromRawCSS handles values with colons', () => {
2112 test('', async () => {
2113 const testEditor = createTestEditor();
2114 const element = document.createElement('div');
2115 testEditor.setRootElement(element);
2117 await testEditor.update(() => {
2118 const root = $getRoot();
2119 const paragraph = $createParagraphNode();
2120 const textNode = $createTextNode('Hello, World!');
2122 'font-family: double:prefix:Arial; color: color:white; font-size: 30px',
2124 $addNodeStyle(textNode);
2125 paragraph.append(textNode);
2126 root.append(paragraph);
2128 const selection = $createRangeSelection();
2129 $setSelection(selection);
2130 selection.insertParagraph();
2132 key: textNode.getKey(),
2138 key: textNode.getKey(),
2143 const cssFontFamilyValue = $getSelectionStyleValueForProperty(
2148 expect(cssFontFamilyValue).toBe('double:prefix:Arial');
2150 const cssColorValue = $getSelectionStyleValueForProperty(
2155 expect(cssColorValue).toBe('color:white');
2157 const cssFontSizeValue = $getSelectionStyleValueForProperty(
2162 expect(cssFontSizeValue).toBe('30px');
2167 describe('$patchStyle', () => {
2168 it('should patch the style with the new style object', async () => {
2169 await editor!.update(() => {
2170 const root = $getRoot();
2171 const paragraph = $createParagraphNode();
2172 const textNode = $createTextNode('Hello, World!');
2173 textNode.setStyle('font-family: serif; color: red;');
2174 $addNodeStyle(textNode);
2175 paragraph.append(textNode);
2176 root.append(paragraph);
2178 const selection = $createRangeSelection();
2179 $setSelection(selection);
2180 selection.insertParagraph();
2182 key: textNode.getKey(),
2188 key: textNode.getKey(),
2195 'font-family': 'Arial',
2198 $patchStyleText(selection, newStyle);
2200 const cssFontFamilyValue = $getSelectionStyleValueForProperty(
2205 expect(cssFontFamilyValue).toBe('Arial');
2207 const cssColorValue = $getSelectionStyleValueForProperty(
2212 expect(cssColorValue).toBe('blue');
2216 it('should patch the style with property function', async () => {
2217 await editor!.update(() => {
2218 const currentColor = 'red';
2219 const nextColor = 'blue';
2221 const root = $getRoot();
2222 const paragraph = $createParagraphNode();
2223 const textNode = $createTextNode('Hello, World!');
2224 textNode.setStyle(`color: ${currentColor};`);
2225 $addNodeStyle(textNode);
2226 paragraph.append(textNode);
2227 root.append(paragraph);
2229 const selection = $createRangeSelection();
2230 $setSelection(selection);
2231 selection.insertParagraph();
2233 key: textNode.getKey(),
2239 key: textNode.getKey(),
2246 (current: string | null, target: LexicalNode | RangeSelection) =>
2251 $patchStyleText(selection, newStyle);
2253 const cssColorValue = $getSelectionStyleValueForProperty(
2259 expect(cssColorValue).toBe(nextColor);
2260 expect(newStyle.color).toHaveBeenCalledTimes(1);
2262 const lastCall = newStyle.color.mock.lastCall!;
2263 expect(lastCall[0]).toBe(currentColor);
2264 // @ts-ignore - It expected to be a LexicalNode
2265 expect($isTextNode(lastCall[1])).toBeTruthy();
2270 describe('$setBlocksType', () => {
2271 test('Collapsed selection in text', async () => {
2272 const testEditor = createTestEditor();
2273 const element = document.createElement('div');
2274 testEditor.setRootElement(element);
2276 await testEditor.update(() => {
2277 const root = $getRoot();
2278 const paragraph1 = $createParagraphNode();
2279 const text1 = $createTextNode('text 1');
2280 const paragraph2 = $createParagraphNode();
2281 const text2 = $createTextNode('text 2');
2282 root.append(paragraph1, paragraph2);
2283 paragraph1.append(text1);
2284 paragraph2.append(text2);
2286 const selection = $createRangeSelection();
2287 $setSelection(selection);
2290 offset: text1.__text.length,
2295 offset: text1.__text.length,
2299 $setBlocksType(selection, () => {
2300 return $createHeadingNode('h1');
2303 const rootChildren = root.getChildren();
2304 expect(rootChildren[0].__type).toBe('heading');
2305 expect(rootChildren[1].__type).toBe('paragraph');
2306 expect(rootChildren.length).toBe(2);
2310 test('Collapsed selection in element', async () => {
2311 const testEditor = createTestEditor();
2312 const element = document.createElement('div');
2313 testEditor.setRootElement(element);
2315 await testEditor.update(() => {
2316 const root = $getRoot();
2317 const paragraph1 = $createParagraphNode();
2318 const paragraph2 = $createParagraphNode();
2319 root.append(paragraph1, paragraph2);
2321 const selection = $createRangeSelection();
2322 $setSelection(selection);
2334 $setBlocksType(selection, () => {
2335 return $createHeadingNode('h1');
2338 const rootChildren = root.getChildren();
2339 expect(rootChildren[0].__type).toBe('heading');
2340 expect(rootChildren[1].__type).toBe('paragraph');
2341 expect(rootChildren.length).toBe(2);
2345 test('Two elements, same top-element', async () => {
2346 const testEditor = createTestEditor();
2347 const element = document.createElement('div');
2348 testEditor.setRootElement(element);
2350 await testEditor.update(() => {
2351 const root = $getRoot();
2352 const paragraph1 = $createParagraphNode();
2353 const text1 = $createTextNode('text 1');
2354 const paragraph2 = $createParagraphNode();
2355 const text2 = $createTextNode('text 2');
2356 root.append(paragraph1, paragraph2);
2357 paragraph1.append(text1);
2358 paragraph2.append(text2);
2360 const selection = $createRangeSelection();
2361 $setSelection(selection);
2369 offset: text1.__text.length,
2373 $setBlocksType(selection, () => {
2374 return $createHeadingNode('h1');
2377 const rootChildren = root.getChildren();
2378 expect(rootChildren[0].__type).toBe('heading');
2379 expect(rootChildren[1].__type).toBe('heading');
2380 expect(rootChildren.length).toBe(2);
2384 test('Two empty elements, same top-element', async () => {
2385 const testEditor = createTestEditor();
2386 const element = document.createElement('div');
2387 testEditor.setRootElement(element);
2389 await testEditor.update(() => {
2390 const root = $getRoot();
2391 const paragraph1 = $createParagraphNode();
2392 const paragraph2 = $createParagraphNode();
2393 root.append(paragraph1, paragraph2);
2395 const selection = $createRangeSelection();
2396 $setSelection(selection);
2398 key: paragraph1.__key,
2403 key: paragraph2.__key,
2408 $setBlocksType(selection, () => {
2409 return $createHeadingNode('h1');
2412 const rootChildren = root.getChildren();
2413 expect(rootChildren[0].__type).toBe('heading');
2414 expect(rootChildren[1].__type).toBe('heading');
2415 expect(rootChildren.length).toBe(2);
2416 const sel = $getSelection()!;
2417 expect(sel.getNodes().length).toBe(2);
2421 test('Two elements, same top-element', async () => {
2422 const testEditor = createTestEditor();
2423 const element = document.createElement('div');
2424 testEditor.setRootElement(element);
2426 await testEditor.update(() => {
2427 const root = $getRoot();
2428 const paragraph1 = $createParagraphNode();
2429 const text1 = $createTextNode('text 1');
2430 const paragraph2 = $createParagraphNode();
2431 const text2 = $createTextNode('text 2');
2432 root.append(paragraph1, paragraph2);
2433 paragraph1.append(text1);
2434 paragraph2.append(text2);
2436 const selection = $createRangeSelection();
2437 $setSelection(selection);
2445 offset: text1.__text.length,
2449 $setBlocksType(selection, () => {
2450 return $createHeadingNode('h1');
2453 const rootChildren = root.getChildren();
2454 expect(rootChildren[0].__type).toBe('heading');
2455 expect(rootChildren[1].__type).toBe('heading');
2456 expect(rootChildren.length).toBe(2);
2460 test('Collapsed in element inside top-element', async () => {
2461 const testEditor = createTestEditor();
2462 const element = document.createElement('div');
2463 testEditor.setRootElement(element);
2465 await testEditor.update(() => {
2466 const root = $getRoot();
2467 const table = $createTableNodeWithDimensions(1, 1);
2468 const row = table.getFirstChild();
2469 invariant($isElementNode(row));
2470 const column = row.getFirstChild();
2471 invariant($isElementNode(column));
2472 const paragraph = column.getFirstChild();
2473 invariant($isElementNode(paragraph));
2474 if (paragraph.getFirstChild()) {
2475 paragraph.getFirstChild()!.remove();
2479 const selection = $createRangeSelection();
2480 $setSelection(selection);
2482 key: paragraph.__key,
2487 key: paragraph.__key,
2492 const columnChildrenPrev = column.getChildren();
2493 expect(columnChildrenPrev[0].__type).toBe('paragraph');
2494 $setBlocksType(selection, () => {
2495 return $createHeadingNode('h1');
2498 const columnChildrenAfter = column.getChildren();
2499 expect(columnChildrenAfter[0].__type).toBe('heading');
2500 expect(columnChildrenAfter.length).toBe(1);
2504 test('Collapsed in text inside top-element', async () => {
2505 const testEditor = createTestEditor();
2506 const element = document.createElement('div');
2507 testEditor.setRootElement(element);
2509 await testEditor.update(() => {
2510 const root = $getRoot();
2511 const table = $createTableNodeWithDimensions(1, 1);
2512 const row = table.getFirstChild();
2513 invariant($isElementNode(row));
2514 const column = row.getFirstChild();
2515 invariant($isElementNode(column));
2516 const paragraph = column.getFirstChild();
2517 invariant($isElementNode(paragraph));
2518 const text = $createTextNode('foo');
2520 paragraph.append(text);
2522 const selectionz = $createRangeSelection();
2523 $setSelection(selectionz);
2526 offset: text.__text.length,
2531 offset: text.__text.length,
2534 const selection = $getSelection() as RangeSelection;
2536 const columnChildrenPrev = column.getChildren();
2537 expect(columnChildrenPrev[0].__type).toBe('paragraph');
2538 $setBlocksType(selection, () => {
2539 return $createHeadingNode('h1');
2542 const columnChildrenAfter = column.getChildren();
2543 expect(columnChildrenAfter[0].__type).toBe('heading');
2544 expect(columnChildrenAfter.length).toBe(1);
2548 test('Full editor selection with a mix of top-elements', async () => {
2549 const testEditor = createTestEditor();
2550 const element = document.createElement('div');
2551 testEditor.setRootElement(element);
2553 await testEditor.update(() => {
2554 const root = $getRoot();
2556 const paragraph1 = $createParagraphNode();
2557 const paragraph2 = $createParagraphNode();
2558 const text1 = $createTextNode();
2559 const text2 = $createTextNode();
2560 paragraph1.append(text1);
2561 paragraph2.append(text2);
2562 root.append(paragraph1, paragraph2);
2564 const table = $createTableNodeWithDimensions(1, 2);
2565 const row = table.getFirstChild();
2566 invariant($isElementNode(row));
2567 const columns = row.getChildren();
2570 const column1 = columns[0];
2571 const paragraph3 = $createParagraphNode();
2572 const paragraph4 = $createParagraphNode();
2573 const text3 = $createTextNode();
2574 const text4 = $createTextNode();
2575 paragraph1.append(text3);
2576 paragraph2.append(text4);
2577 invariant($isElementNode(column1));
2578 column1.append(paragraph3, paragraph4);
2580 const column2 = columns[1];
2581 const paragraph5 = $createParagraphNode();
2582 const paragraph6 = $createParagraphNode();
2583 invariant($isElementNode(column2));
2584 column2.append(paragraph5, paragraph6);
2586 const paragraph7 = $createParagraphNode();
2587 root.append(paragraph7);
2589 const selectionz = $createRangeSelection();
2590 $setSelection(selectionz);
2592 key: paragraph1.__key,
2597 key: paragraph7.__key,
2601 const selection = $getSelection() as RangeSelection;
2603 $setBlocksType(selection, () => {
2604 return $createHeadingNode('h1');
2606 expect(JSON.stringify(testEditor._pendingEditorState?.toJSON())).toBe(
2607 '{"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}}',
2612 test('Paragraph with links to heading with links', async () => {
2613 const testEditor = createTestEditor();
2614 const element = document.createElement('div');
2615 testEditor.setRootElement(element);
2617 await testEditor.update(() => {
2618 const root = $getRoot();
2619 const paragraph = $createParagraphNode();
2620 const text1 = $createTextNode('Links: ');
2621 const text2 = $createTextNode('link1');
2622 const text3 = $createTextNode('link2');
2626 $createLinkNode('https://lexical.dev').append(text2),
2627 $createTextNode(' '),
2628 $createLinkNode('https://playground.lexical.dev').append(text3),
2632 const paragraphChildrenKeys = [...paragraph.getChildrenKeys()];
2633 const selection = $createRangeSelection();
2634 $setSelection(selection);
2636 key: text1.getKey(),
2641 key: text3.getKey(),
2646 $setBlocksType(selection, () => {
2647 return $createHeadingNode('h1');
2650 const rootChildren = root.getChildren();
2651 expect(rootChildren.length).toBe(1);
2652 invariant($isElementNode(rootChildren[0]));
2653 expect(rootChildren[0].getType()).toBe('heading');
2654 expect(rootChildren[0].getChildrenKeys()).toEqual(
2655 paragraphChildrenKeys,
2660 test('Nested list', async () => {
2661 const testEditor = createTestEditor();
2662 const element = document.createElement('div');
2663 testEditor.setRootElement(element);
2665 await testEditor.update(() => {
2666 const root = $getRoot();
2667 const ul1 = $createListNode('bullet');
2668 const text1 = $createTextNode('1');
2669 const li1 = $createListItemNode().append(text1);
2670 const li1_wrapper = $createListItemNode();
2671 const ul2 = $createListNode('bullet');
2672 const text1_1 = $createTextNode('1.1');
2673 const li1_1 = $createListItemNode().append(text1_1);
2674 ul1.append(li1, li1_wrapper);
2675 li1_wrapper.append(ul2);
2679 const selection = $createRangeSelection();
2680 $setSelection(selection);
2682 key: text1.getKey(),
2687 key: text1_1.getKey(),
2692 $setBlocksType(selection, () => {
2693 return $createHeadingNode('h1');
2696 expect(element.innerHTML).toStrictEqual(
2697 `<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>`,
2701 test('Nested list with listItem twice indented from his father', async () => {
2702 const testEditor = createTestEditor();
2703 const element = document.createElement('div');
2704 testEditor.setRootElement(element);
2706 await testEditor.update(() => {
2707 const root = $getRoot();
2708 const ul1 = $createListNode('bullet');
2709 const li1_wrapper = $createListItemNode();
2710 const ul2 = $createListNode('bullet');
2711 const text1_1 = $createTextNode('1.1');
2712 const li1_1 = $createListItemNode().append(text1_1);
2713 ul1.append(li1_wrapper);
2714 li1_wrapper.append(ul2);
2718 const selection = $createRangeSelection();
2719 $setSelection(selection);
2721 key: text1_1.getKey(),
2726 key: text1_1.getKey(),
2731 $setBlocksType(selection, () => {
2732 return $createHeadingNode('h1');
2735 expect(element.innerHTML).toStrictEqual(
2736 `<h1 style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">1.1</span></h1>`,