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