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 {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";
77 import {$createHeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
79 interface ExpectedSelection {
86 initializeClipboard();
88 jest.mock('lexical/shared/environment', () => {
89 const originalModule = jest.requireActual('lexical/shared/environment');
91 return {...originalModule, IS_FIREFOX: true};
94 Range.prototype.getBoundingClientRect = function (): DOMRect {
113 describe('LexicalSelection tests', () => {
114 let container: HTMLElement;
115 let root: HTMLDivElement;
116 let editor: LexicalEditor | null = null;
118 beforeEach(async () => {
119 container = document.createElement('div');
120 document.body.appendChild(container);
122 root = document.createElement('div');
123 root.setAttribute('contenteditable', 'true');
124 container.append(root);
129 afterEach(async () => {
130 document.body.removeChild(container);
133 async function init() {
135 editor = createTestEditor({
140 h1: 'editor-heading-h1',
141 h2: 'editor-heading-h2',
142 h3: 'editor-heading-h3',
143 h4: 'editor-heading-h4',
144 h5: 'editor-heading-h5',
145 h6: 'editor-heading-h6',
147 image: 'editor-image',
149 ol: 'editor-list-ol',
150 ul: 'editor-list-ul',
152 listitem: 'editor-listitem',
153 paragraph: 'editor-paragraph',
154 quote: 'editor-quote',
156 bold: 'editor-text-bold',
157 code: 'editor-text-code',
158 hashtag: 'editor-text-hashtag',
159 italic: 'editor-text-italic',
160 link: 'editor-text-link',
161 strikethrough: 'editor-text-strikethrough',
162 underline: 'editor-text-underline',
163 underlineStrikethrough: 'editor-text-underlineStrikethrough',
169 registerHistory(editor, createEmptyHistoryState(), 300),
170 registerRichText(editor),
173 editor.setRootElement(root);
174 editor.update(() => {
175 const p = $createParagraphNode();
178 editor.commitUpdates();
181 // Focus first element
182 setNativeSelectionWithPaths(
183 editor!.getRootElement()!,
191 async function update(fn: () => void) {
193 editor!.commitUpdates();
196 test('Expect initial output to be a block with no text.', () => {
197 expect(container!.innerHTML).toBe(
198 '<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>',
202 function assertSelection(
203 rootElement: HTMLElement,
204 expectedSelection: ExpectedSelection,
206 const actualSelection = window.getSelection()!;
208 expect(actualSelection.anchorNode).toBe(
209 getNodeFromPath(expectedSelection.anchorPath, rootElement),
211 expect(actualSelection.anchorOffset).toBe(expectedSelection.anchorOffset);
212 expect(actualSelection.focusNode).toBe(
213 getNodeFromPath(expectedSelection.focusPath, rootElement),
215 expect(actualSelection.focusOffset).toBe(expectedSelection.focusOffset);
218 // eslint-disable-next-line @typescript-eslint/no-unused-vars
219 const GRAPHEME_SCENARIOS = [
221 description: 'grapheme cluster',
222 // Hangul grapheme cluster.
223 // https://www.compart.com/en/unicode/U+AC01
224 grapheme: '\u1100\u1161\u11A8',
227 description: 'extended grapheme cluster',
228 // Tamil 'ni' grapheme cluster.
229 // http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters
230 grapheme: '\u0BA8\u0BBF',
233 description: 'tailored grapheme cluster',
234 // Devangari 'kshi' tailored grapheme cluster.
235 // http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters
236 grapheme: '\u0915\u094D\u0937\u093F',
239 description: 'Emoji sequence combined using zero-width joiners',
240 // https://emojipedia.org/family-woman-woman-girl-boy/
242 '\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66',
245 description: 'Emoji sequence with skin-tone modifier',
246 // https://emojipedia.org/clapping-hands-medium-skin-tone/
247 grapheme: '\uD83D\uDC4F\uD83C\uDFFD',
254 '<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>',
257 anchorPath: [0, 0, 0],
259 focusPath: [0, 0, 0],
268 name: 'Simple typing',
272 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
273 '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong></p></div>',
276 anchorPath: [0, 0, 0],
278 focusPath: [0, 0, 0],
288 name: 'Simple typing in bold',
292 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
293 '<em class="editor-text-italic" data-lexical-text="true">Hello</em></p></div>',
296 anchorPath: [0, 0, 0],
298 focusPath: [0, 0, 0],
308 name: 'Simple typing in italic',
312 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
313 '<strong class="editor-text-bold editor-text-italic" data-lexical-text="true">Hello</strong></p></div>',
316 anchorPath: [0, 0, 0],
318 focusPath: [0, 0, 0],
329 name: 'Simple typing in italic + bold',
333 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
334 '<span class="editor-text-underline" data-lexical-text="true">Hello</span></p></div>',
337 anchorPath: [0, 0, 0],
339 focusPath: [0, 0, 0],
349 name: 'Simple typing in underline',
353 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
354 '<span class="editor-text-strikethrough" data-lexical-text="true">Hello</span></p></div>',
357 anchorPath: [0, 0, 0],
359 focusPath: [0, 0, 0],
362 formatStrikeThrough(),
369 name: 'Simple typing in strikethrough',
373 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
374 '<span class="editor-text-underlineStrikethrough" data-lexical-text="true">Hello</span></p></div>',
377 anchorPath: [0, 0, 0],
379 focusPath: [0, 0, 0],
383 formatStrikeThrough(),
390 name: 'Simple typing in underline + strikethrough',
394 '<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>',
397 anchorPath: [0, 0, 0],
399 focusPath: [0, 0, 0],
415 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
416 '<span data-lexical-text="true">Dominic Gannaway</span>' +
420 anchorPath: [0, 0, 0],
422 focusPath: [0, 0, 0],
424 inputs: [insertTokenNode('Dominic Gannaway')],
425 name: 'Creation of an token node',
429 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
430 '<span data-lexical-text="true">Dominic Gannaway</span>' +
439 insertText('Dominic Gannaway'),
440 moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16),
441 convertToTokenNode(),
443 name: 'Convert text to an token node',
447 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
448 '<span data-lexical-text="true">Dominic Gannaway</span>' +
456 inputs: [insertSegmentedNode('Dominic Gannaway')],
457 name: 'Creation of a segmented node',
461 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
462 '<span data-lexical-text="true">Dominic Gannaway</span>' +
471 insertText('Dominic Gannaway'),
472 moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16),
473 convertToSegmentedNode(),
475 name: 'Convert text to a segmented node',
479 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
480 '<p class="editor-paragraph"><br></p>' +
481 '<p class="editor-paragraph">' +
482 '<strong class="editor-text-bold" data-lexical-text="true">Hello world</strong>' +
484 '<p class="editor-paragraph"><br></p>' +
494 insertText('Hello world'),
496 moveNativeSelection([0], 0, [2], 0),
499 name: 'Format selection that starts and ends on element and retain selection',
503 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
504 '<p class="editor-paragraph"><br></p>' +
505 '<p class="editor-paragraph">' +
506 '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong>' +
508 '<p class="editor-paragraph">' +
509 '<strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
511 '<p class="editor-paragraph"><br></p>' +
525 moveNativeSelection([0], 0, [3], 0),
528 name: 'Format multiline text selection that starts and ends on element and retain selection',
532 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
533 '<p class="editor-paragraph">' +
534 '<span data-lexical-text="true">He</span>' +
535 '<strong class="editor-text-bold" data-lexical-text="true">llo</strong>' +
537 '<p class="editor-paragraph">' +
538 '<strong class="editor-text-bold" data-lexical-text="true">wo</strong>' +
539 '<span data-lexical-text="true">rld</span>' +
544 anchorPath: [0, 1, 0],
546 focusPath: [1, 0, 0],
552 moveNativeSelection([0, 0, 0], 2, [1, 0, 0], 2),
555 name: 'Format multiline text selection that starts and ends within text',
559 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
560 '<p class="editor-paragraph"><br></p>' +
561 '<p class="editor-paragraph">' +
562 '<span data-lexical-text="true">Hello </span>' +
563 '<strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
565 '<p class="editor-paragraph"><br></p>' +
569 anchorPath: [1, 1, 0],
575 insertText('Hello world'),
577 moveNativeSelection([1, 0, 0], 6, [2], 0),
580 name: 'Format selection that starts on text and ends on element and retain selection',
584 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
585 '<p class="editor-paragraph"><br></p>' +
586 '<p class="editor-paragraph">' +
587 '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong>' +
588 '<span data-lexical-text="true"> world</span>' +
590 '<p class="editor-paragraph"><br></p>' +
596 focusPath: [1, 0, 0],
600 insertText('Hello world'),
602 moveNativeSelection([0], 0, [1, 0, 0], 5),
605 name: 'Format selection that starts on element and ends on text and retain selection',
610 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
611 '<p class="editor-paragraph"><br></p>' +
612 '<p class="editor-paragraph">' +
613 '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong><strong class="editor-text-bold" data-lexical-text="true"> world</strong>' +
615 '<p class="editor-paragraph"><br></p>' +
619 anchorPath: [1, 0, 0],
625 insertTokenNode('Hello'),
626 insertText(' world'),
628 moveNativeSelection([1, 0, 0], 2, [2], 0),
631 name: 'Format selection that starts on middle of token node should format complete node',
636 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
637 '<p class="editor-paragraph"><br></p>' +
638 '<p class="editor-paragraph">' +
639 '<strong class="editor-text-bold" data-lexical-text="true">Hello </strong><strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
641 '<p class="editor-paragraph"><br></p>' +
647 focusPath: [1, 1, 0],
651 insertText('Hello '),
652 insertTokenNode('world'),
654 moveNativeSelection([0], 0, [1, 1, 0], 2),
657 name: 'Format selection that ends on middle of token node should format complete node',
662 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
663 '<p class="editor-paragraph"><br></p>' +
664 '<p class="editor-paragraph">' +
665 '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong><span data-lexical-text="true"> world</span>' +
667 '<p class="editor-paragraph"><br></p>' +
671 anchorPath: [1, 0, 0],
673 focusPath: [1, 0, 0],
677 insertTokenNode('Hello'),
678 insertText(' world'),
680 moveNativeSelection([1, 0, 0], 2, [1, 0, 0], 3),
683 name: 'Format token node if it is the single one selected',
688 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
689 '<p class="editor-paragraph"><br></p>' +
690 '<p class="editor-paragraph">' +
691 '<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>' +
693 '<p class="editor-paragraph"><br></p>' +
703 insertText('Hello '),
704 insertTokenNode('beautiful'),
705 insertText(' world'),
707 moveNativeSelection([0], 0, [2], 0),
710 name: 'Format selection that contains a token node in the middle should format the token node',
715 whitespaceCharacter: ' ',
716 whitespaceName: 'space',
719 whitespaceCharacter: '\u00a0',
720 whitespaceName: 'non-breaking space',
723 whitespaceCharacter: '\u2000',
724 whitespaceName: 'en quad',
727 whitespaceCharacter: '\u2001',
728 whitespaceName: 'em quad',
731 whitespaceCharacter: '\u2002',
732 whitespaceName: 'en space',
735 whitespaceCharacter: '\u2003',
736 whitespaceName: 'em space',
739 whitespaceCharacter: '\u2004',
740 whitespaceName: 'three-per-em space',
743 whitespaceCharacter: '\u2005',
744 whitespaceName: 'four-per-em space',
747 whitespaceCharacter: '\u2006',
748 whitespaceName: 'six-per-em space',
751 whitespaceCharacter: '\u2007',
752 whitespaceName: 'figure space',
755 whitespaceCharacter: '\u2008',
756 whitespaceName: 'punctuation space',
759 whitespaceCharacter: '\u2009',
760 whitespaceName: 'thin space',
763 whitespaceCharacter: '\u200A',
764 whitespaceName: 'hair space',
766 ].flatMap(({whitespaceCharacter, whitespaceName}) => [
768 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(
770 )}</span></p></div>`,
773 anchorPath: [0, 0, 0],
775 focusPath: [0, 0, 0],
778 insertText(`Hello${whitespaceCharacter}world`),
779 deleteWordBackward(1),
781 name: `Type two words separated by a ${whitespaceName}, delete word backward from end`,
784 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(
786 )}world</span></p></div>`,
789 anchorPath: [0, 0, 0],
791 focusPath: [0, 0, 0],
794 insertText(`Hello${whitespaceCharacter}world`),
795 moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
796 deleteWordForward(1),
798 name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning`,
802 '<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>',
805 anchorPath: [0, 0, 0],
807 focusPath: [0, 0, 0],
810 insertText(`Hello${whitespaceCharacter}world`),
811 moveNativeSelection([0, 0, 0], 5, [0, 0, 0], 5),
812 deleteWordForward(1),
814 name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning of preceding whitespace`,
818 '<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>',
821 anchorPath: [0, 0, 0],
823 focusPath: [0, 0, 0],
826 insertText(`Hello${whitespaceCharacter}world`),
827 moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 6),
828 deleteWordBackward(1),
830 name: `Type two words separated by a ${whitespaceName}, delete word backward from end of trailing whitespace`,
834 '<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>',
837 anchorPath: [0, 0, 0],
839 focusPath: [0, 0, 0],
841 inputs: [insertText('Hello world'), deleteWordBackward(1), undo(1)],
842 name: `Type a word, delete it and undo the deletion`,
846 '<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>',
849 anchorPath: [0, 0, 0],
851 focusPath: [0, 0, 0],
854 insertText('Hello world'),
855 deleteWordBackward(1),
859 name: `Type a word, delete it and undo the deletion`,
863 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
864 '<span data-lexical-text="true">this is weird test</span></p></div>',
867 anchorPath: [0, 0, 0],
869 focusPath: [0, 0, 0],
872 insertText('this is weird test'),
873 moveNativeSelection([0, 0, 0], 14, [0, 0, 0], 14),
876 name: 'Type a sentence, move the caret to the middle and move with the arrows to the start',
880 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph">' +
881 '<span data-lexical-text="true">Hello </span>' +
882 '<span data-lexical-text="true">Bob</span>' +
886 anchorPath: [0, 1, 0],
888 focusPath: [0, 1, 0],
891 insertText('Hello '),
892 insertTokenNode('Bob'),
897 name: 'Type a text and token text, move the caret to the end of the first text',
901 '<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>',
904 anchorPath: [0, 0, 0],
906 focusPath: [0, 0, 0],
909 pastePlain('ABD\tEFG'),
913 deleteWordForward(1),
915 name: 'Paste text, move selection and delete word forward',
920 suite.forEach((testUnit, i) => {
921 const name = testUnit.name || 'Test case';
923 test(name + ` (#${i + 1})`, async () => {
924 await applySelectionInputs(testUnit.inputs, update, editor!);
926 // Validate HTML matches
927 expect(container.innerHTML).toBe(testUnit.expectedHTML);
929 // Validate selection matches
930 const rootElement = editor!.getRootElement()!;
931 const expectedSelection = testUnit.expectedSelection;
933 assertSelection(rootElement, expectedSelection);
937 test('insert text one selected node element selection', async () => {
938 await editor!.update(() => {
939 const root = $getRoot();
941 const paragraph = root.getFirstChild<ParagraphNode>()!;
943 const elementNode = $createTestElementNode();
944 const text = $createTextNode('foo');
946 paragraph.append(elementNode);
947 elementNode.append(text);
949 const selection = $createRangeSelection();
950 selection.anchor.set(text.__key, 0, 'text');
951 selection.focus.set(paragraph.__key, 1, 'element');
953 selection.insertText('');
955 expect(root.getTextContent()).toBe('');
959 test('getNodes resolves nested block nodes', async () => {
960 await editor!.update(() => {
961 const root = $getRoot();
963 const paragraph = root.getFirstChild<ParagraphNode>()!;
965 const elementNode = $createTestElementNode();
966 const text = $createTextNode();
968 paragraph.append(elementNode);
969 elementNode.append(text);
971 const selectedNodes = $getSelection()!.getNodes();
973 expect(selectedNodes.length).toBe(1);
974 expect(selectedNodes[0].getKey()).toBe(text.getKey());
978 describe('Block selection moves when new nodes are inserted', () => {
981 anchorOffset: number;
984 paragraph: ElementNode,
987 expectedAnchor: LexicalNode;
988 expectedAnchorOffset: number;
989 expectedFocus: LexicalNode;
990 expectedFocusOffset: number;
992 fnBefore?: (paragraph: ElementNode, text: TextNode) => void;
993 invertSelection?: true;
996 // Collapsed selection on end; add/remove/replace beginning
999 fn: (paragraph, text) => {
1000 const newText = $createTextNode('2');
1001 text.insertBefore(newText);
1004 expectedAnchor: paragraph,
1005 expectedAnchorOffset: 3,
1006 expectedFocus: paragraph,
1007 expectedFocusOffset: 3,
1011 name: 'insertBefore - Collapsed selection on end; add beginning',
1015 fn: (paragraph, text) => {
1016 const newText = $createTextNode('2');
1017 text.insertAfter(newText);
1020 expectedAnchor: paragraph,
1021 expectedAnchorOffset: 3,
1022 expectedFocus: paragraph,
1023 expectedFocusOffset: 3,
1027 name: 'insertAfter - Collapsed selection on end; add beginning',
1031 fn: (paragraph, text) => {
1035 expectedAnchor: paragraph,
1036 expectedAnchorOffset: 3,
1037 expectedFocus: paragraph,
1038 expectedFocusOffset: 3,
1042 name: 'splitText - Collapsed selection on end; add beginning',
1046 fn: (paragraph, text) => {
1050 expectedAnchor: paragraph,
1051 expectedAnchorOffset: 0,
1052 expectedFocus: paragraph,
1053 expectedFocusOffset: 0,
1057 name: 'remove - Collapsed selection on end; add beginning',
1061 fn: (paragraph, text) => {
1062 const newText = $createTextNode('replacement');
1063 text.replace(newText);
1066 expectedAnchor: paragraph,
1067 expectedAnchorOffset: 1,
1068 expectedFocus: paragraph,
1069 expectedFocusOffset: 1,
1073 name: 'replace - Collapsed selection on end; replace beginning',
1075 // All selected; add/remove/replace on beginning
1078 fn: (paragraph, text) => {
1079 const newText = $createTextNode('2');
1080 text.insertBefore(newText);
1083 expectedAnchor: text,
1084 expectedAnchorOffset: 0,
1085 expectedFocus: paragraph,
1086 expectedFocusOffset: 3,
1090 name: 'insertBefore - All selected; add on beginning',
1094 fn: (paragraph, originalText) => {
1095 const [, text] = originalText.splitText(1);
1098 expectedAnchor: text,
1099 expectedAnchorOffset: 0,
1100 expectedFocus: paragraph,
1101 expectedFocusOffset: 3,
1105 name: 'splitNodes - All selected; add on beginning',
1109 fn: (paragraph, text) => {
1113 expectedAnchor: paragraph,
1114 expectedAnchorOffset: 0,
1115 expectedFocus: paragraph,
1116 expectedFocusOffset: 0,
1120 name: 'remove - All selected; remove on beginning',
1124 fn: (paragraph, text) => {
1125 const newText = $createTextNode('replacement');
1126 text.replace(newText);
1129 expectedAnchor: paragraph,
1130 expectedAnchorOffset: 0,
1131 expectedFocus: paragraph,
1132 expectedFocusOffset: 1,
1136 name: 'replace - All selected; replace on beginning',
1138 // Selection beginning; add/remove/replace on end
1141 fn: (paragraph, originalText1) => {
1142 const originalText2 = originalText1.getPreviousSibling()!;
1143 const lastChild = paragraph.getLastChild()!;
1144 const newText = $createTextNode('2');
1145 lastChild.insertBefore(newText);
1148 expectedAnchor: originalText2,
1149 expectedAnchorOffset: 0,
1150 expectedFocus: originalText1,
1151 expectedFocusOffset: 0,
1154 fnBefore: (paragraph, originalText1) => {
1155 const originalText2 = $createTextNode('bar');
1156 originalText1.insertBefore(originalText2);
1159 name: 'insertBefore - Selection beginning; add on end',
1163 fn: (paragraph, text) => {
1164 const lastChild = paragraph.getLastChild()!;
1165 const newText = $createTextNode('2');
1166 lastChild.insertAfter(newText);
1169 expectedAnchor: text,
1170 expectedAnchorOffset: 0,
1171 expectedFocus: paragraph,
1172 expectedFocusOffset: 1,
1176 name: 'insertAfter - Selection beginning; add on end',
1180 fn: (paragraph, originalText1) => {
1181 const originalText2 = originalText1.getPreviousSibling()!;
1182 const [, text] = originalText1.splitText(1);
1185 expectedAnchor: originalText2,
1186 expectedAnchorOffset: 0,
1187 expectedFocus: text,
1188 expectedFocusOffset: 0,
1191 fnBefore: (paragraph, originalText1) => {
1192 const originalText2 = $createTextNode('bar');
1193 originalText1.insertBefore(originalText2);
1196 name: 'splitText - Selection beginning; add on end',
1200 fn: (paragraph, text) => {
1201 const lastChild = paragraph.getLastChild()!;
1205 expectedAnchor: text,
1206 expectedAnchorOffset: 0,
1207 expectedFocus: text,
1208 expectedFocusOffset: 3,
1212 name: 'remove - Selection beginning; remove on end',
1216 fn: (paragraph, text) => {
1217 const newText = $createTextNode('replacement');
1218 const lastChild = paragraph.getLastChild()!;
1219 lastChild.replace(newText);
1222 expectedAnchor: paragraph,
1223 expectedAnchorOffset: 0,
1224 expectedFocus: paragraph,
1225 expectedFocusOffset: 1,
1229 name: 'replace - Selection beginning; replace on end',
1231 // All selected; add/remove/replace in end offset [1, 2] -> [1, N, 2]
1234 fn: (paragraph, text) => {
1235 const lastChild = paragraph.getLastChild()!;
1236 const newText = $createTextNode('2');
1237 lastChild.insertBefore(newText);
1240 expectedAnchor: text,
1241 expectedAnchorOffset: 0,
1242 expectedFocus: paragraph,
1243 expectedFocusOffset: 2,
1247 name: 'insertBefore - All selected; add in end offset',
1251 fn: (paragraph, text) => {
1252 const newText = $createTextNode('2');
1253 text.insertAfter(newText);
1256 expectedAnchor: text,
1257 expectedAnchorOffset: 0,
1258 expectedFocus: paragraph,
1259 expectedFocusOffset: 2,
1263 name: 'insertAfter - All selected; add in end offset',
1267 fn: (paragraph, originalText1) => {
1268 const originalText2 = originalText1.getPreviousSibling()!;
1269 const [, text] = originalText1.splitText(1);
1272 expectedAnchor: originalText2,
1273 expectedAnchorOffset: 0,
1274 expectedFocus: text,
1275 expectedFocusOffset: 0,
1278 fnBefore: (paragraph, originalText1) => {
1279 const originalText2 = $createTextNode('bar');
1280 originalText1.insertBefore(originalText2);
1283 name: 'splitText - All selected; add in end offset',
1287 fn: (paragraph, originalText1) => {
1288 const lastChild = paragraph.getLastChild()!;
1292 expectedAnchor: originalText1,
1293 expectedAnchorOffset: 0,
1294 expectedFocus: originalText1,
1295 expectedFocusOffset: 3,
1298 fnBefore: (paragraph, originalText1) => {
1299 const originalText2 = $createTextNode('bar');
1300 originalText1.insertBefore(originalText2);
1303 name: 'remove - All selected; remove in end offset',
1307 fn: (paragraph, originalText1) => {
1308 const newText = $createTextNode('replacement');
1309 const lastChild = paragraph.getLastChild()!;
1310 lastChild.replace(newText);
1313 expectedAnchor: paragraph,
1314 expectedAnchorOffset: 1,
1315 expectedFocus: paragraph,
1316 expectedFocusOffset: 2,
1319 fnBefore: (paragraph, originalText1) => {
1320 const originalText2 = $createTextNode('bar');
1321 originalText1.insertBefore(originalText2);
1324 name: 'replace - All selected; replace in end offset',
1326 // All selected; add/remove/replace in middle [1, 2, 3] -> [1, 2, N, 3]
1329 fn: (paragraph, originalText1) => {
1330 const originalText2 = originalText1.getPreviousSibling()!;
1331 const lastChild = paragraph.getLastChild()!;
1332 const newText = $createTextNode('2');
1333 lastChild.insertBefore(newText);
1336 expectedAnchor: originalText2,
1337 expectedAnchorOffset: 0,
1338 expectedFocus: paragraph,
1339 expectedFocusOffset: 3,
1342 fnBefore: (paragraph, originalText1) => {
1343 const originalText2 = $createTextNode('bar');
1344 originalText1.insertBefore(originalText2);
1347 name: 'insertBefore - All selected; add in middle',
1351 fn: (paragraph, originalText1) => {
1352 const originalText2 = originalText1.getPreviousSibling()!;
1353 const newText = $createTextNode('2');
1354 originalText1.insertAfter(newText);
1357 expectedAnchor: originalText2,
1358 expectedAnchorOffset: 0,
1359 expectedFocus: paragraph,
1360 expectedFocusOffset: 3,
1363 fnBefore: (paragraph, originalText1) => {
1364 const originalText2 = $createTextNode('bar');
1365 originalText1.insertBefore(originalText2);
1368 name: 'insertAfter - All selected; add in middle',
1372 fn: (paragraph, originalText1) => {
1373 const originalText2 = originalText1.getPreviousSibling()!;
1374 originalText1.splitText(1);
1377 expectedAnchor: originalText2,
1378 expectedAnchorOffset: 0,
1379 expectedFocus: paragraph,
1380 expectedFocusOffset: 3,
1383 fnBefore: (paragraph, originalText1) => {
1384 const originalText2 = $createTextNode('bar');
1385 originalText1.insertBefore(originalText2);
1388 name: 'splitText - All selected; add in middle',
1392 fn: (paragraph, originalText1) => {
1393 const originalText2 = originalText1.getPreviousSibling()!;
1394 originalText1.remove();
1397 expectedAnchor: originalText2,
1398 expectedAnchorOffset: 0,
1399 expectedFocus: paragraph,
1400 expectedFocusOffset: 1,
1403 fnBefore: (paragraph, originalText1) => {
1404 const originalText2 = $createTextNode('bar');
1405 originalText1.insertBefore(originalText2);
1408 name: 'remove - All selected; remove in middle',
1412 fn: (paragraph, originalText1) => {
1413 const newText = $createTextNode('replacement');
1414 originalText1.replace(newText);
1417 expectedAnchor: paragraph,
1418 expectedAnchorOffset: 0,
1419 expectedFocus: paragraph,
1420 expectedFocusOffset: 2,
1423 fnBefore: (paragraph, originalText1) => {
1424 const originalText2 = $createTextNode('bar');
1425 originalText1.insertBefore(originalText2);
1428 name: 'replace - All selected; replace in middle',
1433 fn: (paragraph, originalText1) => {
1434 const originalText2 = paragraph.getLastChild()!;
1435 const newText = $createTextNode('new');
1436 originalText1.insertBefore(newText);
1439 expectedAnchor: originalText2,
1440 expectedAnchorOffset: 'bar'.length,
1441 expectedFocus: originalText2,
1442 expectedFocusOffset: 'bar'.length,
1445 fnBefore: (paragraph, originalText1) => {
1446 const originalText2 = $createTextNode('bar');
1447 paragraph.append(originalText2);
1450 name: "Selection resolves to the end of text node when it's at the end (1)",
1454 fn: (paragraph, originalText1) => {
1455 const originalText2 = paragraph.getLastChild()!;
1456 const newText = $createTextNode('new');
1457 originalText1.insertBefore(newText);
1460 expectedAnchor: originalText1,
1461 expectedAnchorOffset: 0,
1462 expectedFocus: originalText2,
1463 expectedFocusOffset: 'bar'.length,
1466 fnBefore: (paragraph, originalText1) => {
1467 const originalText2 = $createTextNode('bar');
1468 paragraph.append(originalText2);
1471 name: "Selection resolves to the end of text node when it's at the end (2)",
1475 fn: (paragraph, originalText1) => {
1476 originalText1.getNextSibling()!.remove();
1479 expectedAnchor: originalText1,
1480 expectedAnchorOffset: 3,
1481 expectedFocus: originalText1,
1482 expectedFocusOffset: 3,
1486 name: 'remove - Remove with collapsed selection at offset #4221',
1490 fn: (paragraph, originalText1) => {
1491 originalText1.getNextSibling()!.remove();
1494 expectedAnchor: originalText1,
1495 expectedAnchorOffset: 0,
1496 expectedFocus: originalText1,
1497 expectedFocusOffset: 3,
1501 name: 'remove - Remove with non-collapsed selection at offset',
1505 .flatMap((testCase) => {
1506 // Test inverse selection
1509 anchorOffset: testCase.focusOffset,
1510 focusOffset: testCase.anchorOffset,
1511 invertSelection: true,
1512 name: testCase.name + ' (inverse selection)',
1514 return [testCase, inverse];
1528 // eslint-disable-next-line no-only-tests/no-only-tests
1529 const test_ = only === true ? test.only : test;
1530 test_(name, async () => {
1531 await editor!.update(() => {
1532 const root = $getRoot();
1534 const paragraph = root.getFirstChild<ParagraphNode>()!;
1535 const textNode = $createTextNode('foo');
1536 // Note: line break can't be selected by the DOM
1537 const linebreak = $createLineBreakNode();
1539 const selection = $getSelection();
1541 if (!$isRangeSelection(selection)) {
1545 const anchor = selection.anchor;
1546 const focus = selection.focus;
1548 paragraph.append(textNode, linebreak);
1550 fnBefore(paragraph, textNode);
1552 anchor.set(paragraph.getKey(), anchorOffset, 'element');
1553 focus.set(paragraph.getKey(), focusOffset, 'element');
1557 expectedAnchorOffset,
1559 expectedFocusOffset,
1560 } = fn(paragraph, textNode);
1562 if (invertSelection !== true) {
1563 expect(selection.anchor.key).toBe(expectedAnchor.__key);
1564 expect(selection.anchor.offset).toBe(expectedAnchorOffset);
1565 expect(selection.focus.key).toBe(expectedFocus.__key);
1566 expect(selection.focus.offset).toBe(expectedFocusOffset);
1568 expect(selection.anchor.key).toBe(expectedFocus.__key);
1569 expect(selection.anchor.offset).toBe(expectedFocusOffset);
1570 expect(selection.focus.key).toBe(expectedAnchor.__key);
1571 expect(selection.focus.offset).toBe(expectedAnchorOffset);
1579 describe('Selection correctly resolves to a sibling ElementNode when a node is removed', () => {
1580 test('', async () => {
1581 await editor!.update(() => {
1582 const root = $getRoot();
1584 const listNode = $createListNode('bullet');
1585 const listItemNode = $createListItemNode();
1586 const paragraph = $createParagraphNode();
1588 root.append(listNode);
1590 listNode.append(listItemNode);
1591 listItemNode.select();
1592 listNode.insertAfter(paragraph);
1593 listItemNode.remove();
1595 const selection = $getSelection();
1597 if (!$isRangeSelection(selection)) {
1601 expect(selection.anchor.getNode().__type).toBe('paragraph');
1602 expect(selection.focus.getNode().__type).toBe('paragraph');
1607 describe('Selection correctly resolves to a sibling ElementNode when a selected node child is removed', () => {
1608 test('', async () => {
1609 let paragraphNodeKey: string;
1610 await editor!.update(() => {
1611 const root = $getRoot();
1613 const paragraphNode = $createParagraphNode();
1614 paragraphNodeKey = paragraphNode.__key;
1615 const listNode = $createListNode('number');
1616 const listItemNode1 = $createListItemNode();
1617 const textNode1 = $createTextNode('foo');
1618 const listItemNode2 = $createListItemNode();
1619 const listNode2 = $createListNode('number');
1620 const listItemNode2x1 = $createListItemNode();
1622 listNode.append(listItemNode1, listItemNode2);
1623 listItemNode1.append(textNode1);
1624 listItemNode2.append(listNode2);
1625 listNode2.append(listItemNode2x1);
1626 root.append(paragraphNode, listNode);
1628 listItemNode2.select();
1632 await editor!.getEditorState().read(() => {
1633 const selection = $assertRangeSelection($getSelection());
1634 expect(selection.anchor.key).toBe(paragraphNodeKey);
1635 expect(selection.focus.key).toBe(paragraphNodeKey);
1640 describe('Selection correctly resolves to a sibling ElementNode that has multiple children with the correct offset when a node is removed', () => {
1641 test('', async () => {
1642 await editor!.update(() => {
1651 const root = $getRoot();
1653 const paragraph = $createParagraphNode();
1654 const link = $createLinkNode('bullet');
1655 const textOne = $createTextNode('Hello');
1656 const br = $createLineBreakNode();
1657 const textTwo = $createTextNode('world');
1658 const textThree = $createTextNode(' ');
1660 root.append(paragraph);
1661 link.append(textOne);
1663 link.append(textTwo);
1665 paragraph.append(link);
1666 paragraph.append(textThree);
1672 const expectedKey = link.getKey();
1674 const selection = $getSelection();
1676 if (!$isRangeSelection(selection)) {
1680 const {anchor, focus} = selection;
1682 expect(anchor.getNode().getKey()).toBe(expectedKey);
1683 expect(focus.getNode().getKey()).toBe(expectedKey);
1684 expect(anchor.offset).toBe(3);
1685 expect(focus.offset).toBe(3);
1690 test('isBackward', async () => {
1691 await editor!.update(() => {
1692 const root = $getRoot();
1694 const paragraph = root.getFirstChild<ParagraphNode>()!;
1695 const paragraphKey = paragraph.getKey();
1696 const textNode = $createTextNode('foo');
1697 const textNodeKey = textNode.getKey();
1698 // Note: line break can't be selected by the DOM
1699 const linebreak = $createLineBreakNode();
1701 const selection = $getSelection();
1703 if (!$isRangeSelection(selection)) {
1707 const anchor = selection.anchor;
1708 const focus = selection.focus;
1709 paragraph.append(textNode, linebreak);
1710 anchor.set(textNodeKey, 0, 'text');
1711 focus.set(textNodeKey, 0, 'text');
1713 expect(selection.isBackward()).toBe(false);
1715 anchor.set(paragraphKey, 1, 'element');
1716 focus.set(paragraphKey, 1, 'element');
1718 expect(selection.isBackward()).toBe(false);
1720 anchor.set(paragraphKey, 0, 'element');
1721 focus.set(paragraphKey, 1, 'element');
1723 expect(selection.isBackward()).toBe(false);
1725 anchor.set(paragraphKey, 1, 'element');
1726 focus.set(paragraphKey, 0, 'element');
1728 expect(selection.isBackward()).toBe(true);
1732 describe('Decorator text content for selection', () => {
1736 textNode1: TextNode;
1737 textNode2: TextNode;
1738 decorator: DecoratorNode<unknown>;
1739 paragraph: ParagraphNode;
1743 invertSelection?: true;
1746 fn: ({textNode1, anchor, focus}) => {
1747 anchor.set(textNode1.getKey(), 1, 'text');
1748 focus.set(textNode1.getKey(), 1, 'text');
1752 name: 'Not included if cursor right before it',
1755 fn: ({textNode2, anchor, focus}) => {
1756 anchor.set(textNode2.getKey(), 0, 'text');
1757 focus.set(textNode2.getKey(), 0, 'text');
1761 name: 'Not included if cursor right after it',
1764 fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
1765 anchor.set(textNode1.getKey(), 1, 'text');
1766 focus.set(textNode2.getKey(), 0, 'text');
1768 return decorator.getTextContent();
1770 name: 'Included if decorator is selected within text',
1773 fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
1774 anchor.set(textNode1.getKey(), 0, 'text');
1775 focus.set(textNode2.getKey(), 0, 'text');
1777 return textNode1.getTextContent() + decorator.getTextContent();
1779 name: 'Included if decorator is selected with another node before it',
1782 fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
1783 anchor.set(textNode1.getKey(), 1, 'text');
1784 focus.set(textNode2.getKey(), 1, 'text');
1786 return decorator.getTextContent() + textNode2.getTextContent();
1788 name: 'Included if decorator is selected with another node after it',
1791 fn: ({paragraph, textNode1, textNode2, decorator, anchor, focus}) => {
1794 anchor.set(paragraph.getKey(), 0, 'element');
1795 focus.set(paragraph.getKey(), 1, 'element');
1797 return decorator.getTextContent();
1799 name: 'Included if decorator is selected as the only node',
1803 .flatMap((testCase) => {
1806 invertSelection: true,
1807 name: testCase.name + ' (inverse selection)',
1810 return [testCase, inverse];
1812 .forEach(({name, fn, invertSelection}) => {
1813 it(name, async () => {
1814 await editor!.update(() => {
1815 const root = $getRoot();
1817 const paragraph = root.getFirstChild<ParagraphNode>()!;
1818 const textNode1 = $createTextNode('1');
1819 const textNode2 = $createTextNode('2');
1820 const decorator = $createTestDecoratorNode();
1822 paragraph.append(textNode1, decorator, textNode2);
1824 const selection = $getSelection();
1826 if (!$isRangeSelection(selection)) {
1830 const expectedTextContent = fn({
1831 anchor: invertSelection ? selection.focus : selection.anchor,
1833 focus: invertSelection ? selection.anchor : selection.focus,
1839 expect(selection.getTextContent()).toBe(expectedTextContent);
1845 describe('insertParagraph', () => {
1846 test('three text nodes at offset 0 on third node', async () => {
1847 const testEditor = createTestEditor();
1848 const element = document.createElement('div');
1849 testEditor.setRootElement(element);
1851 await testEditor.update(() => {
1852 const root = $getRoot();
1854 const paragraph = $createParagraphNode();
1855 const text = $createTextNode('Hello ');
1856 const text2 = $createTextNode('awesome');
1858 text2.toggleFormat('bold');
1860 const text3 = $createTextNode(' world');
1862 paragraph.append(text, text2, text3);
1863 root.append(paragraph);
1866 key: text3.getKey(),
1872 key: text3.getKey(),
1877 const selection = $getSelection();
1879 if (!$isRangeSelection(selection)) {
1883 selection.insertParagraph();
1886 expect(element.innerHTML).toBe(
1887 '<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>',
1891 test('four text nodes at offset 0 on third node', async () => {
1892 const testEditor = createTestEditor();
1893 const element = document.createElement('div');
1894 testEditor.setRootElement(element);
1896 await testEditor.update(() => {
1897 const root = $getRoot();
1899 const paragraph = $createParagraphNode();
1900 const text = $createTextNode('Hello ');
1901 const text2 = $createTextNode('awesome ');
1903 text2.toggleFormat('bold');
1905 const text3 = $createTextNode('beautiful');
1906 const text4 = $createTextNode(' world');
1908 text4.toggleFormat('bold');
1910 paragraph.append(text, text2, text3, text4);
1911 root.append(paragraph);
1914 key: text3.getKey(),
1920 key: text3.getKey(),
1925 const selection = $getSelection();
1927 if (!$isRangeSelection(selection)) {
1931 selection.insertParagraph();
1934 expect(element.innerHTML).toBe(
1935 '<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>',
1939 it('adjust offset for inline elements text formatting', async () => {
1942 await editor!.update(() => {
1943 const root = $getRoot();
1945 const text1 = $createTextNode('--');
1946 const text2 = $createTextNode('abc');
1947 const text3 = $createTextNode('--');
1950 $createParagraphNode().append(
1952 $createLinkNode('https://lexical.dev').append(text2),
1958 key: text1.getKey(),
1964 key: text3.getKey(),
1969 const selection = $getSelection();
1971 if (!$isRangeSelection(selection)) {
1975 selection.formatText('bold');
1977 expect(text2.hasFormat('bold')).toBe(true);
1982 describe('Node.replace', () => {
1983 let text1: TextNode,
1986 paragraph: ParagraphNode,
1987 testEditor: LexicalEditor;
1989 beforeEach(async () => {
1990 testEditor = createTestEditor();
1992 const element = document.createElement('div');
1993 testEditor.setRootElement(element);
1995 await testEditor.update(() => {
1996 const root = $getRoot();
1998 paragraph = $createParagraphNode();
1999 text1 = $createTextNode('Hello ');
2000 text2 = $createTextNode('awesome');
2002 text2.toggleFormat('bold');
2004 text3 = $createTextNode(' world');
2006 paragraph.append(text1, text2, text3);
2007 root.append(paragraph);
2014 text2.replace($createTestDecoratorNode());
2021 name: 'moves selection to to next text node if replacing with decorator',
2025 text3.replace($createTestDecoratorNode());
2027 text2.replace($createTestDecoratorNode());
2030 key: paragraph.__key,
2034 name: 'moves selection to parent if next sibling is not a text node',
2036 ].forEach((testCase) => {
2037 test(testCase.name, async () => {
2038 await testEditor.update(() => {
2039 const {key, offset} = testCase.fn();
2041 const selection = $getSelection();
2043 if (!$isRangeSelection(selection)) {
2047 expect(selection.anchor.key).toBe(key);
2048 expect(selection.anchor.offset).toBe(offset);
2049 expect(selection.focus.key).toBe(key);
2050 expect(selection.focus.offset).toBe(offset);
2056 describe('Testing that $getStyleObjectFromRawCSS handles unformatted css text ', () => {
2057 test('', async () => {
2058 const testEditor = createTestEditor();
2059 const element = document.createElement('div');
2060 testEditor.setRootElement(element);
2062 await testEditor.update(() => {
2063 const root = $getRoot();
2064 const paragraph = $createParagraphNode();
2065 const textNode = $createTextNode('Hello, World!');
2067 ' font-family : Arial ; color : red ;top : 50px',
2069 $addNodeStyle(textNode);
2070 paragraph.append(textNode);
2071 root.append(paragraph);
2073 const selection = $createRangeSelection();
2074 $setSelection(selection);
2075 selection.insertParagraph();
2077 key: textNode.getKey(),
2083 key: textNode.getKey(),
2088 const cssFontFamilyValue = $getSelectionStyleValueForProperty(
2093 expect(cssFontFamilyValue).toBe('Arial');
2095 const cssColorValue = $getSelectionStyleValueForProperty(
2100 expect(cssColorValue).toBe('red');
2102 const cssTopValue = $getSelectionStyleValueForProperty(
2107 expect(cssTopValue).toBe('50px');
2112 describe('Testing that getStyleObjectFromRawCSS handles values with colons', () => {
2113 test('', async () => {
2114 const testEditor = createTestEditor();
2115 const element = document.createElement('div');
2116 testEditor.setRootElement(element);
2118 await testEditor.update(() => {
2119 const root = $getRoot();
2120 const paragraph = $createParagraphNode();
2121 const textNode = $createTextNode('Hello, World!');
2123 'font-family: double:prefix:Arial; color: color:white; font-size: 30px',
2125 $addNodeStyle(textNode);
2126 paragraph.append(textNode);
2127 root.append(paragraph);
2129 const selection = $createRangeSelection();
2130 $setSelection(selection);
2131 selection.insertParagraph();
2133 key: textNode.getKey(),
2139 key: textNode.getKey(),
2144 const cssFontFamilyValue = $getSelectionStyleValueForProperty(
2149 expect(cssFontFamilyValue).toBe('double:prefix:Arial');
2151 const cssColorValue = $getSelectionStyleValueForProperty(
2156 expect(cssColorValue).toBe('color:white');
2158 const cssFontSizeValue = $getSelectionStyleValueForProperty(
2163 expect(cssFontSizeValue).toBe('30px');
2168 describe('$patchStyle', () => {
2169 it('should patch the style with the new style object', async () => {
2170 await editor!.update(() => {
2171 const root = $getRoot();
2172 const paragraph = $createParagraphNode();
2173 const textNode = $createTextNode('Hello, World!');
2174 textNode.setStyle('font-family: serif; color: red;');
2175 $addNodeStyle(textNode);
2176 paragraph.append(textNode);
2177 root.append(paragraph);
2179 const selection = $createRangeSelection();
2180 $setSelection(selection);
2181 selection.insertParagraph();
2183 key: textNode.getKey(),
2189 key: textNode.getKey(),
2196 'font-family': 'Arial',
2199 $patchStyleText(selection, newStyle);
2201 const cssFontFamilyValue = $getSelectionStyleValueForProperty(
2206 expect(cssFontFamilyValue).toBe('Arial');
2208 const cssColorValue = $getSelectionStyleValueForProperty(
2213 expect(cssColorValue).toBe('blue');
2217 it('should patch the style with property function', async () => {
2218 await editor!.update(() => {
2219 const currentColor = 'red';
2220 const nextColor = 'blue';
2222 const root = $getRoot();
2223 const paragraph = $createParagraphNode();
2224 const textNode = $createTextNode('Hello, World!');
2225 textNode.setStyle(`color: ${currentColor};`);
2226 $addNodeStyle(textNode);
2227 paragraph.append(textNode);
2228 root.append(paragraph);
2230 const selection = $createRangeSelection();
2231 $setSelection(selection);
2232 selection.insertParagraph();
2234 key: textNode.getKey(),
2240 key: textNode.getKey(),
2247 (current: string | null, target: LexicalNode | RangeSelection) =>
2252 $patchStyleText(selection, newStyle);
2254 const cssColorValue = $getSelectionStyleValueForProperty(
2260 expect(cssColorValue).toBe(nextColor);
2261 expect(newStyle.color).toHaveBeenCalledTimes(1);
2263 const lastCall = newStyle.color.mock.lastCall!;
2264 expect(lastCall[0]).toBe(currentColor);
2265 // @ts-ignore - It expected to be a LexicalNode
2266 expect($isTextNode(lastCall[1])).toBeTruthy();
2271 describe('$setBlocksType', () => {
2272 test('Collapsed selection in text', async () => {
2273 const testEditor = createTestEditor();
2274 const element = document.createElement('div');
2275 testEditor.setRootElement(element);
2277 await testEditor.update(() => {
2278 const root = $getRoot();
2279 const paragraph1 = $createParagraphNode();
2280 const text1 = $createTextNode('text 1');
2281 const paragraph2 = $createParagraphNode();
2282 const text2 = $createTextNode('text 2');
2283 root.append(paragraph1, paragraph2);
2284 paragraph1.append(text1);
2285 paragraph2.append(text2);
2287 const selection = $createRangeSelection();
2288 $setSelection(selection);
2291 offset: text1.__text.length,
2296 offset: text1.__text.length,
2300 $setBlocksType(selection, () => {
2301 return $createHeadingNode('h1');
2304 const rootChildren = root.getChildren();
2305 expect(rootChildren[0].__type).toBe('heading');
2306 expect(rootChildren[1].__type).toBe('paragraph');
2307 expect(rootChildren.length).toBe(2);
2311 test('Collapsed selection in element', async () => {
2312 const testEditor = createTestEditor();
2313 const element = document.createElement('div');
2314 testEditor.setRootElement(element);
2316 await testEditor.update(() => {
2317 const root = $getRoot();
2318 const paragraph1 = $createParagraphNode();
2319 const paragraph2 = $createParagraphNode();
2320 root.append(paragraph1, paragraph2);
2322 const selection = $createRangeSelection();
2323 $setSelection(selection);
2335 $setBlocksType(selection, () => {
2336 return $createHeadingNode('h1');
2339 const rootChildren = root.getChildren();
2340 expect(rootChildren[0].__type).toBe('heading');
2341 expect(rootChildren[1].__type).toBe('paragraph');
2342 expect(rootChildren.length).toBe(2);
2346 test('Two elements, same top-element', async () => {
2347 const testEditor = createTestEditor();
2348 const element = document.createElement('div');
2349 testEditor.setRootElement(element);
2351 await testEditor.update(() => {
2352 const root = $getRoot();
2353 const paragraph1 = $createParagraphNode();
2354 const text1 = $createTextNode('text 1');
2355 const paragraph2 = $createParagraphNode();
2356 const text2 = $createTextNode('text 2');
2357 root.append(paragraph1, paragraph2);
2358 paragraph1.append(text1);
2359 paragraph2.append(text2);
2361 const selection = $createRangeSelection();
2362 $setSelection(selection);
2370 offset: text1.__text.length,
2374 $setBlocksType(selection, () => {
2375 return $createHeadingNode('h1');
2378 const rootChildren = root.getChildren();
2379 expect(rootChildren[0].__type).toBe('heading');
2380 expect(rootChildren[1].__type).toBe('heading');
2381 expect(rootChildren.length).toBe(2);
2385 test('Two empty elements, same top-element', async () => {
2386 const testEditor = createTestEditor();
2387 const element = document.createElement('div');
2388 testEditor.setRootElement(element);
2390 await testEditor.update(() => {
2391 const root = $getRoot();
2392 const paragraph1 = $createParagraphNode();
2393 const paragraph2 = $createParagraphNode();
2394 root.append(paragraph1, paragraph2);
2396 const selection = $createRangeSelection();
2397 $setSelection(selection);
2399 key: paragraph1.__key,
2404 key: paragraph2.__key,
2409 $setBlocksType(selection, () => {
2410 return $createHeadingNode('h1');
2413 const rootChildren = root.getChildren();
2414 expect(rootChildren[0].__type).toBe('heading');
2415 expect(rootChildren[1].__type).toBe('heading');
2416 expect(rootChildren.length).toBe(2);
2417 const sel = $getSelection()!;
2418 expect(sel.getNodes().length).toBe(2);
2422 test('Two elements, same top-element', async () => {
2423 const testEditor = createTestEditor();
2424 const element = document.createElement('div');
2425 testEditor.setRootElement(element);
2427 await testEditor.update(() => {
2428 const root = $getRoot();
2429 const paragraph1 = $createParagraphNode();
2430 const text1 = $createTextNode('text 1');
2431 const paragraph2 = $createParagraphNode();
2432 const text2 = $createTextNode('text 2');
2433 root.append(paragraph1, paragraph2);
2434 paragraph1.append(text1);
2435 paragraph2.append(text2);
2437 const selection = $createRangeSelection();
2438 $setSelection(selection);
2446 offset: text1.__text.length,
2450 $setBlocksType(selection, () => {
2451 return $createHeadingNode('h1');
2454 const rootChildren = root.getChildren();
2455 expect(rootChildren[0].__type).toBe('heading');
2456 expect(rootChildren[1].__type).toBe('heading');
2457 expect(rootChildren.length).toBe(2);
2461 test('Collapsed in element inside top-element', async () => {
2462 const testEditor = createTestEditor();
2463 const element = document.createElement('div');
2464 testEditor.setRootElement(element);
2466 await testEditor.update(() => {
2467 const root = $getRoot();
2468 const table = $createTableNodeWithDimensions(1, 1);
2469 const row = table.getFirstChild();
2470 invariant($isElementNode(row));
2471 const column = row.getFirstChild();
2472 invariant($isElementNode(column));
2473 const paragraph = column.getFirstChild();
2474 invariant($isElementNode(paragraph));
2475 if (paragraph.getFirstChild()) {
2476 paragraph.getFirstChild()!.remove();
2480 const selection = $createRangeSelection();
2481 $setSelection(selection);
2483 key: paragraph.__key,
2488 key: paragraph.__key,
2493 const columnChildrenPrev = column.getChildren();
2494 expect(columnChildrenPrev[0].__type).toBe('paragraph');
2495 $setBlocksType(selection, () => {
2496 return $createHeadingNode('h1');
2499 const columnChildrenAfter = column.getChildren();
2500 expect(columnChildrenAfter[0].__type).toBe('heading');
2501 expect(columnChildrenAfter.length).toBe(1);
2505 test('Collapsed in text inside top-element', async () => {
2506 const testEditor = createTestEditor();
2507 const element = document.createElement('div');
2508 testEditor.setRootElement(element);
2510 await testEditor.update(() => {
2511 const root = $getRoot();
2512 const table = $createTableNodeWithDimensions(1, 1);
2513 const row = table.getFirstChild();
2514 invariant($isElementNode(row));
2515 const column = row.getFirstChild();
2516 invariant($isElementNode(column));
2517 const paragraph = column.getFirstChild();
2518 invariant($isElementNode(paragraph));
2519 const text = $createTextNode('foo');
2521 paragraph.append(text);
2523 const selectionz = $createRangeSelection();
2524 $setSelection(selectionz);
2527 offset: text.__text.length,
2532 offset: text.__text.length,
2535 const selection = $getSelection() as RangeSelection;
2537 const columnChildrenPrev = column.getChildren();
2538 expect(columnChildrenPrev[0].__type).toBe('paragraph');
2539 $setBlocksType(selection, () => {
2540 return $createHeadingNode('h1');
2543 const columnChildrenAfter = column.getChildren();
2544 expect(columnChildrenAfter[0].__type).toBe('heading');
2545 expect(columnChildrenAfter.length).toBe(1);
2549 test('Full editor selection with a mix of top-elements', async () => {
2550 const testEditor = createTestEditor();
2551 const element = document.createElement('div');
2552 testEditor.setRootElement(element);
2554 await testEditor.update(() => {
2555 const root = $getRoot();
2557 const paragraph1 = $createParagraphNode();
2558 const paragraph2 = $createParagraphNode();
2559 const text1 = $createTextNode();
2560 const text2 = $createTextNode();
2561 paragraph1.append(text1);
2562 paragraph2.append(text2);
2563 root.append(paragraph1, paragraph2);
2565 const table = $createTableNodeWithDimensions(1, 2);
2566 const row = table.getFirstChild();
2567 invariant($isElementNode(row));
2568 const columns = row.getChildren();
2571 const column1 = columns[0];
2572 const paragraph3 = $createParagraphNode();
2573 const paragraph4 = $createParagraphNode();
2574 const text3 = $createTextNode();
2575 const text4 = $createTextNode();
2576 paragraph1.append(text3);
2577 paragraph2.append(text4);
2578 invariant($isElementNode(column1));
2579 column1.append(paragraph3, paragraph4);
2581 const column2 = columns[1];
2582 const paragraph5 = $createParagraphNode();
2583 const paragraph6 = $createParagraphNode();
2584 invariant($isElementNode(column2));
2585 column2.append(paragraph5, paragraph6);
2587 const paragraph7 = $createParagraphNode();
2588 root.append(paragraph7);
2590 const selectionz = $createRangeSelection();
2591 $setSelection(selectionz);
2593 key: paragraph1.__key,
2598 key: paragraph7.__key,
2602 const selection = $getSelection() as RangeSelection;
2604 $setBlocksType(selection, () => {
2605 return $createHeadingNode('h1');
2607 expect(JSON.stringify(testEditor._pendingEditorState?.toJSON())).toBe(
2608 '{"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}}',
2613 test('Paragraph with links to heading with links', async () => {
2614 const testEditor = createTestEditor();
2615 const element = document.createElement('div');
2616 testEditor.setRootElement(element);
2618 await testEditor.update(() => {
2619 const root = $getRoot();
2620 const paragraph = $createParagraphNode();
2621 const text1 = $createTextNode('Links: ');
2622 const text2 = $createTextNode('link1');
2623 const text3 = $createTextNode('link2');
2627 $createLinkNode('https://lexical.dev').append(text2),
2628 $createTextNode(' '),
2629 $createLinkNode('https://playground.lexical.dev').append(text3),
2633 const paragraphChildrenKeys = [...paragraph.getChildrenKeys()];
2634 const selection = $createRangeSelection();
2635 $setSelection(selection);
2637 key: text1.getKey(),
2642 key: text3.getKey(),
2647 $setBlocksType(selection, () => {
2648 return $createHeadingNode('h1');
2651 const rootChildren = root.getChildren();
2652 expect(rootChildren.length).toBe(1);
2653 invariant($isElementNode(rootChildren[0]));
2654 expect(rootChildren[0].getType()).toBe('heading');
2655 expect(rootChildren[0].getChildrenKeys()).toEqual(
2656 paragraphChildrenKeys,
2661 test('Nested list', async () => {
2662 const testEditor = createTestEditor();
2663 const element = document.createElement('div');
2664 testEditor.setRootElement(element);
2666 await testEditor.update(() => {
2667 const root = $getRoot();
2668 const ul1 = $createListNode('bullet');
2669 const text1 = $createTextNode('1');
2670 const li1 = $createListItemNode().append(text1);
2671 const li1_wrapper = $createListItemNode();
2672 const ul2 = $createListNode('bullet');
2673 const text1_1 = $createTextNode('1.1');
2674 const li1_1 = $createListItemNode().append(text1_1);
2675 ul1.append(li1, li1_wrapper);
2676 li1_wrapper.append(ul2);
2680 const selection = $createRangeSelection();
2681 $setSelection(selection);
2683 key: text1.getKey(),
2688 key: text1_1.getKey(),
2693 $setBlocksType(selection, () => {
2694 return $createHeadingNode('h1');
2697 expect(element.innerHTML).toStrictEqual(
2698 `<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>`,
2702 test('Nested list with listItem twice indented from his father', async () => {
2703 const testEditor = createTestEditor();
2704 const element = document.createElement('div');
2705 testEditor.setRootElement(element);
2707 await testEditor.update(() => {
2708 const root = $getRoot();
2709 const ul1 = $createListNode('bullet');
2710 const li1_wrapper = $createListItemNode();
2711 const ul2 = $createListNode('bullet');
2712 const text1_1 = $createTextNode('1.1');
2713 const li1_1 = $createListItemNode().append(text1_1);
2714 ul1.append(li1_wrapper);
2715 li1_wrapper.append(ul2);
2719 const selection = $createRangeSelection();
2720 $setSelection(selection);
2722 key: text1_1.getKey(),
2727 key: text1_1.getKey(),
2732 $setBlocksType(selection, () => {
2733 return $createHeadingNode('h1');
2736 expect(element.innerHTML).toStrictEqual(
2737 `<h1 style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">1.1</span></h1>`,