2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
11 $createTextNode, $getEditor,
26 $createTestSegmentedNode,
28 } from '../../../__tests__/utils';
38 } from '../../../LexicalConstants';
42 getEditorStateTextContent,
43 } from '../../../LexicalUtils';
44 import {$generateHtmlFromNodes} from "@lexical/html";
46 const editorConfig = Object.freeze({
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',
61 describe('LexicalTextNode tests', () => {
62 let container: HTMLElement;
64 beforeEach(async () => {
65 container = document.createElement('div');
66 document.body.appendChild(container);
71 document.body.removeChild(container);
76 async function update(fn: () => void) {
78 editor.commitUpdates();
79 return Promise.resolve().then();
82 let editor: LexicalEditor;
84 async function init() {
85 const root = document.createElement('div');
86 root.setAttribute('contenteditable', 'true');
87 container.innerHTML = '';
88 container.appendChild(root);
90 editor = createTestEditor();
91 editor.setRootElement(root);
93 // Insert initial block
95 const paragraph = $createParagraphNode();
96 const text = $createTextNode();
97 text.toggleUnmergeable();
98 paragraph.append(text);
99 $getRoot().append(paragraph);
103 describe('exportJSON()', () => {
104 test('should return and object conforming to the expected schema', async () => {
106 const node = $createTextNode();
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.
113 expect(node.exportJSON()).toStrictEqual({
126 describe('root.getTextContent()', () => {
127 test('writable nodes', async () => {
131 const textNode = $createTextNode('Text');
132 nodeKey = textNode.getKey();
134 expect(textNode.getTextContent()).toBe('Text');
135 expect(textNode.__text).toBe('Text');
137 $getRoot().getFirstChild<ElementNode>()!.append(textNode);
141 editor.getEditorState().read(() => {
142 const root = $getRoot();
143 return root.__cachedText;
146 expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text');
148 // Make sure that the editor content is still set after further reconciliations
150 $getNodeByKey(nodeKey)!.markDirty();
152 expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text');
155 test('prepend node', async () => {
157 const textNode = $createTextNode('World').toggleUnmergeable();
158 $getRoot().getFirstChild<ElementNode>()!.append(textNode);
162 const textNode = $createTextNode('Hello ').toggleUnmergeable();
163 const previousTextNode = $getRoot()
164 .getFirstChild<ElementNode>()!
166 previousTextNode.insertBefore(textNode);
169 expect(getEditorStateTextContent(editor.getEditorState())).toBe(
175 describe('setTextContent()', () => {
176 test('writable nodes', async () => {
178 const textNode = $createTextNode('My new text node');
179 textNode.setTextContent('My newer text node');
181 expect(textNode.getTextContent()).toBe('My newer text node');
188 ['italic', IS_ITALIC],
189 ['strikethrough', IS_STRIKETHROUGH],
190 ['underline', IS_UNDERLINE],
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);
199 test(`getFormatFlags(${formatFlag})`, async () => {
201 const root = $getRoot();
202 const paragraphNode = root.getFirstChild<ParagraphNode>()!;
203 const textNode = paragraphNode.getFirstChild<TextNode>()!;
204 const newFormat = textNode.getFormatFlags(formatFlag, null);
206 expect(newFormat).toBe(stateFormat);
208 textNode.setFormat(newFormat);
209 const newFormat2 = textNode.getFormatFlags(formatFlag, null);
211 expect(newFormat2).toBe(0);
215 test(`predicate for ${formatFlag}`, async () => {
217 const root = $getRoot();
218 const paragraphNode = root.getFirstChild<ParagraphNode>()!;
219 const textNode = paragraphNode.getFirstChild<TextNode>()!;
221 textNode.setFormat(stateFormat);
223 expect(flagPredicate(textNode)).toBe(true);
227 test(`toggling for ${formatFlag}`, async () => {
228 // Toggle method hasn't been implemented for this flag.
229 if (flagToggle === null) {
234 const root = $getRoot();
235 const paragraphNode = root.getFirstChild<ParagraphNode>()!;
236 const textNode = paragraphNode.getFirstChild<TextNode>()!;
238 expect(flagPredicate(textNode)).toBe(false);
240 flagToggle(textNode);
242 expect(flagPredicate(textNode)).toBe(true);
244 flagToggle(textNode);
246 expect(flagPredicate(textNode)).toBe(false);
251 test('setting subscript clears superscript', async () => {
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);
264 test('setting superscript clears subscript', async () => {
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);
277 test('clearing subscript does not set superscript', async () => {
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);
290 test('clearing superscript does not set subscript', async () => {
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);
303 test('selectPrevious()', async () => {
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);
311 let selection = textNode2.selectPrevious();
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);
318 selection = textNode.selectPrevious();
320 expect(selection.anchor.getNode()).toBe(paragraphNode);
321 expect(selection.anchor.offset).toBe(0);
325 test('selectNext()', async () => {
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);
334 if ($isNodeSelection(selection)) {
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);
343 selection = textNode2.selectNext();
345 expect(selection.anchor.getNode()).toBe(paragraphNode);
346 expect(selection.anchor.offset).toBe(2);
350 describe('select()', () => {
369 [undefined, undefined],
375 [anchorOffset, focusOffset],
376 [expectedAnchorOffset, expectedFocusOffset],
379 const paragraphNode = $createParagraphNode();
380 const textNode = $createTextNode('Hello World');
381 paragraphNode.append(textNode);
382 $getRoot().append(paragraphNode);
384 const selection = textNode.select(anchorOffset, focusOffset);
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);
395 describe('splitText()', () => {
396 test('convert segmented node into plain text', async () => {
398 const segmentedNode = $createTestSegmentedNode('Hello World');
399 const paragraphNode = $createParagraphNode();
400 paragraphNode.append(segmentedNode);
402 const [middle, next] = segmentedNode.splitText(5);
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]);
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']],
423 '"%s" splitText(...%p)',
424 async (initialString, splitOffsets, splitStrings) => {
426 const paragraphNode = $createParagraphNode();
427 const textNode = $createTextNode(initialString);
428 paragraphNode.append(textNode);
430 const splitNodes = textNode.splitText(...splitOffsets);
432 expect(paragraphNode.getChildren()).toHaveLength(splitStrings.length);
433 expect(splitNodes.map((node) => node.getTextContent())).toEqual(
440 test('splitText moves composition key to last node', async () => {
442 const paragraphNode = $createParagraphNode();
443 const textNode = $createTextNode('12345');
444 paragraphNode.append(textNode);
445 $setCompositionKey(textNode.getKey());
447 const [, splitNode2] = textNode.splitText(1);
448 expect($getCompositionKey()).toBe(splitNode2.getKey());
542 '"%s" splitText(...%p) with select(...%p)',
547 {anchorNodeIndex, anchorOffset, focusNodeIndex, focusOffset},
550 const paragraphNode = $createParagraphNode();
551 const textNode = $createTextNode(initialString);
552 paragraphNode.append(textNode);
553 $getRoot().append(paragraphNode);
555 const selection = textNode.select(...selectionOffsets);
556 const childrenNodes = textNode.splitText(...splitOffsets);
558 expect(selection.anchor.getNode()).toBe(
559 childrenNodes[anchorNodeIndex],
561 expect(selection.anchor.offset).toBe(anchorOffset);
562 expect(selection.focus.getNode()).toBe(childrenNodes[focusNodeIndex]);
563 expect(selection.focus.offset).toBe(focusOffset);
568 test('with detached parent', async () => {
570 const textNode = $createTextNode('foo');
571 const splits = textNode.splitText(1, 2);
572 expect(splits.map((split) => split.getTextContent())).toEqual([
581 describe('createDOM()', () => {
583 ['no formatting', 0, 'My text node', '<span>My text node</span>'],
588 '<strong class="my-bold-class">My text node</strong>',
590 ['bold + empty', IS_BOLD, '', `<strong class="my-bold-class"></strong>`],
595 '<span class="my-underline-class">My text node</span>',
601 '<span class="my-strikethrough-class">My text node</span>',
607 '<mark><span class="my-highlight-class">My text node</span></mark>',
613 '<em class="my-italic-class">My text node</em>',
619 '<code spellcheck="false"><span class="my-code-class">My text node</span></code>',
622 'underline + strikethrough',
623 IS_UNDERLINE | IS_STRIKETHROUGH,
625 '<span class="my-underline-strikethrough-class">' +
626 'My text node</span>',
632 '<code spellcheck="false"><em class="my-code-class my-italic-class">My text node</em></code>',
635 'code + underline + strikethrough',
636 IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH,
638 '<code spellcheck="false"><span class="my-underline-strikethrough-class my-code-class">' +
639 'My text node</span></code>',
642 'highlight + italic',
643 IS_HIGHLIGHT | IS_ITALIC,
645 '<mark><em class="my-highlight-class my-italic-class">My text node</em></mark>',
648 'code + underline + strikethrough + bold + italic',
649 IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH | IS_BOLD | IS_ITALIC,
651 '<code spellcheck="false"><strong class="my-underline-strikethrough-class my-bold-class my-code-class my-italic-class">My text node</strong></code>',
654 'code + underline + strikethrough + bold + italic + highlight',
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>',
664 ])('%s text format type', async (_type, format, contents, expectedHTML) => {
666 const textNode = $createTextNode(contents);
667 textNode.setFormat(format);
668 const element = textNode.createDOM(editorConfig);
670 expect(element.outerHTML).toBe(expectedHTML);
674 describe('has parent node', () => {
676 ['no formatting', 0, 'My text node', '<span>My text node</span>'],
677 ['no formatting + empty string', 0, '', `<span></span>`],
679 '%s text format type',
680 async (_type, format, contents, expectedHTML) => {
682 const paragraphNode = $createParagraphNode();
683 const textNode = $createTextNode(contents);
684 textNode.setFormat(format);
685 paragraphNode.append(textNode);
686 const element = textNode.createDOM(editorConfig);
688 expect(element.outerHTML).toBe(expectedHTML);
695 describe('updateDOM()', () => {
702 text: 'My text node',
707 text: 'My text node',
719 text: 'My text node',
724 text: 'My text node',
727 expectedHTML: '<strong class="my-bold-class">My text node</strong>',
736 text: 'My text node',
741 text: 'My new text node',
745 '<strong class="my-bold-class">My new text node</strong>',
750 'removing code block',
752 format: IS_CODE | IS_BOLD,
754 text: 'My text node',
759 text: 'My new text node',
770 {text: prevText, mode: prevMode, format: prevFormat},
771 {text: nextText, mode: nextMode, format: nextFormat},
772 {result, expectedHTML},
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);
783 expect(textNode.updateDOM(prevTextNode, element, editorConfig)).toBe(
786 // Only need to bother about DOM element contents if updateDOM()
789 expect(element.outerHTML).toBe(expectedHTML);
796 describe('exportDOM()', () => {
798 test('simple text exports as a text node', async () => {
800 const paragraph = $getRoot().getFirstChild<ElementNode>()!;
801 const textNode = $createTextNode('hello');
802 paragraph.append(textNode);
804 const html = $generateHtmlFromNodes($getEditor(), null);
805 expect(html).toBe('<p>hello</p>');
809 test('simple text wrapped in span if leading or ending spacing', async () => {
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>',
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);
824 const html = $generateHtmlFromNodes($getEditor(), null);
825 expect(html).toBe(expectedHtml);
830 test('text with formats exports using format elements instead of classes', async () => {
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);
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>');
848 test('mergeWithSibling', async () => {
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);
857 const selection = $getSelection();
858 textNode2.mergeWithSibling(textNode1);
860 if (!$isRangeSelection(selection)) {
864 expect(selection.anchor.getNode()).toBe(textNode2);
865 expect(selection.anchor.offset).toBe(1);
866 expect(selection.focus.offset).toBe(1);
868 textNode2.mergeWithSibling(textNode3);
870 expect(selection.anchor.getNode()).toBe(textNode2);
871 expect(selection.anchor.offset).toBe(1);
872 expect(selection.focus.offset).toBe(1);
875 expect(getEditorStateTextContent(editor.getEditorState())).toBe('123');