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,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"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,
1051 __last: paragraphKey,
1059 expect(parsedParagraph).toEqual({
1065 __key: paragraphKey,
1073 __type: 'paragraph',
1075 expect(parsedText).toEqual({
1081 __parent: paragraphKey,
1084 __text: 'Hello world',
1089 it('Parses the text content of the editor state', async () => {
1090 expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1093 expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1099 describe('node selection', () => {
1100 beforeEach(async () => {
1103 await update(() => {
1104 const paragraph = $createParagraphNode();
1105 originalText = $createTextNode('Hello world');
1106 const selection = $createNodeSelection();
1107 selection.add(originalText.getKey());
1108 $setSelection(selection);
1109 paragraph.append(originalText);
1110 $getRoot().append(paragraph);
1112 const stringifiedEditorState = JSON.stringify(
1113 editor.getEditorState().toJSON(),
1115 parsedEditorState = editor.parseEditorState(stringifiedEditorState);
1116 parsedEditorState.read(() => {
1117 parsedRoot = $getRoot();
1118 parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode;
1119 paragraphKey = parsedParagraph.getKey();
1120 parsedText = parsedParagraph.getFirstChild() as TextNode;
1121 textKey = parsedText.getKey();
1125 it('Parses the nodes of a stringified editor state', async () => {
1126 expect(parsedRoot).toEqual({
1129 __first: paragraphKey,
1131 __last: paragraphKey,
1139 expect(parsedParagraph).toEqual({
1145 __key: paragraphKey,
1153 __type: 'paragraph',
1155 expect(parsedText).toEqual({
1161 __parent: paragraphKey,
1164 __text: 'Hello world',
1169 it('Parses the text content of the editor state', async () => {
1170 expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1173 expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1180 describe('$parseSerializedNode()', () => {
1181 it('parses serialized nodes', async () => {
1182 const expectedTextContent = 'Hello world\n\nHello world';
1183 let actualTextContent: string;
1185 await update(() => {
1188 const paragraph = $createParagraphNode();
1189 paragraph.append($createTextNode('Hello world'));
1190 root.append(paragraph);
1192 const stringifiedEditorState = JSON.stringify(editor.getEditorState());
1193 const parsedEditorStateJson = JSON.parse(stringifiedEditorState);
1194 const rootJson = parsedEditorStateJson.root;
1195 await update(() => {
1196 const children = rootJson.children.map($parseSerializedNode);
1198 root.append(...children);
1199 actualTextContent = root.getTextContent();
1201 expect(actualTextContent!).toEqual(expectedTextContent);
1205 describe('Node children', () => {
1206 beforeEach(async () => {
1212 async function reset() {
1215 await update(() => {
1216 const root = $getRoot();
1217 const paragraph = $createParagraphNode();
1218 root.append(paragraph);
1222 it('moves node to different tree branches', async () => {
1223 function $createElementNodeWithText(text: string) {
1224 const elementNode = $createTestElementNode();
1225 const textNode = $createTextNode(text);
1226 elementNode.append(textNode);
1228 return [elementNode, textNode];
1231 let paragraphNodeKey: string;
1232 let elementNode1Key: string;
1233 let textNode1Key: string;
1234 let elementNode2Key: string;
1235 let textNode2Key: string;
1237 await update(() => {
1238 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1239 paragraphNodeKey = paragraph.getKey();
1241 const [elementNode1, textNode1] = $createElementNodeWithText('A');
1242 elementNode1Key = elementNode1.getKey();
1243 textNode1Key = textNode1.getKey();
1245 const [elementNode2, textNode2] = $createElementNodeWithText('B');
1246 elementNode2Key = elementNode2.getKey();
1247 textNode2Key = textNode2.getKey();
1249 paragraph.append(elementNode1, elementNode2);
1252 await update(() => {
1253 const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1254 const elementNode2 = $getNodeByKey(elementNode2Key) as TextNode;
1255 elementNode1.append(elementNode2);
1265 for (let i = 0; i < keys.length; i++) {
1266 expect(editor._editorState._nodeMap.has(keys[i])).toBe(true);
1267 expect(editor._keyToDOMMap.has(keys[i])).toBe(true);
1270 expect(editor._editorState._nodeMap.size).toBe(keys.length + 1); // + root
1271 expect(editor._keyToDOMMap.size).toBe(keys.length + 1); // + root
1272 expect(container.innerHTML).toBe(
1273 '<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>',
1277 it('moves node to different tree branches (inverse)', async () => {
1278 function $createElementNodeWithText(text: string) {
1279 const elementNode = $createTestElementNode();
1280 const textNode = $createTextNode(text);
1281 elementNode.append(textNode);
1286 let elementNode1Key: string;
1287 let elementNode2Key: string;
1289 await update(() => {
1290 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1292 const elementNode1 = $createElementNodeWithText('A');
1293 elementNode1Key = elementNode1.getKey();
1295 const elementNode2 = $createElementNodeWithText('B');
1296 elementNode2Key = elementNode2.getKey();
1298 paragraph.append(elementNode1, elementNode2);
1301 await update(() => {
1302 const elementNode1 = $getNodeByKey(elementNode1Key) as TextNode;
1303 const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1304 elementNode2.append(elementNode1);
1307 expect(container.innerHTML).toBe(
1308 '<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>',
1312 it('moves node to different tree branches (node appended twice in two different branches)', async () => {
1313 function $createElementNodeWithText(text: string) {
1314 const elementNode = $createTestElementNode();
1315 const textNode = $createTextNode(text);
1316 elementNode.append(textNode);
1321 let elementNode1Key: string;
1322 let elementNode2Key: string;
1323 let elementNode3Key: string;
1325 await update(() => {
1326 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1328 const elementNode1 = $createElementNodeWithText('A');
1329 elementNode1Key = elementNode1.getKey();
1331 const elementNode2 = $createElementNodeWithText('B');
1332 elementNode2Key = elementNode2.getKey();
1334 const elementNode3 = $createElementNodeWithText('C');
1335 elementNode3Key = elementNode3.getKey();
1337 paragraph.append(elementNode1, elementNode2, elementNode3);
1340 await update(() => {
1341 const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1342 const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1343 const elementNode3 = $getNodeByKey(elementNode3Key) as TextNode;
1344 elementNode2.append(elementNode3);
1345 elementNode1.append(elementNode3);
1348 expect(container.innerHTML).toBe(
1349 '<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>',
1354 it('can subscribe and unsubscribe from commands and the callback is fired', () => {
1357 const commandListener = jest.fn();
1358 const command = createCommand('TEST_COMMAND');
1359 const payload = 'testPayload';
1360 const removeCommandListener = editor.registerCommand(
1363 COMMAND_PRIORITY_EDITOR,
1365 editor.dispatchCommand(command, payload);
1366 editor.dispatchCommand(command, payload);
1367 editor.dispatchCommand(command, payload);
1369 expect(commandListener).toHaveBeenCalledTimes(3);
1370 expect(commandListener).toHaveBeenCalledWith(payload, editor);
1372 removeCommandListener();
1374 editor.dispatchCommand(command, payload);
1375 editor.dispatchCommand(command, payload);
1376 editor.dispatchCommand(command, payload);
1378 expect(commandListener).toHaveBeenCalledTimes(3);
1379 expect(commandListener).toHaveBeenCalledWith(payload, editor);
1382 it('removes the command from the command map when no listener are attached', () => {
1385 const commandListener = jest.fn();
1386 const commandListenerTwo = jest.fn();
1387 const command = createCommand('TEST_COMMAND');
1388 const removeCommandListener = editor.registerCommand(
1391 COMMAND_PRIORITY_EDITOR,
1393 const removeCommandListenerTwo = editor.registerCommand(
1396 COMMAND_PRIORITY_EDITOR,
1399 expect(editor._commands).toEqual(
1404 new Set([commandListener, commandListenerTwo]),
1414 removeCommandListener();
1416 expect(editor._commands).toEqual(
1421 new Set([commandListenerTwo]),
1431 removeCommandListenerTwo();
1433 expect(editor._commands).toEqual(new Map());
1436 it('can register transforms before updates', async () => {
1439 const emptyTransform = () => {
1443 const removeTextTransform = editor.registerNodeTransform(
1447 const removeParagraphTransform = editor.registerNodeTransform(
1452 await editor.update(() => {
1453 const root = $getRoot();
1454 const paragraph = $createParagraphNode();
1455 root.append(paragraph);
1458 removeTextTransform();
1459 removeParagraphTransform();
1462 it('textcontent listener', async () => {
1465 const fn = jest.fn();
1466 editor.update(() => {
1467 const root = $getRoot();
1468 const paragraph = $createParagraphNode();
1469 const textNode = $createTextNode('foo');
1470 root.append(paragraph);
1471 paragraph.append(textNode);
1473 editor.registerTextContentListener((text) => {
1477 await editor.update(() => {
1478 const root = $getRoot();
1479 const child = root.getLastDescendant()!;
1480 child.insertAfter($createTextNode('bar'));
1483 expect(fn).toHaveBeenCalledTimes(1);
1484 expect(fn).toHaveBeenCalledWith('foobar');
1486 await editor.update(() => {
1487 const root = $getRoot();
1488 const child = root.getLastDescendant()!;
1489 child.insertAfter($createLineBreakNode());
1492 expect(fn).toHaveBeenCalledTimes(2);
1493 expect(fn).toHaveBeenCalledWith('foobar\n');
1495 await editor.update(() => {
1496 const root = $getRoot();
1498 const paragraph = $createParagraphNode();
1499 const paragraph2 = $createParagraphNode();
1500 root.append(paragraph);
1501 paragraph.append($createTextNode('bar'));
1502 paragraph2.append($createTextNode('yar'));
1503 paragraph.insertAfter(paragraph2);
1506 expect(fn).toHaveBeenCalledTimes(3);
1507 expect(fn).toHaveBeenCalledWith('bar\n\nyar');
1509 await editor.update(() => {
1510 const root = $getRoot();
1511 const paragraph = $createParagraphNode();
1512 const paragraph2 = $createParagraphNode();
1513 root.getLastChild()!.insertAfter(paragraph);
1514 paragraph.append($createTextNode('bar2'));
1515 paragraph2.append($createTextNode('yar2'));
1516 paragraph.insertAfter(paragraph2);
1519 expect(fn).toHaveBeenCalledTimes(4);
1520 expect(fn).toHaveBeenCalledWith('bar\n\nyar\n\nbar2\n\nyar2');
1523 it('mutation listener', async () => {
1526 const paragraphNodeMutations = jest.fn();
1527 const textNodeMutations = jest.fn();
1528 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1529 skipInitialization: false,
1531 editor.registerMutationListener(TextNode, textNodeMutations, {
1532 skipInitialization: false,
1534 const paragraphKeys: string[] = [];
1535 const textNodeKeys: string[] = [];
1537 // No await intentional (batch with next)
1538 editor.update(() => {
1539 const root = $getRoot();
1540 const paragraph = $createParagraphNode();
1541 const textNode = $createTextNode('foo');
1542 root.append(paragraph);
1543 paragraph.append(textNode);
1544 paragraphKeys.push(paragraph.getKey());
1545 textNodeKeys.push(textNode.getKey());
1548 await editor.update(() => {
1549 const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
1550 const textNode2 = $createTextNode('bar').toggleFormat('bold');
1551 const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1552 textNode.insertAfter(textNode2);
1553 textNode2.insertAfter(textNode3);
1554 textNodeKeys.push(textNode2.getKey());
1555 textNodeKeys.push(textNode3.getKey());
1558 await editor.update(() => {
1562 await editor.update(() => {
1563 const root = $getRoot();
1564 const paragraph = $createParagraphNode();
1566 paragraphKeys.push(paragraph.getKey());
1568 // Created and deleted in the same update (not attached to node)
1569 textNodeKeys.push($createTextNode('zzz').getKey());
1570 root.append(paragraph);
1573 expect(paragraphNodeMutations.mock.calls.length).toBe(3);
1574 expect(textNodeMutations.mock.calls.length).toBe(2);
1576 const [paragraphMutation1, paragraphMutation2, paragraphMutation3] =
1577 paragraphNodeMutations.mock.calls;
1578 const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
1580 expect(paragraphMutation1[0].size).toBe(1);
1581 expect(paragraphMutation1[0].get(paragraphKeys[0])).toBe('created');
1582 expect(paragraphMutation1[0].size).toBe(1);
1583 expect(paragraphMutation2[0].get(paragraphKeys[0])).toBe('destroyed');
1584 expect(paragraphMutation3[0].size).toBe(1);
1585 expect(paragraphMutation3[0].get(paragraphKeys[1])).toBe('created');
1586 expect(textNodeMutation1[0].size).toBe(3);
1587 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1588 expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
1589 expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
1590 expect(textNodeMutation2[0].size).toBe(3);
1591 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1592 expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
1593 expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
1595 it('mutation listener on newly initialized editor', async () => {
1596 editor = createEditor();
1597 const textNodeMutations = jest.fn();
1598 editor.registerMutationListener(TextNode, textNodeMutations, {
1599 skipInitialization: false,
1601 expect(textNodeMutations.mock.calls.length).toBe(0);
1603 it('mutation listener with setEditorState', async () => {
1606 await editor.update(() => {
1607 $getRoot().append($createParagraphNode());
1610 const initialEditorState = editor.getEditorState();
1611 const textNodeMutations = jest.fn();
1612 editor.registerMutationListener(TextNode, textNodeMutations, {
1613 skipInitialization: false,
1615 const textNodeKeys: string[] = [];
1617 await editor.update(() => {
1618 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1619 const textNode1 = $createTextNode('foo');
1620 paragraph.append(textNode1);
1621 textNodeKeys.push(textNode1.getKey());
1624 const fooEditorState = editor.getEditorState();
1626 await editor.setEditorState(initialEditorState);
1627 // This line should have no effect on the mutation listeners
1628 const parsedFooEditorState = editor.parseEditorState(
1629 JSON.stringify(fooEditorState),
1632 await editor.update(() => {
1633 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1634 const textNode2 = $createTextNode('bar').toggleFormat('bold');
1635 const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1636 paragraph.append(textNode2, textNode3);
1637 textNodeKeys.push(textNode2.getKey(), textNode3.getKey());
1640 await editor.setEditorState(parsedFooEditorState);
1642 expect(textNodeMutations.mock.calls.length).toBe(4);
1649 ] = textNodeMutations.mock.calls;
1651 expect(textNodeMutation1[0].size).toBe(1);
1652 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1653 expect(textNodeMutation2[0].size).toBe(1);
1654 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1655 expect(textNodeMutation3[0].size).toBe(2);
1656 expect(textNodeMutation3[0].get(textNodeKeys[1])).toBe('created');
1657 expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('created');
1658 expect(textNodeMutation4[0].size).toBe(3); // +1 newly generated key by parseEditorState
1659 expect(textNodeMutation4[0].get(textNodeKeys[1])).toBe('destroyed');
1660 expect(textNodeMutation4[0].get(textNodeKeys[2])).toBe('destroyed');
1663 it('mutation listener set for original node should work with the replaced node', async () => {
1665 function TestBase() {
1666 const edContainer = document.createElement('div');
1667 edContainer.contentEditable = 'true';
1669 editor = useLexicalEditor(edContainer, undefined, [
1673 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1674 withKlass: TestTextNode,
1681 setContainerChild(TestBase());
1683 const textNodeMutations = jest.fn();
1684 const textNodeMutationsB = jest.fn();
1685 editor.registerMutationListener(TextNode, textNodeMutations, {
1686 skipInitialization: false,
1688 const textNodeKeys: string[] = [];
1690 // No await intentional (batch with next)
1691 editor.update(() => {
1692 const root = $getRoot();
1693 const paragraph = $createParagraphNode();
1694 const textNode = $createTextNode('foo');
1695 root.append(paragraph);
1696 paragraph.append(textNode);
1697 textNodeKeys.push(textNode.getKey());
1700 await editor.update(() => {
1701 const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
1702 const textNode2 = $createTextNode('bar').toggleFormat('bold');
1703 const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1704 textNode.insertAfter(textNode2);
1705 textNode2.insertAfter(textNode3);
1706 textNodeKeys.push(textNode2.getKey());
1707 textNodeKeys.push(textNode3.getKey());
1710 editor.registerMutationListener(TextNode, textNodeMutationsB, {
1711 skipInitialization: false,
1714 await editor.update(() => {
1718 await editor.update(() => {
1719 const root = $getRoot();
1720 const paragraph = $createParagraphNode();
1722 // Created and deleted in the same update (not attached to node)
1723 textNodeKeys.push($createTextNode('zzz').getKey());
1724 root.append(paragraph);
1727 expect(textNodeMutations.mock.calls.length).toBe(2);
1728 expect(textNodeMutationsB.mock.calls.length).toBe(2);
1730 const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
1732 expect(textNodeMutation1[0].size).toBe(3);
1733 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1734 expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
1735 expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
1736 expect([...textNodeMutation1[1].updateTags]).toEqual([]);
1737 expect(textNodeMutation2[0].size).toBe(3);
1738 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1739 expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
1740 expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
1741 expect([...textNodeMutation2[1].updateTags]).toEqual([]);
1743 const [textNodeMutationB1, textNodeMutationB2] =
1744 textNodeMutationsB.mock.calls;
1746 expect(textNodeMutationB1[0].size).toBe(3);
1747 expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
1748 expect(textNodeMutationB1[0].get(textNodeKeys[1])).toBe('created');
1749 expect(textNodeMutationB1[0].get(textNodeKeys[2])).toBe('created');
1750 expect([...textNodeMutationB1[1].updateTags]).toEqual([
1751 'registerMutationListener',
1753 expect(textNodeMutationB2[0].size).toBe(3);
1754 expect(textNodeMutationB2[0].get(textNodeKeys[0])).toBe('destroyed');
1755 expect(textNodeMutationB2[0].get(textNodeKeys[1])).toBe('destroyed');
1756 expect(textNodeMutationB2[0].get(textNodeKeys[2])).toBe('destroyed');
1757 expect([...textNodeMutationB2[1].updateTags]).toEqual([]);
1760 it('mutation listener should work with the replaced node', async () => {
1762 function TestBase() {
1763 const edContainer = document.createElement('div');
1764 edContainer.contentEditable = 'true';
1766 editor = useLexicalEditor(edContainer, undefined, [
1770 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1771 withKlass: TestTextNode,
1778 setContainerChild(TestBase());
1780 const textNodeMutations = jest.fn();
1781 const textNodeMutationsB = jest.fn();
1782 editor.registerMutationListener(TestTextNode, textNodeMutations, {
1783 skipInitialization: false,
1785 const textNodeKeys: string[] = [];
1787 await editor.update(() => {
1788 const root = $getRoot();
1789 const paragraph = $createParagraphNode();
1790 const textNode = $createTextNode('foo');
1791 root.append(paragraph);
1792 paragraph.append(textNode);
1793 textNodeKeys.push(textNode.getKey());
1796 editor.registerMutationListener(TestTextNode, textNodeMutationsB, {
1797 skipInitialization: false,
1800 expect(textNodeMutations.mock.calls.length).toBe(1);
1802 const [textNodeMutation1] = textNodeMutations.mock.calls;
1804 expect(textNodeMutation1[0].size).toBe(1);
1805 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1806 expect([...textNodeMutation1[1].updateTags]).toEqual([]);
1808 const [textNodeMutationB1] = textNodeMutationsB.mock.calls;
1810 expect(textNodeMutationB1[0].size).toBe(1);
1811 expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
1812 expect([...textNodeMutationB1[1].updateTags]).toEqual([
1813 'registerMutationListener',
1817 it('mutation listeners does not trigger when other node types are mutated', async () => {
1820 const paragraphNodeMutations = jest.fn();
1821 const textNodeMutations = jest.fn();
1822 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1823 skipInitialization: false,
1825 editor.registerMutationListener(TextNode, textNodeMutations, {
1826 skipInitialization: false,
1829 await editor.update(() => {
1830 $getRoot().append($createParagraphNode());
1833 expect(paragraphNodeMutations.mock.calls.length).toBe(1);
1834 expect(textNodeMutations.mock.calls.length).toBe(0);
1837 it('mutation listeners with normalization', async () => {
1840 const textNodeMutations = jest.fn();
1841 editor.registerMutationListener(TextNode, textNodeMutations, {
1842 skipInitialization: false,
1844 const textNodeKeys: string[] = [];
1846 await editor.update(() => {
1847 const root = $getRoot();
1848 const paragraph = $createParagraphNode();
1849 const textNode1 = $createTextNode('foo');
1850 const textNode2 = $createTextNode('bar');
1852 textNodeKeys.push(textNode1.getKey(), textNode2.getKey());
1853 root.append(paragraph);
1854 paragraph.append(textNode1, textNode2);
1857 await editor.update(() => {
1858 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1859 const textNode3 = $createTextNode('xyz').toggleFormat('bold');
1860 paragraph.append(textNode3);
1861 textNodeKeys.push(textNode3.getKey());
1864 await editor.update(() => {
1865 const textNode3 = $getNodeByKey(textNodeKeys[2]) as TextNode;
1866 textNode3.toggleFormat('bold'); // Normalize with foobar
1869 expect(textNodeMutations.mock.calls.length).toBe(3);
1871 const [textNodeMutation1, textNodeMutation2, textNodeMutation3] =
1872 textNodeMutations.mock.calls;
1874 expect(textNodeMutation1[0].size).toBe(1);
1875 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1876 expect(textNodeMutation2[0].size).toBe(2);
1877 expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('created');
1878 expect(textNodeMutation3[0].size).toBe(2);
1879 expect(textNodeMutation3[0].get(textNodeKeys[0])).toBe('updated');
1880 expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('destroyed');
1883 it('mutation "update" listener', async () => {
1886 const paragraphNodeMutations = jest.fn();
1887 const textNodeMutations = jest.fn();
1889 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1890 skipInitialization: false,
1892 editor.registerMutationListener(TextNode, textNodeMutations, {
1893 skipInitialization: false,
1896 const paragraphNodeKeys: string[] = [];
1897 const textNodeKeys: string[] = [];
1899 await editor.update(() => {
1900 const root = $getRoot();
1901 const paragraph = $createParagraphNode();
1902 const textNode1 = $createTextNode('foo');
1903 textNodeKeys.push(textNode1.getKey());
1904 paragraphNodeKeys.push(paragraph.getKey());
1905 root.append(paragraph);
1906 paragraph.append(textNode1);
1909 expect(paragraphNodeMutations.mock.calls.length).toBe(1);
1911 const [paragraphNodeMutation1] = paragraphNodeMutations.mock.calls;
1912 expect(textNodeMutations.mock.calls.length).toBe(1);
1914 const [textNodeMutation1] = textNodeMutations.mock.calls;
1916 expect(textNodeMutation1[0].size).toBe(1);
1917 expect(paragraphNodeMutation1[0].size).toBe(1);
1919 // Change first text node's content.
1920 await editor.update(() => {
1921 const textNode1 = $getNodeByKey(textNodeKeys[0]) as TextNode;
1922 textNode1.setTextContent('Test'); // Normalize with foobar
1925 // Append text node to paragraph.
1926 await editor.update(() => {
1927 const paragraphNode1 = $getNodeByKey(
1928 paragraphNodeKeys[0],
1930 const textNode1 = $createTextNode('foo');
1931 paragraphNode1.append(textNode1);
1934 expect(textNodeMutations.mock.calls.length).toBe(3);
1936 const textNodeMutation2 = textNodeMutations.mock.calls[1];
1938 // Show TextNode was updated when text content changed.
1939 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('updated');
1940 expect(paragraphNodeMutations.mock.calls.length).toBe(2);
1942 const paragraphNodeMutation2 = paragraphNodeMutations.mock.calls[1];
1944 // Show ParagraphNode was updated when new text node was appended.
1945 expect(paragraphNodeMutation2[0].get(paragraphNodeKeys[0])).toBe('updated');
1947 let tableCellKey: string;
1948 let tableRowKey: string;
1950 const tableCellMutations = jest.fn();
1951 const tableRowMutations = jest.fn();
1953 editor.registerMutationListener(TableCellNode, tableCellMutations, {
1954 skipInitialization: false,
1956 editor.registerMutationListener(TableRowNode, tableRowMutations, {
1957 skipInitialization: false,
1961 await editor.update(() => {
1962 const root = $getRoot();
1963 const tableCell = $createTableCellNode(0);
1964 const tableRow = $createTableRowNode();
1965 const table = $createTableNode();
1967 tableRow.append(tableCell);
1968 table.append(tableRow);
1971 tableRowKey = tableRow.getKey();
1972 tableCellKey = tableCell.getKey();
1974 // Add New Table Cell To Row
1976 await editor.update(() => {
1977 const tableRow = $getNodeByKey(tableRowKey) as TableRowNode;
1978 const tableCell = $createTableCellNode(0);
1979 tableRow.append(tableCell);
1982 // Update Table Cell
1983 await editor.update(() => {
1984 const tableCell = $getNodeByKey(tableCellKey) as TableCellNode;
1985 tableCell.toggleHeaderStyle(1);
1988 expect(tableCellMutations.mock.calls.length).toBe(3);
1989 const tableCellMutation3 = tableCellMutations.mock.calls[2];
1991 // Show table cell is updated when header value changes.
1992 expect(tableCellMutation3[0].get(tableCellKey!)).toBe('updated');
1993 expect(tableRowMutations.mock.calls.length).toBe(2);
1995 const tableRowMutation2 = tableRowMutations.mock.calls[1];
1997 // Show row is updated when a new child is added.
1998 expect(tableRowMutation2[0].get(tableRowKey!)).toBe('updated');
2001 it('editable listener', () => {
2004 const editableFn = jest.fn();
2005 editor.registerEditableListener(editableFn);
2007 expect(editor.isEditable()).toBe(true);
2009 editor.setEditable(false);
2011 expect(editor.isEditable()).toBe(false);
2013 editor.setEditable(true);
2015 expect(editableFn.mock.calls).toEqual([[false], [true]]);
2018 it('does not add new listeners while triggering existing', async () => {
2019 const updateListener = jest.fn();
2020 const mutationListener = jest.fn();
2021 const nodeTransformListener = jest.fn();
2022 const textContentListener = jest.fn();
2023 const editableListener = jest.fn();
2024 const commandListener = jest.fn();
2025 const TEST_COMMAND = createCommand('TEST_COMMAND');
2029 editor.registerUpdateListener(() => {
2032 editor.registerUpdateListener(() => {
2037 editor.registerMutationListener(
2041 editor.registerMutationListener(
2046 {skipInitialization: true},
2049 {skipInitialization: false},
2052 editor.registerNodeTransform(ParagraphNode, () => {
2053 nodeTransformListener();
2054 editor.registerNodeTransform(ParagraphNode, () => {
2055 nodeTransformListener();
2059 editor.registerEditableListener(() => {
2061 editor.registerEditableListener(() => {
2066 editor.registerTextContentListener(() => {
2067 textContentListener();
2068 editor.registerTextContentListener(() => {
2069 textContentListener();
2073 editor.registerCommand(
2077 editor.registerCommand(
2080 COMMAND_PRIORITY_LOW,
2084 COMMAND_PRIORITY_LOW,
2087 await update(() => {
2089 $createParagraphNode().append($createTextNode('Hello world')),
2093 editor.dispatchCommand(TEST_COMMAND, false);
2095 editor.setEditable(false);
2097 expect(updateListener).toHaveBeenCalledTimes(1);
2098 expect(editableListener).toHaveBeenCalledTimes(1);
2099 expect(commandListener).toHaveBeenCalledTimes(1);
2100 expect(textContentListener).toHaveBeenCalledTimes(1);
2101 expect(nodeTransformListener).toHaveBeenCalledTimes(1);
2102 expect(mutationListener).toHaveBeenCalledTimes(1);
2105 it('calls mutation listener with initial state', async () => {
2106 // TODO add tests for node replacement
2107 const mutationListenerA = jest.fn();
2108 const mutationListenerB = jest.fn();
2109 const mutationListenerC = jest.fn();
2112 editor.registerMutationListener(TextNode, mutationListenerA, {
2113 skipInitialization: false,
2115 expect(mutationListenerA).toHaveBeenCalledTimes(0);
2117 await update(() => {
2119 $createParagraphNode().append($createTextNode('Hello world')),
2123 function asymmetricMatcher<T>(asymmetricMatch: (x: T) => boolean) {
2124 return {asymmetricMatch};
2127 expect(mutationListenerA).toHaveBeenCalledTimes(1);
2128 expect(mutationListenerA).toHaveBeenLastCalledWith(
2130 expect.objectContaining({
2131 updateTags: asymmetricMatcher(
2132 (s: Set<string>) => !s.has('registerMutationListener'),
2136 editor.registerMutationListener(TextNode, mutationListenerB, {
2137 skipInitialization: false,
2139 editor.registerMutationListener(TextNode, mutationListenerC, {
2140 skipInitialization: true,
2142 expect(mutationListenerA).toHaveBeenCalledTimes(1);
2143 expect(mutationListenerB).toHaveBeenCalledTimes(1);
2144 expect(mutationListenerB).toHaveBeenLastCalledWith(
2146 expect.objectContaining({
2147 updateTags: asymmetricMatcher((s: Set<string>) =>
2148 s.has('registerMutationListener'),
2152 expect(mutationListenerC).toHaveBeenCalledTimes(0);
2153 await update(() => {
2155 $createParagraphNode().append($createTextNode('Another update!')),
2158 expect(mutationListenerA).toHaveBeenCalledTimes(2);
2159 expect(mutationListenerB).toHaveBeenCalledTimes(2);
2160 expect(mutationListenerC).toHaveBeenCalledTimes(1);
2161 [mutationListenerA, mutationListenerB, mutationListenerC].forEach((fn) => {
2162 expect(fn).toHaveBeenLastCalledWith(
2164 expect.objectContaining({
2165 updateTags: asymmetricMatcher(
2166 (s: Set<string>) => !s.has('registerMutationListener'),
2173 it('can use discrete for synchronous updates', () => {
2175 const onUpdate = jest.fn();
2176 editor.registerUpdateListener(onUpdate);
2180 $createParagraphNode().append($createTextNode('Sync update')),
2188 const textContent = editor
2190 .read(() => $getRoot().getTextContent());
2191 expect(textContent).toBe('Sync update');
2192 expect(onUpdate).toHaveBeenCalledTimes(1);
2195 it('can use discrete after a non-discrete update to flush the entire queue', () => {
2196 const headless = createTestHeadlessEditor();
2197 const onUpdate = jest.fn();
2198 headless.registerUpdateListener(onUpdate);
2199 headless.update(() => {
2201 $createParagraphNode().append($createTextNode('Async update')),
2207 $createParagraphNode().append($createTextNode('Sync update')),
2215 const textContent = headless
2217 .read(() => $getRoot().getTextContent());
2218 expect(textContent).toBe('Async update\n\nSync update');
2219 expect(onUpdate).toHaveBeenCalledTimes(1);
2222 it('can use discrete after a non-discrete setEditorState to flush the entire queue', () => {
2227 $createParagraphNode().append($createTextNode('Async update')),
2235 const headless = createTestHeadlessEditor(editor.getEditorState());
2239 $createParagraphNode().append($createTextNode('Sync update')),
2246 const textContent = headless
2248 .read(() => $getRoot().getTextContent());
2249 expect(textContent).toBe('Async update\n\nSync update');
2252 it('can use discrete in a nested update to flush the entire queue', () => {
2254 const onUpdate = jest.fn();
2255 editor.registerUpdateListener(onUpdate);
2256 editor.update(() => {
2258 $createParagraphNode().append($createTextNode('Async update')),
2263 $createParagraphNode().append($createTextNode('Sync update')),
2272 const textContent = editor
2274 .read(() => $getRoot().getTextContent());
2275 expect(textContent).toBe('Async update\n\nSync update');
2276 expect(onUpdate).toHaveBeenCalledTimes(1);
2279 it('does not include linebreak into inline elements', async () => {
2282 await editor.update(() => {
2284 $createParagraphNode().append(
2285 $createTextNode('Hello'),
2286 $createTestInlineElementNode(),
2291 expect(container.firstElementChild?.innerHTML).toBe(
2292 '<p><span data-lexical-text="true">Hello</span><a></a></p>',
2296 it('reconciles state without root element', () => {
2297 editor = createTestEditor({});
2298 const state = editor.parseEditorState(
2299 `{"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}}`,
2301 editor.setEditorState(state);
2302 expect(editor._editorState).toBe(state);
2303 expect(editor._pendingEditorState).toBe(null);
2306 describe('node replacement', () => {
2307 it('should work correctly', async () => {
2308 const onError = jest.fn();
2310 const newEditor = createTestEditor({
2315 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2321 bold: 'editor-text-bold',
2322 italic: 'editor-text-italic',
2323 underline: 'editor-text-underline',
2328 newEditor.setRootElement(container);
2330 await newEditor.update(() => {
2331 const root = $getRoot();
2332 const paragraph = $createParagraphNode();
2333 const text = $createTextNode('123');
2334 root.append(paragraph);
2335 paragraph.append(text);
2336 expect(text instanceof TestTextNode).toBe(true);
2337 expect(text.getTextContent()).toBe('123');
2340 expect(onError).not.toHaveBeenCalled();
2343 it('should fail if node keys are re-used', async () => {
2344 const onError = jest.fn();
2346 const newEditor = createTestEditor({
2351 with: (node: TextNode) =>
2352 new TestTextNode(node.getTextContent(), node.getKey()),
2358 bold: 'editor-text-bold',
2359 italic: 'editor-text-italic',
2360 underline: 'editor-text-underline',
2365 newEditor.setRootElement(container);
2367 await newEditor.update(() => {
2369 $createTextNode('123');
2370 expect(false).toBe('unreachable');
2373 newEditor.commitUpdates();
2375 expect(onError).toHaveBeenCalledWith(
2376 expect.objectContaining({
2377 message: expect.stringMatching(/TestTextNode.*re-use key.*TextNode/),
2382 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 () => {
2383 const onError = jest.fn();
2385 const newEditor = createTestEditor({
2390 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2396 bold: 'editor-text-bold',
2397 italic: 'editor-text-italic',
2398 underline: 'editor-text-underline',
2403 newEditor.setRootElement(container);
2405 const mockTransform = jest.fn();
2406 const removeTransform = newEditor.registerNodeTransform(
2411 await newEditor.update(() => {
2412 const root = $getRoot();
2413 const paragraph = $createParagraphNode();
2414 const text = $createTextNode('123');
2415 root.append(paragraph);
2416 paragraph.append(text);
2417 expect(text instanceof TestTextNode).toBe(true);
2418 expect(text.getTextContent()).toBe('123');
2421 await newEditor.getEditorState().read(() => {
2422 expect(mockTransform).toHaveBeenCalledTimes(0);
2425 expect(onError).not.toHaveBeenCalled();
2429 it('node transform to the nodes specified by "replace" should be applied also to the nodes specified by "with" when "withKlass" is specified', async () => {
2430 const onError = jest.fn();
2432 const newEditor = createTestEditor({
2437 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2438 withKlass: TestTextNode,
2444 bold: 'editor-text-bold',
2445 italic: 'editor-text-italic',
2446 underline: 'editor-text-underline',
2451 newEditor.setRootElement(container);
2453 const mockTransform = jest.fn();
2454 const removeTransform = newEditor.registerNodeTransform(
2459 await newEditor.update(() => {
2460 const root = $getRoot();
2461 const paragraph = $createParagraphNode();
2462 const text = $createTextNode('123');
2463 root.append(paragraph);
2464 paragraph.append(text);
2465 expect(text instanceof TestTextNode).toBe(true);
2466 expect(text.getTextContent()).toBe('123');
2469 await newEditor.getEditorState().read(() => {
2470 expect(mockTransform).toHaveBeenCalledTimes(1);
2473 expect(onError).not.toHaveBeenCalled();
2478 it('recovers from reconciler failure and trigger proper prev editor state', async () => {
2479 const updateListener = jest.fn();
2480 const textListener = jest.fn();
2481 const onError = jest.fn();
2482 const updateError = new Error('Failed updateDOM');
2486 editor.registerUpdateListener(updateListener);
2487 editor.registerTextContentListener(textListener);
2489 await update(() => {
2491 $createParagraphNode().append($createTextNode('Hello')),
2495 // Cause reconciler error in update dom, so that it attempts to fallback by
2496 // reseting editor and rerendering whole content
2497 jest.spyOn(ParagraphNode.prototype, 'updateDOM').mockImplementation(() => {
2501 const editorState = editor.getEditorState();
2503 editor.registerUpdateListener(updateListener);
2505 await update(() => {
2507 $createParagraphNode().append($createTextNode('world')),
2511 expect(onError).toBeCalledWith(updateError);
2512 expect(textListener).toBeCalledWith('Hello\n\nworld');
2513 expect(updateListener.mock.lastCall[0].prevEditorState).toBe(editorState);
2516 it('should call importDOM methods only once', async () => {
2517 jest.spyOn(ParagraphNode, 'importDOM');
2519 class CustomParagraphNode extends ParagraphNode {
2521 return 'custom-paragraph';
2524 static clone(node: CustomParagraphNode) {
2525 return new CustomParagraphNode(node.__key);
2528 static importJSON() {
2529 return new CustomParagraphNode();
2533 return {...super.exportJSON(), type: 'custom-paragraph'};
2537 createTestEditor({nodes: [CustomParagraphNode]});
2539 expect(ParagraphNode.importDOM).toHaveBeenCalledTimes(1);
2542 it('root element count is always positive', () => {
2543 const newEditor1 = createTestEditor();
2544 const newEditor2 = createTestEditor();
2546 const container1 = document.createElement('div');
2547 const container2 = document.createElement('div');
2549 newEditor1.setRootElement(container1);
2550 newEditor1.setRootElement(null);
2552 newEditor1.setRootElement(container1);
2553 newEditor2.setRootElement(container2);
2554 newEditor1.setRootElement(null);
2555 newEditor2.setRootElement(null);
2558 describe('html config', () => {
2559 it('should override export output function', async () => {
2560 const onError = jest.fn();
2562 const newEditor = createTestEditor({
2568 invariant($isTextNode(target));
2571 element: target.hasFormat('bold')
2572 ? document.createElement('bor')
2573 : document.createElement('foo'),
2582 newEditor.setRootElement(container);
2584 newEditor.update(() => {
2585 const root = $getRoot();
2586 const paragraph = $createParagraphNode();
2587 const text = $createTextNode();
2588 root.append(paragraph);
2589 paragraph.append(text);
2591 const selection = $createNodeSelection();
2592 selection.add(text.getKey());
2594 const htmlFoo = $generateHtmlFromNodes(newEditor, selection);
2595 expect(htmlFoo).toBe('<foo></foo>');
2597 text.toggleFormat('bold');
2599 const htmlBold = $generateHtmlFromNodes(newEditor, selection);
2600 expect(htmlBold).toBe('<bor></bor>');
2603 expect(onError).not.toHaveBeenCalled();
2606 it('should override import conversion function', async () => {
2607 const onError = jest.fn();
2609 const newEditor = createTestEditor({
2613 conversion: () => ({node: $createTextNode('yolo')}),
2621 newEditor.setRootElement(container);
2623 newEditor.update(() => {
2624 const html = '<figure></figure>';
2626 const parser = new DOMParser();
2627 const dom = parser.parseFromString(html, 'text/html');
2628 const node = $generateNodesFromDOM(newEditor, dom)[0];
2630 expect(node).toEqual({
2633 __key: node.getKey(),
2644 expect(onError).not.toHaveBeenCalled();