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.
9 import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
16 } from '@lexical/table';
21 $createRangeSelection,
24 $getNearestNodeFromDOMNode,
32 COMMAND_PRIORITY_EDITOR,
41 type LexicalNodeReplacement,
47 import invariant from 'lexical/shared/invariant';
50 $createTestElementNode,
51 $createTestInlineElementNode,
53 createTestHeadlessEditor,
57 describe('LexicalEditor tests', () => {
58 let container: HTMLElement;
59 function setContainerChild(el: HTMLElement) {
60 container.innerHTML = '';
65 container = document.createElement('div');
66 document.body.appendChild(container);
70 document.body.removeChild(container);
74 jest.restoreAllMocks();
77 function useLexicalEditor(
78 rootElement: HTMLDivElement,
79 onError?: (error: Error) => void,
80 nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>,
82 const editor = createTestEditor({
84 onError: onError || jest.fn(),
87 bold: 'editor-text-bold',
88 italic: 'editor-text-italic',
89 underline: 'editor-text-underline',
93 editor.setRootElement(rootElement);
97 let editor: LexicalEditor;
99 function init(onError?: (error: Error) => void) {
100 const edContainer = document.createElement('div');
101 edContainer.setAttribute('contenteditable', 'true');
103 setContainerChild(edContainer);
104 editor = useLexicalEditor(edContainer, onError);
107 async function update(fn: () => void) {
110 return Promise.resolve().then();
113 describe('read()', () => {
114 it('Can read the editor state', async () => {
115 init(function onError(err) {
118 expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
119 expect(editor.read(() => $getEditor())).toBe(editor);
120 const onUpdate = jest.fn();
123 const root = $getRoot();
124 const paragraph = $createParagraphNode();
125 const text = $createTextNode('This works!');
126 root.append(paragraph);
127 paragraph.append(text);
131 expect(onUpdate).toHaveBeenCalledTimes(0);
132 // This read will flush pending updates
133 expect(editor.read(() => $getRoot().getTextContent())).toEqual(
136 expect(onUpdate).toHaveBeenCalledTimes(1);
137 // Check to make sure there is not an unexpected reconciliation
138 await Promise.resolve().then();
139 expect(onUpdate).toHaveBeenCalledTimes(1);
141 const rootElement = editor.getRootElement();
142 expect(rootElement).toBeDefined();
143 // The root never works for this call
144 expect($getNearestNodeFromDOMNode(rootElement!)).toBe(null);
145 const paragraphDom = rootElement!.querySelector('p');
146 expect(paragraphDom).toBeDefined();
148 $isParagraphNode($getNearestNodeFromDOMNode(paragraphDom!)),
151 $getNearestNodeFromDOMNode(paragraphDom!)!.getTextContent(),
152 ).toBe('This works!');
153 const textDom = paragraphDom!.querySelector('span');
154 expect(textDom).toBeDefined();
155 expect($isTextNode($getNearestNodeFromDOMNode(textDom!))).toBe(true);
156 expect($getNearestNodeFromDOMNode(textDom!)!.getTextContent()).toBe(
160 $getNearestNodeFromDOMNode(textDom!.firstChild!)!.getTextContent(),
161 ).toBe('This works!');
163 expect(onUpdate).toHaveBeenCalledTimes(1);
165 it('runs transforms the editor state', async () => {
166 init(function onError(err) {
169 expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
170 expect(editor.read(() => $getEditor())).toBe(editor);
171 editor.registerNodeTransform(TextNode, (node) => {
172 if (node.getTextContent() === 'This works!') {
173 node.replace($createTextNode('Transforms work!'));
176 const onUpdate = jest.fn();
179 const root = $getRoot();
180 const paragraph = $createParagraphNode();
181 const text = $createTextNode('This works!');
182 root.append(paragraph);
183 paragraph.append(text);
187 expect(onUpdate).toHaveBeenCalledTimes(0);
188 // This read will flush pending updates
189 expect(editor.read(() => $getRoot().getTextContent())).toEqual(
192 expect(editor.getRootElement()!.textContent).toEqual('Transforms work!');
193 expect(onUpdate).toHaveBeenCalledTimes(1);
194 // Check to make sure there is not an unexpected reconciliation
195 await Promise.resolve().then();
196 expect(onUpdate).toHaveBeenCalledTimes(1);
197 expect(editor.read(() => $getRoot().getTextContent())).toEqual(
201 it('can be nested in an update or read', async () => {
202 init(function onError(err) {
205 editor.update(() => {
206 const root = $getRoot();
207 const paragraph = $createParagraphNode();
208 const text = $createTextNode('This works!');
209 root.append(paragraph);
210 paragraph.append(text);
212 expect($getRoot().getTextContent()).toBe('This works!');
215 // Nesting update in read works, although it is discouraged in the documentation.
216 editor.update(() => {
217 expect($getRoot().getTextContent()).toBe('This works!');
220 // Updating after a nested read will fail as it has already been committed
223 $createParagraphNode().append(
224 $createTextNode('update-read-update'),
231 expect($getRoot().getTextContent()).toBe('This works!');
237 it('Should create an editor with an initial editor state', async () => {
238 const rootElement = document.createElement('div');
240 container.appendChild(rootElement);
242 const initialEditor = createTestEditor({
246 initialEditor.update(() => {
247 const root = $getRoot();
248 const paragraph = $createParagraphNode();
249 const text = $createTextNode('This works!');
250 root.append(paragraph);
251 paragraph.append(text);
254 initialEditor.setRootElement(rootElement);
256 // Wait for update to complete
257 await Promise.resolve().then();
259 expect(container.innerHTML).toBe(
260 '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">This works!</span></p></div>',
263 const initialEditorState = initialEditor.getEditorState();
264 initialEditor.setRootElement(null);
266 expect(container.innerHTML).toBe(
267 '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"></div>',
270 editor = createTestEditor({
271 editorState: initialEditorState,
274 editor.setRootElement(rootElement);
276 expect(editor.getEditorState()).toEqual(initialEditorState);
277 expect(container.innerHTML).toBe(
278 '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">This works!</span></p></div>',
282 it('Should handle nested updates in the correct sequence', async () => {
284 const onUpdate = jest.fn();
286 let log: Array<string> = [];
288 editor.registerUpdateListener(onUpdate);
289 editor.update(() => {
290 const root = $getRoot();
291 const paragraph = $createParagraphNode();
292 const text = $createTextNode('This works!');
293 root.append(paragraph);
294 paragraph.append(text);
300 // To enforce the update
301 $getRoot().markDirty();
330 // Wait for update to complete
331 await Promise.resolve().then();
333 expect(onUpdate).toHaveBeenCalledTimes(1);
334 expect(log).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1']);
340 // To enforce the update
341 $getRoot().markDirty();
349 $setCompositionKey('root');
373 // Wait for update to complete
374 await Promise.resolve().then();
376 expect(log).toEqual(['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2']);
379 editor.registerNodeTransform(TextNode, () => {
380 log.push('TextTransform A3');
383 log.push('TextTransform B3');
387 log.push('TextTransform C3');
393 // Wait for update to complete
394 await Promise.resolve().then();
396 expect(log).toEqual([
406 $getRoot().getLastDescendant()!.markDirty();
415 // Wait for update to complete
416 await Promise.resolve().then();
418 expect(log).toEqual([
427 it('nested update after selection update triggers exactly 1 update', async () => {
429 const onUpdate = jest.fn();
430 editor.registerUpdateListener(onUpdate);
431 editor.update(() => {
432 $setSelection($createRangeSelection());
433 editor.update(() => {
435 $createParagraphNode().append($createTextNode('Sync update')),
440 await Promise.resolve().then();
442 const textContent = editor
444 .read(() => $getRoot().getTextContent());
445 expect(textContent).toBe('Sync update');
446 expect(onUpdate).toHaveBeenCalledTimes(1);
449 it('update does not call onUpdate callback when no dirty nodes', () => {
452 const fn = jest.fn();
461 expect(fn).toHaveBeenCalledTimes(0);
464 it('editor.focus() callback is called', async () => {
467 await editor.update(() => {
468 const root = $getRoot();
469 root.append($createParagraphNode());
472 const fn = jest.fn();
474 await editor.focus(fn);
476 expect(fn).toHaveBeenCalledTimes(1);
479 it('Synchronously runs three transforms, two of them depend on the other', async () => {
483 const italicsListener = editor.registerNodeTransform(TextNode, (node) => {
485 node.getTextContent() === 'foo' &&
486 node.hasFormat('bold') &&
487 !node.hasFormat('italic')
489 node.toggleFormat('italic');
494 const boldListener = editor.registerNodeTransform(TextNode, (node) => {
495 if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) {
496 node.toggleFormat('bold');
501 const underlineListener = editor.registerNodeTransform(TextNode, (node) => {
503 node.getTextContent() === 'foo' &&
504 node.hasFormat('bold') &&
505 !node.hasFormat('underline')
507 node.toggleFormat('underline');
511 await editor.update(() => {
512 const root = $getRoot();
513 const paragraph = $createParagraphNode();
514 root.append(paragraph);
515 paragraph.append($createTextNode('foo'));
521 expect(container.innerHTML).toBe(
522 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><strong class="editor-text-bold editor-text-italic editor-text-underline" data-lexical-text="true">foo</strong></p></div>',
526 it('Synchronously runs three transforms, two of them depend on the other (2)', async () => {
529 // Add transform makes everything dirty the first time (let's not leverage this here)
530 const skipFirst = [true, true, true];
532 // 2. (Block transform) Add text
533 const testParagraphListener = editor.registerNodeTransform(
537 skipFirst[0] = false;
542 if (paragraph.isEmpty()) {
543 paragraph.append($createTextNode('foo'));
548 // 2. (Text transform) Add bold to text
549 const boldListener = editor.registerNodeTransform(TextNode, (node) => {
550 if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) {
551 node.toggleFormat('bold');
555 // 3. (Block transform) Add italics to bold text
556 const italicsListener = editor.registerNodeTransform(
559 const child = paragraph.getLastDescendant();
562 $isTextNode(child) &&
563 child.hasFormat('bold') &&
564 !child.hasFormat('italic')
566 child.toggleFormat('italic');
571 await editor.update(() => {
572 const root = $getRoot();
573 const paragraph = $createParagraphNode();
574 root.append(paragraph);
577 await editor.update(() => {
578 const root = $getRoot();
579 const paragraph = root.getFirstChild();
580 paragraph!.markDirty();
583 testParagraphListener();
587 expect(container.innerHTML).toBe(
588 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><strong class="editor-text-bold editor-text-italic" data-lexical-text="true">foo</strong></p></div>',
592 it('Synchronously runs three transforms, two of them depend on previously merged text content', async () => {
593 const hasRun = [false, false, false];
596 // 1. [Foo] into [<empty>,Fo,o,<empty>,!,<empty>]
597 const fooListener = editor.registerNodeTransform(TextNode, (node) => {
598 if (node.getTextContent() === 'Foo' && !hasRun[0]) {
599 const [before, after] = node.splitText(2);
601 before.insertBefore($createTextNode(''));
602 after.insertAfter($createTextNode(''));
603 after.insertAfter($createTextNode('!'));
604 after.insertAfter($createTextNode(''));
610 // 2. [Foo!] into [<empty>,Fo,o!,<empty>,!,<empty>]
611 const megaFooListener = editor.registerNodeTransform(
614 const child = paragraph.getFirstChild();
617 $isTextNode(child) &&
618 child.getTextContent() === 'Foo!' &&
621 const [before, after] = child.splitText(2);
623 before.insertBefore($createTextNode(''));
624 after.insertAfter($createTextNode(''));
625 after.insertAfter($createTextNode('!'));
626 after.insertAfter($createTextNode(''));
633 // 3. [Foo!!] into formatted bold [<empty>,Fo,o!!,<empty>]
634 const boldFooListener = editor.registerNodeTransform(TextNode, (node) => {
635 if (node.getTextContent() === 'Foo!!' && !hasRun[2]) {
636 node.toggleFormat('bold');
638 const [before, after] = node.splitText(2);
639 before.insertBefore($createTextNode(''));
640 after.insertAfter($createTextNode(''));
646 await editor.update(() => {
647 const root = $getRoot();
648 const paragraph = $createParagraphNode();
650 root.append(paragraph);
651 paragraph.append($createTextNode('Foo'));
658 expect(container.innerHTML).toBe(
659 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><strong class="editor-text-bold" data-lexical-text="true">Foo!!</strong></p></div>',
663 it('text transform runs when node is removed', async () => {
666 const executeTransform = jest.fn();
667 let hasBeenRemoved = false;
668 const removeListener = editor.registerNodeTransform(TextNode, (node) => {
669 if (hasBeenRemoved) {
674 await editor.update(() => {
675 const root = $getRoot();
676 const paragraph = $createParagraphNode();
677 root.append(paragraph);
679 $createTextNode('Foo').toggleUnmergeable(),
680 $createTextNode('Bar').toggleUnmergeable(),
684 await editor.update(() => {
685 $getRoot().getLastDescendant()!.remove();
686 hasBeenRemoved = true;
689 expect(executeTransform).toHaveBeenCalledTimes(1);
694 it('transforms only run on nodes that were explicitly marked as dirty', async () => {
697 let executeParagraphNodeTransform = () => {
701 let executeTextNodeTransform = () => {
705 const removeParagraphTransform = editor.registerNodeTransform(
708 executeParagraphNodeTransform();
711 const removeTextNodeTransform = editor.registerNodeTransform(
714 executeTextNodeTransform();
718 await editor.update(() => {
719 const root = $getRoot();
720 const paragraph = $createParagraphNode();
721 root.append(paragraph);
722 paragraph.append($createTextNode('Foo'));
725 await editor.update(() => {
726 const root = $getRoot();
727 const paragraph = root.getFirstChild() as ParagraphNode;
728 const textNode = paragraph.getFirstChild() as TextNode;
730 textNode.getWritable();
732 executeParagraphNodeTransform = jest.fn();
733 executeTextNodeTransform = jest.fn();
736 expect(executeParagraphNodeTransform).toHaveBeenCalledTimes(0);
737 expect(executeTextNodeTransform).toHaveBeenCalledTimes(1);
739 removeParagraphTransform();
740 removeTextNodeTransform();
743 describe('transforms on siblings', () => {
744 let textNodeKeys: string[];
745 let textTransformCount: number[];
746 let removeTransform: () => void;
748 beforeEach(async () => {
752 textTransformCount = [];
754 await editor.update(() => {
755 const root = $getRoot();
756 const paragraph0 = $createParagraphNode();
757 const paragraph1 = $createParagraphNode();
758 const textNodes: Array<LexicalNode> = [];
760 for (let i = 0; i < 6; i++) {
761 const node = $createTextNode(String(i)).toggleUnmergeable();
762 textNodes.push(node);
763 textNodeKeys.push(node.getKey());
764 textTransformCount[i] = 0;
767 root.append(paragraph0, paragraph1);
768 paragraph0.append(...textNodes.slice(0, 3));
769 paragraph1.append(...textNodes.slice(3));
772 removeTransform = editor.registerNodeTransform(TextNode, (node) => {
773 textTransformCount[Number(node.__text)]++;
781 it('on remove', async () => {
782 await editor.update(() => {
783 const textNode1 = $getNodeByKey(textNodeKeys[1])!;
786 expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1]);
789 it('on replace', async () => {
790 await editor.update(() => {
791 const textNode1 = $getNodeByKey(textNodeKeys[1])!;
792 const textNode4 = $getNodeByKey(textNodeKeys[4])!;
793 textNode4.replace(textNode1);
795 expect(textTransformCount).toEqual([2, 2, 2, 2, 1, 2]);
798 it('on insertBefore', async () => {
799 await editor.update(() => {
800 const textNode1 = $getNodeByKey(textNodeKeys[1])!;
801 const textNode4 = $getNodeByKey(textNodeKeys[4])!;
802 textNode4.insertBefore(textNode1);
804 expect(textTransformCount).toEqual([2, 2, 2, 2, 2, 1]);
807 it('on insertAfter', async () => {
808 await editor.update(() => {
809 const textNode1 = $getNodeByKey(textNodeKeys[1])!;
810 const textNode4 = $getNodeByKey(textNodeKeys[4])!;
811 textNode4.insertAfter(textNode1);
813 expect(textTransformCount).toEqual([2, 2, 2, 1, 2, 2]);
816 it('on splitText', async () => {
817 await editor.update(() => {
818 const textNode1 = $getNodeByKey(textNodeKeys[1]) as TextNode;
819 textNode1.setTextContent('67');
820 textNode1.splitText(1);
821 textTransformCount.push(0, 0);
823 expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1, 1, 1]);
826 it('on append', async () => {
827 await editor.update(() => {
828 const paragraph1 = $getRoot().getFirstChild() as ParagraphNode;
829 paragraph1.append($createTextNode('6').toggleUnmergeable());
830 textTransformCount.push(0);
832 expect(textTransformCount).toEqual([1, 1, 2, 1, 1, 1, 1]);
836 it('Detects infinite recursivity on transforms', async () => {
837 const errorListener = jest.fn();
840 const boldListener = editor.registerNodeTransform(TextNode, (node) => {
841 node.toggleFormat('bold');
844 expect(errorListener).toHaveBeenCalledTimes(0);
846 await editor.update(() => {
847 const root = $getRoot();
848 const paragraph = $createParagraphNode();
849 root.append(paragraph);
850 paragraph.append($createTextNode('foo'));
853 expect(errorListener).toHaveBeenCalledTimes(1);
857 it('Should be able to update an editor state without a root element', () => {
858 const element = document.createElement('div');
859 element.setAttribute('contenteditable', 'true');
860 setContainerChild(element);
862 editor = createTestEditor();
864 editor.update(() => {
865 const root = $getRoot();
866 const paragraph = $createParagraphNode();
867 const text = $createTextNode('This works!');
868 root.append(paragraph);
869 paragraph.append(text);
872 expect(container.innerHTML).toBe('<div contenteditable="true"></div>');
874 editor.setRootElement(element);
876 expect(container.innerHTML).toBe(
877 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">This works!</span></p></div>',
881 it('Should be able to recover from an update error', async () => {
882 const errorListener = jest.fn();
884 editor.update(() => {
885 const root = $getRoot();
887 if (root.getFirstChild() === null) {
888 const paragraph = $createParagraphNode();
889 const text = $createTextNode('This works!');
890 root.append(paragraph);
891 paragraph.append(text);
895 // Wait for update to complete
896 await Promise.resolve().then();
898 expect(container.innerHTML).toBe(
899 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">This works!</span></p></div>',
901 expect(errorListener).toHaveBeenCalledTimes(0);
903 editor.update(() => {
904 const root = $getRoot();
906 .getFirstChild<ElementNode>()!
907 .getFirstChild<ElementNode>()!
908 .getFirstChild<TextNode>()!
909 .setTextContent('Foo');
912 expect(errorListener).toHaveBeenCalledTimes(1);
913 expect(container.innerHTML).toBe(
914 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">This works!</span></p></div>',
918 it('Should be able to handle a change in root element', async () => {
919 const rootListener = jest.fn();
920 const updateListener = jest.fn();
922 let editorInstance = createTestEditor();
923 editorInstance.registerRootListener(rootListener);
924 editorInstance.registerUpdateListener(updateListener);
926 let edContainer: HTMLElement = document.createElement('div');
927 edContainer.setAttribute('contenteditable', 'true');
928 setContainerChild(edContainer);
929 editorInstance.setRootElement(edContainer);
931 function runUpdate(changeElement: boolean) {
932 editorInstance.update(() => {
933 const root = $getRoot();
934 const firstChild = root.getFirstChild() as ParagraphNode | null;
935 const text = changeElement ? 'Change successful' : 'Not changed';
937 if (firstChild === null) {
938 const paragraph = $createParagraphNode();
939 const textNode = $createTextNode(text);
940 paragraph.append(textNode);
941 root.append(paragraph);
943 const textNode = firstChild.getFirstChild() as TextNode;
944 textNode.setTextContent(text);
949 setContainerChild(edContainer);
950 editorInstance.setRootElement(edContainer);
952 editorInstance.commitUpdates();
954 expect(container.innerHTML).toBe(
955 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">Not changed</span></p></div>',
958 edContainer = document.createElement('span');
959 edContainer.setAttribute('contenteditable', 'true');
961 editorInstance.setRootElement(edContainer);
962 setContainerChild(edContainer);
963 editorInstance.commitUpdates();
965 expect(rootListener).toHaveBeenCalledTimes(3);
966 expect(updateListener).toHaveBeenCalledTimes(3);
967 expect(container.innerHTML).toBe(
968 '<span contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">Change successful</span></p></span>',
972 for (const editable of [true, false]) {
973 it(`Retains pendingEditor while rootNode is not set (${
974 editable ? 'editable' : 'non-editable'
976 const JSON_EDITOR_STATE =
977 '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"123","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
979 const contentEditable = editor.getRootElement();
980 editor.setEditable(editable);
981 editor.setRootElement(null);
982 const editorState = editor.parseEditorState(JSON_EDITOR_STATE);
983 editor.setEditorState(editorState);
984 editor.update(() => {
987 editor.setRootElement(contentEditable);
988 expect(JSON.stringify(editor.getEditorState().toJSON())).toBe(
994 describe('parseEditorState()', () => {
995 let originalText: TextNode;
996 let parsedParagraph: ParagraphNode;
997 let parsedRoot: RootNode;
998 let parsedText: TextNode;
999 let paragraphKey: string;
1000 let textKey: string;
1001 let parsedEditorState: EditorState;
1003 it('exportJSON API - parses parsed JSON', async () => {
1004 await update(() => {
1005 const paragraph = $createParagraphNode();
1006 originalText = $createTextNode('Hello world');
1007 originalText.select(6, 11);
1008 paragraph.append(originalText);
1009 $getRoot().append(paragraph);
1011 const stringifiedEditorState = JSON.stringify(editor.getEditorState());
1012 const parsedEditorStateFromObject = editor.parseEditorState(
1013 JSON.parse(stringifiedEditorState),
1015 parsedEditorStateFromObject.read(() => {
1016 const root = $getRoot();
1017 expect(root.getTextContent()).toMatch(/Hello world/);
1021 describe('range selection', () => {
1022 beforeEach(async () => {
1025 await update(() => {
1026 const paragraph = $createParagraphNode();
1027 originalText = $createTextNode('Hello world');
1028 originalText.select(6, 11);
1029 paragraph.append(originalText);
1030 $getRoot().append(paragraph);
1032 const stringifiedEditorState = JSON.stringify(
1033 editor.getEditorState().toJSON(),
1035 parsedEditorState = editor.parseEditorState(stringifiedEditorState);
1036 parsedEditorState.read(() => {
1037 parsedRoot = $getRoot();
1038 parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode;
1039 paragraphKey = parsedParagraph.getKey();
1040 parsedText = parsedParagraph.getFirstChild() as TextNode;
1041 textKey = parsedText.getKey();
1045 it('Parses the nodes of a stringified editor state', async () => {
1046 expect(parsedRoot).toEqual({
1049 __first: paragraphKey,
1053 __last: paragraphKey,
1061 expect(parsedParagraph).toEqual({
1066 __key: paragraphKey,
1075 __type: 'paragraph',
1077 expect(parsedText).toEqual({
1083 __parent: paragraphKey,
1086 __text: 'Hello world',
1091 it('Parses the text content of the editor state', async () => {
1092 expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1095 expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1101 describe('node selection', () => {
1102 beforeEach(async () => {
1105 await update(() => {
1106 const paragraph = $createParagraphNode();
1107 originalText = $createTextNode('Hello world');
1108 const selection = $createNodeSelection();
1109 selection.add(originalText.getKey());
1110 $setSelection(selection);
1111 paragraph.append(originalText);
1112 $getRoot().append(paragraph);
1114 const stringifiedEditorState = JSON.stringify(
1115 editor.getEditorState().toJSON(),
1117 parsedEditorState = editor.parseEditorState(stringifiedEditorState);
1118 parsedEditorState.read(() => {
1119 parsedRoot = $getRoot();
1120 parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode;
1121 paragraphKey = parsedParagraph.getKey();
1122 parsedText = parsedParagraph.getFirstChild() as TextNode;
1123 textKey = parsedText.getKey();
1127 it('Parses the nodes of a stringified editor state', async () => {
1128 expect(parsedRoot).toEqual({
1131 __first: paragraphKey,
1135 __last: paragraphKey,
1143 expect(parsedParagraph).toEqual({
1148 __key: paragraphKey,
1157 __type: 'paragraph',
1159 expect(parsedText).toEqual({
1165 __parent: paragraphKey,
1168 __text: 'Hello world',
1173 it('Parses the text content of the editor state', async () => {
1174 expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1177 expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1184 describe('$parseSerializedNode()', () => {
1185 it('parses serialized nodes', async () => {
1186 const expectedTextContent = 'Hello world\n\nHello world';
1187 let actualTextContent: string;
1189 await update(() => {
1192 const paragraph = $createParagraphNode();
1193 paragraph.append($createTextNode('Hello world'));
1194 root.append(paragraph);
1196 const stringifiedEditorState = JSON.stringify(editor.getEditorState());
1197 const parsedEditorStateJson = JSON.parse(stringifiedEditorState);
1198 const rootJson = parsedEditorStateJson.root;
1199 await update(() => {
1200 const children = rootJson.children.map($parseSerializedNode);
1202 root.append(...children);
1203 actualTextContent = root.getTextContent();
1205 expect(actualTextContent!).toEqual(expectedTextContent);
1209 describe('Node children', () => {
1210 beforeEach(async () => {
1216 async function reset() {
1219 await update(() => {
1220 const root = $getRoot();
1221 const paragraph = $createParagraphNode();
1222 root.append(paragraph);
1226 it('moves node to different tree branches', async () => {
1227 function $createElementNodeWithText(text: string) {
1228 const elementNode = $createTestElementNode();
1229 const textNode = $createTextNode(text);
1230 elementNode.append(textNode);
1232 return [elementNode, textNode];
1235 let paragraphNodeKey: string;
1236 let elementNode1Key: string;
1237 let textNode1Key: string;
1238 let elementNode2Key: string;
1239 let textNode2Key: string;
1241 await update(() => {
1242 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1243 paragraphNodeKey = paragraph.getKey();
1245 const [elementNode1, textNode1] = $createElementNodeWithText('A');
1246 elementNode1Key = elementNode1.getKey();
1247 textNode1Key = textNode1.getKey();
1249 const [elementNode2, textNode2] = $createElementNodeWithText('B');
1250 elementNode2Key = elementNode2.getKey();
1251 textNode2Key = textNode2.getKey();
1253 paragraph.append(elementNode1, elementNode2);
1256 await update(() => {
1257 const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1258 const elementNode2 = $getNodeByKey(elementNode2Key) as TextNode;
1259 elementNode1.append(elementNode2);
1269 for (let i = 0; i < keys.length; i++) {
1270 expect(editor._editorState._nodeMap.has(keys[i])).toBe(true);
1271 expect(editor._keyToDOMMap.has(keys[i])).toBe(true);
1274 expect(editor._editorState._nodeMap.size).toBe(keys.length + 1); // + root
1275 expect(editor._keyToDOMMap.size).toBe(keys.length + 1); // + root
1276 expect(container.innerHTML).toBe(
1277 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><div><span data-lexical-text="true">A</span><div><span data-lexical-text="true">B</span></div></div></p></div>',
1281 it('moves node to different tree branches (inverse)', async () => {
1282 function $createElementNodeWithText(text: string) {
1283 const elementNode = $createTestElementNode();
1284 const textNode = $createTextNode(text);
1285 elementNode.append(textNode);
1290 let elementNode1Key: string;
1291 let elementNode2Key: string;
1293 await update(() => {
1294 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1296 const elementNode1 = $createElementNodeWithText('A');
1297 elementNode1Key = elementNode1.getKey();
1299 const elementNode2 = $createElementNodeWithText('B');
1300 elementNode2Key = elementNode2.getKey();
1302 paragraph.append(elementNode1, elementNode2);
1305 await update(() => {
1306 const elementNode1 = $getNodeByKey(elementNode1Key) as TextNode;
1307 const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1308 elementNode2.append(elementNode1);
1311 expect(container.innerHTML).toBe(
1312 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><div><span data-lexical-text="true">B</span><div><span data-lexical-text="true">A</span></div></div></p></div>',
1316 it('moves node to different tree branches (node appended twice in two different branches)', async () => {
1317 function $createElementNodeWithText(text: string) {
1318 const elementNode = $createTestElementNode();
1319 const textNode = $createTextNode(text);
1320 elementNode.append(textNode);
1325 let elementNode1Key: string;
1326 let elementNode2Key: string;
1327 let elementNode3Key: string;
1329 await update(() => {
1330 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1332 const elementNode1 = $createElementNodeWithText('A');
1333 elementNode1Key = elementNode1.getKey();
1335 const elementNode2 = $createElementNodeWithText('B');
1336 elementNode2Key = elementNode2.getKey();
1338 const elementNode3 = $createElementNodeWithText('C');
1339 elementNode3Key = elementNode3.getKey();
1341 paragraph.append(elementNode1, elementNode2, elementNode3);
1344 await update(() => {
1345 const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1346 const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1347 const elementNode3 = $getNodeByKey(elementNode3Key) as TextNode;
1348 elementNode2.append(elementNode3);
1349 elementNode1.append(elementNode3);
1352 expect(container.innerHTML).toBe(
1353 '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><div><span data-lexical-text="true">A</span><div><span data-lexical-text="true">C</span></div></div><div><span data-lexical-text="true">B</span></div></p></div>',
1358 it('can subscribe and unsubscribe from commands and the callback is fired', () => {
1361 const commandListener = jest.fn();
1362 const command = createCommand('TEST_COMMAND');
1363 const payload = 'testPayload';
1364 const removeCommandListener = editor.registerCommand(
1367 COMMAND_PRIORITY_EDITOR,
1369 editor.dispatchCommand(command, payload);
1370 editor.dispatchCommand(command, payload);
1371 editor.dispatchCommand(command, payload);
1373 expect(commandListener).toHaveBeenCalledTimes(3);
1374 expect(commandListener).toHaveBeenCalledWith(payload, editor);
1376 removeCommandListener();
1378 editor.dispatchCommand(command, payload);
1379 editor.dispatchCommand(command, payload);
1380 editor.dispatchCommand(command, payload);
1382 expect(commandListener).toHaveBeenCalledTimes(3);
1383 expect(commandListener).toHaveBeenCalledWith(payload, editor);
1386 it('removes the command from the command map when no listener are attached', () => {
1389 const commandListener = jest.fn();
1390 const commandListenerTwo = jest.fn();
1391 const command = createCommand('TEST_COMMAND');
1392 const removeCommandListener = editor.registerCommand(
1395 COMMAND_PRIORITY_EDITOR,
1397 const removeCommandListenerTwo = editor.registerCommand(
1400 COMMAND_PRIORITY_EDITOR,
1403 expect(editor._commands).toEqual(
1408 new Set([commandListener, commandListenerTwo]),
1418 removeCommandListener();
1420 expect(editor._commands).toEqual(
1425 new Set([commandListenerTwo]),
1435 removeCommandListenerTwo();
1437 expect(editor._commands).toEqual(new Map());
1440 it('can register transforms before updates', async () => {
1443 const emptyTransform = () => {
1447 const removeTextTransform = editor.registerNodeTransform(
1451 const removeParagraphTransform = editor.registerNodeTransform(
1456 await editor.update(() => {
1457 const root = $getRoot();
1458 const paragraph = $createParagraphNode();
1459 root.append(paragraph);
1462 removeTextTransform();
1463 removeParagraphTransform();
1466 it('textcontent listener', async () => {
1469 const fn = jest.fn();
1470 editor.update(() => {
1471 const root = $getRoot();
1472 const paragraph = $createParagraphNode();
1473 const textNode = $createTextNode('foo');
1474 root.append(paragraph);
1475 paragraph.append(textNode);
1477 editor.registerTextContentListener((text) => {
1481 await editor.update(() => {
1482 const root = $getRoot();
1483 const child = root.getLastDescendant()!;
1484 child.insertAfter($createTextNode('bar'));
1487 expect(fn).toHaveBeenCalledTimes(1);
1488 expect(fn).toHaveBeenCalledWith('foobar');
1490 await editor.update(() => {
1491 const root = $getRoot();
1492 const child = root.getLastDescendant()!;
1493 child.insertAfter($createLineBreakNode());
1496 expect(fn).toHaveBeenCalledTimes(2);
1497 expect(fn).toHaveBeenCalledWith('foobar\n');
1499 await editor.update(() => {
1500 const root = $getRoot();
1502 const paragraph = $createParagraphNode();
1503 const paragraph2 = $createParagraphNode();
1504 root.append(paragraph);
1505 paragraph.append($createTextNode('bar'));
1506 paragraph2.append($createTextNode('yar'));
1507 paragraph.insertAfter(paragraph2);
1510 expect(fn).toHaveBeenCalledTimes(3);
1511 expect(fn).toHaveBeenCalledWith('bar\n\nyar');
1513 await editor.update(() => {
1514 const root = $getRoot();
1515 const paragraph = $createParagraphNode();
1516 const paragraph2 = $createParagraphNode();
1517 root.getLastChild()!.insertAfter(paragraph);
1518 paragraph.append($createTextNode('bar2'));
1519 paragraph2.append($createTextNode('yar2'));
1520 paragraph.insertAfter(paragraph2);
1523 expect(fn).toHaveBeenCalledTimes(4);
1524 expect(fn).toHaveBeenCalledWith('bar\n\nyar\n\nbar2\n\nyar2');
1527 it('mutation listener', async () => {
1530 const paragraphNodeMutations = jest.fn();
1531 const textNodeMutations = jest.fn();
1532 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1533 skipInitialization: false,
1535 editor.registerMutationListener(TextNode, textNodeMutations, {
1536 skipInitialization: false,
1538 const paragraphKeys: string[] = [];
1539 const textNodeKeys: string[] = [];
1541 // No await intentional (batch with next)
1542 editor.update(() => {
1543 const root = $getRoot();
1544 const paragraph = $createParagraphNode();
1545 const textNode = $createTextNode('foo');
1546 root.append(paragraph);
1547 paragraph.append(textNode);
1548 paragraphKeys.push(paragraph.getKey());
1549 textNodeKeys.push(textNode.getKey());
1552 await editor.update(() => {
1553 const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
1554 const textNode2 = $createTextNode('bar').toggleFormat('bold');
1555 const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1556 textNode.insertAfter(textNode2);
1557 textNode2.insertAfter(textNode3);
1558 textNodeKeys.push(textNode2.getKey());
1559 textNodeKeys.push(textNode3.getKey());
1562 await editor.update(() => {
1566 await editor.update(() => {
1567 const root = $getRoot();
1568 const paragraph = $createParagraphNode();
1570 paragraphKeys.push(paragraph.getKey());
1572 // Created and deleted in the same update (not attached to node)
1573 textNodeKeys.push($createTextNode('zzz').getKey());
1574 root.append(paragraph);
1577 expect(paragraphNodeMutations.mock.calls.length).toBe(3);
1578 expect(textNodeMutations.mock.calls.length).toBe(2);
1580 const [paragraphMutation1, paragraphMutation2, paragraphMutation3] =
1581 paragraphNodeMutations.mock.calls;
1582 const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
1584 expect(paragraphMutation1[0].size).toBe(1);
1585 expect(paragraphMutation1[0].get(paragraphKeys[0])).toBe('created');
1586 expect(paragraphMutation1[0].size).toBe(1);
1587 expect(paragraphMutation2[0].get(paragraphKeys[0])).toBe('destroyed');
1588 expect(paragraphMutation3[0].size).toBe(1);
1589 expect(paragraphMutation3[0].get(paragraphKeys[1])).toBe('created');
1590 expect(textNodeMutation1[0].size).toBe(3);
1591 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1592 expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
1593 expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
1594 expect(textNodeMutation2[0].size).toBe(3);
1595 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1596 expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
1597 expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
1599 it('mutation listener on newly initialized editor', async () => {
1600 editor = createEditor();
1601 const textNodeMutations = jest.fn();
1602 editor.registerMutationListener(TextNode, textNodeMutations, {
1603 skipInitialization: false,
1605 expect(textNodeMutations.mock.calls.length).toBe(0);
1607 it('mutation listener with setEditorState', async () => {
1610 await editor.update(() => {
1611 $getRoot().append($createParagraphNode());
1614 const initialEditorState = editor.getEditorState();
1615 const textNodeMutations = jest.fn();
1616 editor.registerMutationListener(TextNode, textNodeMutations, {
1617 skipInitialization: false,
1619 const textNodeKeys: string[] = [];
1621 await editor.update(() => {
1622 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1623 const textNode1 = $createTextNode('foo');
1624 paragraph.append(textNode1);
1625 textNodeKeys.push(textNode1.getKey());
1628 const fooEditorState = editor.getEditorState();
1630 await editor.setEditorState(initialEditorState);
1631 // This line should have no effect on the mutation listeners
1632 const parsedFooEditorState = editor.parseEditorState(
1633 JSON.stringify(fooEditorState),
1636 await editor.update(() => {
1637 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1638 const textNode2 = $createTextNode('bar').toggleFormat('bold');
1639 const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1640 paragraph.append(textNode2, textNode3);
1641 textNodeKeys.push(textNode2.getKey(), textNode3.getKey());
1644 await editor.setEditorState(parsedFooEditorState);
1646 expect(textNodeMutations.mock.calls.length).toBe(4);
1653 ] = textNodeMutations.mock.calls;
1655 expect(textNodeMutation1[0].size).toBe(1);
1656 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1657 expect(textNodeMutation2[0].size).toBe(1);
1658 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1659 expect(textNodeMutation3[0].size).toBe(2);
1660 expect(textNodeMutation3[0].get(textNodeKeys[1])).toBe('created');
1661 expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('created');
1662 expect(textNodeMutation4[0].size).toBe(3); // +1 newly generated key by parseEditorState
1663 expect(textNodeMutation4[0].get(textNodeKeys[1])).toBe('destroyed');
1664 expect(textNodeMutation4[0].get(textNodeKeys[2])).toBe('destroyed');
1667 it('mutation listener set for original node should work with the replaced node', async () => {
1669 function TestBase() {
1670 const edContainer = document.createElement('div');
1671 edContainer.contentEditable = 'true';
1673 editor = useLexicalEditor(edContainer, undefined, [
1677 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1678 withKlass: TestTextNode,
1685 setContainerChild(TestBase());
1687 const textNodeMutations = jest.fn();
1688 const textNodeMutationsB = jest.fn();
1689 editor.registerMutationListener(TextNode, textNodeMutations, {
1690 skipInitialization: false,
1692 const textNodeKeys: string[] = [];
1694 // No await intentional (batch with next)
1695 editor.update(() => {
1696 const root = $getRoot();
1697 const paragraph = $createParagraphNode();
1698 const textNode = $createTextNode('foo');
1699 root.append(paragraph);
1700 paragraph.append(textNode);
1701 textNodeKeys.push(textNode.getKey());
1704 await editor.update(() => {
1705 const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
1706 const textNode2 = $createTextNode('bar').toggleFormat('bold');
1707 const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1708 textNode.insertAfter(textNode2);
1709 textNode2.insertAfter(textNode3);
1710 textNodeKeys.push(textNode2.getKey());
1711 textNodeKeys.push(textNode3.getKey());
1714 editor.registerMutationListener(TextNode, textNodeMutationsB, {
1715 skipInitialization: false,
1718 await editor.update(() => {
1722 await editor.update(() => {
1723 const root = $getRoot();
1724 const paragraph = $createParagraphNode();
1726 // Created and deleted in the same update (not attached to node)
1727 textNodeKeys.push($createTextNode('zzz').getKey());
1728 root.append(paragraph);
1731 expect(textNodeMutations.mock.calls.length).toBe(2);
1732 expect(textNodeMutationsB.mock.calls.length).toBe(2);
1734 const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
1736 expect(textNodeMutation1[0].size).toBe(3);
1737 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1738 expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
1739 expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
1740 expect([...textNodeMutation1[1].updateTags]).toEqual([]);
1741 expect(textNodeMutation2[0].size).toBe(3);
1742 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1743 expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
1744 expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
1745 expect([...textNodeMutation2[1].updateTags]).toEqual([]);
1747 const [textNodeMutationB1, textNodeMutationB2] =
1748 textNodeMutationsB.mock.calls;
1750 expect(textNodeMutationB1[0].size).toBe(3);
1751 expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
1752 expect(textNodeMutationB1[0].get(textNodeKeys[1])).toBe('created');
1753 expect(textNodeMutationB1[0].get(textNodeKeys[2])).toBe('created');
1754 expect([...textNodeMutationB1[1].updateTags]).toEqual([
1755 'registerMutationListener',
1757 expect(textNodeMutationB2[0].size).toBe(3);
1758 expect(textNodeMutationB2[0].get(textNodeKeys[0])).toBe('destroyed');
1759 expect(textNodeMutationB2[0].get(textNodeKeys[1])).toBe('destroyed');
1760 expect(textNodeMutationB2[0].get(textNodeKeys[2])).toBe('destroyed');
1761 expect([...textNodeMutationB2[1].updateTags]).toEqual([]);
1764 it('mutation listener should work with the replaced node', async () => {
1766 function TestBase() {
1767 const edContainer = document.createElement('div');
1768 edContainer.contentEditable = 'true';
1770 editor = useLexicalEditor(edContainer, undefined, [
1774 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1775 withKlass: TestTextNode,
1782 setContainerChild(TestBase());
1784 const textNodeMutations = jest.fn();
1785 const textNodeMutationsB = jest.fn();
1786 editor.registerMutationListener(TestTextNode, textNodeMutations, {
1787 skipInitialization: false,
1789 const textNodeKeys: string[] = [];
1791 await editor.update(() => {
1792 const root = $getRoot();
1793 const paragraph = $createParagraphNode();
1794 const textNode = $createTextNode('foo');
1795 root.append(paragraph);
1796 paragraph.append(textNode);
1797 textNodeKeys.push(textNode.getKey());
1800 editor.registerMutationListener(TestTextNode, textNodeMutationsB, {
1801 skipInitialization: false,
1804 expect(textNodeMutations.mock.calls.length).toBe(1);
1806 const [textNodeMutation1] = textNodeMutations.mock.calls;
1808 expect(textNodeMutation1[0].size).toBe(1);
1809 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1810 expect([...textNodeMutation1[1].updateTags]).toEqual([]);
1812 const [textNodeMutationB1] = textNodeMutationsB.mock.calls;
1814 expect(textNodeMutationB1[0].size).toBe(1);
1815 expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
1816 expect([...textNodeMutationB1[1].updateTags]).toEqual([
1817 'registerMutationListener',
1821 it('mutation listeners does not trigger when other node types are mutated', async () => {
1824 const paragraphNodeMutations = jest.fn();
1825 const textNodeMutations = jest.fn();
1826 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1827 skipInitialization: false,
1829 editor.registerMutationListener(TextNode, textNodeMutations, {
1830 skipInitialization: false,
1833 await editor.update(() => {
1834 $getRoot().append($createParagraphNode());
1837 expect(paragraphNodeMutations.mock.calls.length).toBe(1);
1838 expect(textNodeMutations.mock.calls.length).toBe(0);
1841 it('mutation listeners with normalization', async () => {
1844 const textNodeMutations = jest.fn();
1845 editor.registerMutationListener(TextNode, textNodeMutations, {
1846 skipInitialization: false,
1848 const textNodeKeys: string[] = [];
1850 await editor.update(() => {
1851 const root = $getRoot();
1852 const paragraph = $createParagraphNode();
1853 const textNode1 = $createTextNode('foo');
1854 const textNode2 = $createTextNode('bar');
1856 textNodeKeys.push(textNode1.getKey(), textNode2.getKey());
1857 root.append(paragraph);
1858 paragraph.append(textNode1, textNode2);
1861 await editor.update(() => {
1862 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1863 const textNode3 = $createTextNode('xyz').toggleFormat('bold');
1864 paragraph.append(textNode3);
1865 textNodeKeys.push(textNode3.getKey());
1868 await editor.update(() => {
1869 const textNode3 = $getNodeByKey(textNodeKeys[2]) as TextNode;
1870 textNode3.toggleFormat('bold'); // Normalize with foobar
1873 expect(textNodeMutations.mock.calls.length).toBe(3);
1875 const [textNodeMutation1, textNodeMutation2, textNodeMutation3] =
1876 textNodeMutations.mock.calls;
1878 expect(textNodeMutation1[0].size).toBe(1);
1879 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1880 expect(textNodeMutation2[0].size).toBe(2);
1881 expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('created');
1882 expect(textNodeMutation3[0].size).toBe(2);
1883 expect(textNodeMutation3[0].get(textNodeKeys[0])).toBe('updated');
1884 expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('destroyed');
1887 it('mutation "update" listener', async () => {
1890 const paragraphNodeMutations = jest.fn();
1891 const textNodeMutations = jest.fn();
1893 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1894 skipInitialization: false,
1896 editor.registerMutationListener(TextNode, textNodeMutations, {
1897 skipInitialization: false,
1900 const paragraphNodeKeys: string[] = [];
1901 const textNodeKeys: string[] = [];
1903 await editor.update(() => {
1904 const root = $getRoot();
1905 const paragraph = $createParagraphNode();
1906 const textNode1 = $createTextNode('foo');
1907 textNodeKeys.push(textNode1.getKey());
1908 paragraphNodeKeys.push(paragraph.getKey());
1909 root.append(paragraph);
1910 paragraph.append(textNode1);
1913 expect(paragraphNodeMutations.mock.calls.length).toBe(1);
1915 const [paragraphNodeMutation1] = paragraphNodeMutations.mock.calls;
1916 expect(textNodeMutations.mock.calls.length).toBe(1);
1918 const [textNodeMutation1] = textNodeMutations.mock.calls;
1920 expect(textNodeMutation1[0].size).toBe(1);
1921 expect(paragraphNodeMutation1[0].size).toBe(1);
1923 // Change first text node's content.
1924 await editor.update(() => {
1925 const textNode1 = $getNodeByKey(textNodeKeys[0]) as TextNode;
1926 textNode1.setTextContent('Test'); // Normalize with foobar
1929 // Append text node to paragraph.
1930 await editor.update(() => {
1931 const paragraphNode1 = $getNodeByKey(
1932 paragraphNodeKeys[0],
1934 const textNode1 = $createTextNode('foo');
1935 paragraphNode1.append(textNode1);
1938 expect(textNodeMutations.mock.calls.length).toBe(3);
1940 const textNodeMutation2 = textNodeMutations.mock.calls[1];
1942 // Show TextNode was updated when text content changed.
1943 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('updated');
1944 expect(paragraphNodeMutations.mock.calls.length).toBe(2);
1946 const paragraphNodeMutation2 = paragraphNodeMutations.mock.calls[1];
1948 // Show ParagraphNode was updated when new text node was appended.
1949 expect(paragraphNodeMutation2[0].get(paragraphNodeKeys[0])).toBe('updated');
1951 let tableCellKey: string;
1952 let tableRowKey: string;
1954 const tableCellMutations = jest.fn();
1955 const tableRowMutations = jest.fn();
1957 editor.registerMutationListener(TableCellNode, tableCellMutations, {
1958 skipInitialization: false,
1960 editor.registerMutationListener(TableRowNode, tableRowMutations, {
1961 skipInitialization: false,
1965 await editor.update(() => {
1966 const root = $getRoot();
1967 const tableCell = $createTableCellNode(0);
1968 const tableRow = $createTableRowNode();
1969 const table = $createTableNode();
1971 tableRow.append(tableCell);
1972 table.append(tableRow);
1975 tableRowKey = tableRow.getKey();
1976 tableCellKey = tableCell.getKey();
1978 // Add New Table Cell To Row
1980 await editor.update(() => {
1981 const tableRow = $getNodeByKey(tableRowKey) as TableRowNode;
1982 const tableCell = $createTableCellNode(0);
1983 tableRow.append(tableCell);
1986 // Update Table Cell
1987 await editor.update(() => {
1988 const tableCell = $getNodeByKey(tableCellKey) as TableCellNode;
1989 tableCell.toggleHeaderStyle(1);
1992 expect(tableCellMutations.mock.calls.length).toBe(3);
1993 const tableCellMutation3 = tableCellMutations.mock.calls[2];
1995 // Show table cell is updated when header value changes.
1996 expect(tableCellMutation3[0].get(tableCellKey!)).toBe('updated');
1997 expect(tableRowMutations.mock.calls.length).toBe(2);
1999 const tableRowMutation2 = tableRowMutations.mock.calls[1];
2001 // Show row is updated when a new child is added.
2002 expect(tableRowMutation2[0].get(tableRowKey!)).toBe('updated');
2005 it('editable listener', () => {
2008 const editableFn = jest.fn();
2009 editor.registerEditableListener(editableFn);
2011 expect(editor.isEditable()).toBe(true);
2013 editor.setEditable(false);
2015 expect(editor.isEditable()).toBe(false);
2017 editor.setEditable(true);
2019 expect(editableFn.mock.calls).toEqual([[false], [true]]);
2022 it('does not add new listeners while triggering existing', async () => {
2023 const updateListener = jest.fn();
2024 const mutationListener = jest.fn();
2025 const nodeTransformListener = jest.fn();
2026 const textContentListener = jest.fn();
2027 const editableListener = jest.fn();
2028 const commandListener = jest.fn();
2029 const TEST_COMMAND = createCommand('TEST_COMMAND');
2033 editor.registerUpdateListener(() => {
2036 editor.registerUpdateListener(() => {
2041 editor.registerMutationListener(
2045 editor.registerMutationListener(
2050 {skipInitialization: true},
2053 {skipInitialization: false},
2056 editor.registerNodeTransform(ParagraphNode, () => {
2057 nodeTransformListener();
2058 editor.registerNodeTransform(ParagraphNode, () => {
2059 nodeTransformListener();
2063 editor.registerEditableListener(() => {
2065 editor.registerEditableListener(() => {
2070 editor.registerTextContentListener(() => {
2071 textContentListener();
2072 editor.registerTextContentListener(() => {
2073 textContentListener();
2077 editor.registerCommand(
2081 editor.registerCommand(
2084 COMMAND_PRIORITY_LOW,
2088 COMMAND_PRIORITY_LOW,
2091 await update(() => {
2093 $createParagraphNode().append($createTextNode('Hello world')),
2097 editor.dispatchCommand(TEST_COMMAND, false);
2099 editor.setEditable(false);
2101 expect(updateListener).toHaveBeenCalledTimes(1);
2102 expect(editableListener).toHaveBeenCalledTimes(1);
2103 expect(commandListener).toHaveBeenCalledTimes(1);
2104 expect(textContentListener).toHaveBeenCalledTimes(1);
2105 expect(nodeTransformListener).toHaveBeenCalledTimes(1);
2106 expect(mutationListener).toHaveBeenCalledTimes(1);
2109 it('calls mutation listener with initial state', async () => {
2110 // TODO add tests for node replacement
2111 const mutationListenerA = jest.fn();
2112 const mutationListenerB = jest.fn();
2113 const mutationListenerC = jest.fn();
2116 editor.registerMutationListener(TextNode, mutationListenerA, {
2117 skipInitialization: false,
2119 expect(mutationListenerA).toHaveBeenCalledTimes(0);
2121 await update(() => {
2123 $createParagraphNode().append($createTextNode('Hello world')),
2127 function asymmetricMatcher<T>(asymmetricMatch: (x: T) => boolean) {
2128 return {asymmetricMatch};
2131 expect(mutationListenerA).toHaveBeenCalledTimes(1);
2132 expect(mutationListenerA).toHaveBeenLastCalledWith(
2134 expect.objectContaining({
2135 updateTags: asymmetricMatcher(
2136 (s: Set<string>) => !s.has('registerMutationListener'),
2140 editor.registerMutationListener(TextNode, mutationListenerB, {
2141 skipInitialization: false,
2143 editor.registerMutationListener(TextNode, mutationListenerC, {
2144 skipInitialization: true,
2146 expect(mutationListenerA).toHaveBeenCalledTimes(1);
2147 expect(mutationListenerB).toHaveBeenCalledTimes(1);
2148 expect(mutationListenerB).toHaveBeenLastCalledWith(
2150 expect.objectContaining({
2151 updateTags: asymmetricMatcher((s: Set<string>) =>
2152 s.has('registerMutationListener'),
2156 expect(mutationListenerC).toHaveBeenCalledTimes(0);
2157 await update(() => {
2159 $createParagraphNode().append($createTextNode('Another update!')),
2162 expect(mutationListenerA).toHaveBeenCalledTimes(2);
2163 expect(mutationListenerB).toHaveBeenCalledTimes(2);
2164 expect(mutationListenerC).toHaveBeenCalledTimes(1);
2165 [mutationListenerA, mutationListenerB, mutationListenerC].forEach((fn) => {
2166 expect(fn).toHaveBeenLastCalledWith(
2168 expect.objectContaining({
2169 updateTags: asymmetricMatcher(
2170 (s: Set<string>) => !s.has('registerMutationListener'),
2177 it('can use discrete for synchronous updates', () => {
2179 const onUpdate = jest.fn();
2180 editor.registerUpdateListener(onUpdate);
2184 $createParagraphNode().append($createTextNode('Sync update')),
2192 const textContent = editor
2194 .read(() => $getRoot().getTextContent());
2195 expect(textContent).toBe('Sync update');
2196 expect(onUpdate).toHaveBeenCalledTimes(1);
2199 it('can use discrete after a non-discrete update to flush the entire queue', () => {
2200 const headless = createTestHeadlessEditor();
2201 const onUpdate = jest.fn();
2202 headless.registerUpdateListener(onUpdate);
2203 headless.update(() => {
2205 $createParagraphNode().append($createTextNode('Async update')),
2211 $createParagraphNode().append($createTextNode('Sync update')),
2219 const textContent = headless
2221 .read(() => $getRoot().getTextContent());
2222 expect(textContent).toBe('Async update\n\nSync update');
2223 expect(onUpdate).toHaveBeenCalledTimes(1);
2226 it('can use discrete after a non-discrete setEditorState to flush the entire queue', () => {
2231 $createParagraphNode().append($createTextNode('Async update')),
2239 const headless = createTestHeadlessEditor(editor.getEditorState());
2243 $createParagraphNode().append($createTextNode('Sync update')),
2250 const textContent = headless
2252 .read(() => $getRoot().getTextContent());
2253 expect(textContent).toBe('Async update\n\nSync update');
2256 it('can use discrete in a nested update to flush the entire queue', () => {
2258 const onUpdate = jest.fn();
2259 editor.registerUpdateListener(onUpdate);
2260 editor.update(() => {
2262 $createParagraphNode().append($createTextNode('Async update')),
2267 $createParagraphNode().append($createTextNode('Sync update')),
2276 const textContent = editor
2278 .read(() => $getRoot().getTextContent());
2279 expect(textContent).toBe('Async update\n\nSync update');
2280 expect(onUpdate).toHaveBeenCalledTimes(1);
2283 it('does not include linebreak into inline elements', async () => {
2286 await editor.update(() => {
2288 $createParagraphNode().append(
2289 $createTextNode('Hello'),
2290 $createTestInlineElementNode(),
2295 expect(container.firstElementChild?.innerHTML).toBe(
2296 '<p><span data-lexical-text="true">Hello</span><a></a></p>',
2300 it('reconciles state without root element', () => {
2301 editor = createTestEditor({});
2302 const state = editor.parseEditorState(
2303 `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`,
2305 editor.setEditorState(state);
2306 expect(editor._editorState).toBe(state);
2307 expect(editor._pendingEditorState).toBe(null);
2310 describe('node replacement', () => {
2311 it('should work correctly', async () => {
2312 const onError = jest.fn();
2314 const newEditor = createTestEditor({
2319 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2325 bold: 'editor-text-bold',
2326 italic: 'editor-text-italic',
2327 underline: 'editor-text-underline',
2332 newEditor.setRootElement(container);
2334 await newEditor.update(() => {
2335 const root = $getRoot();
2336 const paragraph = $createParagraphNode();
2337 const text = $createTextNode('123');
2338 root.append(paragraph);
2339 paragraph.append(text);
2340 expect(text instanceof TestTextNode).toBe(true);
2341 expect(text.getTextContent()).toBe('123');
2344 expect(onError).not.toHaveBeenCalled();
2347 it('should fail if node keys are re-used', async () => {
2348 const onError = jest.fn();
2350 const newEditor = createTestEditor({
2355 with: (node: TextNode) =>
2356 new TestTextNode(node.getTextContent(), node.getKey()),
2362 bold: 'editor-text-bold',
2363 italic: 'editor-text-italic',
2364 underline: 'editor-text-underline',
2369 newEditor.setRootElement(container);
2371 await newEditor.update(() => {
2373 $createTextNode('123');
2374 expect(false).toBe('unreachable');
2377 newEditor.commitUpdates();
2379 expect(onError).toHaveBeenCalledWith(
2380 expect.objectContaining({
2381 message: expect.stringMatching(/TestTextNode.*re-use key.*TextNode/),
2386 it('node transform to the nodes specified by "replace" should not be applied to the nodes specified by "with" when "withKlass" is not specified', async () => {
2387 const onError = jest.fn();
2389 const newEditor = createTestEditor({
2394 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2400 bold: 'editor-text-bold',
2401 italic: 'editor-text-italic',
2402 underline: 'editor-text-underline',
2407 newEditor.setRootElement(container);
2409 const mockTransform = jest.fn();
2410 const removeTransform = newEditor.registerNodeTransform(
2415 await newEditor.update(() => {
2416 const root = $getRoot();
2417 const paragraph = $createParagraphNode();
2418 const text = $createTextNode('123');
2419 root.append(paragraph);
2420 paragraph.append(text);
2421 expect(text instanceof TestTextNode).toBe(true);
2422 expect(text.getTextContent()).toBe('123');
2425 await newEditor.getEditorState().read(() => {
2426 expect(mockTransform).toHaveBeenCalledTimes(0);
2429 expect(onError).not.toHaveBeenCalled();
2433 it('node transform to the nodes specified by "replace" should be applied also to the nodes specified by "with" when "withKlass" is specified', async () => {
2434 const onError = jest.fn();
2436 const newEditor = createTestEditor({
2441 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2442 withKlass: TestTextNode,
2448 bold: 'editor-text-bold',
2449 italic: 'editor-text-italic',
2450 underline: 'editor-text-underline',
2455 newEditor.setRootElement(container);
2457 const mockTransform = jest.fn();
2458 const removeTransform = newEditor.registerNodeTransform(
2463 await newEditor.update(() => {
2464 const root = $getRoot();
2465 const paragraph = $createParagraphNode();
2466 const text = $createTextNode('123');
2467 root.append(paragraph);
2468 paragraph.append(text);
2469 expect(text instanceof TestTextNode).toBe(true);
2470 expect(text.getTextContent()).toBe('123');
2473 await newEditor.getEditorState().read(() => {
2474 expect(mockTransform).toHaveBeenCalledTimes(1);
2477 expect(onError).not.toHaveBeenCalled();
2482 it('recovers from reconciler failure and trigger proper prev editor state', async () => {
2483 const updateListener = jest.fn();
2484 const textListener = jest.fn();
2485 const onError = jest.fn();
2486 const updateError = new Error('Failed updateDOM');
2490 editor.registerUpdateListener(updateListener);
2491 editor.registerTextContentListener(textListener);
2493 await update(() => {
2495 $createParagraphNode().append($createTextNode('Hello')),
2499 // Cause reconciler error in update dom, so that it attempts to fallback by
2500 // reseting editor and rerendering whole content
2501 jest.spyOn(ParagraphNode.prototype, 'updateDOM').mockImplementation(() => {
2505 const editorState = editor.getEditorState();
2507 editor.registerUpdateListener(updateListener);
2509 await update(() => {
2511 $createParagraphNode().append($createTextNode('world')),
2515 expect(onError).toBeCalledWith(updateError);
2516 expect(textListener).toBeCalledWith('Hello\n\nworld');
2517 expect(updateListener.mock.lastCall[0].prevEditorState).toBe(editorState);
2520 it('should call importDOM methods only once', async () => {
2521 jest.spyOn(ParagraphNode, 'importDOM');
2523 class CustomParagraphNode extends ParagraphNode {
2525 return 'custom-paragraph';
2528 static clone(node: CustomParagraphNode) {
2529 return new CustomParagraphNode(node.__key);
2532 static importJSON() {
2533 return new CustomParagraphNode();
2537 return {...super.exportJSON(), type: 'custom-paragraph'};
2541 createTestEditor({nodes: [CustomParagraphNode]});
2543 expect(ParagraphNode.importDOM).toHaveBeenCalledTimes(1);
2546 it('root element count is always positive', () => {
2547 const newEditor1 = createTestEditor();
2548 const newEditor2 = createTestEditor();
2550 const container1 = document.createElement('div');
2551 const container2 = document.createElement('div');
2553 newEditor1.setRootElement(container1);
2554 newEditor1.setRootElement(null);
2556 newEditor1.setRootElement(container1);
2557 newEditor2.setRootElement(container2);
2558 newEditor1.setRootElement(null);
2559 newEditor2.setRootElement(null);
2562 describe('html config', () => {
2563 it('should override export output function', async () => {
2564 const onError = jest.fn();
2566 const newEditor = createTestEditor({
2572 invariant($isTextNode(target));
2575 element: target.hasFormat('bold')
2576 ? document.createElement('bor')
2577 : document.createElement('foo'),
2586 newEditor.setRootElement(container);
2588 newEditor.update(() => {
2589 const root = $getRoot();
2590 const paragraph = $createParagraphNode();
2591 const text = $createTextNode();
2592 root.append(paragraph);
2593 paragraph.append(text);
2595 const selection = $createNodeSelection();
2596 selection.add(text.getKey());
2598 const htmlFoo = $generateHtmlFromNodes(newEditor, selection);
2599 expect(htmlFoo).toBe('<foo></foo>');
2601 text.toggleFormat('bold');
2603 const htmlBold = $generateHtmlFromNodes(newEditor, selection);
2604 expect(htmlBold).toBe('<bor></bor>');
2607 expect(onError).not.toHaveBeenCalled();
2610 it('should override import conversion function', async () => {
2611 const onError = jest.fn();
2613 const newEditor = createTestEditor({
2617 conversion: () => ({node: $createTextNode('yolo')}),
2625 newEditor.setRootElement(container);
2627 newEditor.update(() => {
2628 const html = '<figure></figure>';
2630 const parser = new DOMParser();
2631 const dom = parser.parseFromString(html, 'text/html');
2632 const node = $generateNodesFromDOM(newEditor, dom)[0];
2634 expect(node).toEqual({
2637 __key: node.getKey(),
2648 expect(onError).not.toHaveBeenCalled();