]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts
c54760ff242119335da20194934db6b14926d846
[bookstack] / resources / js / wysiwyg / lexical / core / nodes / __tests__ / unit / LexicalTextNode.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 {
10   $createParagraphNode,
11   $createTextNode, $getEditor,
12   $getNodeByKey,
13   $getRoot,
14   $getSelection,
15   $isNodeSelection,
16   $isRangeSelection,
17   ElementNode,
18   LexicalEditor,
19   ParagraphNode,
20   TextFormatType,
21   TextModeType,
22   TextNode,
23 } from 'lexical';
24
25 import {
26   $createTestSegmentedNode,
27   createTestEditor,
28 } from '../../../__tests__/utils';
29 import {
30   IS_BOLD,
31   IS_CODE,
32   IS_HIGHLIGHT,
33   IS_ITALIC,
34   IS_STRIKETHROUGH,
35   IS_SUBSCRIPT,
36   IS_SUPERSCRIPT,
37   IS_UNDERLINE,
38 } from '../../../LexicalConstants';
39 import {
40   $getCompositionKey,
41   $setCompositionKey,
42   getEditorStateTextContent,
43 } from '../../../LexicalUtils';
44 import {$generateHtmlFromNodes} from "@lexical/html";
45
46 const editorConfig = Object.freeze({
47   namespace: '',
48   theme: {
49     text: {
50       bold: 'my-bold-class',
51       code: 'my-code-class',
52       highlight: 'my-highlight-class',
53       italic: 'my-italic-class',
54       strikethrough: 'my-strikethrough-class',
55       underline: 'my-underline-class',
56       underlineStrikethrough: 'my-underline-strikethrough-class',
57     },
58   },
59 });
60
61 describe('LexicalTextNode tests', () => {
62   let container: HTMLElement;
63
64   beforeEach(async () => {
65     container = document.createElement('div');
66     document.body.appendChild(container);
67
68     await init();
69   });
70   afterEach(() => {
71     document.body.removeChild(container);
72     // @ts-ignore
73     container = null;
74   });
75
76   async function update(fn: () => void) {
77     editor.update(fn);
78     editor.commitUpdates();
79     return Promise.resolve().then();
80   }
81
82   let editor: LexicalEditor;
83
84   async function init() {
85     const root = document.createElement('div');
86     root.setAttribute('contenteditable', 'true');
87     container.innerHTML = '';
88     container.appendChild(root);
89
90     editor = createTestEditor();
91     editor.setRootElement(root);
92
93     // Insert initial block
94     await update(() => {
95       const paragraph = $createParagraphNode();
96       const text = $createTextNode();
97       text.toggleUnmergeable();
98       paragraph.append(text);
99       $getRoot().append(paragraph);
100     });
101   }
102
103   describe('exportJSON()', () => {
104     test('should return and object conforming to the expected schema', async () => {
105       await update(() => {
106         const node = $createTextNode();
107
108         // If you broke this test, you changed the public interface of a
109         // serialized Lexical Core Node. Please ensure the correct adapter
110         // logic is in place in the corresponding importJSON  method
111         // to accomodate these changes.
112
113         expect(node.exportJSON()).toStrictEqual({
114           detail: 0,
115           format: 0,
116           mode: 'normal',
117           style: '',
118           text: '',
119           type: 'text',
120           version: 1,
121         });
122       });
123     });
124   });
125
126   describe('root.getTextContent()', () => {
127     test('writable nodes', async () => {
128       let nodeKey: string;
129
130       await update(() => {
131         const textNode = $createTextNode('Text');
132         nodeKey = textNode.getKey();
133
134         expect(textNode.getTextContent()).toBe('Text');
135         expect(textNode.__text).toBe('Text');
136
137         $getRoot().getFirstChild<ElementNode>()!.append(textNode);
138       });
139
140       expect(
141         editor.getEditorState().read(() => {
142           const root = $getRoot();
143           return root.__cachedText;
144         }),
145       );
146       expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text');
147
148       // Make sure that the editor content is still set after further reconciliations
149       await update(() => {
150         $getNodeByKey(nodeKey)!.markDirty();
151       });
152       expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text');
153     });
154
155     test('prepend node', async () => {
156       await update(() => {
157         const textNode = $createTextNode('World').toggleUnmergeable();
158         $getRoot().getFirstChild<ElementNode>()!.append(textNode);
159       });
160
161       await update(() => {
162         const textNode = $createTextNode('Hello ').toggleUnmergeable();
163         const previousTextNode = $getRoot()
164           .getFirstChild<ElementNode>()!
165           .getFirstChild()!;
166         previousTextNode.insertBefore(textNode);
167       });
168
169       expect(getEditorStateTextContent(editor.getEditorState())).toBe(
170         'Hello World',
171       );
172     });
173   });
174
175   describe('setTextContent()', () => {
176     test('writable nodes', async () => {
177       await update(() => {
178         const textNode = $createTextNode('My new text node');
179         textNode.setTextContent('My newer text node');
180
181         expect(textNode.getTextContent()).toBe('My newer text node');
182       });
183     });
184   });
185
186   describe.each([
187     ['bold', IS_BOLD],
188     ['italic', IS_ITALIC],
189     ['strikethrough', IS_STRIKETHROUGH],
190     ['underline', IS_UNDERLINE],
191     ['code', IS_CODE],
192     ['subscript', IS_SUBSCRIPT],
193     ['superscript', IS_SUPERSCRIPT],
194     ['highlight', IS_HIGHLIGHT],
195   ] as const)('%s flag', (formatFlag: TextFormatType, stateFormat: number) => {
196     const flagPredicate = (node: TextNode) => node.hasFormat(formatFlag);
197     const flagToggle = (node: TextNode) => node.toggleFormat(formatFlag);
198
199     test(`getFormatFlags(${formatFlag})`, async () => {
200       await update(() => {
201         const root = $getRoot();
202         const paragraphNode = root.getFirstChild<ParagraphNode>()!;
203         const textNode = paragraphNode.getFirstChild<TextNode>()!;
204         const newFormat = textNode.getFormatFlags(formatFlag, null);
205
206         expect(newFormat).toBe(stateFormat);
207
208         textNode.setFormat(newFormat);
209         const newFormat2 = textNode.getFormatFlags(formatFlag, null);
210
211         expect(newFormat2).toBe(0);
212       });
213     });
214
215     test(`predicate for ${formatFlag}`, async () => {
216       await update(() => {
217         const root = $getRoot();
218         const paragraphNode = root.getFirstChild<ParagraphNode>()!;
219         const textNode = paragraphNode.getFirstChild<TextNode>()!;
220
221         textNode.setFormat(stateFormat);
222
223         expect(flagPredicate(textNode)).toBe(true);
224       });
225     });
226
227     test(`toggling for ${formatFlag}`, async () => {
228       // Toggle method hasn't been implemented for this flag.
229       if (flagToggle === null) {
230         return;
231       }
232
233       await update(() => {
234         const root = $getRoot();
235         const paragraphNode = root.getFirstChild<ParagraphNode>()!;
236         const textNode = paragraphNode.getFirstChild<TextNode>()!;
237
238         expect(flagPredicate(textNode)).toBe(false);
239
240         flagToggle(textNode);
241
242         expect(flagPredicate(textNode)).toBe(true);
243
244         flagToggle(textNode);
245
246         expect(flagPredicate(textNode)).toBe(false);
247       });
248     });
249   });
250
251   test('setting subscript clears superscript', async () => {
252     await update(() => {
253       const paragraphNode = $createParagraphNode();
254       const textNode = $createTextNode('Hello World');
255       paragraphNode.append(textNode);
256       $getRoot().append(paragraphNode);
257       textNode.toggleFormat('superscript');
258       textNode.toggleFormat('subscript');
259       expect(textNode.hasFormat('subscript')).toBe(true);
260       expect(textNode.hasFormat('superscript')).toBe(false);
261     });
262   });
263
264   test('setting superscript clears subscript', async () => {
265     await update(() => {
266       const paragraphNode = $createParagraphNode();
267       const textNode = $createTextNode('Hello World');
268       paragraphNode.append(textNode);
269       $getRoot().append(paragraphNode);
270       textNode.toggleFormat('subscript');
271       textNode.toggleFormat('superscript');
272       expect(textNode.hasFormat('superscript')).toBe(true);
273       expect(textNode.hasFormat('subscript')).toBe(false);
274     });
275   });
276
277   test('clearing subscript does not set superscript', async () => {
278     await update(() => {
279       const paragraphNode = $createParagraphNode();
280       const textNode = $createTextNode('Hello World');
281       paragraphNode.append(textNode);
282       $getRoot().append(paragraphNode);
283       textNode.toggleFormat('subscript');
284       textNode.toggleFormat('subscript');
285       expect(textNode.hasFormat('subscript')).toBe(false);
286       expect(textNode.hasFormat('superscript')).toBe(false);
287     });
288   });
289
290   test('clearing superscript does not set subscript', async () => {
291     await update(() => {
292       const paragraphNode = $createParagraphNode();
293       const textNode = $createTextNode('Hello World');
294       paragraphNode.append(textNode);
295       $getRoot().append(paragraphNode);
296       textNode.toggleFormat('superscript');
297       textNode.toggleFormat('superscript');
298       expect(textNode.hasFormat('superscript')).toBe(false);
299       expect(textNode.hasFormat('subscript')).toBe(false);
300     });
301   });
302
303   test('selectPrevious()', async () => {
304     await update(() => {
305       const paragraphNode = $createParagraphNode();
306       const textNode = $createTextNode('Hello World');
307       const textNode2 = $createTextNode('Goodbye Earth');
308       paragraphNode.append(textNode, textNode2);
309       $getRoot().append(paragraphNode);
310
311       let selection = textNode2.selectPrevious();
312
313       expect(selection.anchor.getNode()).toBe(textNode);
314       expect(selection.anchor.offset).toBe(11);
315       expect(selection.focus.getNode()).toBe(textNode);
316       expect(selection.focus.offset).toBe(11);
317
318       selection = textNode.selectPrevious();
319
320       expect(selection.anchor.getNode()).toBe(paragraphNode);
321       expect(selection.anchor.offset).toBe(0);
322     });
323   });
324
325   test('selectNext()', async () => {
326     await update(() => {
327       const paragraphNode = $createParagraphNode();
328       const textNode = $createTextNode('Hello World');
329       const textNode2 = $createTextNode('Goodbye Earth');
330       paragraphNode.append(textNode, textNode2);
331       $getRoot().append(paragraphNode);
332       let selection = textNode.selectNext(1, 3);
333
334       if ($isNodeSelection(selection)) {
335         return;
336       }
337
338       expect(selection.anchor.getNode()).toBe(textNode2);
339       expect(selection.anchor.offset).toBe(1);
340       expect(selection.focus.getNode()).toBe(textNode2);
341       expect(selection.focus.offset).toBe(3);
342
343       selection = textNode2.selectNext();
344
345       expect(selection.anchor.getNode()).toBe(paragraphNode);
346       expect(selection.anchor.offset).toBe(2);
347     });
348   });
349
350   describe('select()', () => {
351     test.each([
352       [
353         [2, 4],
354         [2, 4],
355       ],
356       [
357         [4, 2],
358         [4, 2],
359       ],
360       [
361         [undefined, 2],
362         [11, 2],
363       ],
364       [
365         [2, undefined],
366         [2, 11],
367       ],
368       [
369         [undefined, undefined],
370         [11, 11],
371       ],
372     ])(
373       'select(...%p)',
374       async (
375         [anchorOffset, focusOffset],
376         [expectedAnchorOffset, expectedFocusOffset],
377       ) => {
378         await update(() => {
379           const paragraphNode = $createParagraphNode();
380           const textNode = $createTextNode('Hello World');
381           paragraphNode.append(textNode);
382           $getRoot().append(paragraphNode);
383
384           const selection = textNode.select(anchorOffset, focusOffset);
385
386           expect(selection.focus.getNode()).toBe(textNode);
387           expect(selection.anchor.offset).toBe(expectedAnchorOffset);
388           expect(selection.focus.getNode()).toBe(textNode);
389           expect(selection.focus.offset).toBe(expectedFocusOffset);
390         });
391       },
392     );
393   });
394
395   describe('splitText()', () => {
396     test('convert segmented node into plain text', async () => {
397       await update(() => {
398         const segmentedNode = $createTestSegmentedNode('Hello World');
399         const paragraphNode = $createParagraphNode();
400         paragraphNode.append(segmentedNode);
401
402         const [middle, next] = segmentedNode.splitText(5);
403
404         const children = paragraphNode.getAllTextNodes();
405         expect(paragraphNode.getTextContent()).toBe('Hello World');
406         expect(children[0].isSimpleText()).toBe(true);
407         expect(children[0].getTextContent()).toBe('Hello');
408         expect(middle).toBe(children[0]);
409         expect(next).toBe(children[1]);
410       });
411     });
412     test.each([
413       ['a', [], ['a']],
414       ['a', [1], ['a']],
415       ['a', [5], ['a']],
416       ['Hello World', [], ['Hello World']],
417       ['Hello World', [3], ['Hel', 'lo World']],
418       ['Hello World', [3, 3], ['Hel', 'lo World']],
419       ['Hello World', [3, 7], ['Hel', 'lo W', 'orld']],
420       ['Hello World', [7, 3], ['Hel', 'lo W', 'orld']],
421       ['Hello World', [3, 7, 99], ['Hel', 'lo W', 'orld']],
422     ])(
423       '"%s" splitText(...%p)',
424       async (initialString, splitOffsets, splitStrings) => {
425         await update(() => {
426           const paragraphNode = $createParagraphNode();
427           const textNode = $createTextNode(initialString);
428           paragraphNode.append(textNode);
429
430           const splitNodes = textNode.splitText(...splitOffsets);
431
432           expect(paragraphNode.getChildren()).toHaveLength(splitStrings.length);
433           expect(splitNodes.map((node) => node.getTextContent())).toEqual(
434             splitStrings,
435           );
436         });
437       },
438     );
439
440     test('splitText moves composition key to last node', async () => {
441       await update(() => {
442         const paragraphNode = $createParagraphNode();
443         const textNode = $createTextNode('12345');
444         paragraphNode.append(textNode);
445         $setCompositionKey(textNode.getKey());
446
447         const [, splitNode2] = textNode.splitText(1);
448         expect($getCompositionKey()).toBe(splitNode2.getKey());
449       });
450     });
451
452     test.each([
453       [
454         'Hello',
455         [4],
456         [3, 3],
457         {
458           anchorNodeIndex: 0,
459           anchorOffset: 3,
460           focusNodeIndex: 0,
461           focusOffset: 3,
462         },
463       ],
464       [
465         'Hello',
466         [4],
467         [5, 5],
468         {
469           anchorNodeIndex: 1,
470           anchorOffset: 1,
471           focusNodeIndex: 1,
472           focusOffset: 1,
473         },
474       ],
475       [
476         'Hello World',
477         [4],
478         [2, 7],
479         {
480           anchorNodeIndex: 0,
481           anchorOffset: 2,
482           focusNodeIndex: 1,
483           focusOffset: 3,
484         },
485       ],
486       [
487         'Hello World',
488         [4],
489         [2, 4],
490         {
491           anchorNodeIndex: 0,
492           anchorOffset: 2,
493           focusNodeIndex: 0,
494           focusOffset: 4,
495         },
496       ],
497       [
498         'Hello World',
499         [4],
500         [7, 2],
501         {
502           anchorNodeIndex: 1,
503           anchorOffset: 3,
504           focusNodeIndex: 0,
505           focusOffset: 2,
506         },
507       ],
508       [
509         'Hello World',
510         [4, 6],
511         [2, 9],
512         {
513           anchorNodeIndex: 0,
514           anchorOffset: 2,
515           focusNodeIndex: 2,
516           focusOffset: 3,
517         },
518       ],
519       [
520         'Hello World',
521         [4, 6],
522         [9, 2],
523         {
524           anchorNodeIndex: 2,
525           anchorOffset: 3,
526           focusNodeIndex: 0,
527           focusOffset: 2,
528         },
529       ],
530       [
531         'Hello World',
532         [4, 6],
533         [9, 9],
534         {
535           anchorNodeIndex: 2,
536           anchorOffset: 3,
537           focusNodeIndex: 2,
538           focusOffset: 3,
539         },
540       ],
541     ])(
542       '"%s" splitText(...%p) with select(...%p)',
543       async (
544         initialString,
545         splitOffsets,
546         selectionOffsets,
547         {anchorNodeIndex, anchorOffset, focusNodeIndex, focusOffset},
548       ) => {
549         await update(() => {
550           const paragraphNode = $createParagraphNode();
551           const textNode = $createTextNode(initialString);
552           paragraphNode.append(textNode);
553           $getRoot().append(paragraphNode);
554
555           const selection = textNode.select(...selectionOffsets);
556           const childrenNodes = textNode.splitText(...splitOffsets);
557
558           expect(selection.anchor.getNode()).toBe(
559             childrenNodes[anchorNodeIndex],
560           );
561           expect(selection.anchor.offset).toBe(anchorOffset);
562           expect(selection.focus.getNode()).toBe(childrenNodes[focusNodeIndex]);
563           expect(selection.focus.offset).toBe(focusOffset);
564         });
565       },
566     );
567
568     test('with detached parent', async () => {
569       await update(() => {
570         const textNode = $createTextNode('foo');
571         const splits = textNode.splitText(1, 2);
572         expect(splits.map((split) => split.getTextContent())).toEqual([
573           'f',
574           'o',
575           'o',
576         ]);
577       });
578     });
579   });
580
581   describe('createDOM()', () => {
582     test.each([
583       ['no formatting', 0, 'My text node', '<span>My text node</span>'],
584       [
585         'bold',
586         IS_BOLD,
587         'My text node',
588         '<strong class="my-bold-class">My text node</strong>',
589       ],
590       ['bold + empty', IS_BOLD, '', `<strong class="my-bold-class"></strong>`],
591       [
592         'underline',
593         IS_UNDERLINE,
594         'My text node',
595         '<span class="my-underline-class">My text node</span>',
596       ],
597       [
598         'strikethrough',
599         IS_STRIKETHROUGH,
600         'My text node',
601         '<span class="my-strikethrough-class">My text node</span>',
602       ],
603       [
604         'highlight',
605         IS_HIGHLIGHT,
606         'My text node',
607         '<mark><span class="my-highlight-class">My text node</span></mark>',
608       ],
609       [
610         'italic',
611         IS_ITALIC,
612         'My text node',
613         '<em class="my-italic-class">My text node</em>',
614       ],
615       [
616         'code',
617         IS_CODE,
618         'My text node',
619         '<code spellcheck="false"><span class="my-code-class">My text node</span></code>',
620       ],
621       [
622         'underline + strikethrough',
623         IS_UNDERLINE | IS_STRIKETHROUGH,
624         'My text node',
625         '<span class="my-underline-strikethrough-class">' +
626           'My text node</span>',
627       ],
628       [
629         'code + italic',
630         IS_CODE | IS_ITALIC,
631         'My text node',
632         '<code spellcheck="false"><em class="my-code-class my-italic-class">My text node</em></code>',
633       ],
634       [
635         'code + underline + strikethrough',
636         IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH,
637         'My text node',
638         '<code spellcheck="false"><span class="my-underline-strikethrough-class my-code-class">' +
639           'My text node</span></code>',
640       ],
641       [
642         'highlight + italic',
643         IS_HIGHLIGHT | IS_ITALIC,
644         'My text node',
645         '<mark><em class="my-highlight-class my-italic-class">My text node</em></mark>',
646       ],
647       [
648         'code + underline + strikethrough + bold + italic',
649         IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH | IS_BOLD | IS_ITALIC,
650         'My text node',
651         '<code spellcheck="false"><strong class="my-underline-strikethrough-class my-bold-class my-code-class my-italic-class">My text node</strong></code>',
652       ],
653       [
654         'code + underline + strikethrough + bold + italic + highlight',
655         IS_CODE |
656           IS_UNDERLINE |
657           IS_STRIKETHROUGH |
658           IS_BOLD |
659           IS_ITALIC |
660           IS_HIGHLIGHT,
661         'My text node',
662         '<code spellcheck="false"><strong class="my-underline-strikethrough-class my-bold-class my-code-class my-highlight-class my-italic-class">My text node</strong></code>',
663       ],
664     ])('%s text format type', async (_type, format, contents, expectedHTML) => {
665       await update(() => {
666         const textNode = $createTextNode(contents);
667         textNode.setFormat(format);
668         const element = textNode.createDOM(editorConfig);
669
670         expect(element.outerHTML).toBe(expectedHTML);
671       });
672     });
673
674     describe('has parent node', () => {
675       test.each([
676         ['no formatting', 0, 'My text node', '<span>My text node</span>'],
677         ['no formatting + empty string', 0, '', `<span></span>`],
678       ])(
679         '%s text format type',
680         async (_type, format, contents, expectedHTML) => {
681           await update(() => {
682             const paragraphNode = $createParagraphNode();
683             const textNode = $createTextNode(contents);
684             textNode.setFormat(format);
685             paragraphNode.append(textNode);
686             const element = textNode.createDOM(editorConfig);
687
688             expect(element.outerHTML).toBe(expectedHTML);
689           });
690         },
691       );
692     });
693   });
694
695   describe('updateDOM()', () => {
696     test.each([
697       [
698         'different tags',
699         {
700           format: IS_BOLD,
701           mode: 'normal',
702           text: 'My text node',
703         },
704         {
705           format: IS_ITALIC,
706           mode: 'normal',
707           text: 'My text node',
708         },
709         {
710           expectedHTML: null,
711           result: true,
712         },
713       ],
714       [
715         'no change in tags',
716         {
717           format: IS_BOLD,
718           mode: 'normal',
719           text: 'My text node',
720         },
721         {
722           format: IS_BOLD,
723           mode: 'normal',
724           text: 'My text node',
725         },
726         {
727           expectedHTML: '<strong class="my-bold-class">My text node</strong>',
728           result: false,
729         },
730       ],
731       [
732         'change in text',
733         {
734           format: IS_BOLD,
735           mode: 'normal',
736           text: 'My text node',
737         },
738         {
739           format: IS_BOLD,
740           mode: 'normal',
741           text: 'My new text node',
742         },
743         {
744           expectedHTML:
745             '<strong class="my-bold-class">My new text node</strong>',
746           result: false,
747         },
748       ],
749       [
750         'removing code block',
751         {
752           format: IS_CODE | IS_BOLD,
753           mode: 'normal',
754           text: 'My text node',
755         },
756         {
757           format: IS_BOLD,
758           mode: 'normal',
759           text: 'My new text node',
760         },
761         {
762           expectedHTML: null,
763           result: true,
764         },
765       ],
766     ])(
767       '%s',
768       async (
769         _desc,
770         {text: prevText, mode: prevMode, format: prevFormat},
771         {text: nextText, mode: nextMode, format: nextFormat},
772         {result, expectedHTML},
773       ) => {
774         await update(() => {
775           const prevTextNode = $createTextNode(prevText);
776           prevTextNode.setMode(prevMode as TextModeType);
777           prevTextNode.setFormat(prevFormat);
778           const element = prevTextNode.createDOM(editorConfig);
779           const textNode = $createTextNode(nextText);
780           textNode.setMode(nextMode as TextModeType);
781           textNode.setFormat(nextFormat);
782
783           expect(textNode.updateDOM(prevTextNode, element, editorConfig)).toBe(
784             result,
785           );
786           // Only need to bother about DOM element contents if updateDOM()
787           // returns false.
788           if (!result) {
789             expect(element.outerHTML).toBe(expectedHTML);
790           }
791         });
792       },
793     );
794   });
795
796   describe('exportDOM()', () => {
797
798     test('simple text exports as a text node', async () => {
799       await update(() => {
800         const paragraph = $getRoot().getFirstChild<ElementNode>()!;
801         const textNode = $createTextNode('hello');
802         paragraph.append(textNode);
803
804         const html = $generateHtmlFromNodes($getEditor(), null);
805         expect(html).toBe('<p>hello</p>');
806       });
807     });
808
809     test('simple text wrapped in span if leading or ending spacing', async () => {
810
811       const textByExpectedHtml = {
812         'hello ': '<p><span style="white-space: pre-wrap;">hello </span></p>',
813         ' hello': '<p><span style="white-space: pre-wrap;"> hello</span></p>',
814         ' hello ': '<p><span style="white-space: pre-wrap;"> hello </span></p>',
815       }
816
817       await update(() => {
818         const paragraph = $getRoot().getFirstChild<ElementNode>()!;
819         for (const [text, expectedHtml] of Object.entries(textByExpectedHtml)) {
820           paragraph.getChildren().forEach(c => c.remove(true));
821           const textNode = $createTextNode(text);
822           paragraph.append(textNode);
823
824           const html = $generateHtmlFromNodes($getEditor(), null);
825           expect(html).toBe(expectedHtml);
826         }
827       });
828     });
829
830     test('text with formats exports using format elements instead of classes', async () => {
831       await update(() => {
832         const paragraph = $getRoot().getFirstChild<ElementNode>()!;
833         const textNode = $createTextNode('hello');
834         textNode.toggleFormat('bold');
835         textNode.toggleFormat('subscript');
836         textNode.toggleFormat('italic');
837         textNode.toggleFormat('underline');
838         textNode.toggleFormat('code');
839         paragraph.append(textNode);
840
841         const html = $generateHtmlFromNodes($getEditor(), null);
842         expect(html).toBe('<p><u><em><b><code spellcheck="false"><strong>hello</strong></code></b></em></u></p>');
843       });
844     });
845
846   });
847
848   test('mergeWithSibling', async () => {
849     await update(() => {
850       const paragraph = $getRoot().getFirstChild<ElementNode>()!;
851       const textNode1 = $createTextNode('1');
852       const textNode2 = $createTextNode('2');
853       const textNode3 = $createTextNode('3');
854       paragraph.append(textNode1, textNode2, textNode3);
855       textNode2.select();
856
857       const selection = $getSelection();
858       textNode2.mergeWithSibling(textNode1);
859
860       if (!$isRangeSelection(selection)) {
861         return;
862       }
863
864       expect(selection.anchor.getNode()).toBe(textNode2);
865       expect(selection.anchor.offset).toBe(1);
866       expect(selection.focus.offset).toBe(1);
867
868       textNode2.mergeWithSibling(textNode3);
869
870       expect(selection.anchor.getNode()).toBe(textNode2);
871       expect(selection.anchor.offset).toBe(1);
872       expect(selection.focus.offset).toBe(1);
873     });
874
875     expect(getEditorStateTextContent(editor.getEditorState())).toBe('123');
876   });
877 });