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