]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx
68e9dcab5557a0d0368683c5d8a947db62680441
[bookstack] / resources / js / wysiwyg / lexical / selection / __tests__ / unit / LexicalSelection.test.tsx
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import {$createLinkNode} from '@lexical/link';
10 import {$createListItemNode, $createListNode} from '@lexical/list';
11 import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
12 import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
13 import {ContentEditable} from '@lexical/react/LexicalContentEditable';
14 import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
15 import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
16 import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
17 import {$createHeadingNode} from '@lexical/rich-text';
18 import {
19   $addNodeStyle,
20   $getSelectionStyleValueForProperty,
21   $patchStyleText,
22   $setBlocksType,
23 } from '@lexical/selection';
24 import {$createTableNodeWithDimensions} from '@lexical/table';
25 import {
26   $createLineBreakNode,
27   $createParagraphNode,
28   $createRangeSelection,
29   $createTextNode,
30   $getRoot,
31   $getSelection,
32   $isElementNode,
33   $isRangeSelection,
34   $isTextNode,
35   $setSelection,
36   DecoratorNode,
37   ElementNode,
38   LexicalEditor,
39   LexicalNode,
40   ParagraphNode,
41   PointType,
42   type RangeSelection,
43   TextNode,
44 } from 'lexical';
45 import {
46   $assertRangeSelection,
47   $createTestDecoratorNode,
48   $createTestElementNode,
49   createTestEditor,
50   initializeClipboard,
51   invariant,
52   TestComposer,
53 } from 'lexical/__tests__/utils';
54 import {createRoot, Root} from 'react-dom/client';
55 import * as ReactTestUtils from 'lexical/shared/react-test-utils';
56
57 import {
58   $setAnchorPoint,
59   $setFocusPoint,
60   applySelectionInputs,
61   convertToSegmentedNode,
62   convertToTokenNode,
63   deleteBackward,
64   deleteWordBackward,
65   deleteWordForward,
66   formatBold,
67   formatItalic,
68   formatStrikeThrough,
69   formatUnderline,
70   getNodeFromPath,
71   insertParagraph,
72   insertSegmentedNode,
73   insertText,
74   insertTokenNode,
75   moveBackward,
76   moveEnd,
77   moveNativeSelection,
78   pastePlain,
79   printWhitespace,
80   redo,
81   setNativeSelectionWithPaths,
82   undo,
83 } from '../utils';
84
85 interface ExpectedSelection {
86   anchorPath: number[];
87   anchorOffset: number;
88   focusPath: number[];
89   focusOffset: number;
90 }
91
92 initializeClipboard();
93
94 jest.mock('lexical/shared/environment', () => {
95   const originalModule = jest.requireActual('lexical/shared/environment');
96
97   return {...originalModule, IS_FIREFOX: true};
98 });
99
100 Range.prototype.getBoundingClientRect = function (): DOMRect {
101   const rect = {
102     bottom: 0,
103     height: 0,
104     left: 0,
105     right: 0,
106     top: 0,
107     width: 0,
108     x: 0,
109     y: 0,
110   };
111   return {
112     ...rect,
113     toJSON() {
114       return rect;
115     },
116   };
117 };
118
119 describe('LexicalSelection tests', () => {
120   let container: HTMLElement;
121   let reactRoot: Root;
122   let editor: LexicalEditor | null = null;
123
124   beforeEach(async () => {
125     container = document.createElement('div');
126     document.body.appendChild(container);
127     reactRoot = createRoot(container);
128     await init();
129   });
130
131   afterEach(async () => {
132     // Ensure we are clearing out any React state and running effects with
133     // act
134     await ReactTestUtils.act(async () => {
135       reactRoot.unmount();
136       await Promise.resolve().then();
137     });
138     document.body.removeChild(container);
139   });
140
141   async function init() {
142     function TestBase() {
143       function TestPlugin() {
144         [editor] = useLexicalComposerContext();
145
146         return null;
147       }
148
149       return (
150         <TestComposer
151           config={{
152             nodes: [],
153             theme: {
154               code: 'editor-code',
155               heading: {
156                 h1: 'editor-heading-h1',
157                 h2: 'editor-heading-h2',
158                 h3: 'editor-heading-h3',
159                 h4: 'editor-heading-h4',
160                 h5: 'editor-heading-h5',
161                 h6: 'editor-heading-h6',
162               },
163               image: 'editor-image',
164               list: {
165                 ol: 'editor-list-ol',
166                 ul: 'editor-list-ul',
167               },
168               listitem: 'editor-listitem',
169               paragraph: 'editor-paragraph',
170               quote: 'editor-quote',
171               text: {
172                 bold: 'editor-text-bold',
173                 code: 'editor-text-code',
174                 hashtag: 'editor-text-hashtag',
175                 italic: 'editor-text-italic',
176                 link: 'editor-text-link',
177                 strikethrough: 'editor-text-strikethrough',
178                 underline: 'editor-text-underline',
179                 underlineStrikethrough: 'editor-text-underlineStrikethrough',
180               },
181             },
182           }}>
183           <RichTextPlugin
184             contentEditable={
185               // eslint-disable-next-line jsx-a11y/aria-role, @typescript-eslint/no-explicit-any
186               <ContentEditable role={null as any} spellCheck={null as any} />
187             }
188             placeholder={null}
189             ErrorBoundary={LexicalErrorBoundary}
190           />
191           <HistoryPlugin />
192           <TestPlugin />
193           <AutoFocusPlugin />
194         </TestComposer>
195       );
196     }
197
198     await ReactTestUtils.act(async () => {
199       reactRoot.render(<TestBase />);
200       await Promise.resolve().then();
201     });
202
203     await Promise.resolve().then();
204     // Focus first element
205     setNativeSelectionWithPaths(
206       editor!.getRootElement()!,
207       [0, 0],
208       0,
209       [0, 0],
210       0,
211     );
212   }
213
214   async function update(fn: () => void) {
215     await ReactTestUtils.act(async () => {
216       await editor!.update(fn);
217     });
218   }
219
220   test('Expect initial output to be a block with no text.', () => {
221     expect(container!.innerHTML).toBe(
222       '<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>',
223     );
224   });
225
226   function assertSelection(
227     rootElement: HTMLElement,
228     expectedSelection: ExpectedSelection,
229   ) {
230     const actualSelection = window.getSelection()!;
231
232     expect(actualSelection.anchorNode).toBe(
233       getNodeFromPath(expectedSelection.anchorPath, rootElement),
234     );
235     expect(actualSelection.anchorOffset).toBe(expectedSelection.anchorOffset);
236     expect(actualSelection.focusNode).toBe(
237       getNodeFromPath(expectedSelection.focusPath, rootElement),
238     );
239     expect(actualSelection.focusOffset).toBe(expectedSelection.focusOffset);
240   }
241
242   // eslint-disable-next-line @typescript-eslint/no-unused-vars
243   const GRAPHEME_SCENARIOS = [
244     {
245       description: 'grapheme cluster',
246       // Hangul grapheme cluster.
247       // https://www.compart.com/en/unicode/U+AC01
248       grapheme: '\u1100\u1161\u11A8',
249     },
250     {
251       description: 'extended grapheme cluster',
252       // Tamil 'ni' grapheme cluster.
253       // http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters
254       grapheme: '\u0BA8\u0BBF',
255     },
256     {
257       description: 'tailored grapheme cluster',
258       // Devangari 'kshi' tailored grapheme cluster.
259       // http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters
260       grapheme: '\u0915\u094D\u0937\u093F',
261     },
262     {
263       description: 'Emoji sequence combined using zero-width joiners',
264       // https://emojipedia.org/family-woman-woman-girl-boy/
265       grapheme:
266         '\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66',
267     },
268     {
269       description: 'Emoji sequence with skin-tone modifier',
270       // https://emojipedia.org/clapping-hands-medium-skin-tone/
271       grapheme: '\uD83D\uDC4F\uD83C\uDFFD',
272     },
273   ];
274
275   const suite = [
276     {
277       expectedHTML:
278         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello</span></p></div>',
279       expectedSelection: {
280         anchorOffset: 5,
281         anchorPath: [0, 0, 0],
282         focusOffset: 5,
283         focusPath: [0, 0, 0],
284       },
285       inputs: [
286         insertText('H'),
287         insertText('e'),
288         insertText('l'),
289         insertText('l'),
290         insertText('o'),
291       ],
292       name: 'Simple typing',
293     },
294     {
295       expectedHTML:
296         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
297         '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong></p></div>',
298       expectedSelection: {
299         anchorOffset: 5,
300         anchorPath: [0, 0, 0],
301         focusOffset: 5,
302         focusPath: [0, 0, 0],
303       },
304       inputs: [
305         formatBold(),
306         insertText('H'),
307         insertText('e'),
308         insertText('l'),
309         insertText('l'),
310         insertText('o'),
311       ],
312       name: 'Simple typing in bold',
313     },
314     {
315       expectedHTML:
316         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
317         '<em class="editor-text-italic" data-lexical-text="true">Hello</em></p></div>',
318       expectedSelection: {
319         anchorOffset: 5,
320         anchorPath: [0, 0, 0],
321         focusOffset: 5,
322         focusPath: [0, 0, 0],
323       },
324       inputs: [
325         formatItalic(),
326         insertText('H'),
327         insertText('e'),
328         insertText('l'),
329         insertText('l'),
330         insertText('o'),
331       ],
332       name: 'Simple typing in italic',
333     },
334     {
335       expectedHTML:
336         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
337         '<strong class="editor-text-bold editor-text-italic" data-lexical-text="true">Hello</strong></p></div>',
338       expectedSelection: {
339         anchorOffset: 5,
340         anchorPath: [0, 0, 0],
341         focusOffset: 5,
342         focusPath: [0, 0, 0],
343       },
344       inputs: [
345         formatItalic(),
346         formatBold(),
347         insertText('H'),
348         insertText('e'),
349         insertText('l'),
350         insertText('l'),
351         insertText('o'),
352       ],
353       name: 'Simple typing in italic + bold',
354     },
355     {
356       expectedHTML:
357         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
358         '<span class="editor-text-underline" data-lexical-text="true">Hello</span></p></div>',
359       expectedSelection: {
360         anchorOffset: 5,
361         anchorPath: [0, 0, 0],
362         focusOffset: 5,
363         focusPath: [0, 0, 0],
364       },
365       inputs: [
366         formatUnderline(),
367         insertText('H'),
368         insertText('e'),
369         insertText('l'),
370         insertText('l'),
371         insertText('o'),
372       ],
373       name: 'Simple typing in underline',
374     },
375     {
376       expectedHTML:
377         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
378         '<span class="editor-text-strikethrough" data-lexical-text="true">Hello</span></p></div>',
379       expectedSelection: {
380         anchorOffset: 5,
381         anchorPath: [0, 0, 0],
382         focusOffset: 5,
383         focusPath: [0, 0, 0],
384       },
385       inputs: [
386         formatStrikeThrough(),
387         insertText('H'),
388         insertText('e'),
389         insertText('l'),
390         insertText('l'),
391         insertText('o'),
392       ],
393       name: 'Simple typing in strikethrough',
394     },
395     {
396       expectedHTML:
397         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
398         '<span class="editor-text-underlineStrikethrough" data-lexical-text="true">Hello</span></p></div>',
399       expectedSelection: {
400         anchorOffset: 5,
401         anchorPath: [0, 0, 0],
402         focusOffset: 5,
403         focusPath: [0, 0, 0],
404       },
405       inputs: [
406         formatUnderline(),
407         formatStrikeThrough(),
408         insertText('H'),
409         insertText('e'),
410         insertText('l'),
411         insertText('l'),
412         insertText('o'),
413       ],
414       name: 'Simple typing in underline + strikethrough',
415     },
416     {
417       expectedHTML:
418         '<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>',
419       expectedSelection: {
420         anchorOffset: 4,
421         anchorPath: [0, 0, 0],
422         focusOffset: 4,
423         focusPath: [0, 0, 0],
424       },
425       inputs: [
426         insertText('1'),
427         insertText('2'),
428         insertText('3'),
429         deleteBackward(1),
430         insertText('4'),
431         insertText('5'),
432         deleteBackward(1),
433         insertText('6'),
434       ],
435       name: 'Deletion',
436     },
437     {
438       expectedHTML:
439         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
440         '<span data-lexical-text="true">Dominic Gannaway</span>' +
441         '</p></div>',
442       expectedSelection: {
443         anchorOffset: 16,
444         anchorPath: [0, 0, 0],
445         focusOffset: 16,
446         focusPath: [0, 0, 0],
447       },
448       inputs: [insertTokenNode('Dominic Gannaway')],
449       name: 'Creation of an token node',
450     },
451     {
452       expectedHTML:
453         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
454         '<span data-lexical-text="true">Dominic Gannaway</span>' +
455         '</p></div>',
456       expectedSelection: {
457         anchorOffset: 1,
458         anchorPath: [0],
459         focusOffset: 1,
460         focusPath: [0],
461       },
462       inputs: [
463         insertText('Dominic Gannaway'),
464         moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16),
465         convertToTokenNode(),
466       ],
467       name: 'Convert text to an token node',
468     },
469     {
470       expectedHTML:
471         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
472         '<span data-lexical-text="true">Dominic Gannaway</span>' +
473         '</p></div>',
474       expectedSelection: {
475         anchorOffset: 1,
476         anchorPath: [0],
477         focusOffset: 1,
478         focusPath: [0],
479       },
480       inputs: [insertSegmentedNode('Dominic Gannaway')],
481       name: 'Creation of a segmented node',
482     },
483     {
484       expectedHTML:
485         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
486         '<span data-lexical-text="true">Dominic Gannaway</span>' +
487         '</p></div>',
488       expectedSelection: {
489         anchorOffset: 1,
490         anchorPath: [0],
491         focusOffset: 1,
492         focusPath: [0],
493       },
494       inputs: [
495         insertText('Dominic Gannaway'),
496         moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16),
497         convertToSegmentedNode(),
498       ],
499       name: 'Convert text to a segmented node',
500     },
501     {
502       expectedHTML:
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" dir="ltr">' +
506         '<strong class="editor-text-bold" data-lexical-text="true">Hello world</strong>' +
507         '</p>' +
508         '<p class="editor-paragraph"><br></p>' +
509         '</div>',
510       expectedSelection: {
511         anchorOffset: 0,
512         anchorPath: [0],
513         focusOffset: 0,
514         focusPath: [2],
515       },
516       inputs: [
517         insertParagraph(),
518         insertText('Hello world'),
519         insertParagraph(),
520         moveNativeSelection([0], 0, [2], 0),
521         formatBold(),
522       ],
523       name: 'Format selection that starts and ends on element and retain selection',
524     },
525     {
526       expectedHTML:
527         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
528         '<p class="editor-paragraph"><br></p>' +
529         '<p class="editor-paragraph" dir="ltr">' +
530         '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong>' +
531         '</p>' +
532         '<p class="editor-paragraph" dir="ltr">' +
533         '<strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
534         '</p>' +
535         '<p class="editor-paragraph"><br></p>' +
536         '</div>',
537       expectedSelection: {
538         anchorOffset: 0,
539         anchorPath: [0],
540         focusOffset: 0,
541         focusPath: [3],
542       },
543       inputs: [
544         insertParagraph(),
545         insertText('Hello'),
546         insertParagraph(),
547         insertText('world'),
548         insertParagraph(),
549         moveNativeSelection([0], 0, [3], 0),
550         formatBold(),
551       ],
552       name: 'Format multiline text selection that starts and ends on element and retain selection',
553     },
554     {
555       expectedHTML:
556         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
557         '<p class="editor-paragraph" dir="ltr">' +
558         '<span data-lexical-text="true">He</span>' +
559         '<strong class="editor-text-bold" data-lexical-text="true">llo</strong>' +
560         '</p>' +
561         '<p class="editor-paragraph" dir="ltr">' +
562         '<strong class="editor-text-bold" data-lexical-text="true">wo</strong>' +
563         '<span data-lexical-text="true">rld</span>' +
564         '</p>' +
565         '</div>',
566       expectedSelection: {
567         anchorOffset: 0,
568         anchorPath: [0, 1, 0],
569         focusOffset: 2,
570         focusPath: [1, 0, 0],
571       },
572       inputs: [
573         insertText('Hello'),
574         insertParagraph(),
575         insertText('world'),
576         moveNativeSelection([0, 0, 0], 2, [1, 0, 0], 2),
577         formatBold(),
578       ],
579       name: 'Format multiline text selection that starts and ends within text',
580     },
581     {
582       expectedHTML:
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" dir="ltr">' +
586         '<span data-lexical-text="true">Hello </span>' +
587         '<strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
588         '</p>' +
589         '<p class="editor-paragraph"><br></p>' +
590         '</div>',
591       expectedSelection: {
592         anchorOffset: 0,
593         anchorPath: [1, 1, 0],
594         focusOffset: 0,
595         focusPath: [2],
596       },
597       inputs: [
598         insertParagraph(),
599         insertText('Hello world'),
600         insertParagraph(),
601         moveNativeSelection([1, 0, 0], 6, [2], 0),
602         formatBold(),
603       ],
604       name: 'Format selection that starts on text and ends on element and retain selection',
605     },
606     {
607       expectedHTML:
608         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
609         '<p class="editor-paragraph"><br></p>' +
610         '<p class="editor-paragraph" dir="ltr">' +
611         '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong>' +
612         '<span data-lexical-text="true"> world</span>' +
613         '</p>' +
614         '<p class="editor-paragraph"><br></p>' +
615         '</div>',
616       expectedSelection: {
617         anchorOffset: 0,
618         anchorPath: [0],
619         focusOffset: 5,
620         focusPath: [1, 0, 0],
621       },
622       inputs: [
623         insertParagraph(),
624         insertText('Hello world'),
625         insertParagraph(),
626         moveNativeSelection([0], 0, [1, 0, 0], 5),
627         formatBold(),
628       ],
629       name: 'Format selection that starts on element and ends on text and retain selection',
630     },
631
632     {
633       expectedHTML:
634         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
635         '<p class="editor-paragraph"><br></p>' +
636         '<p class="editor-paragraph" dir="ltr">' +
637         '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong><strong class="editor-text-bold" data-lexical-text="true"> world</strong>' +
638         '</p>' +
639         '<p class="editor-paragraph"><br></p>' +
640         '</div>',
641       expectedSelection: {
642         anchorOffset: 2,
643         anchorPath: [1, 0, 0],
644         focusOffset: 0,
645         focusPath: [2],
646       },
647       inputs: [
648         insertParagraph(),
649         insertTokenNode('Hello'),
650         insertText(' world'),
651         insertParagraph(),
652         moveNativeSelection([1, 0, 0], 2, [2], 0),
653         formatBold(),
654       ],
655       name: 'Format selection that starts on middle of token node should format complete node',
656     },
657
658     {
659       expectedHTML:
660         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
661         '<p class="editor-paragraph"><br></p>' +
662         '<p class="editor-paragraph" dir="ltr">' +
663         '<strong class="editor-text-bold" data-lexical-text="true">Hello </strong><strong class="editor-text-bold" data-lexical-text="true">world</strong>' +
664         '</p>' +
665         '<p class="editor-paragraph"><br></p>' +
666         '</div>',
667       expectedSelection: {
668         anchorOffset: 0,
669         anchorPath: [0],
670         focusOffset: 2,
671         focusPath: [1, 1, 0],
672       },
673       inputs: [
674         insertParagraph(),
675         insertText('Hello '),
676         insertTokenNode('world'),
677         insertParagraph(),
678         moveNativeSelection([0], 0, [1, 1, 0], 2),
679         formatBold(),
680       ],
681       name: 'Format selection that ends on middle of token node should format complete node',
682     },
683
684     {
685       expectedHTML:
686         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
687         '<p class="editor-paragraph"><br></p>' +
688         '<p class="editor-paragraph" dir="ltr">' +
689         '<strong class="editor-text-bold" data-lexical-text="true">Hello</strong><span data-lexical-text="true"> world</span>' +
690         '</p>' +
691         '<p class="editor-paragraph"><br></p>' +
692         '</div>',
693       expectedSelection: {
694         anchorOffset: 2,
695         anchorPath: [1, 0, 0],
696         focusOffset: 3,
697         focusPath: [1, 0, 0],
698       },
699       inputs: [
700         insertParagraph(),
701         insertTokenNode('Hello'),
702         insertText(' world'),
703         insertParagraph(),
704         moveNativeSelection([1, 0, 0], 2, [1, 0, 0], 3),
705         formatBold(),
706       ],
707       name: 'Format token node if it is the single one selected',
708     },
709
710     {
711       expectedHTML:
712         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">' +
713         '<p class="editor-paragraph"><br></p>' +
714         '<p class="editor-paragraph" dir="ltr">' +
715         '<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>' +
716         '</p>' +
717         '<p class="editor-paragraph"><br></p>' +
718         '</div>',
719       expectedSelection: {
720         anchorOffset: 0,
721         anchorPath: [0],
722         focusOffset: 0,
723         focusPath: [2],
724       },
725       inputs: [
726         insertParagraph(),
727         insertText('Hello '),
728         insertTokenNode('beautiful'),
729         insertText(' world'),
730         insertParagraph(),
731         moveNativeSelection([0], 0, [2], 0),
732         formatBold(),
733       ],
734       name: 'Format selection that contains a token node in the middle should format the token node',
735     },
736
737     // Tests need fixing:
738     // ...GRAPHEME_SCENARIOS.flatMap(({description, grapheme}) => [
739     //   {
740     //     name: `Delete backward eliminates entire ${description} (${grapheme})`,
741     //     inputs: [insertText(grapheme + grapheme), deleteBackward(1)],
742     //     expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir=\"ltr\"><span>${grapheme}</span></p></div>`,
743     //     expectedSelection: {
744     //       anchorPath: [0, 0, 0],
745     //       anchorOffset: grapheme.length,
746     //       focusPath: [0, 0, 0],
747     //       focusOffset: grapheme.length,
748     //     },
749     //     setup: emptySetup,
750     //   },
751     //   {
752     //     name: `Delete forward eliminates entire ${description} (${grapheme})`,
753     //     inputs: [
754     //       insertText(grapheme + grapheme),
755     //       moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
756     //       deleteForward(),
757     //     ],
758     //     expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir=\"ltr\"><span>${grapheme}</span></p></div>`,
759     //     expectedSelection: {
760     //       anchorPath: [0, 0, 0],
761     //       anchorOffset: 0,
762     //       focusPath: [0, 0, 0],
763     //       focusOffset: 0,
764     //     },
765     //     setup: emptySetup,
766     //   },
767     //   {
768     //     name: `Move backward skips over grapheme cluster (${grapheme})`,
769     //     inputs: [insertText(grapheme + grapheme), moveBackward(1)],
770     //     expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir=\"ltr\"><span>${grapheme}${grapheme}</span></p></div>`,
771     //     expectedSelection: {
772     //       anchorPath: [0, 0, 0],
773     //       anchorOffset: grapheme.length,
774     //       focusPath: [0, 0, 0],
775     //       focusOffset: grapheme.length,
776     //     },
777     //     setup: emptySetup,
778     //   },
779     //   {
780     //     name: `Move forward skips over grapheme cluster (${grapheme})`,
781     //     inputs: [
782     //       insertText(grapheme + grapheme),
783     //       moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
784     //       moveForward(),
785     //     ],
786     //     expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir=\"ltr\"><span>${grapheme}${grapheme}</span></p></div>`,
787     //     expectedSelection: {
788     //       anchorPath: [0, 0, 0],
789     //       anchorOffset: grapheme.length,
790     //       focusPath: [0, 0, 0],
791     //       focusOffset: grapheme.length,
792     //     },
793     //     setup: emptySetup,
794     //   },
795     // ]),
796     // {
797     //   name: 'Jump to beginning and insert',
798     //   inputs: [
799     //     insertText('1'),
800     //     insertText('1'),
801     //     insertText('2'),
802     //     insertText('3'),
803     //     moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
804     //     insertText('a'),
805     //     insertText('b'),
806     //     insertText('c'),
807     //     deleteForward(),
808     //   ],
809     //   expectedHTML:
810     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">abc123</span></p></div>',
811     //   expectedSelection: {
812     //     anchorPath: [0, 0, 0],
813     //     anchorOffset: 3,
814     //     focusPath: [0, 0, 0],
815     //     focusOffset: 3,
816     //   },
817     // },
818     // {
819     //   name: 'Select and replace',
820     //   inputs: [
821     //     insertText('Hello draft!'),
822     //     moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
823     //     insertText('lexical'),
824     //   ],
825     //   expectedHTML:
826     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello lexical!</span></p></div>',
827     //   expectedSelection: {
828     //     anchorPath: [0, 0, 0],
829     //     anchorOffset: 13,
830     //     focusPath: [0, 0, 0],
831     //     focusOffset: 13,
832     //   },
833     // },
834     // {
835     //   name: 'Select and bold',
836     //   inputs: [
837     //     insertText('Hello draft!'),
838     //     moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
839     //     formatBold(),
840     //   ],
841     //   expectedHTML:
842     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
843     //     '<strong class="editor-text-bold" data-lexical-text="true">draft</strong><span data-lexical-text="true">!</span></p></div>',
844     //   expectedSelection: {
845     //     anchorPath: [0, 1, 0],
846     //     anchorOffset: 0,
847     //     focusPath: [0, 1, 0],
848     //     focusOffset: 5,
849     //   },
850     // },
851     // {
852     //   name: 'Select and italic',
853     //   inputs: [
854     //     insertText('Hello draft!'),
855     //     moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
856     //     formatItalic(),
857     //   ],
858     //   expectedHTML:
859     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
860     //     '<em class="editor-text-italic" data-lexical-text="true">draft</em><span data-lexical-text="true">!</span></p></div>',
861     //   expectedSelection: {
862     //     anchorPath: [0, 1, 0],
863     //     anchorOffset: 0,
864     //     focusPath: [0, 1, 0],
865     //     focusOffset: 5,
866     //   },
867     // },
868     // {
869     //   name: 'Select and bold + italic',
870     //   inputs: [
871     //     insertText('Hello draft!'),
872     //     moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
873     //     formatBold(),
874     //     formatItalic(),
875     //   ],
876     //   expectedHTML:
877     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
878     //     '<strong class="editor-text-bold editor-text-italic" data-lexical-text="true">draft</strong><span data-lexical-text="true">!</span></p></div>',
879     //   expectedSelection: {
880     //     anchorPath: [0, 1, 0],
881     //     anchorOffset: 0,
882     //     focusPath: [0, 1, 0],
883     //     focusOffset: 5,
884     //   },
885     // },
886     // {
887     //   name: 'Select and underline',
888     //   inputs: [
889     //     insertText('Hello draft!'),
890     //     moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
891     //     formatUnderline(),
892     //   ],
893     //   expectedHTML:
894     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
895     //     '<span class="editor-text-underline" data-lexical-text="true">draft</span><span data-lexical-text="true">!</span></p></div>',
896     //   expectedSelection: {
897     //     anchorPath: [0, 1, 0],
898     //     anchorOffset: 0,
899     //     focusPath: [0, 1, 0],
900     //     focusOffset: 5,
901     //   },
902     // },
903     // {
904     //   name: 'Select and strikethrough',
905     //   inputs: [
906     //     insertText('Hello draft!'),
907     //     moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
908     //     formatStrikeThrough(),
909     //   ],
910     //   expectedHTML:
911     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
912     //     '<span class="editor-text-strikethrough" data-lexical-text="true">draft</span><span data-lexical-text="true">!</span></p></div>',
913     //   expectedSelection: {
914     //     anchorPath: [0, 1, 0],
915     //     anchorOffset: 0,
916     //     focusPath: [0, 1, 0],
917     //     focusOffset: 5,
918     //   },
919     // },
920     // {
921     //   name: 'Select and underline + strikethrough',
922     //   inputs: [
923     //     insertText('Hello draft!'),
924     //     moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11),
925     //     formatUnderline(),
926     //     formatStrikeThrough(),
927     //   ],
928     //   expectedHTML:
929     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span>' +
930     //     '<span class="editor-text-underlineStrikethrough" data-lexical-text="true">draft</span><span data-lexical-text="true">!</span></p></div>',
931     //   expectedSelection: {
932     //     anchorPath: [0, 1, 0],
933     //     anchorOffset: 0,
934     //     focusPath: [0, 1, 0],
935     //     focusOffset: 5,
936     //   },
937     // },
938     // {
939     //   name: 'Select and replace all',
940     //   inputs: [
941     //     insertText('This is broken.'),
942     //     moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 15),
943     //     insertText('This works!'),
944     //   ],
945     //   expectedHTML:
946     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">This works!</span></p></div>',
947     //   expectedSelection: {
948     //     anchorPath: [0, 0, 0],
949     //     anchorOffset: 11,
950     //     focusPath: [0, 0, 0],
951     //     focusOffset: 11,
952     //   },
953     // },
954     // {
955     //   name: 'Select and delete',
956     //   inputs: [
957     //     insertText('A lion.'),
958     //     moveNativeSelection([0, 0, 0], 2, [0, 0, 0], 6),
959     //     deleteForward(),
960     //     insertText('duck'),
961     //     moveNativeSelection([0, 0, 0], 2, [0, 0, 0], 6),
962     //   ],
963     //   expectedHTML:
964     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">A duck.</span></p></div>',
965     //   expectedSelection: {
966     //     anchorPath: [0, 0, 0],
967     //     anchorOffset: 2,
968     //     focusPath: [0, 0, 0],
969     //     focusOffset: 6,
970     //   },
971     // },
972     // {
973     //   name: 'Inserting a paragraph',
974     //   inputs: [insertParagraph()],
975     //   expectedHTML:
976     //     '<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"><br></span></p>' +
977     //     '<p class="editor-paragraph"><span data-lexical-text="true"><br></span></p></div>',
978     //   expectedSelection: {
979     //     anchorPath: [1, 0, 0],
980     //     anchorOffset: 0,
981     //     focusPath: [1, 0, 0],
982     //     focusOffset: 0,
983     //   },
984     // },
985     // {
986     //   name: 'Inserting a paragraph and then removing it',
987     //   inputs: [insertParagraph(), deleteBackward(1)],
988     //   expectedHTML:
989     //     '<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"><br></span></p></div>',
990     //   expectedSelection: {
991     //     anchorPath: [0, 0, 0],
992     //     anchorOffset: 0,
993     //     focusPath: [0, 0, 0],
994     //     focusOffset: 0,
995     //   },
996     // },
997     // {
998     //   name: 'Inserting a paragraph part way through text',
999     //   inputs: [
1000     //     insertText('Hello world'),
1001     //     moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 6),
1002     //     insertParagraph(),
1003     //   ],
1004     //   expectedHTML:
1005     //     '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span></p>' +
1006     //     '<p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">world</span></p></div>',
1007     //   expectedSelection: {
1008     //     anchorPath: [1, 0, 0],
1009     //     anchorOffset: 0,
1010     //     focusPath: [1, 0, 0],
1011     //     focusOffset: 0,
1012     //   },
1013     // },
1014     // {
1015     //   name: 'Inserting two paragraphs and then deleting via selection',
1016     //   inputs: [
1017     //     insertText('123'),
1018     //     insertParagraph(),
1019     //     insertText('456'),
1020     //     moveNativeSelection([0, 0, 0], 0, [1, 0, 0], 3),
1021     //     deleteBackward(1),
1022     //   ],
1023     //   expectedHTML:
1024     //     '<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"><br></span></p></div>',
1025     //   expectedSelection: {
1026     //     anchorPath: [0, 0, 0],
1027     //     anchorOffset: 0,
1028     //     focusPath: [0, 0, 0],
1029     //     focusOffset: 0,
1030     //   },
1031     // },
1032     ...[
1033       {
1034         whitespaceCharacter: ' ',
1035         whitespaceName: 'space',
1036       },
1037       {
1038         whitespaceCharacter: '\u00a0',
1039         whitespaceName: 'non-breaking space',
1040       },
1041       {
1042         whitespaceCharacter: '\u2000',
1043         whitespaceName: 'en quad',
1044       },
1045       {
1046         whitespaceCharacter: '\u2001',
1047         whitespaceName: 'em quad',
1048       },
1049       {
1050         whitespaceCharacter: '\u2002',
1051         whitespaceName: 'en space',
1052       },
1053       {
1054         whitespaceCharacter: '\u2003',
1055         whitespaceName: 'em space',
1056       },
1057       {
1058         whitespaceCharacter: '\u2004',
1059         whitespaceName: 'three-per-em space',
1060       },
1061       {
1062         whitespaceCharacter: '\u2005',
1063         whitespaceName: 'four-per-em space',
1064       },
1065       {
1066         whitespaceCharacter: '\u2006',
1067         whitespaceName: 'six-per-em space',
1068       },
1069       {
1070         whitespaceCharacter: '\u2007',
1071         whitespaceName: 'figure space',
1072       },
1073       {
1074         whitespaceCharacter: '\u2008',
1075         whitespaceName: 'punctuation space',
1076       },
1077       {
1078         whitespaceCharacter: '\u2009',
1079         whitespaceName: 'thin space',
1080       },
1081       {
1082         whitespaceCharacter: '\u200A',
1083         whitespaceName: 'hair space',
1084       },
1085     ].flatMap(({whitespaceCharacter, whitespaceName}) => [
1086       {
1087         expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello${printWhitespace(
1088           whitespaceCharacter,
1089         )}</span></p></div>`,
1090         expectedSelection: {
1091           anchorOffset: 6,
1092           anchorPath: [0, 0, 0],
1093           focusOffset: 6,
1094           focusPath: [0, 0, 0],
1095         },
1096         inputs: [
1097           insertText(`Hello${whitespaceCharacter}world`),
1098           deleteWordBackward(1),
1099         ],
1100         name: `Type two words separated by a ${whitespaceName}, delete word backward from end`,
1101       },
1102       {
1103         expectedHTML: `<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">${printWhitespace(
1104           whitespaceCharacter,
1105         )}world</span></p></div>`,
1106         expectedSelection: {
1107           anchorOffset: 0,
1108           anchorPath: [0, 0, 0],
1109           focusOffset: 0,
1110           focusPath: [0, 0, 0],
1111         },
1112         inputs: [
1113           insertText(`Hello${whitespaceCharacter}world`),
1114           moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0),
1115           deleteWordForward(1),
1116         ],
1117         name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning`,
1118       },
1119       {
1120         expectedHTML:
1121           '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello</span></p></div>',
1122         expectedSelection: {
1123           anchorOffset: 5,
1124           anchorPath: [0, 0, 0],
1125           focusOffset: 5,
1126           focusPath: [0, 0, 0],
1127         },
1128         inputs: [
1129           insertText(`Hello${whitespaceCharacter}world`),
1130           moveNativeSelection([0, 0, 0], 5, [0, 0, 0], 5),
1131           deleteWordForward(1),
1132         ],
1133         name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning of preceding whitespace`,
1134       },
1135       {
1136         expectedHTML:
1137           '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">world</span></p></div>',
1138         expectedSelection: {
1139           anchorOffset: 0,
1140           anchorPath: [0, 0, 0],
1141           focusOffset: 0,
1142           focusPath: [0, 0, 0],
1143         },
1144         inputs: [
1145           insertText(`Hello${whitespaceCharacter}world`),
1146           moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 6),
1147           deleteWordBackward(1),
1148         ],
1149         name: `Type two words separated by a ${whitespaceName}, delete word backward from end of trailing whitespace`,
1150       },
1151       {
1152         expectedHTML:
1153           '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello world</span></p></div>',
1154         expectedSelection: {
1155           anchorOffset: 11,
1156           anchorPath: [0, 0, 0],
1157           focusOffset: 11,
1158           focusPath: [0, 0, 0],
1159         },
1160         inputs: [insertText('Hello world'), deleteWordBackward(1), undo(1)],
1161         name: `Type a word, delete it and undo the deletion`,
1162       },
1163       {
1164         expectedHTML:
1165           '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">Hello </span></p></div>',
1166         expectedSelection: {
1167           anchorOffset: 6,
1168           anchorPath: [0, 0, 0],
1169           focusOffset: 6,
1170           focusPath: [0, 0, 0],
1171         },
1172         inputs: [
1173           insertText('Hello world'),
1174           deleteWordBackward(1),
1175           undo(1),
1176           redo(1),
1177         ],
1178         name: `Type a word, delete it and undo the deletion`,
1179       },
1180       {
1181         expectedHTML:
1182           '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
1183           '<span data-lexical-text="true">this is weird test</span></p></div>',
1184         expectedSelection: {
1185           anchorOffset: 0,
1186           anchorPath: [0, 0, 0],
1187           focusOffset: 0,
1188           focusPath: [0, 0, 0],
1189         },
1190         inputs: [
1191           insertText('this is weird test'),
1192           moveNativeSelection([0, 0, 0], 14, [0, 0, 0], 14),
1193           moveBackward(14),
1194         ],
1195         name: 'Type a sentence, move the caret to the middle and move with the arrows to the start',
1196       },
1197       {
1198         expectedHTML:
1199           '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr">' +
1200           '<span data-lexical-text="true">Hello </span>' +
1201           '<span data-lexical-text="true">Bob</span>' +
1202           '</p></div>',
1203         expectedSelection: {
1204           anchorOffset: 3,
1205           anchorPath: [0, 1, 0],
1206           focusOffset: 3,
1207           focusPath: [0, 1, 0],
1208         },
1209         inputs: [
1210           insertText('Hello '),
1211           insertTokenNode('Bob'),
1212           moveBackward(1),
1213           moveBackward(1),
1214           moveEnd(),
1215         ],
1216         name: 'Type a text and token text, move the caret to the end of the first text',
1217       },
1218       {
1219         expectedHTML:
1220           '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p class="editor-paragraph" dir="ltr"><span data-lexical-text="true">ABD</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">EFG</span></p></div>',
1221         expectedSelection: {
1222           anchorOffset: 3,
1223           anchorPath: [0, 0, 0],
1224           focusOffset: 3,
1225           focusPath: [0, 0, 0],
1226         },
1227         inputs: [
1228           pastePlain('ABD\tEFG'),
1229           moveBackward(5),
1230           insertText('C'),
1231           moveBackward(1),
1232           deleteWordForward(1),
1233         ],
1234         name: 'Paste text, move selection and delete word forward',
1235       },
1236     ]),
1237   ];
1238
1239   suite.forEach((testUnit, i) => {
1240     const name = testUnit.name || 'Test case';
1241
1242     test(name + ` (#${i + 1})`, async () => {
1243       await applySelectionInputs(testUnit.inputs, update, editor!);
1244
1245       // Validate HTML matches
1246       expect(container.innerHTML).toBe(testUnit.expectedHTML);
1247
1248       // Validate selection matches
1249       const rootElement = editor!.getRootElement()!;
1250       const expectedSelection = testUnit.expectedSelection;
1251
1252       assertSelection(rootElement, expectedSelection);
1253     });
1254   });
1255
1256   test('insert text one selected node element selection', async () => {
1257     await ReactTestUtils.act(async () => {
1258       await editor!.update(() => {
1259         const root = $getRoot();
1260
1261         const paragraph = root.getFirstChild<ParagraphNode>()!;
1262
1263         const elementNode = $createTestElementNode();
1264         const text = $createTextNode('foo');
1265
1266         paragraph.append(elementNode);
1267         elementNode.append(text);
1268
1269         const selection = $createRangeSelection();
1270         selection.anchor.set(text.__key, 0, 'text');
1271         selection.focus.set(paragraph.__key, 1, 'element');
1272
1273         selection.insertText('');
1274
1275         expect(root.getTextContent()).toBe('');
1276       });
1277     });
1278   });
1279
1280   test('getNodes resolves nested block nodes', async () => {
1281     await ReactTestUtils.act(async () => {
1282       await editor!.update(() => {
1283         const root = $getRoot();
1284
1285         const paragraph = root.getFirstChild<ParagraphNode>()!;
1286
1287         const elementNode = $createTestElementNode();
1288         const text = $createTextNode();
1289
1290         paragraph.append(elementNode);
1291         elementNode.append(text);
1292
1293         const selectedNodes = $getSelection()!.getNodes();
1294
1295         expect(selectedNodes.length).toBe(1);
1296         expect(selectedNodes[0].getKey()).toBe(text.getKey());
1297       });
1298     });
1299   });
1300
1301   describe('Block selection moves when new nodes are inserted', () => {
1302     const baseCases: {
1303       name: string;
1304       anchorOffset: number;
1305       focusOffset: number;
1306       fn: (
1307         paragraph: ElementNode,
1308         text: TextNode,
1309       ) => {
1310         expectedAnchor: LexicalNode;
1311         expectedAnchorOffset: number;
1312         expectedFocus: LexicalNode;
1313         expectedFocusOffset: number;
1314       };
1315       fnBefore?: (paragraph: ElementNode, text: TextNode) => void;
1316       invertSelection?: true;
1317       only?: true;
1318     }[] = [
1319       // Collapsed selection on end; add/remove/replace beginning
1320       {
1321         anchorOffset: 2,
1322         fn: (paragraph, text) => {
1323           const newText = $createTextNode('2');
1324           text.insertBefore(newText);
1325
1326           return {
1327             expectedAnchor: paragraph,
1328             expectedAnchorOffset: 3,
1329             expectedFocus: paragraph,
1330             expectedFocusOffset: 3,
1331           };
1332         },
1333         focusOffset: 2,
1334         name: 'insertBefore - Collapsed selection on end; add beginning',
1335       },
1336       {
1337         anchorOffset: 2,
1338         fn: (paragraph, text) => {
1339           const newText = $createTextNode('2');
1340           text.insertAfter(newText);
1341
1342           return {
1343             expectedAnchor: paragraph,
1344             expectedAnchorOffset: 3,
1345             expectedFocus: paragraph,
1346             expectedFocusOffset: 3,
1347           };
1348         },
1349         focusOffset: 2,
1350         name: 'insertAfter - Collapsed selection on end; add beginning',
1351       },
1352       {
1353         anchorOffset: 2,
1354         fn: (paragraph, text) => {
1355           text.splitText(1);
1356
1357           return {
1358             expectedAnchor: paragraph,
1359             expectedAnchorOffset: 3,
1360             expectedFocus: paragraph,
1361             expectedFocusOffset: 3,
1362           };
1363         },
1364         focusOffset: 2,
1365         name: 'splitText - Collapsed selection on end; add beginning',
1366       },
1367       {
1368         anchorOffset: 1,
1369         fn: (paragraph, text) => {
1370           text.remove();
1371
1372           return {
1373             expectedAnchor: paragraph,
1374             expectedAnchorOffset: 0,
1375             expectedFocus: paragraph,
1376             expectedFocusOffset: 0,
1377           };
1378         },
1379         focusOffset: 1,
1380         name: 'remove - Collapsed selection on end; add beginning',
1381       },
1382       {
1383         anchorOffset: 1,
1384         fn: (paragraph, text) => {
1385           const newText = $createTextNode('replacement');
1386           text.replace(newText);
1387
1388           return {
1389             expectedAnchor: paragraph,
1390             expectedAnchorOffset: 1,
1391             expectedFocus: paragraph,
1392             expectedFocusOffset: 1,
1393           };
1394         },
1395         focusOffset: 1,
1396         name: 'replace - Collapsed selection on end; replace beginning',
1397       },
1398       // All selected; add/remove/replace on beginning
1399       {
1400         anchorOffset: 0,
1401         fn: (paragraph, text) => {
1402           const newText = $createTextNode('2');
1403           text.insertBefore(newText);
1404
1405           return {
1406             expectedAnchor: text,
1407             expectedAnchorOffset: 0,
1408             expectedFocus: paragraph,
1409             expectedFocusOffset: 3,
1410           };
1411         },
1412         focusOffset: 2,
1413         name: 'insertBefore - All selected; add on beginning',
1414       },
1415       {
1416         anchorOffset: 0,
1417         fn: (paragraph, originalText) => {
1418           const [, text] = originalText.splitText(1);
1419
1420           return {
1421             expectedAnchor: text,
1422             expectedAnchorOffset: 0,
1423             expectedFocus: paragraph,
1424             expectedFocusOffset: 3,
1425           };
1426         },
1427         focusOffset: 2,
1428         name: 'splitNodes - All selected; add on beginning',
1429       },
1430       {
1431         anchorOffset: 0,
1432         fn: (paragraph, text) => {
1433           text.remove();
1434
1435           return {
1436             expectedAnchor: paragraph,
1437             expectedAnchorOffset: 0,
1438             expectedFocus: paragraph,
1439             expectedFocusOffset: 0,
1440           };
1441         },
1442         focusOffset: 1,
1443         name: 'remove - All selected; remove on beginning',
1444       },
1445       {
1446         anchorOffset: 0,
1447         fn: (paragraph, text) => {
1448           const newText = $createTextNode('replacement');
1449           text.replace(newText);
1450
1451           return {
1452             expectedAnchor: paragraph,
1453             expectedAnchorOffset: 0,
1454             expectedFocus: paragraph,
1455             expectedFocusOffset: 1,
1456           };
1457         },
1458         focusOffset: 1,
1459         name: 'replace - All selected; replace on beginning',
1460       },
1461       // Selection beginning; add/remove/replace on end
1462       {
1463         anchorOffset: 0,
1464         fn: (paragraph, originalText1) => {
1465           const originalText2 = originalText1.getPreviousSibling()!;
1466           const lastChild = paragraph.getLastChild()!;
1467           const newText = $createTextNode('2');
1468           lastChild.insertBefore(newText);
1469
1470           return {
1471             expectedAnchor: originalText2,
1472             expectedAnchorOffset: 0,
1473             expectedFocus: originalText1,
1474             expectedFocusOffset: 0,
1475           };
1476         },
1477         fnBefore: (paragraph, originalText1) => {
1478           const originalText2 = $createTextNode('bar');
1479           originalText1.insertBefore(originalText2);
1480         },
1481         focusOffset: 1,
1482         name: 'insertBefore - Selection beginning; add on end',
1483       },
1484       {
1485         anchorOffset: 0,
1486         fn: (paragraph, text) => {
1487           const lastChild = paragraph.getLastChild()!;
1488           const newText = $createTextNode('2');
1489           lastChild.insertAfter(newText);
1490
1491           return {
1492             expectedAnchor: text,
1493             expectedAnchorOffset: 0,
1494             expectedFocus: paragraph,
1495             expectedFocusOffset: 1,
1496           };
1497         },
1498         focusOffset: 1,
1499         name: 'insertAfter - Selection beginning; add on end',
1500       },
1501       {
1502         anchorOffset: 0,
1503         fn: (paragraph, originalText1) => {
1504           const originalText2 = originalText1.getPreviousSibling()!;
1505           const [, text] = originalText1.splitText(1);
1506
1507           return {
1508             expectedAnchor: originalText2,
1509             expectedAnchorOffset: 0,
1510             expectedFocus: text,
1511             expectedFocusOffset: 0,
1512           };
1513         },
1514         fnBefore: (paragraph, originalText1) => {
1515           const originalText2 = $createTextNode('bar');
1516           originalText1.insertBefore(originalText2);
1517         },
1518         focusOffset: 1,
1519         name: 'splitText - Selection beginning; add on end',
1520       },
1521       {
1522         anchorOffset: 0,
1523         fn: (paragraph, text) => {
1524           const lastChild = paragraph.getLastChild()!;
1525           lastChild.remove();
1526
1527           return {
1528             expectedAnchor: text,
1529             expectedAnchorOffset: 0,
1530             expectedFocus: text,
1531             expectedFocusOffset: 3,
1532           };
1533         },
1534         focusOffset: 1,
1535         name: 'remove - Selection beginning; remove on end',
1536       },
1537       {
1538         anchorOffset: 0,
1539         fn: (paragraph, text) => {
1540           const newText = $createTextNode('replacement');
1541           const lastChild = paragraph.getLastChild()!;
1542           lastChild.replace(newText);
1543
1544           return {
1545             expectedAnchor: paragraph,
1546             expectedAnchorOffset: 0,
1547             expectedFocus: paragraph,
1548             expectedFocusOffset: 1,
1549           };
1550         },
1551         focusOffset: 1,
1552         name: 'replace - Selection beginning; replace on end',
1553       },
1554       // All selected; add/remove/replace in end offset [1, 2] -> [1, N, 2]
1555       {
1556         anchorOffset: 0,
1557         fn: (paragraph, text) => {
1558           const lastChild = paragraph.getLastChild()!;
1559           const newText = $createTextNode('2');
1560           lastChild.insertBefore(newText);
1561
1562           return {
1563             expectedAnchor: text,
1564             expectedAnchorOffset: 0,
1565             expectedFocus: paragraph,
1566             expectedFocusOffset: 2,
1567           };
1568         },
1569         focusOffset: 1,
1570         name: 'insertBefore - All selected; add in end offset',
1571       },
1572       {
1573         anchorOffset: 0,
1574         fn: (paragraph, text) => {
1575           const newText = $createTextNode('2');
1576           text.insertAfter(newText);
1577
1578           return {
1579             expectedAnchor: text,
1580             expectedAnchorOffset: 0,
1581             expectedFocus: paragraph,
1582             expectedFocusOffset: 2,
1583           };
1584         },
1585         focusOffset: 1,
1586         name: 'insertAfter - All selected; add in end offset',
1587       },
1588       {
1589         anchorOffset: 0,
1590         fn: (paragraph, originalText1) => {
1591           const originalText2 = originalText1.getPreviousSibling()!;
1592           const [, text] = originalText1.splitText(1);
1593
1594           return {
1595             expectedAnchor: originalText2,
1596             expectedAnchorOffset: 0,
1597             expectedFocus: text,
1598             expectedFocusOffset: 0,
1599           };
1600         },
1601         fnBefore: (paragraph, originalText1) => {
1602           const originalText2 = $createTextNode('bar');
1603           originalText1.insertBefore(originalText2);
1604         },
1605         focusOffset: 1,
1606         name: 'splitText - All selected; add in end offset',
1607       },
1608       {
1609         anchorOffset: 1,
1610         fn: (paragraph, originalText1) => {
1611           const lastChild = paragraph.getLastChild()!;
1612           lastChild.remove();
1613
1614           return {
1615             expectedAnchor: originalText1,
1616             expectedAnchorOffset: 0,
1617             expectedFocus: originalText1,
1618             expectedFocusOffset: 3,
1619           };
1620         },
1621         fnBefore: (paragraph, originalText1) => {
1622           const originalText2 = $createTextNode('bar');
1623           originalText1.insertBefore(originalText2);
1624         },
1625         focusOffset: 2,
1626         name: 'remove - All selected; remove in end offset',
1627       },
1628       {
1629         anchorOffset: 1,
1630         fn: (paragraph, originalText1) => {
1631           const newText = $createTextNode('replacement');
1632           const lastChild = paragraph.getLastChild()!;
1633           lastChild.replace(newText);
1634
1635           return {
1636             expectedAnchor: paragraph,
1637             expectedAnchorOffset: 1,
1638             expectedFocus: paragraph,
1639             expectedFocusOffset: 2,
1640           };
1641         },
1642         fnBefore: (paragraph, originalText1) => {
1643           const originalText2 = $createTextNode('bar');
1644           originalText1.insertBefore(originalText2);
1645         },
1646         focusOffset: 2,
1647         name: 'replace - All selected; replace in end offset',
1648       },
1649       // All selected; add/remove/replace in middle [1, 2, 3] -> [1, 2, N, 3]
1650       {
1651         anchorOffset: 0,
1652         fn: (paragraph, originalText1) => {
1653           const originalText2 = originalText1.getPreviousSibling()!;
1654           const lastChild = paragraph.getLastChild()!;
1655           const newText = $createTextNode('2');
1656           lastChild.insertBefore(newText);
1657
1658           return {
1659             expectedAnchor: originalText2,
1660             expectedAnchorOffset: 0,
1661             expectedFocus: paragraph,
1662             expectedFocusOffset: 3,
1663           };
1664         },
1665         fnBefore: (paragraph, originalText1) => {
1666           const originalText2 = $createTextNode('bar');
1667           originalText1.insertBefore(originalText2);
1668         },
1669         focusOffset: 2,
1670         name: 'insertBefore - All selected; add in middle',
1671       },
1672       {
1673         anchorOffset: 0,
1674         fn: (paragraph, originalText1) => {
1675           const originalText2 = originalText1.getPreviousSibling()!;
1676           const newText = $createTextNode('2');
1677           originalText1.insertAfter(newText);
1678
1679           return {
1680             expectedAnchor: originalText2,
1681             expectedAnchorOffset: 0,
1682             expectedFocus: paragraph,
1683             expectedFocusOffset: 3,
1684           };
1685         },
1686         fnBefore: (paragraph, originalText1) => {
1687           const originalText2 = $createTextNode('bar');
1688           originalText1.insertBefore(originalText2);
1689         },
1690         focusOffset: 2,
1691         name: 'insertAfter - All selected; add in middle',
1692       },
1693       {
1694         anchorOffset: 0,
1695         fn: (paragraph, originalText1) => {
1696           const originalText2 = originalText1.getPreviousSibling()!;
1697           originalText1.splitText(1);
1698
1699           return {
1700             expectedAnchor: originalText2,
1701             expectedAnchorOffset: 0,
1702             expectedFocus: paragraph,
1703             expectedFocusOffset: 3,
1704           };
1705         },
1706         fnBefore: (paragraph, originalText1) => {
1707           const originalText2 = $createTextNode('bar');
1708           originalText1.insertBefore(originalText2);
1709         },
1710         focusOffset: 2,
1711         name: 'splitText - All selected; add in middle',
1712       },
1713       {
1714         anchorOffset: 0,
1715         fn: (paragraph, originalText1) => {
1716           const originalText2 = originalText1.getPreviousSibling()!;
1717           originalText1.remove();
1718
1719           return {
1720             expectedAnchor: originalText2,
1721             expectedAnchorOffset: 0,
1722             expectedFocus: paragraph,
1723             expectedFocusOffset: 1,
1724           };
1725         },
1726         fnBefore: (paragraph, originalText1) => {
1727           const originalText2 = $createTextNode('bar');
1728           originalText1.insertBefore(originalText2);
1729         },
1730         focusOffset: 2,
1731         name: 'remove - All selected; remove in middle',
1732       },
1733       {
1734         anchorOffset: 0,
1735         fn: (paragraph, originalText1) => {
1736           const newText = $createTextNode('replacement');
1737           originalText1.replace(newText);
1738
1739           return {
1740             expectedAnchor: paragraph,
1741             expectedAnchorOffset: 0,
1742             expectedFocus: paragraph,
1743             expectedFocusOffset: 2,
1744           };
1745         },
1746         fnBefore: (paragraph, originalText1) => {
1747           const originalText2 = $createTextNode('bar');
1748           originalText1.insertBefore(originalText2);
1749         },
1750         focusOffset: 2,
1751         name: 'replace - All selected; replace in middle',
1752       },
1753       // Edge cases
1754       {
1755         anchorOffset: 3,
1756         fn: (paragraph, originalText1) => {
1757           const originalText2 = paragraph.getLastChild()!;
1758           const newText = $createTextNode('new');
1759           originalText1.insertBefore(newText);
1760
1761           return {
1762             expectedAnchor: originalText2,
1763             expectedAnchorOffset: 'bar'.length,
1764             expectedFocus: originalText2,
1765             expectedFocusOffset: 'bar'.length,
1766           };
1767         },
1768         fnBefore: (paragraph, originalText1) => {
1769           const originalText2 = $createTextNode('bar');
1770           paragraph.append(originalText2);
1771         },
1772         focusOffset: 3,
1773         name: "Selection resolves to the end of text node when it's at the end (1)",
1774       },
1775       {
1776         anchorOffset: 0,
1777         fn: (paragraph, originalText1) => {
1778           const originalText2 = paragraph.getLastChild()!;
1779           const newText = $createTextNode('new');
1780           originalText1.insertBefore(newText);
1781
1782           return {
1783             expectedAnchor: originalText1,
1784             expectedAnchorOffset: 0,
1785             expectedFocus: originalText2,
1786             expectedFocusOffset: 'bar'.length,
1787           };
1788         },
1789         fnBefore: (paragraph, originalText1) => {
1790           const originalText2 = $createTextNode('bar');
1791           paragraph.append(originalText2);
1792         },
1793         focusOffset: 3,
1794         name: "Selection resolves to the end of text node when it's at the end (2)",
1795       },
1796       {
1797         anchorOffset: 1,
1798         fn: (paragraph, originalText1) => {
1799           originalText1.getNextSibling()!.remove();
1800
1801           return {
1802             expectedAnchor: originalText1,
1803             expectedAnchorOffset: 3,
1804             expectedFocus: originalText1,
1805             expectedFocusOffset: 3,
1806           };
1807         },
1808         focusOffset: 1,
1809         name: 'remove - Remove with collapsed selection at offset #4221',
1810       },
1811       {
1812         anchorOffset: 0,
1813         fn: (paragraph, originalText1) => {
1814           originalText1.getNextSibling()!.remove();
1815
1816           return {
1817             expectedAnchor: originalText1,
1818             expectedAnchorOffset: 0,
1819             expectedFocus: originalText1,
1820             expectedFocusOffset: 3,
1821           };
1822         },
1823         focusOffset: 1,
1824         name: 'remove - Remove with non-collapsed selection at offset',
1825       },
1826     ];
1827     baseCases
1828       .flatMap((testCase) => {
1829         // Test inverse selection
1830         const inverse = {
1831           ...testCase,
1832           anchorOffset: testCase.focusOffset,
1833           focusOffset: testCase.anchorOffset,
1834           invertSelection: true,
1835           name: testCase.name + ' (inverse selection)',
1836         };
1837         return [testCase, inverse];
1838       })
1839       .forEach(
1840         ({
1841           name,
1842           fn,
1843           fnBefore = () => {
1844             return;
1845           },
1846           anchorOffset,
1847           focusOffset,
1848           invertSelection,
1849           only,
1850         }) => {
1851           // eslint-disable-next-line no-only-tests/no-only-tests
1852           const test_ = only === true ? test.only : test;
1853           test_(name, async () => {
1854             await ReactTestUtils.act(async () => {
1855               await editor!.update(() => {
1856                 const root = $getRoot();
1857
1858                 const paragraph = root.getFirstChild<ParagraphNode>()!;
1859                 const textNode = $createTextNode('foo');
1860                 // Note: line break can't be selected by the DOM
1861                 const linebreak = $createLineBreakNode();
1862
1863                 const selection = $getSelection();
1864
1865                 if (!$isRangeSelection(selection)) {
1866                   return;
1867                 }
1868
1869                 const anchor = selection.anchor;
1870                 const focus = selection.focus;
1871
1872                 paragraph.append(textNode, linebreak);
1873
1874                 fnBefore(paragraph, textNode);
1875
1876                 anchor.set(paragraph.getKey(), anchorOffset, 'element');
1877                 focus.set(paragraph.getKey(), focusOffset, 'element');
1878
1879                 const {
1880                   expectedAnchor,
1881                   expectedAnchorOffset,
1882                   expectedFocus,
1883                   expectedFocusOffset,
1884                 } = fn(paragraph, textNode);
1885
1886                 if (invertSelection !== true) {
1887                   expect(selection.anchor.key).toBe(expectedAnchor.__key);
1888                   expect(selection.anchor.offset).toBe(expectedAnchorOffset);
1889                   expect(selection.focus.key).toBe(expectedFocus.__key);
1890                   expect(selection.focus.offset).toBe(expectedFocusOffset);
1891                 } else {
1892                   expect(selection.anchor.key).toBe(expectedFocus.__key);
1893                   expect(selection.anchor.offset).toBe(expectedFocusOffset);
1894                   expect(selection.focus.key).toBe(expectedAnchor.__key);
1895                   expect(selection.focus.offset).toBe(expectedAnchorOffset);
1896                 }
1897               });
1898             });
1899           });
1900         },
1901       );
1902   });
1903
1904   describe('Selection correctly resolves to a sibling ElementNode when a node is removed', () => {
1905     test('', async () => {
1906       await ReactTestUtils.act(async () => {
1907         await editor!.update(() => {
1908           const root = $getRoot();
1909
1910           const listNode = $createListNode('bullet');
1911           const listItemNode = $createListItemNode();
1912           const paragraph = $createParagraphNode();
1913
1914           root.append(listNode);
1915
1916           listNode.append(listItemNode);
1917           listItemNode.select();
1918           listNode.insertAfter(paragraph);
1919           listItemNode.remove();
1920
1921           const selection = $getSelection();
1922
1923           if (!$isRangeSelection(selection)) {
1924             return;
1925           }
1926
1927           expect(selection.anchor.getNode().__type).toBe('paragraph');
1928           expect(selection.focus.getNode().__type).toBe('paragraph');
1929         });
1930       });
1931     });
1932   });
1933
1934   describe('Selection correctly resolves to a sibling ElementNode when a selected node child is removed', () => {
1935     test('', async () => {
1936       await ReactTestUtils.act(async () => {
1937         let paragraphNodeKey: string;
1938         await editor!.update(() => {
1939           const root = $getRoot();
1940
1941           const paragraphNode = $createParagraphNode();
1942           paragraphNodeKey = paragraphNode.__key;
1943           const listNode = $createListNode('number');
1944           const listItemNode1 = $createListItemNode();
1945           const textNode1 = $createTextNode('foo');
1946           const listItemNode2 = $createListItemNode();
1947           const listNode2 = $createListNode('number');
1948           const listItemNode2x1 = $createListItemNode();
1949
1950           listNode.append(listItemNode1, listItemNode2);
1951           listItemNode1.append(textNode1);
1952           listItemNode2.append(listNode2);
1953           listNode2.append(listItemNode2x1);
1954           root.append(paragraphNode, listNode);
1955
1956           listItemNode2.select();
1957
1958           listNode.remove();
1959         });
1960         await editor!.getEditorState().read(() => {
1961           const selection = $assertRangeSelection($getSelection());
1962           expect(selection.anchor.key).toBe(paragraphNodeKey);
1963           expect(selection.focus.key).toBe(paragraphNodeKey);
1964         });
1965       });
1966     });
1967   });
1968
1969   describe('Selection correctly resolves to a sibling ElementNode that has multiple children with the correct offset when a node is removed', () => {
1970     test('', async () => {
1971       await ReactTestUtils.act(async () => {
1972         await editor!.update(() => {
1973           // Arrange
1974           // Root
1975           //  |- Paragraph
1976           //    |- Link
1977           //      |- Text
1978           //      |- LineBreak
1979           //      |- Text
1980           //    |- Text
1981           const root = $getRoot();
1982
1983           const paragraph = $createParagraphNode();
1984           const link = $createLinkNode('bullet');
1985           const textOne = $createTextNode('Hello');
1986           const br = $createLineBreakNode();
1987           const textTwo = $createTextNode('world');
1988           const textThree = $createTextNode(' ');
1989
1990           root.append(paragraph);
1991           link.append(textOne);
1992           link.append(br);
1993           link.append(textTwo);
1994
1995           paragraph.append(link);
1996           paragraph.append(textThree);
1997
1998           textThree.select();
1999           // Act
2000           textThree.remove();
2001           // Assert
2002           const expectedKey = link.getKey();
2003
2004           const selection = $getSelection();
2005
2006           if (!$isRangeSelection(selection)) {
2007             return;
2008           }
2009
2010           const {anchor, focus} = selection;
2011
2012           expect(anchor.getNode().getKey()).toBe(expectedKey);
2013           expect(focus.getNode().getKey()).toBe(expectedKey);
2014           expect(anchor.offset).toBe(3);
2015           expect(focus.offset).toBe(3);
2016         });
2017       });
2018     });
2019   });
2020
2021   test('isBackward', async () => {
2022     await ReactTestUtils.act(async () => {
2023       await editor!.update(() => {
2024         const root = $getRoot();
2025
2026         const paragraph = root.getFirstChild<ParagraphNode>()!;
2027         const paragraphKey = paragraph.getKey();
2028         const textNode = $createTextNode('foo');
2029         const textNodeKey = textNode.getKey();
2030         // Note: line break can't be selected by the DOM
2031         const linebreak = $createLineBreakNode();
2032
2033         const selection = $getSelection();
2034
2035         if (!$isRangeSelection(selection)) {
2036           return;
2037         }
2038
2039         const anchor = selection.anchor;
2040         const focus = selection.focus;
2041         paragraph.append(textNode, linebreak);
2042         anchor.set(textNodeKey, 0, 'text');
2043         focus.set(textNodeKey, 0, 'text');
2044
2045         expect(selection.isBackward()).toBe(false);
2046
2047         anchor.set(paragraphKey, 1, 'element');
2048         focus.set(paragraphKey, 1, 'element');
2049
2050         expect(selection.isBackward()).toBe(false);
2051
2052         anchor.set(paragraphKey, 0, 'element');
2053         focus.set(paragraphKey, 1, 'element');
2054
2055         expect(selection.isBackward()).toBe(false);
2056
2057         anchor.set(paragraphKey, 1, 'element');
2058         focus.set(paragraphKey, 0, 'element');
2059
2060         expect(selection.isBackward()).toBe(true);
2061       });
2062     });
2063   });
2064
2065   describe('Decorator text content for selection', () => {
2066     const baseCases: {
2067       name: string;
2068       fn: (opts: {
2069         textNode1: TextNode;
2070         textNode2: TextNode;
2071         decorator: DecoratorNode<unknown>;
2072         paragraph: ParagraphNode;
2073         anchor: PointType;
2074         focus: PointType;
2075       }) => string;
2076       invertSelection?: true;
2077     }[] = [
2078       {
2079         fn: ({textNode1, anchor, focus}) => {
2080           anchor.set(textNode1.getKey(), 1, 'text');
2081           focus.set(textNode1.getKey(), 1, 'text');
2082
2083           return '';
2084         },
2085         name: 'Not included if cursor right before it',
2086       },
2087       {
2088         fn: ({textNode2, anchor, focus}) => {
2089           anchor.set(textNode2.getKey(), 0, 'text');
2090           focus.set(textNode2.getKey(), 0, 'text');
2091
2092           return '';
2093         },
2094         name: 'Not included if cursor right after it',
2095       },
2096       {
2097         fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
2098           anchor.set(textNode1.getKey(), 1, 'text');
2099           focus.set(textNode2.getKey(), 0, 'text');
2100
2101           return decorator.getTextContent();
2102         },
2103         name: 'Included if decorator is selected within text',
2104       },
2105       {
2106         fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
2107           anchor.set(textNode1.getKey(), 0, 'text');
2108           focus.set(textNode2.getKey(), 0, 'text');
2109
2110           return textNode1.getTextContent() + decorator.getTextContent();
2111         },
2112         name: 'Included if decorator is selected with another node before it',
2113       },
2114       {
2115         fn: ({textNode1, textNode2, decorator, anchor, focus}) => {
2116           anchor.set(textNode1.getKey(), 1, 'text');
2117           focus.set(textNode2.getKey(), 1, 'text');
2118
2119           return decorator.getTextContent() + textNode2.getTextContent();
2120         },
2121         name: 'Included if decorator is selected with another node after it',
2122       },
2123       {
2124         fn: ({paragraph, textNode1, textNode2, decorator, anchor, focus}) => {
2125           textNode1.remove();
2126           textNode2.remove();
2127           anchor.set(paragraph.getKey(), 0, 'element');
2128           focus.set(paragraph.getKey(), 1, 'element');
2129
2130           return decorator.getTextContent();
2131         },
2132         name: 'Included if decorator is selected as the only node',
2133       },
2134     ];
2135     baseCases
2136       .flatMap((testCase) => {
2137         const inverse = {
2138           ...testCase,
2139           invertSelection: true,
2140           name: testCase.name + ' (inverse selection)',
2141         };
2142
2143         return [testCase, inverse];
2144       })
2145       .forEach(({name, fn, invertSelection}) => {
2146         it(name, async () => {
2147           await ReactTestUtils.act(async () => {
2148             await editor!.update(() => {
2149               const root = $getRoot();
2150
2151               const paragraph = root.getFirstChild<ParagraphNode>()!;
2152               const textNode1 = $createTextNode('1');
2153               const textNode2 = $createTextNode('2');
2154               const decorator = $createTestDecoratorNode();
2155
2156               paragraph.append(textNode1, decorator, textNode2);
2157
2158               const selection = $getSelection();
2159
2160               if (!$isRangeSelection(selection)) {
2161                 return;
2162               }
2163
2164               const expectedTextContent = fn({
2165                 anchor: invertSelection ? selection.focus : selection.anchor,
2166                 decorator,
2167                 focus: invertSelection ? selection.anchor : selection.focus,
2168                 paragraph,
2169                 textNode1,
2170                 textNode2,
2171               });
2172
2173               expect(selection.getTextContent()).toBe(expectedTextContent);
2174             });
2175           });
2176         });
2177       });
2178   });
2179
2180   describe('insertParagraph', () => {
2181     test('three text nodes at offset 0 on third node', async () => {
2182       const testEditor = createTestEditor();
2183       const element = document.createElement('div');
2184       testEditor.setRootElement(element);
2185
2186       await testEditor.update(() => {
2187         const root = $getRoot();
2188
2189         const paragraph = $createParagraphNode();
2190         const text = $createTextNode('Hello ');
2191         const text2 = $createTextNode('awesome');
2192
2193         text2.toggleFormat('bold');
2194
2195         const text3 = $createTextNode(' world');
2196
2197         paragraph.append(text, text2, text3);
2198         root.append(paragraph);
2199
2200         $setAnchorPoint({
2201           key: text3.getKey(),
2202           offset: 0,
2203           type: 'text',
2204         });
2205
2206         $setFocusPoint({
2207           key: text3.getKey(),
2208           offset: 0,
2209           type: 'text',
2210         });
2211
2212         const selection = $getSelection();
2213
2214         if (!$isRangeSelection(selection)) {
2215           return;
2216         }
2217
2218         selection.insertParagraph();
2219       });
2220
2221       expect(element.innerHTML).toBe(
2222         '<p dir="ltr"><span data-lexical-text="true">Hello </span><strong data-lexical-text="true">awesome</strong></p><p dir="ltr"><span data-lexical-text="true"> world</span></p>',
2223       );
2224     });
2225
2226     test('four text nodes at offset 0 on third node', async () => {
2227       const testEditor = createTestEditor();
2228       const element = document.createElement('div');
2229       testEditor.setRootElement(element);
2230
2231       await testEditor.update(() => {
2232         const root = $getRoot();
2233
2234         const paragraph = $createParagraphNode();
2235         const text = $createTextNode('Hello ');
2236         const text2 = $createTextNode('awesome ');
2237
2238         text2.toggleFormat('bold');
2239
2240         const text3 = $createTextNode('beautiful');
2241         const text4 = $createTextNode(' world');
2242
2243         text4.toggleFormat('bold');
2244
2245         paragraph.append(text, text2, text3, text4);
2246         root.append(paragraph);
2247
2248         $setAnchorPoint({
2249           key: text3.getKey(),
2250           offset: 0,
2251           type: 'text',
2252         });
2253
2254         $setFocusPoint({
2255           key: text3.getKey(),
2256           offset: 0,
2257           type: 'text',
2258         });
2259
2260         const selection = $getSelection();
2261
2262         if (!$isRangeSelection(selection)) {
2263           return;
2264         }
2265
2266         selection.insertParagraph();
2267       });
2268
2269       expect(element.innerHTML).toBe(
2270         '<p dir="ltr"><span data-lexical-text="true">Hello </span><strong data-lexical-text="true">awesome </strong></p><p dir="ltr"><span data-lexical-text="true">beautiful</span><strong data-lexical-text="true"> world</strong></p>',
2271       );
2272     });
2273
2274     it('adjust offset for inline elements text formatting', async () => {
2275       await init();
2276
2277       await ReactTestUtils.act(async () => {
2278         await editor!.update(() => {
2279           const root = $getRoot();
2280
2281           const text1 = $createTextNode('--');
2282           const text2 = $createTextNode('abc');
2283           const text3 = $createTextNode('--');
2284
2285           root.append(
2286             $createParagraphNode().append(
2287               text1,
2288               $createLinkNode('https://lexical.dev').append(text2),
2289               text3,
2290             ),
2291           );
2292
2293           $setAnchorPoint({
2294             key: text1.getKey(),
2295             offset: 2,
2296             type: 'text',
2297           });
2298
2299           $setFocusPoint({
2300             key: text3.getKey(),
2301             offset: 0,
2302             type: 'text',
2303           });
2304
2305           const selection = $getSelection();
2306
2307           if (!$isRangeSelection(selection)) {
2308             return;
2309           }
2310
2311           selection.formatText('bold');
2312
2313           expect(text2.hasFormat('bold')).toBe(true);
2314         });
2315       });
2316     });
2317   });
2318
2319   describe('Node.replace', () => {
2320     let text1: TextNode,
2321       text2: TextNode,
2322       text3: TextNode,
2323       paragraph: ParagraphNode,
2324       testEditor: LexicalEditor;
2325
2326     beforeEach(async () => {
2327       testEditor = createTestEditor();
2328
2329       const element = document.createElement('div');
2330       testEditor.setRootElement(element);
2331
2332       await testEditor.update(() => {
2333         const root = $getRoot();
2334
2335         paragraph = $createParagraphNode();
2336         text1 = $createTextNode('Hello ');
2337         text2 = $createTextNode('awesome');
2338
2339         text2.toggleFormat('bold');
2340
2341         text3 = $createTextNode(' world');
2342
2343         paragraph.append(text1, text2, text3);
2344         root.append(paragraph);
2345       });
2346     });
2347     [
2348       {
2349         fn: () => {
2350           text2.select(1, 1);
2351           text2.replace($createTestDecoratorNode());
2352
2353           return {
2354             key: text3.__key,
2355             offset: 0,
2356           };
2357         },
2358         name: 'moves selection to to next text node if replacing with decorator',
2359       },
2360       {
2361         fn: () => {
2362           text3.replace($createTestDecoratorNode());
2363           text2.select(1, 1);
2364           text2.replace($createTestDecoratorNode());
2365
2366           return {
2367             key: paragraph.__key,
2368             offset: 2,
2369           };
2370         },
2371         name: 'moves selection to parent if next sibling is not a text node',
2372       },
2373     ].forEach((testCase) => {
2374       test(testCase.name, async () => {
2375         await testEditor.update(() => {
2376           const {key, offset} = testCase.fn();
2377
2378           const selection = $getSelection();
2379
2380           if (!$isRangeSelection(selection)) {
2381             return;
2382           }
2383
2384           expect(selection.anchor.key).toBe(key);
2385           expect(selection.anchor.offset).toBe(offset);
2386           expect(selection.focus.key).toBe(key);
2387           expect(selection.focus.offset).toBe(offset);
2388         });
2389       });
2390     });
2391   });
2392
2393   describe('Testing that $getStyleObjectFromRawCSS handles unformatted css text ', () => {
2394     test('', async () => {
2395       const testEditor = createTestEditor();
2396       const element = document.createElement('div');
2397       testEditor.setRootElement(element);
2398
2399       await testEditor.update(() => {
2400         const root = $getRoot();
2401         const paragraph = $createParagraphNode();
2402         const textNode = $createTextNode('Hello, World!');
2403         textNode.setStyle(
2404           '   font-family  : Arial  ;  color    :   red   ;top     : 50px',
2405         );
2406         $addNodeStyle(textNode);
2407         paragraph.append(textNode);
2408         root.append(paragraph);
2409
2410         const selection = $createRangeSelection();
2411         $setSelection(selection);
2412         selection.insertParagraph();
2413         $setAnchorPoint({
2414           key: textNode.getKey(),
2415           offset: 0,
2416           type: 'text',
2417         });
2418
2419         $setFocusPoint({
2420           key: textNode.getKey(),
2421           offset: 10,
2422           type: 'text',
2423         });
2424
2425         const cssFontFamilyValue = $getSelectionStyleValueForProperty(
2426           selection,
2427           'font-family',
2428           '',
2429         );
2430         expect(cssFontFamilyValue).toBe('Arial');
2431
2432         const cssColorValue = $getSelectionStyleValueForProperty(
2433           selection,
2434           'color',
2435           '',
2436         );
2437         expect(cssColorValue).toBe('red');
2438
2439         const cssTopValue = $getSelectionStyleValueForProperty(
2440           selection,
2441           'top',
2442           '',
2443         );
2444         expect(cssTopValue).toBe('50px');
2445       });
2446     });
2447   });
2448
2449   describe('Testing that getStyleObjectFromRawCSS handles values with colons', () => {
2450     test('', async () => {
2451       const testEditor = createTestEditor();
2452       const element = document.createElement('div');
2453       testEditor.setRootElement(element);
2454
2455       await testEditor.update(() => {
2456         const root = $getRoot();
2457         const paragraph = $createParagraphNode();
2458         const textNode = $createTextNode('Hello, World!');
2459         textNode.setStyle(
2460           'font-family: double:prefix:Arial; color: color:white; font-size: 30px',
2461         );
2462         $addNodeStyle(textNode);
2463         paragraph.append(textNode);
2464         root.append(paragraph);
2465
2466         const selection = $createRangeSelection();
2467         $setSelection(selection);
2468         selection.insertParagraph();
2469         $setAnchorPoint({
2470           key: textNode.getKey(),
2471           offset: 0,
2472           type: 'text',
2473         });
2474
2475         $setFocusPoint({
2476           key: textNode.getKey(),
2477           offset: 10,
2478           type: 'text',
2479         });
2480
2481         const cssFontFamilyValue = $getSelectionStyleValueForProperty(
2482           selection,
2483           'font-family',
2484           '',
2485         );
2486         expect(cssFontFamilyValue).toBe('double:prefix:Arial');
2487
2488         const cssColorValue = $getSelectionStyleValueForProperty(
2489           selection,
2490           'color',
2491           '',
2492         );
2493         expect(cssColorValue).toBe('color:white');
2494
2495         const cssFontSizeValue = $getSelectionStyleValueForProperty(
2496           selection,
2497           'font-size',
2498           '',
2499         );
2500         expect(cssFontSizeValue).toBe('30px');
2501       });
2502     });
2503   });
2504
2505   describe('$patchStyle', () => {
2506     it('should patch the style with the new style object', async () => {
2507       await ReactTestUtils.act(async () => {
2508         await editor!.update(() => {
2509           const root = $getRoot();
2510           const paragraph = $createParagraphNode();
2511           const textNode = $createTextNode('Hello, World!');
2512           textNode.setStyle('font-family: serif; color: red;');
2513           $addNodeStyle(textNode);
2514           paragraph.append(textNode);
2515           root.append(paragraph);
2516
2517           const selection = $createRangeSelection();
2518           $setSelection(selection);
2519           selection.insertParagraph();
2520           $setAnchorPoint({
2521             key: textNode.getKey(),
2522             offset: 0,
2523             type: 'text',
2524           });
2525
2526           $setFocusPoint({
2527             key: textNode.getKey(),
2528             offset: 10,
2529             type: 'text',
2530           });
2531
2532           const newStyle = {
2533             color: 'blue',
2534             'font-family': 'Arial',
2535           };
2536
2537           $patchStyleText(selection, newStyle);
2538
2539           const cssFontFamilyValue = $getSelectionStyleValueForProperty(
2540             selection,
2541             'font-family',
2542             '',
2543           );
2544           expect(cssFontFamilyValue).toBe('Arial');
2545
2546           const cssColorValue = $getSelectionStyleValueForProperty(
2547             selection,
2548             'color',
2549             '',
2550           );
2551           expect(cssColorValue).toBe('blue');
2552         });
2553       });
2554     });
2555
2556     it('should patch the style with property function', async () => {
2557       await ReactTestUtils.act(async () => {
2558         await editor!.update(() => {
2559           const currentColor = 'red';
2560           const nextColor = 'blue';
2561
2562           const root = $getRoot();
2563           const paragraph = $createParagraphNode();
2564           const textNode = $createTextNode('Hello, World!');
2565           textNode.setStyle(`color: ${currentColor};`);
2566           $addNodeStyle(textNode);
2567           paragraph.append(textNode);
2568           root.append(paragraph);
2569
2570           const selection = $createRangeSelection();
2571           $setSelection(selection);
2572           selection.insertParagraph();
2573           $setAnchorPoint({
2574             key: textNode.getKey(),
2575             offset: 0,
2576             type: 'text',
2577           });
2578
2579           $setFocusPoint({
2580             key: textNode.getKey(),
2581             offset: 10,
2582             type: 'text',
2583           });
2584
2585           const newStyle = {
2586             color: jest.fn(
2587               (current: string | null, target: LexicalNode | RangeSelection) =>
2588                 nextColor,
2589             ),
2590           };
2591
2592           $patchStyleText(selection, newStyle);
2593
2594           const cssColorValue = $getSelectionStyleValueForProperty(
2595             selection,
2596             'color',
2597             '',
2598           );
2599
2600           expect(cssColorValue).toBe(nextColor);
2601           expect(newStyle.color).toHaveBeenCalledTimes(1);
2602
2603           const lastCall = newStyle.color.mock.lastCall!;
2604           expect(lastCall[0]).toBe(currentColor);
2605           // @ts-ignore - It expected to be a LexicalNode
2606           expect($isTextNode(lastCall[1])).toBeTruthy();
2607         });
2608       });
2609     });
2610   });
2611
2612   describe('$setBlocksType', () => {
2613     test('Collapsed selection in text', async () => {
2614       const testEditor = createTestEditor();
2615       const element = document.createElement('div');
2616       testEditor.setRootElement(element);
2617
2618       await testEditor.update(() => {
2619         const root = $getRoot();
2620         const paragraph1 = $createParagraphNode();
2621         const text1 = $createTextNode('text 1');
2622         const paragraph2 = $createParagraphNode();
2623         const text2 = $createTextNode('text 2');
2624         root.append(paragraph1, paragraph2);
2625         paragraph1.append(text1);
2626         paragraph2.append(text2);
2627
2628         const selection = $createRangeSelection();
2629         $setSelection(selection);
2630         $setAnchorPoint({
2631           key: text1.__key,
2632           offset: text1.__text.length,
2633           type: 'text',
2634         });
2635         $setFocusPoint({
2636           key: text1.__key,
2637           offset: text1.__text.length,
2638           type: 'text',
2639         });
2640
2641         $setBlocksType(selection, () => {
2642           return $createHeadingNode('h1');
2643         });
2644
2645         const rootChildren = root.getChildren();
2646         expect(rootChildren[0].__type).toBe('heading');
2647         expect(rootChildren[1].__type).toBe('paragraph');
2648         expect(rootChildren.length).toBe(2);
2649       });
2650     });
2651
2652     test('Collapsed selection in element', async () => {
2653       const testEditor = createTestEditor();
2654       const element = document.createElement('div');
2655       testEditor.setRootElement(element);
2656
2657       await testEditor.update(() => {
2658         const root = $getRoot();
2659         const paragraph1 = $createParagraphNode();
2660         const paragraph2 = $createParagraphNode();
2661         root.append(paragraph1, paragraph2);
2662
2663         const selection = $createRangeSelection();
2664         $setSelection(selection);
2665         $setAnchorPoint({
2666           key: 'root',
2667           offset: 0,
2668           type: 'element',
2669         });
2670         $setFocusPoint({
2671           key: 'root',
2672           offset: 0,
2673           type: 'element',
2674         });
2675
2676         $setBlocksType(selection, () => {
2677           return $createHeadingNode('h1');
2678         });
2679
2680         const rootChildren = root.getChildren();
2681         expect(rootChildren[0].__type).toBe('heading');
2682         expect(rootChildren[1].__type).toBe('paragraph');
2683         expect(rootChildren.length).toBe(2);
2684       });
2685     });
2686
2687     test('Two elements, same top-element', async () => {
2688       const testEditor = createTestEditor();
2689       const element = document.createElement('div');
2690       testEditor.setRootElement(element);
2691
2692       await testEditor.update(() => {
2693         const root = $getRoot();
2694         const paragraph1 = $createParagraphNode();
2695         const text1 = $createTextNode('text 1');
2696         const paragraph2 = $createParagraphNode();
2697         const text2 = $createTextNode('text 2');
2698         root.append(paragraph1, paragraph2);
2699         paragraph1.append(text1);
2700         paragraph2.append(text2);
2701
2702         const selection = $createRangeSelection();
2703         $setSelection(selection);
2704         $setAnchorPoint({
2705           key: text1.__key,
2706           offset: 0,
2707           type: 'text',
2708         });
2709         $setFocusPoint({
2710           key: text2.__key,
2711           offset: text1.__text.length,
2712           type: 'text',
2713         });
2714
2715         $setBlocksType(selection, () => {
2716           return $createHeadingNode('h1');
2717         });
2718
2719         const rootChildren = root.getChildren();
2720         expect(rootChildren[0].__type).toBe('heading');
2721         expect(rootChildren[1].__type).toBe('heading');
2722         expect(rootChildren.length).toBe(2);
2723       });
2724     });
2725
2726     test('Two empty elements, same top-element', async () => {
2727       const testEditor = createTestEditor();
2728       const element = document.createElement('div');
2729       testEditor.setRootElement(element);
2730
2731       await testEditor.update(() => {
2732         const root = $getRoot();
2733         const paragraph1 = $createParagraphNode();
2734         const paragraph2 = $createParagraphNode();
2735         root.append(paragraph1, paragraph2);
2736
2737         const selection = $createRangeSelection();
2738         $setSelection(selection);
2739         $setAnchorPoint({
2740           key: paragraph1.__key,
2741           offset: 0,
2742           type: 'element',
2743         });
2744         $setFocusPoint({
2745           key: paragraph2.__key,
2746           offset: 0,
2747           type: 'element',
2748         });
2749
2750         $setBlocksType(selection, () => {
2751           return $createHeadingNode('h1');
2752         });
2753
2754         const rootChildren = root.getChildren();
2755         expect(rootChildren[0].__type).toBe('heading');
2756         expect(rootChildren[1].__type).toBe('heading');
2757         expect(rootChildren.length).toBe(2);
2758         const sel = $getSelection()!;
2759         expect(sel.getNodes().length).toBe(2);
2760       });
2761     });
2762
2763     test('Two elements, same top-element', async () => {
2764       const testEditor = createTestEditor();
2765       const element = document.createElement('div');
2766       testEditor.setRootElement(element);
2767
2768       await testEditor.update(() => {
2769         const root = $getRoot();
2770         const paragraph1 = $createParagraphNode();
2771         const text1 = $createTextNode('text 1');
2772         const paragraph2 = $createParagraphNode();
2773         const text2 = $createTextNode('text 2');
2774         root.append(paragraph1, paragraph2);
2775         paragraph1.append(text1);
2776         paragraph2.append(text2);
2777
2778         const selection = $createRangeSelection();
2779         $setSelection(selection);
2780         $setAnchorPoint({
2781           key: text1.__key,
2782           offset: 0,
2783           type: 'text',
2784         });
2785         $setFocusPoint({
2786           key: text2.__key,
2787           offset: text1.__text.length,
2788           type: 'text',
2789         });
2790
2791         $setBlocksType(selection, () => {
2792           return $createHeadingNode('h1');
2793         });
2794
2795         const rootChildren = root.getChildren();
2796         expect(rootChildren[0].__type).toBe('heading');
2797         expect(rootChildren[1].__type).toBe('heading');
2798         expect(rootChildren.length).toBe(2);
2799       });
2800     });
2801
2802     test('Collapsed in element inside top-element', async () => {
2803       const testEditor = createTestEditor();
2804       const element = document.createElement('div');
2805       testEditor.setRootElement(element);
2806
2807       await testEditor.update(() => {
2808         const root = $getRoot();
2809         const table = $createTableNodeWithDimensions(1, 1);
2810         const row = table.getFirstChild();
2811         invariant($isElementNode(row));
2812         const column = row.getFirstChild();
2813         invariant($isElementNode(column));
2814         const paragraph = column.getFirstChild();
2815         invariant($isElementNode(paragraph));
2816         if (paragraph.getFirstChild()) {
2817           paragraph.getFirstChild()!.remove();
2818         }
2819         root.append(table);
2820
2821         const selection = $createRangeSelection();
2822         $setSelection(selection);
2823         $setAnchorPoint({
2824           key: paragraph.__key,
2825           offset: 0,
2826           type: 'element',
2827         });
2828         $setFocusPoint({
2829           key: paragraph.__key,
2830           offset: 0,
2831           type: 'element',
2832         });
2833
2834         const columnChildrenPrev = column.getChildren();
2835         expect(columnChildrenPrev[0].__type).toBe('paragraph');
2836         $setBlocksType(selection, () => {
2837           return $createHeadingNode('h1');
2838         });
2839
2840         const columnChildrenAfter = column.getChildren();
2841         expect(columnChildrenAfter[0].__type).toBe('heading');
2842         expect(columnChildrenAfter.length).toBe(1);
2843       });
2844     });
2845
2846     test('Collapsed in text inside top-element', async () => {
2847       const testEditor = createTestEditor();
2848       const element = document.createElement('div');
2849       testEditor.setRootElement(element);
2850
2851       await testEditor.update(() => {
2852         const root = $getRoot();
2853         const table = $createTableNodeWithDimensions(1, 1);
2854         const row = table.getFirstChild();
2855         invariant($isElementNode(row));
2856         const column = row.getFirstChild();
2857         invariant($isElementNode(column));
2858         const paragraph = column.getFirstChild();
2859         invariant($isElementNode(paragraph));
2860         const text = $createTextNode('foo');
2861         root.append(table);
2862         paragraph.append(text);
2863
2864         const selectionz = $createRangeSelection();
2865         $setSelection(selectionz);
2866         $setAnchorPoint({
2867           key: text.__key,
2868           offset: text.__text.length,
2869           type: 'text',
2870         });
2871         $setFocusPoint({
2872           key: text.__key,
2873           offset: text.__text.length,
2874           type: 'text',
2875         });
2876         const selection = $getSelection() as RangeSelection;
2877
2878         const columnChildrenPrev = column.getChildren();
2879         expect(columnChildrenPrev[0].__type).toBe('paragraph');
2880         $setBlocksType(selection, () => {
2881           return $createHeadingNode('h1');
2882         });
2883
2884         const columnChildrenAfter = column.getChildren();
2885         expect(columnChildrenAfter[0].__type).toBe('heading');
2886         expect(columnChildrenAfter.length).toBe(1);
2887       });
2888     });
2889
2890     test('Full editor selection with a mix of top-elements', async () => {
2891       const testEditor = createTestEditor();
2892       const element = document.createElement('div');
2893       testEditor.setRootElement(element);
2894
2895       await testEditor.update(() => {
2896         const root = $getRoot();
2897
2898         const paragraph1 = $createParagraphNode();
2899         const paragraph2 = $createParagraphNode();
2900         const text1 = $createTextNode();
2901         const text2 = $createTextNode();
2902         paragraph1.append(text1);
2903         paragraph2.append(text2);
2904         root.append(paragraph1, paragraph2);
2905
2906         const table = $createTableNodeWithDimensions(1, 2);
2907         const row = table.getFirstChild();
2908         invariant($isElementNode(row));
2909         const columns = row.getChildren();
2910         root.append(table);
2911
2912         const column1 = columns[0];
2913         const paragraph3 = $createParagraphNode();
2914         const paragraph4 = $createParagraphNode();
2915         const text3 = $createTextNode();
2916         const text4 = $createTextNode();
2917         paragraph1.append(text3);
2918         paragraph2.append(text4);
2919         invariant($isElementNode(column1));
2920         column1.append(paragraph3, paragraph4);
2921
2922         const column2 = columns[1];
2923         const paragraph5 = $createParagraphNode();
2924         const paragraph6 = $createParagraphNode();
2925         invariant($isElementNode(column2));
2926         column2.append(paragraph5, paragraph6);
2927
2928         const paragraph7 = $createParagraphNode();
2929         root.append(paragraph7);
2930
2931         const selectionz = $createRangeSelection();
2932         $setSelection(selectionz);
2933         $setAnchorPoint({
2934           key: paragraph1.__key,
2935           offset: 0,
2936           type: 'element',
2937         });
2938         $setFocusPoint({
2939           key: paragraph7.__key,
2940           offset: 0,
2941           type: 'element',
2942         });
2943         const selection = $getSelection() as RangeSelection;
2944
2945         $setBlocksType(selection, () => {
2946           return $createHeadingNode('h1');
2947         });
2948         expect(JSON.stringify(testEditor._pendingEditorState?.toJSON())).toBe(
2949           '{"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}}',
2950         );
2951       });
2952     });
2953
2954     test('Paragraph with links to heading with links', async () => {
2955       const testEditor = createTestEditor();
2956       const element = document.createElement('div');
2957       testEditor.setRootElement(element);
2958
2959       await testEditor.update(() => {
2960         const root = $getRoot();
2961         const paragraph = $createParagraphNode();
2962         const text1 = $createTextNode('Links: ');
2963         const text2 = $createTextNode('link1');
2964         const text3 = $createTextNode('link2');
2965         root.append(
2966           paragraph.append(
2967             text1,
2968             $createLinkNode('https://lexical.dev').append(text2),
2969             $createTextNode(' '),
2970             $createLinkNode('https://playground.lexical.dev').append(text3),
2971           ),
2972         );
2973
2974         const paragraphChildrenKeys = [...paragraph.getChildrenKeys()];
2975         const selection = $createRangeSelection();
2976         $setSelection(selection);
2977         $setAnchorPoint({
2978           key: text1.getKey(),
2979           offset: 1,
2980           type: 'text',
2981         });
2982         $setFocusPoint({
2983           key: text3.getKey(),
2984           offset: 1,
2985           type: 'text',
2986         });
2987
2988         $setBlocksType(selection, () => {
2989           return $createHeadingNode('h1');
2990         });
2991
2992         const rootChildren = root.getChildren();
2993         expect(rootChildren.length).toBe(1);
2994         invariant($isElementNode(rootChildren[0]));
2995         expect(rootChildren[0].getType()).toBe('heading');
2996         expect(rootChildren[0].getChildrenKeys()).toEqual(
2997           paragraphChildrenKeys,
2998         );
2999       });
3000     });
3001
3002     test('Nested list', async () => {
3003       const testEditor = createTestEditor();
3004       const element = document.createElement('div');
3005       testEditor.setRootElement(element);
3006
3007       await testEditor.update(() => {
3008         const root = $getRoot();
3009         const ul1 = $createListNode('bullet');
3010         const text1 = $createTextNode('1');
3011         const li1 = $createListItemNode().append(text1);
3012         const li1_wrapper = $createListItemNode();
3013         const ul2 = $createListNode('bullet');
3014         const text1_1 = $createTextNode('1.1');
3015         const li1_1 = $createListItemNode().append(text1_1);
3016         ul1.append(li1, li1_wrapper);
3017         li1_wrapper.append(ul2);
3018         ul2.append(li1_1);
3019         root.append(ul1);
3020
3021         const selection = $createRangeSelection();
3022         $setSelection(selection);
3023         $setAnchorPoint({
3024           key: text1.getKey(),
3025           offset: 1,
3026           type: 'text',
3027         });
3028         $setFocusPoint({
3029           key: text1_1.getKey(),
3030           offset: 1,
3031           type: 'text',
3032         });
3033
3034         $setBlocksType(selection, () => {
3035           return $createHeadingNode('h1');
3036         });
3037       });
3038       expect(element.innerHTML).toStrictEqual(
3039         `<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>`,
3040       );
3041     });
3042
3043     test('Nested list with listItem twice indented from his father', async () => {
3044       const testEditor = createTestEditor();
3045       const element = document.createElement('div');
3046       testEditor.setRootElement(element);
3047
3048       await testEditor.update(() => {
3049         const root = $getRoot();
3050         const ul1 = $createListNode('bullet');
3051         const li1_wrapper = $createListItemNode();
3052         const ul2 = $createListNode('bullet');
3053         const text1_1 = $createTextNode('1.1');
3054         const li1_1 = $createListItemNode().append(text1_1);
3055         ul1.append(li1_wrapper);
3056         li1_wrapper.append(ul2);
3057         ul2.append(li1_1);
3058         root.append(ul1);
3059
3060         const selection = $createRangeSelection();
3061         $setSelection(selection);
3062         $setAnchorPoint({
3063           key: text1_1.getKey(),
3064           offset: 1,
3065           type: 'text',
3066         });
3067         $setFocusPoint({
3068           key: text1_1.getKey(),
3069           offset: 1,
3070           type: 'text',
3071         });
3072
3073         $setBlocksType(selection, () => {
3074           return $createHeadingNode('h1');
3075         });
3076       });
3077       expect(element.innerHTML).toStrictEqual(
3078         `<h1 style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">1.1</span></h1>`,
3079       );
3080     });
3081   });
3082 });