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 {Text} from "@codemirror/state";
45 import {$generateHtmlFromNodes} from "@lexical/html";
46 import {formatBold} from "@lexical/selection/__tests__/utils";
48 const editorConfig = Object.freeze({
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',
63 describe('LexicalTextNode tests', () => {
64 let container: HTMLElement;
66 beforeEach(async () => {
67 container = document.createElement('div');
68 document.body.appendChild(container);
73 document.body.removeChild(container);
78 async function update(fn: () => void) {
80 editor.commitUpdates();
81 return Promise.resolve().then();
84 let editor: LexicalEditor;
86 async function init() {
87 const root = document.createElement('div');
88 root.setAttribute('contenteditable', 'true');
89 container.innerHTML = '';
90 container.appendChild(root);
92 editor = createTestEditor();
93 editor.setRootElement(root);
95 // Insert initial block
97 const paragraph = $createParagraphNode();
98 const text = $createTextNode();
99 text.toggleUnmergeable();
100 paragraph.append(text);
101 $getRoot().append(paragraph);
105 describe('exportJSON()', () => {
106 test('should return and object conforming to the expected schema', async () => {
108 const node = $createTextNode();
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.
115 expect(node.exportJSON()).toStrictEqual({
128 describe('root.getTextContent()', () => {
129 test('writable nodes', async () => {
133 const textNode = $createTextNode('Text');
134 nodeKey = textNode.getKey();
136 expect(textNode.getTextContent()).toBe('Text');
137 expect(textNode.__text).toBe('Text');
139 $getRoot().getFirstChild<ElementNode>()!.append(textNode);
143 editor.getEditorState().read(() => {
144 const root = $getRoot();
145 return root.__cachedText;
148 expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text');
150 // Make sure that the editor content is still set after further reconciliations
152 $getNodeByKey(nodeKey)!.markDirty();
154 expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text');
157 test('prepend node', async () => {
159 const textNode = $createTextNode('World').toggleUnmergeable();
160 $getRoot().getFirstChild<ElementNode>()!.append(textNode);
164 const textNode = $createTextNode('Hello ').toggleUnmergeable();
165 const previousTextNode = $getRoot()
166 .getFirstChild<ElementNode>()!
168 previousTextNode.insertBefore(textNode);
171 expect(getEditorStateTextContent(editor.getEditorState())).toBe(
177 describe('setTextContent()', () => {
178 test('writable nodes', async () => {
180 const textNode = $createTextNode('My new text node');
181 textNode.setTextContent('My newer text node');
183 expect(textNode.getTextContent()).toBe('My newer text node');
190 ['italic', IS_ITALIC],
191 ['strikethrough', IS_STRIKETHROUGH],
192 ['underline', IS_UNDERLINE],
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);
201 test(`getFormatFlags(${formatFlag})`, async () => {
203 const root = $getRoot();
204 const paragraphNode = root.getFirstChild<ParagraphNode>()!;
205 const textNode = paragraphNode.getFirstChild<TextNode>()!;
206 const newFormat = textNode.getFormatFlags(formatFlag, null);
208 expect(newFormat).toBe(stateFormat);
210 textNode.setFormat(newFormat);
211 const newFormat2 = textNode.getFormatFlags(formatFlag, null);
213 expect(newFormat2).toBe(0);
217 test(`predicate for ${formatFlag}`, async () => {
219 const root = $getRoot();
220 const paragraphNode = root.getFirstChild<ParagraphNode>()!;
221 const textNode = paragraphNode.getFirstChild<TextNode>()!;
223 textNode.setFormat(stateFormat);
225 expect(flagPredicate(textNode)).toBe(true);
229 test(`toggling for ${formatFlag}`, async () => {
230 // Toggle method hasn't been implemented for this flag.
231 if (flagToggle === null) {
236 const root = $getRoot();
237 const paragraphNode = root.getFirstChild<ParagraphNode>()!;
238 const textNode = paragraphNode.getFirstChild<TextNode>()!;
240 expect(flagPredicate(textNode)).toBe(false);
242 flagToggle(textNode);
244 expect(flagPredicate(textNode)).toBe(true);
246 flagToggle(textNode);
248 expect(flagPredicate(textNode)).toBe(false);
253 test('setting subscript clears superscript', async () => {
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);
266 test('setting superscript clears subscript', async () => {
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);
279 test('clearing subscript does not set superscript', async () => {
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);
292 test('clearing superscript does not set subscript', async () => {
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);
305 test('selectPrevious()', async () => {
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);
313 let selection = textNode2.selectPrevious();
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);
320 selection = textNode.selectPrevious();
322 expect(selection.anchor.getNode()).toBe(paragraphNode);
323 expect(selection.anchor.offset).toBe(0);
327 test('selectNext()', async () => {
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);
336 if ($isNodeSelection(selection)) {
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);
345 selection = textNode2.selectNext();
347 expect(selection.anchor.getNode()).toBe(paragraphNode);
348 expect(selection.anchor.offset).toBe(2);
352 describe('select()', () => {
371 [undefined, undefined],
377 [anchorOffset, focusOffset],
378 [expectedAnchorOffset, expectedFocusOffset],
381 const paragraphNode = $createParagraphNode();
382 const textNode = $createTextNode('Hello World');
383 paragraphNode.append(textNode);
384 $getRoot().append(paragraphNode);
386 const selection = textNode.select(anchorOffset, focusOffset);
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);
397 describe('splitText()', () => {
398 test('convert segmented node into plain text', async () => {
400 const segmentedNode = $createTestSegmentedNode('Hello World');
401 const paragraphNode = $createParagraphNode();
402 paragraphNode.append(segmentedNode);
404 const [middle, next] = segmentedNode.splitText(5);
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]);
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']],
425 '"%s" splitText(...%p)',
426 async (initialString, splitOffsets, splitStrings) => {
428 const paragraphNode = $createParagraphNode();
429 const textNode = $createTextNode(initialString);
430 paragraphNode.append(textNode);
432 const splitNodes = textNode.splitText(...splitOffsets);
434 expect(paragraphNode.getChildren()).toHaveLength(splitStrings.length);
435 expect(splitNodes.map((node) => node.getTextContent())).toEqual(
442 test('splitText moves composition key to last node', async () => {
444 const paragraphNode = $createParagraphNode();
445 const textNode = $createTextNode('12345');
446 paragraphNode.append(textNode);
447 $setCompositionKey(textNode.getKey());
449 const [, splitNode2] = textNode.splitText(1);
450 expect($getCompositionKey()).toBe(splitNode2.getKey());
544 '"%s" splitText(...%p) with select(...%p)',
549 {anchorNodeIndex, anchorOffset, focusNodeIndex, focusOffset},
552 const paragraphNode = $createParagraphNode();
553 const textNode = $createTextNode(initialString);
554 paragraphNode.append(textNode);
555 $getRoot().append(paragraphNode);
557 const selection = textNode.select(...selectionOffsets);
558 const childrenNodes = textNode.splitText(...splitOffsets);
560 expect(selection.anchor.getNode()).toBe(
561 childrenNodes[anchorNodeIndex],
563 expect(selection.anchor.offset).toBe(anchorOffset);
564 expect(selection.focus.getNode()).toBe(childrenNodes[focusNodeIndex]);
565 expect(selection.focus.offset).toBe(focusOffset);
570 test('with detached parent', async () => {
572 const textNode = $createTextNode('foo');
573 const splits = textNode.splitText(1, 2);
574 expect(splits.map((split) => split.getTextContent())).toEqual([
583 describe('createDOM()', () => {
585 ['no formatting', 0, 'My text node', '<span>My text node</span>'],
590 '<strong class="my-bold-class">My text node</strong>',
592 ['bold + empty', IS_BOLD, '', `<strong class="my-bold-class"></strong>`],
597 '<span class="my-underline-class">My text node</span>',
603 '<span class="my-strikethrough-class">My text node</span>',
609 '<mark><span class="my-highlight-class">My text node</span></mark>',
615 '<em class="my-italic-class">My text node</em>',
621 '<code spellcheck="false"><span class="my-code-class">My text node</span></code>',
624 'underline + strikethrough',
625 IS_UNDERLINE | IS_STRIKETHROUGH,
627 '<span class="my-underline-strikethrough-class">' +
628 'My text node</span>',
634 '<code spellcheck="false"><em class="my-code-class my-italic-class">My text node</em></code>',
637 'code + underline + strikethrough',
638 IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH,
640 '<code spellcheck="false"><span class="my-underline-strikethrough-class my-code-class">' +
641 'My text node</span></code>',
644 'highlight + italic',
645 IS_HIGHLIGHT | IS_ITALIC,
647 '<mark><em class="my-highlight-class my-italic-class">My text node</em></mark>',
650 'code + underline + strikethrough + bold + italic',
651 IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH | IS_BOLD | IS_ITALIC,
653 '<code spellcheck="false"><strong class="my-underline-strikethrough-class my-bold-class my-code-class my-italic-class">My text node</strong></code>',
656 'code + underline + strikethrough + bold + italic + highlight',
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>',
666 ])('%s text format type', async (_type, format, contents, expectedHTML) => {
668 const textNode = $createTextNode(contents);
669 textNode.setFormat(format);
670 const element = textNode.createDOM(editorConfig);
672 expect(element.outerHTML).toBe(expectedHTML);
676 describe('has parent node', () => {
678 ['no formatting', 0, 'My text node', '<span>My text node</span>'],
679 ['no formatting + empty string', 0, '', `<span></span>`],
681 '%s text format type',
682 async (_type, format, contents, expectedHTML) => {
684 const paragraphNode = $createParagraphNode();
685 const textNode = $createTextNode(contents);
686 textNode.setFormat(format);
687 paragraphNode.append(textNode);
688 const element = textNode.createDOM(editorConfig);
690 expect(element.outerHTML).toBe(expectedHTML);
697 describe('updateDOM()', () => {
704 text: 'My text node',
709 text: 'My text node',
721 text: 'My text node',
726 text: 'My text node',
729 expectedHTML: '<strong class="my-bold-class">My text node</strong>',
738 text: 'My text node',
743 text: 'My new text node',
747 '<strong class="my-bold-class">My new text node</strong>',
752 'removing code block',
754 format: IS_CODE | IS_BOLD,
756 text: 'My text node',
761 text: 'My new text node',
772 {text: prevText, mode: prevMode, format: prevFormat},
773 {text: nextText, mode: nextMode, format: nextFormat},
774 {result, expectedHTML},
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);
785 expect(textNode.updateDOM(prevTextNode, element, editorConfig)).toBe(
788 // Only need to bother about DOM element contents if updateDOM()
791 expect(element.outerHTML).toBe(expectedHTML);
798 describe('exportDOM()', () => {
800 test('simple text exports as a text node', async () => {
802 const paragraph = $getRoot().getFirstChild<ElementNode>()!;
803 const textNode = $createTextNode('hello');
804 paragraph.append(textNode);
806 const html = $generateHtmlFromNodes($getEditor(), null);
807 expect(html).toBe('<p>hello</p>');
811 test('simple text wrapped in span if leading or ending spacing', async () => {
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>',
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);
826 const html = $generateHtmlFromNodes($getEditor(), null);
827 expect(html).toBe(expectedHtml);
832 test('text with formats exports using format elements instead of classes', async () => {
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);
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>');
850 test('mergeWithSibling', async () => {
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);
859 const selection = $getSelection();
860 textNode2.mergeWithSibling(textNode1);
862 if (!$isRangeSelection(selection)) {
866 expect(selection.anchor.getNode()).toBe(textNode2);
867 expect(selection.anchor.offset).toBe(1);
868 expect(selection.focus.offset).toBe(1);
870 textNode2.mergeWithSibling(textNode3);
872 expect(selection.anchor.getNode()).toBe(textNode2);
873 expect(selection.anchor.offset).toBe(1);
874 expect(selection.focus.offset).toBe(1);
877 expect(getEditorStateTextContent(editor.getEditorState())).toBe('123');