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