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 $createTestDecoratorNode,
51 $createTestElementNode,
52 $createTestInlineElementNode,
54 createTestHeadlessEditor,
58 describe('LexicalEditor tests', () => {
59 let container: HTMLElement;
60 function setContainerChild(el: HTMLElement) {
61 container.innerHTML = '';
66 container = document.createElement('div');
67 document.body.appendChild(container);
71 document.body.removeChild(container);
75 jest.restoreAllMocks();
78 function useLexicalEditor(
79 rootElement: HTMLDivElement,
80 onError?: (error: Error) => void,
81 nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>,
83 const editor = createTestEditor({
85 onError: onError || jest.fn(),
88 bold: 'editor-text-bold',
89 italic: 'editor-text-italic',
90 underline: 'editor-text-underline',
94 editor.setRootElement(rootElement);
98 let editor: LexicalEditor;
100 function init(onError?: (error: Error) => void) {
101 const edContainer = document.createElement('div');
102 edContainer.setAttribute('contenteditable', 'true');
104 setContainerChild(edContainer);
105 editor = useLexicalEditor(edContainer, onError);
108 async function update(fn: () => void) {
111 return Promise.resolve().then();
114 describe('read()', () => {
115 it('Can read the editor state', async () => {
116 init(function onError(err) {
119 expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
120 expect(editor.read(() => $getEditor())).toBe(editor);
121 const onUpdate = jest.fn();
124 const root = $getRoot();
125 const paragraph = $createParagraphNode();
126 const text = $createTextNode('This works!');
127 root.append(paragraph);
128 paragraph.append(text);
132 expect(onUpdate).toHaveBeenCalledTimes(0);
133 // This read will flush pending updates
134 expect(editor.read(() => $getRoot().getTextContent())).toEqual(
137 expect(onUpdate).toHaveBeenCalledTimes(1);
138 // Check to make sure there is not an unexpected reconciliation
139 await Promise.resolve().then();
140 expect(onUpdate).toHaveBeenCalledTimes(1);
142 const rootElement = editor.getRootElement();
143 expect(rootElement).toBeDefined();
144 // The root never works for this call
145 expect($getNearestNodeFromDOMNode(rootElement!)).toBe(null);
146 const paragraphDom = rootElement!.querySelector('p');
147 expect(paragraphDom).toBeDefined();
149 $isParagraphNode($getNearestNodeFromDOMNode(paragraphDom!)),
152 $getNearestNodeFromDOMNode(paragraphDom!)!.getTextContent(),
153 ).toBe('This works!');
154 const textDom = paragraphDom!.querySelector('span');
155 expect(textDom).toBeDefined();
156 expect($isTextNode($getNearestNodeFromDOMNode(textDom!))).toBe(true);
157 expect($getNearestNodeFromDOMNode(textDom!)!.getTextContent()).toBe(
161 $getNearestNodeFromDOMNode(textDom!.firstChild!)!.getTextContent(),
162 ).toBe('This works!');
164 expect(onUpdate).toHaveBeenCalledTimes(1);
166 it('runs transforms the editor state', async () => {
167 init(function onError(err) {
170 expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
171 expect(editor.read(() => $getEditor())).toBe(editor);
172 editor.registerNodeTransform(TextNode, (node) => {
173 if (node.getTextContent() === 'This works!') {
174 node.replace($createTextNode('Transforms work!'));
177 const onUpdate = jest.fn();
180 const root = $getRoot();
181 const paragraph = $createParagraphNode();
182 const text = $createTextNode('This works!');
183 root.append(paragraph);
184 paragraph.append(text);
188 expect(onUpdate).toHaveBeenCalledTimes(0);
189 // This read will flush pending updates
190 expect(editor.read(() => $getRoot().getTextContent())).toEqual(
193 expect(editor.getRootElement()!.textContent).toEqual('Transforms work!');
194 expect(onUpdate).toHaveBeenCalledTimes(1);
195 // Check to make sure there is not an unexpected reconciliation
196 await Promise.resolve().then();
197 expect(onUpdate).toHaveBeenCalledTimes(1);
198 expect(editor.read(() => $getRoot().getTextContent())).toEqual(
202 it('can be nested in an update or read', async () => {
203 init(function onError(err) {
206 editor.update(() => {
207 const root = $getRoot();
208 const paragraph = $createParagraphNode();
209 const text = $createTextNode('This works!');
210 root.append(paragraph);
211 paragraph.append(text);
213 expect($getRoot().getTextContent()).toBe('This works!');
216 // Nesting update in read works, although it is discouraged in the documentation.
217 editor.update(() => {
218 expect($getRoot().getTextContent()).toBe('This works!');
221 // Updating after a nested read will fail as it has already been committed
224 $createParagraphNode().append(
225 $createTextNode('update-read-update'),
232 expect($getRoot().getTextContent()).toBe('This works!');
238 it('Should create an editor with an initial editor state', async () => {
239 const rootElement = document.createElement('div');
241 container.appendChild(rootElement);
243 const initialEditor = createTestEditor({
247 initialEditor.update(() => {
248 const root = $getRoot();
249 const paragraph = $createParagraphNode();
250 const text = $createTextNode('This works!');
251 root.append(paragraph);
252 paragraph.append(text);
255 initialEditor.setRootElement(rootElement);
257 // Wait for update to complete
258 await Promise.resolve().then();
260 expect(container.innerHTML).toBe(
261 '<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>',
264 const initialEditorState = initialEditor.getEditorState();
265 initialEditor.setRootElement(null);
267 expect(container.innerHTML).toBe(
268 '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"></div>',
271 editor = createTestEditor({
272 editorState: initialEditorState,
275 editor.setRootElement(rootElement);
277 expect(editor.getEditorState()).toEqual(initialEditorState);
278 expect(container.innerHTML).toBe(
279 '<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>',
283 it('Should handle nested updates in the correct sequence', async () => {
285 const onUpdate = jest.fn();
287 let log: Array<string> = [];
289 editor.registerUpdateListener(onUpdate);
290 editor.update(() => {
291 const root = $getRoot();
292 const paragraph = $createParagraphNode();
293 const text = $createTextNode('This works!');
294 root.append(paragraph);
295 paragraph.append(text);
301 // To enforce the update
302 $getRoot().markDirty();
331 // Wait for update to complete
332 await Promise.resolve().then();
334 expect(onUpdate).toHaveBeenCalledTimes(1);
335 expect(log).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1']);
341 // To enforce the update
342 $getRoot().markDirty();
350 $setCompositionKey('root');
374 // Wait for update to complete
375 await Promise.resolve().then();
377 expect(log).toEqual(['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2']);
380 editor.registerNodeTransform(TextNode, () => {
381 log.push('TextTransform A3');
384 log.push('TextTransform B3');
388 log.push('TextTransform C3');
394 // Wait for update to complete
395 await Promise.resolve().then();
397 expect(log).toEqual([
407 $getRoot().getLastDescendant()!.markDirty();
416 // Wait for update to complete
417 await Promise.resolve().then();
419 expect(log).toEqual([
428 it('nested update after selection update triggers exactly 1 update', async () => {
430 const onUpdate = jest.fn();
431 editor.registerUpdateListener(onUpdate);
432 editor.update(() => {
433 $setSelection($createRangeSelection());
434 editor.update(() => {
436 $createParagraphNode().append($createTextNode('Sync update')),
441 await Promise.resolve().then();
443 const textContent = editor
445 .read(() => $getRoot().getTextContent());
446 expect(textContent).toBe('Sync update');
447 expect(onUpdate).toHaveBeenCalledTimes(1);
450 it('update does not call onUpdate callback when no dirty nodes', () => {
453 const fn = jest.fn();
462 expect(fn).toHaveBeenCalledTimes(0);
465 it('editor.focus() callback is called', async () => {
468 await editor.update(() => {
469 const root = $getRoot();
470 root.append($createParagraphNode());
473 const fn = jest.fn();
475 await editor.focus(fn);
477 expect(fn).toHaveBeenCalledTimes(1);
480 it('Synchronously runs three transforms, two of them depend on the other', async () => {
484 const italicsListener = editor.registerNodeTransform(TextNode, (node) => {
486 node.getTextContent() === 'foo' &&
487 node.hasFormat('bold') &&
488 !node.hasFormat('italic')
490 node.toggleFormat('italic');
495 const boldListener = editor.registerNodeTransform(TextNode, (node) => {
496 if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) {
497 node.toggleFormat('bold');
502 const underlineListener = editor.registerNodeTransform(TextNode, (node) => {
504 node.getTextContent() === 'foo' &&
505 node.hasFormat('bold') &&
506 !node.hasFormat('underline')
508 node.toggleFormat('underline');
512 await editor.update(() => {
513 const root = $getRoot();
514 const paragraph = $createParagraphNode();
515 root.append(paragraph);
516 paragraph.append($createTextNode('foo'));
522 expect(container.innerHTML).toBe(
523 '<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>',
527 it('Synchronously runs three transforms, two of them depend on the other (2)', async () => {
530 // Add transform makes everything dirty the first time (let's not leverage this here)
531 const skipFirst = [true, true, true];
533 // 2. (Block transform) Add text
534 const testParagraphListener = editor.registerNodeTransform(
538 skipFirst[0] = false;
543 if (paragraph.isEmpty()) {
544 paragraph.append($createTextNode('foo'));
549 // 2. (Text transform) Add bold to text
550 const boldListener = editor.registerNodeTransform(TextNode, (node) => {
551 if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) {
552 node.toggleFormat('bold');
556 // 3. (Block transform) Add italics to bold text
557 const italicsListener = editor.registerNodeTransform(
560 const child = paragraph.getLastDescendant();
563 $isTextNode(child) &&
564 child.hasFormat('bold') &&
565 !child.hasFormat('italic')
567 child.toggleFormat('italic');
572 await editor.update(() => {
573 const root = $getRoot();
574 const paragraph = $createParagraphNode();
575 root.append(paragraph);
578 await editor.update(() => {
579 const root = $getRoot();
580 const paragraph = root.getFirstChild();
581 paragraph!.markDirty();
584 testParagraphListener();
588 expect(container.innerHTML).toBe(
589 '<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>',
593 it('Synchronously runs three transforms, two of them depend on previously merged text content', async () => {
594 const hasRun = [false, false, false];
597 // 1. [Foo] into [<empty>,Fo,o,<empty>,!,<empty>]
598 const fooListener = editor.registerNodeTransform(TextNode, (node) => {
599 if (node.getTextContent() === 'Foo' && !hasRun[0]) {
600 const [before, after] = node.splitText(2);
602 before.insertBefore($createTextNode(''));
603 after.insertAfter($createTextNode(''));
604 after.insertAfter($createTextNode('!'));
605 after.insertAfter($createTextNode(''));
611 // 2. [Foo!] into [<empty>,Fo,o!,<empty>,!,<empty>]
612 const megaFooListener = editor.registerNodeTransform(
615 const child = paragraph.getFirstChild();
618 $isTextNode(child) &&
619 child.getTextContent() === 'Foo!' &&
622 const [before, after] = child.splitText(2);
624 before.insertBefore($createTextNode(''));
625 after.insertAfter($createTextNode(''));
626 after.insertAfter($createTextNode('!'));
627 after.insertAfter($createTextNode(''));
634 // 3. [Foo!!] into formatted bold [<empty>,Fo,o!!,<empty>]
635 const boldFooListener = editor.registerNodeTransform(TextNode, (node) => {
636 if (node.getTextContent() === 'Foo!!' && !hasRun[2]) {
637 node.toggleFormat('bold');
639 const [before, after] = node.splitText(2);
640 before.insertBefore($createTextNode(''));
641 after.insertAfter($createTextNode(''));
647 await editor.update(() => {
648 const root = $getRoot();
649 const paragraph = $createParagraphNode();
651 root.append(paragraph);
652 paragraph.append($createTextNode('Foo'));
659 expect(container.innerHTML).toBe(
660 '<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>',
664 it('text transform runs when node is removed', async () => {
667 const executeTransform = jest.fn();
668 let hasBeenRemoved = false;
669 const removeListener = editor.registerNodeTransform(TextNode, (node) => {
670 if (hasBeenRemoved) {
675 await editor.update(() => {
676 const root = $getRoot();
677 const paragraph = $createParagraphNode();
678 root.append(paragraph);
680 $createTextNode('Foo').toggleUnmergeable(),
681 $createTextNode('Bar').toggleUnmergeable(),
685 await editor.update(() => {
686 $getRoot().getLastDescendant()!.remove();
687 hasBeenRemoved = true;
690 expect(executeTransform).toHaveBeenCalledTimes(1);
695 it('transforms only run on nodes that were explicitly marked as dirty', async () => {
698 let executeParagraphNodeTransform = () => {
702 let executeTextNodeTransform = () => {
706 const removeParagraphTransform = editor.registerNodeTransform(
709 executeParagraphNodeTransform();
712 const removeTextNodeTransform = editor.registerNodeTransform(
715 executeTextNodeTransform();
719 await editor.update(() => {
720 const root = $getRoot();
721 const paragraph = $createParagraphNode();
722 root.append(paragraph);
723 paragraph.append($createTextNode('Foo'));
726 await editor.update(() => {
727 const root = $getRoot();
728 const paragraph = root.getFirstChild() as ParagraphNode;
729 const textNode = paragraph.getFirstChild() as TextNode;
731 textNode.getWritable();
733 executeParagraphNodeTransform = jest.fn();
734 executeTextNodeTransform = jest.fn();
737 expect(executeParagraphNodeTransform).toHaveBeenCalledTimes(0);
738 expect(executeTextNodeTransform).toHaveBeenCalledTimes(1);
740 removeParagraphTransform();
741 removeTextNodeTransform();
744 describe('transforms on siblings', () => {
745 let textNodeKeys: string[];
746 let textTransformCount: number[];
747 let removeTransform: () => void;
749 beforeEach(async () => {
753 textTransformCount = [];
755 await editor.update(() => {
756 const root = $getRoot();
757 const paragraph0 = $createParagraphNode();
758 const paragraph1 = $createParagraphNode();
759 const textNodes: Array<LexicalNode> = [];
761 for (let i = 0; i < 6; i++) {
762 const node = $createTextNode(String(i)).toggleUnmergeable();
763 textNodes.push(node);
764 textNodeKeys.push(node.getKey());
765 textTransformCount[i] = 0;
768 root.append(paragraph0, paragraph1);
769 paragraph0.append(...textNodes.slice(0, 3));
770 paragraph1.append(...textNodes.slice(3));
773 removeTransform = editor.registerNodeTransform(TextNode, (node) => {
774 textTransformCount[Number(node.__text)]++;
782 it('on remove', async () => {
783 await editor.update(() => {
784 const textNode1 = $getNodeByKey(textNodeKeys[1])!;
787 expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1]);
790 it('on replace', async () => {
791 await editor.update(() => {
792 const textNode1 = $getNodeByKey(textNodeKeys[1])!;
793 const textNode4 = $getNodeByKey(textNodeKeys[4])!;
794 textNode4.replace(textNode1);
796 expect(textTransformCount).toEqual([2, 2, 2, 2, 1, 2]);
799 it('on insertBefore', async () => {
800 await editor.update(() => {
801 const textNode1 = $getNodeByKey(textNodeKeys[1])!;
802 const textNode4 = $getNodeByKey(textNodeKeys[4])!;
803 textNode4.insertBefore(textNode1);
805 expect(textTransformCount).toEqual([2, 2, 2, 2, 2, 1]);
808 it('on insertAfter', async () => {
809 await editor.update(() => {
810 const textNode1 = $getNodeByKey(textNodeKeys[1])!;
811 const textNode4 = $getNodeByKey(textNodeKeys[4])!;
812 textNode4.insertAfter(textNode1);
814 expect(textTransformCount).toEqual([2, 2, 2, 1, 2, 2]);
817 it('on splitText', async () => {
818 await editor.update(() => {
819 const textNode1 = $getNodeByKey(textNodeKeys[1]) as TextNode;
820 textNode1.setTextContent('67');
821 textNode1.splitText(1);
822 textTransformCount.push(0, 0);
824 expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1, 1, 1]);
827 it('on append', async () => {
828 await editor.update(() => {
829 const paragraph1 = $getRoot().getFirstChild() as ParagraphNode;
830 paragraph1.append($createTextNode('6').toggleUnmergeable());
831 textTransformCount.push(0);
833 expect(textTransformCount).toEqual([1, 1, 2, 1, 1, 1, 1]);
837 it('Detects infinite recursivity on transforms', async () => {
838 const errorListener = jest.fn();
841 const boldListener = editor.registerNodeTransform(TextNode, (node) => {
842 node.toggleFormat('bold');
845 expect(errorListener).toHaveBeenCalledTimes(0);
847 await editor.update(() => {
848 const root = $getRoot();
849 const paragraph = $createParagraphNode();
850 root.append(paragraph);
851 paragraph.append($createTextNode('foo'));
854 expect(errorListener).toHaveBeenCalledTimes(1);
858 it('Should be able to update an editor state without a root element', () => {
859 const element = document.createElement('div');
860 element.setAttribute('contenteditable', 'true');
861 setContainerChild(element);
863 editor = createTestEditor();
865 editor.update(() => {
866 const root = $getRoot();
867 const paragraph = $createParagraphNode();
868 const text = $createTextNode('This works!');
869 root.append(paragraph);
870 paragraph.append(text);
873 expect(container.innerHTML).toBe('<div contenteditable="true"></div>');
875 editor.setRootElement(element);
877 expect(container.innerHTML).toBe(
878 '<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>',
882 it('Should be able to recover from an update error', async () => {
883 const errorListener = jest.fn();
885 editor.update(() => {
886 const root = $getRoot();
888 if (root.getFirstChild() === null) {
889 const paragraph = $createParagraphNode();
890 const text = $createTextNode('This works!');
891 root.append(paragraph);
892 paragraph.append(text);
896 // Wait for update to complete
897 await Promise.resolve().then();
899 expect(container.innerHTML).toBe(
900 '<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>',
902 expect(errorListener).toHaveBeenCalledTimes(0);
904 editor.update(() => {
905 const root = $getRoot();
907 .getFirstChild<ElementNode>()!
908 .getFirstChild<ElementNode>()!
909 .getFirstChild<TextNode>()!
910 .setTextContent('Foo');
913 expect(errorListener).toHaveBeenCalledTimes(1);
914 expect(container.innerHTML).toBe(
915 '<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>',
919 it('Should be able to handle a change in root element', async () => {
920 const rootListener = jest.fn();
921 const updateListener = jest.fn();
923 let editorInstance = createTestEditor();
924 editorInstance.registerRootListener(rootListener);
925 editorInstance.registerUpdateListener(updateListener);
927 let edContainer: HTMLElement = document.createElement('div');
928 edContainer.setAttribute('contenteditable', 'true');
929 setContainerChild(edContainer);
930 editorInstance.setRootElement(edContainer);
932 function runUpdate(changeElement: boolean) {
933 editorInstance.update(() => {
934 const root = $getRoot();
935 const firstChild = root.getFirstChild() as ParagraphNode | null;
936 const text = changeElement ? 'Change successful' : 'Not changed';
938 if (firstChild === null) {
939 const paragraph = $createParagraphNode();
940 const textNode = $createTextNode(text);
941 paragraph.append(textNode);
942 root.append(paragraph);
944 const textNode = firstChild.getFirstChild() as TextNode;
945 textNode.setTextContent(text);
950 setContainerChild(edContainer);
951 editorInstance.setRootElement(edContainer);
953 editorInstance.commitUpdates();
955 expect(container.innerHTML).toBe(
956 '<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>',
959 edContainer = document.createElement('span');
960 edContainer.setAttribute('contenteditable', 'true');
962 editorInstance.setRootElement(edContainer);
963 setContainerChild(edContainer);
964 editorInstance.commitUpdates();
966 expect(rootListener).toHaveBeenCalledTimes(3);
967 expect(updateListener).toHaveBeenCalledTimes(3);
968 expect(container.innerHTML).toBe(
969 '<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>',
973 for (const editable of [true, false]) {
974 it(`Retains pendingEditor while rootNode is not set (${
975 editable ? 'editable' : 'non-editable'
977 const JSON_EDITOR_STATE =
978 '{"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}}';
980 const contentEditable = editor.getRootElement();
981 editor.setEditable(editable);
982 editor.setRootElement(null);
983 const editorState = editor.parseEditorState(JSON_EDITOR_STATE);
984 editor.setEditorState(editorState);
985 editor.update(() => {
988 editor.setRootElement(contentEditable);
989 expect(JSON.stringify(editor.getEditorState().toJSON())).toBe(
995 describe('parseEditorState()', () => {
996 let originalText: TextNode;
997 let parsedParagraph: ParagraphNode;
998 let parsedRoot: RootNode;
999 let parsedText: TextNode;
1000 let paragraphKey: string;
1001 let textKey: string;
1002 let parsedEditorState: EditorState;
1004 it('exportJSON API - parses parsed JSON', async () => {
1005 await update(() => {
1006 const paragraph = $createParagraphNode();
1007 originalText = $createTextNode('Hello world');
1008 originalText.select(6, 11);
1009 paragraph.append(originalText);
1010 $getRoot().append(paragraph);
1012 const stringifiedEditorState = JSON.stringify(editor.getEditorState());
1013 const parsedEditorStateFromObject = editor.parseEditorState(
1014 JSON.parse(stringifiedEditorState),
1016 parsedEditorStateFromObject.read(() => {
1017 const root = $getRoot();
1018 expect(root.getTextContent()).toMatch(/Hello world/);
1022 describe('range selection', () => {
1023 beforeEach(async () => {
1026 await update(() => {
1027 const paragraph = $createParagraphNode();
1028 originalText = $createTextNode('Hello world');
1029 originalText.select(6, 11);
1030 paragraph.append(originalText);
1031 $getRoot().append(paragraph);
1033 const stringifiedEditorState = JSON.stringify(
1034 editor.getEditorState().toJSON(),
1036 parsedEditorState = editor.parseEditorState(stringifiedEditorState);
1037 parsedEditorState.read(() => {
1038 parsedRoot = $getRoot();
1039 parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode;
1040 paragraphKey = parsedParagraph.getKey();
1041 parsedText = parsedParagraph.getFirstChild() as TextNode;
1042 textKey = parsedText.getKey();
1046 it('Parses the nodes of a stringified editor state', async () => {
1047 expect(parsedRoot).toEqual({
1050 __first: paragraphKey,
1054 __last: paragraphKey,
1062 expect(parsedParagraph).toEqual({
1067 __key: paragraphKey,
1076 __type: 'paragraph',
1078 expect(parsedText).toEqual({
1084 __parent: paragraphKey,
1087 __text: 'Hello world',
1092 it('Parses the text content of the editor state', async () => {
1093 expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1096 expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1102 describe('node selection', () => {
1103 beforeEach(async () => {
1106 await update(() => {
1107 const paragraph = $createParagraphNode();
1108 originalText = $createTextNode('Hello world');
1109 const selection = $createNodeSelection();
1110 selection.add(originalText.getKey());
1111 $setSelection(selection);
1112 paragraph.append(originalText);
1113 $getRoot().append(paragraph);
1115 const stringifiedEditorState = JSON.stringify(
1116 editor.getEditorState().toJSON(),
1118 parsedEditorState = editor.parseEditorState(stringifiedEditorState);
1119 parsedEditorState.read(() => {
1120 parsedRoot = $getRoot();
1121 parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode;
1122 paragraphKey = parsedParagraph.getKey();
1123 parsedText = parsedParagraph.getFirstChild() as TextNode;
1124 textKey = parsedText.getKey();
1128 it('Parses the nodes of a stringified editor state', async () => {
1129 expect(parsedRoot).toEqual({
1132 __first: paragraphKey,
1136 __last: paragraphKey,
1144 expect(parsedParagraph).toEqual({
1149 __key: paragraphKey,
1158 __type: 'paragraph',
1160 expect(parsedText).toEqual({
1166 __parent: paragraphKey,
1169 __text: 'Hello world',
1174 it('Parses the text content of the editor state', async () => {
1175 expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1178 expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1185 describe('$parseSerializedNode()', () => {
1186 it('parses serialized nodes', async () => {
1187 const expectedTextContent = 'Hello world\n\nHello world';
1188 let actualTextContent: string;
1190 await update(() => {
1193 const paragraph = $createParagraphNode();
1194 paragraph.append($createTextNode('Hello world'));
1195 root.append(paragraph);
1197 const stringifiedEditorState = JSON.stringify(editor.getEditorState());
1198 const parsedEditorStateJson = JSON.parse(stringifiedEditorState);
1199 const rootJson = parsedEditorStateJson.root;
1200 await update(() => {
1201 const children = rootJson.children.map($parseSerializedNode);
1203 root.append(...children);
1204 actualTextContent = root.getTextContent();
1206 expect(actualTextContent!).toEqual(expectedTextContent);
1210 describe('Node children', () => {
1211 beforeEach(async () => {
1217 async function reset() {
1220 await update(() => {
1221 const root = $getRoot();
1222 const paragraph = $createParagraphNode();
1223 root.append(paragraph);
1227 it('moves node to different tree branches', async () => {
1228 function $createElementNodeWithText(text: string) {
1229 const elementNode = $createTestElementNode();
1230 const textNode = $createTextNode(text);
1231 elementNode.append(textNode);
1233 return [elementNode, textNode];
1236 let paragraphNodeKey: string;
1237 let elementNode1Key: string;
1238 let textNode1Key: string;
1239 let elementNode2Key: string;
1240 let textNode2Key: string;
1242 await update(() => {
1243 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1244 paragraphNodeKey = paragraph.getKey();
1246 const [elementNode1, textNode1] = $createElementNodeWithText('A');
1247 elementNode1Key = elementNode1.getKey();
1248 textNode1Key = textNode1.getKey();
1250 const [elementNode2, textNode2] = $createElementNodeWithText('B');
1251 elementNode2Key = elementNode2.getKey();
1252 textNode2Key = textNode2.getKey();
1254 paragraph.append(elementNode1, elementNode2);
1257 await update(() => {
1258 const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1259 const elementNode2 = $getNodeByKey(elementNode2Key) as TextNode;
1260 elementNode1.append(elementNode2);
1270 for (let i = 0; i < keys.length; i++) {
1271 expect(editor._editorState._nodeMap.has(keys[i])).toBe(true);
1272 expect(editor._keyToDOMMap.has(keys[i])).toBe(true);
1275 expect(editor._editorState._nodeMap.size).toBe(keys.length + 1); // + root
1276 expect(editor._keyToDOMMap.size).toBe(keys.length + 1); // + root
1277 expect(container.innerHTML).toBe(
1278 '<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>',
1282 it('moves node to different tree branches (inverse)', async () => {
1283 function $createElementNodeWithText(text: string) {
1284 const elementNode = $createTestElementNode();
1285 const textNode = $createTextNode(text);
1286 elementNode.append(textNode);
1291 let elementNode1Key: string;
1292 let elementNode2Key: string;
1294 await update(() => {
1295 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1297 const elementNode1 = $createElementNodeWithText('A');
1298 elementNode1Key = elementNode1.getKey();
1300 const elementNode2 = $createElementNodeWithText('B');
1301 elementNode2Key = elementNode2.getKey();
1303 paragraph.append(elementNode1, elementNode2);
1306 await update(() => {
1307 const elementNode1 = $getNodeByKey(elementNode1Key) as TextNode;
1308 const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1309 elementNode2.append(elementNode1);
1312 expect(container.innerHTML).toBe(
1313 '<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>',
1317 it('moves node to different tree branches (node appended twice in two different branches)', async () => {
1318 function $createElementNodeWithText(text: string) {
1319 const elementNode = $createTestElementNode();
1320 const textNode = $createTextNode(text);
1321 elementNode.append(textNode);
1326 let elementNode1Key: string;
1327 let elementNode2Key: string;
1328 let elementNode3Key: string;
1330 await update(() => {
1331 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1333 const elementNode1 = $createElementNodeWithText('A');
1334 elementNode1Key = elementNode1.getKey();
1336 const elementNode2 = $createElementNodeWithText('B');
1337 elementNode2Key = elementNode2.getKey();
1339 const elementNode3 = $createElementNodeWithText('C');
1340 elementNode3Key = elementNode3.getKey();
1342 paragraph.append(elementNode1, elementNode2, elementNode3);
1345 await update(() => {
1346 const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1347 const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1348 const elementNode3 = $getNodeByKey(elementNode3Key) as TextNode;
1349 elementNode2.append(elementNode3);
1350 elementNode1.append(elementNode3);
1353 expect(container.innerHTML).toBe(
1354 '<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>',
1359 it('can subscribe and unsubscribe from commands and the callback is fired', () => {
1362 const commandListener = jest.fn();
1363 const command = createCommand('TEST_COMMAND');
1364 const payload = 'testPayload';
1365 const removeCommandListener = editor.registerCommand(
1368 COMMAND_PRIORITY_EDITOR,
1370 editor.dispatchCommand(command, payload);
1371 editor.dispatchCommand(command, payload);
1372 editor.dispatchCommand(command, payload);
1374 expect(commandListener).toHaveBeenCalledTimes(3);
1375 expect(commandListener).toHaveBeenCalledWith(payload, editor);
1377 removeCommandListener();
1379 editor.dispatchCommand(command, payload);
1380 editor.dispatchCommand(command, payload);
1381 editor.dispatchCommand(command, payload);
1383 expect(commandListener).toHaveBeenCalledTimes(3);
1384 expect(commandListener).toHaveBeenCalledWith(payload, editor);
1387 it('removes the command from the command map when no listener are attached', () => {
1390 const commandListener = jest.fn();
1391 const commandListenerTwo = jest.fn();
1392 const command = createCommand('TEST_COMMAND');
1393 const removeCommandListener = editor.registerCommand(
1396 COMMAND_PRIORITY_EDITOR,
1398 const removeCommandListenerTwo = editor.registerCommand(
1401 COMMAND_PRIORITY_EDITOR,
1404 expect(editor._commands).toEqual(
1409 new Set([commandListener, commandListenerTwo]),
1419 removeCommandListener();
1421 expect(editor._commands).toEqual(
1426 new Set([commandListenerTwo]),
1436 removeCommandListenerTwo();
1438 expect(editor._commands).toEqual(new Map());
1441 it('can register transforms before updates', async () => {
1444 const emptyTransform = () => {
1448 const removeTextTransform = editor.registerNodeTransform(
1452 const removeParagraphTransform = editor.registerNodeTransform(
1457 await editor.update(() => {
1458 const root = $getRoot();
1459 const paragraph = $createParagraphNode();
1460 root.append(paragraph);
1463 removeTextTransform();
1464 removeParagraphTransform();
1467 it('textcontent listener', async () => {
1470 const fn = jest.fn();
1471 editor.update(() => {
1472 const root = $getRoot();
1473 const paragraph = $createParagraphNode();
1474 const textNode = $createTextNode('foo');
1475 root.append(paragraph);
1476 paragraph.append(textNode);
1478 editor.registerTextContentListener((text) => {
1482 await editor.update(() => {
1483 const root = $getRoot();
1484 const child = root.getLastDescendant()!;
1485 child.insertAfter($createTextNode('bar'));
1488 expect(fn).toHaveBeenCalledTimes(1);
1489 expect(fn).toHaveBeenCalledWith('foobar');
1491 await editor.update(() => {
1492 const root = $getRoot();
1493 const child = root.getLastDescendant()!;
1494 child.insertAfter($createLineBreakNode());
1497 expect(fn).toHaveBeenCalledTimes(2);
1498 expect(fn).toHaveBeenCalledWith('foobar\n');
1500 await editor.update(() => {
1501 const root = $getRoot();
1503 const paragraph = $createParagraphNode();
1504 const paragraph2 = $createParagraphNode();
1505 root.append(paragraph);
1506 paragraph.append($createTextNode('bar'));
1507 paragraph2.append($createTextNode('yar'));
1508 paragraph.insertAfter(paragraph2);
1511 expect(fn).toHaveBeenCalledTimes(3);
1512 expect(fn).toHaveBeenCalledWith('bar\n\nyar');
1514 await editor.update(() => {
1515 const root = $getRoot();
1516 const paragraph = $createParagraphNode();
1517 const paragraph2 = $createParagraphNode();
1518 root.getLastChild()!.insertAfter(paragraph);
1519 paragraph.append($createTextNode('bar2'));
1520 paragraph2.append($createTextNode('yar2'));
1521 paragraph.insertAfter(paragraph2);
1524 expect(fn).toHaveBeenCalledTimes(4);
1525 expect(fn).toHaveBeenCalledWith('bar\n\nyar\n\nbar2\n\nyar2');
1528 it('mutation listener', async () => {
1531 const paragraphNodeMutations = jest.fn();
1532 const textNodeMutations = jest.fn();
1533 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1534 skipInitialization: false,
1536 editor.registerMutationListener(TextNode, textNodeMutations, {
1537 skipInitialization: false,
1539 const paragraphKeys: string[] = [];
1540 const textNodeKeys: string[] = [];
1542 // No await intentional (batch with next)
1543 editor.update(() => {
1544 const root = $getRoot();
1545 const paragraph = $createParagraphNode();
1546 const textNode = $createTextNode('foo');
1547 root.append(paragraph);
1548 paragraph.append(textNode);
1549 paragraphKeys.push(paragraph.getKey());
1550 textNodeKeys.push(textNode.getKey());
1553 await editor.update(() => {
1554 const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
1555 const textNode2 = $createTextNode('bar').toggleFormat('bold');
1556 const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1557 textNode.insertAfter(textNode2);
1558 textNode2.insertAfter(textNode3);
1559 textNodeKeys.push(textNode2.getKey());
1560 textNodeKeys.push(textNode3.getKey());
1563 await editor.update(() => {
1567 await editor.update(() => {
1568 const root = $getRoot();
1569 const paragraph = $createParagraphNode();
1571 paragraphKeys.push(paragraph.getKey());
1573 // Created and deleted in the same update (not attached to node)
1574 textNodeKeys.push($createTextNode('zzz').getKey());
1575 root.append(paragraph);
1578 expect(paragraphNodeMutations.mock.calls.length).toBe(3);
1579 expect(textNodeMutations.mock.calls.length).toBe(2);
1581 const [paragraphMutation1, paragraphMutation2, paragraphMutation3] =
1582 paragraphNodeMutations.mock.calls;
1583 const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
1585 expect(paragraphMutation1[0].size).toBe(1);
1586 expect(paragraphMutation1[0].get(paragraphKeys[0])).toBe('created');
1587 expect(paragraphMutation1[0].size).toBe(1);
1588 expect(paragraphMutation2[0].get(paragraphKeys[0])).toBe('destroyed');
1589 expect(paragraphMutation3[0].size).toBe(1);
1590 expect(paragraphMutation3[0].get(paragraphKeys[1])).toBe('created');
1591 expect(textNodeMutation1[0].size).toBe(3);
1592 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1593 expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
1594 expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
1595 expect(textNodeMutation2[0].size).toBe(3);
1596 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1597 expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
1598 expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
1600 it('mutation listener on newly initialized editor', async () => {
1601 editor = createEditor();
1602 const textNodeMutations = jest.fn();
1603 editor.registerMutationListener(TextNode, textNodeMutations, {
1604 skipInitialization: false,
1606 expect(textNodeMutations.mock.calls.length).toBe(0);
1608 it('mutation listener with setEditorState', async () => {
1611 await editor.update(() => {
1612 $getRoot().append($createParagraphNode());
1615 const initialEditorState = editor.getEditorState();
1616 const textNodeMutations = jest.fn();
1617 editor.registerMutationListener(TextNode, textNodeMutations, {
1618 skipInitialization: false,
1620 const textNodeKeys: string[] = [];
1622 await editor.update(() => {
1623 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1624 const textNode1 = $createTextNode('foo');
1625 paragraph.append(textNode1);
1626 textNodeKeys.push(textNode1.getKey());
1629 const fooEditorState = editor.getEditorState();
1631 await editor.setEditorState(initialEditorState);
1632 // This line should have no effect on the mutation listeners
1633 const parsedFooEditorState = editor.parseEditorState(
1634 JSON.stringify(fooEditorState),
1637 await editor.update(() => {
1638 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1639 const textNode2 = $createTextNode('bar').toggleFormat('bold');
1640 const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1641 paragraph.append(textNode2, textNode3);
1642 textNodeKeys.push(textNode2.getKey(), textNode3.getKey());
1645 await editor.setEditorState(parsedFooEditorState);
1647 expect(textNodeMutations.mock.calls.length).toBe(4);
1654 ] = textNodeMutations.mock.calls;
1656 expect(textNodeMutation1[0].size).toBe(1);
1657 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1658 expect(textNodeMutation2[0].size).toBe(1);
1659 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1660 expect(textNodeMutation3[0].size).toBe(2);
1661 expect(textNodeMutation3[0].get(textNodeKeys[1])).toBe('created');
1662 expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('created');
1663 expect(textNodeMutation4[0].size).toBe(3); // +1 newly generated key by parseEditorState
1664 expect(textNodeMutation4[0].get(textNodeKeys[1])).toBe('destroyed');
1665 expect(textNodeMutation4[0].get(textNodeKeys[2])).toBe('destroyed');
1668 it('mutation listener set for original node should work with the replaced node', async () => {
1670 function TestBase() {
1671 const edContainer = document.createElement('div');
1672 edContainer.contentEditable = 'true';
1674 editor = useLexicalEditor(edContainer, undefined, [
1678 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1679 withKlass: TestTextNode,
1686 setContainerChild(TestBase());
1688 const textNodeMutations = jest.fn();
1689 const textNodeMutationsB = jest.fn();
1690 editor.registerMutationListener(TextNode, textNodeMutations, {
1691 skipInitialization: false,
1693 const textNodeKeys: string[] = [];
1695 // No await intentional (batch with next)
1696 editor.update(() => {
1697 const root = $getRoot();
1698 const paragraph = $createParagraphNode();
1699 const textNode = $createTextNode('foo');
1700 root.append(paragraph);
1701 paragraph.append(textNode);
1702 textNodeKeys.push(textNode.getKey());
1705 await editor.update(() => {
1706 const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
1707 const textNode2 = $createTextNode('bar').toggleFormat('bold');
1708 const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1709 textNode.insertAfter(textNode2);
1710 textNode2.insertAfter(textNode3);
1711 textNodeKeys.push(textNode2.getKey());
1712 textNodeKeys.push(textNode3.getKey());
1715 editor.registerMutationListener(TextNode, textNodeMutationsB, {
1716 skipInitialization: false,
1719 await editor.update(() => {
1723 await editor.update(() => {
1724 const root = $getRoot();
1725 const paragraph = $createParagraphNode();
1727 // Created and deleted in the same update (not attached to node)
1728 textNodeKeys.push($createTextNode('zzz').getKey());
1729 root.append(paragraph);
1732 expect(textNodeMutations.mock.calls.length).toBe(2);
1733 expect(textNodeMutationsB.mock.calls.length).toBe(2);
1735 const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
1737 expect(textNodeMutation1[0].size).toBe(3);
1738 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1739 expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
1740 expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
1741 expect([...textNodeMutation1[1].updateTags]).toEqual([]);
1742 expect(textNodeMutation2[0].size).toBe(3);
1743 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1744 expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
1745 expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
1746 expect([...textNodeMutation2[1].updateTags]).toEqual([]);
1748 const [textNodeMutationB1, textNodeMutationB2] =
1749 textNodeMutationsB.mock.calls;
1751 expect(textNodeMutationB1[0].size).toBe(3);
1752 expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
1753 expect(textNodeMutationB1[0].get(textNodeKeys[1])).toBe('created');
1754 expect(textNodeMutationB1[0].get(textNodeKeys[2])).toBe('created');
1755 expect([...textNodeMutationB1[1].updateTags]).toEqual([
1756 'registerMutationListener',
1758 expect(textNodeMutationB2[0].size).toBe(3);
1759 expect(textNodeMutationB2[0].get(textNodeKeys[0])).toBe('destroyed');
1760 expect(textNodeMutationB2[0].get(textNodeKeys[1])).toBe('destroyed');
1761 expect(textNodeMutationB2[0].get(textNodeKeys[2])).toBe('destroyed');
1762 expect([...textNodeMutationB2[1].updateTags]).toEqual([]);
1765 it('mutation listener should work with the replaced node', async () => {
1767 function TestBase() {
1768 const edContainer = document.createElement('div');
1769 edContainer.contentEditable = 'true';
1771 editor = useLexicalEditor(edContainer, undefined, [
1775 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1776 withKlass: TestTextNode,
1783 setContainerChild(TestBase());
1785 const textNodeMutations = jest.fn();
1786 const textNodeMutationsB = jest.fn();
1787 editor.registerMutationListener(TestTextNode, textNodeMutations, {
1788 skipInitialization: false,
1790 const textNodeKeys: string[] = [];
1792 await editor.update(() => {
1793 const root = $getRoot();
1794 const paragraph = $createParagraphNode();
1795 const textNode = $createTextNode('foo');
1796 root.append(paragraph);
1797 paragraph.append(textNode);
1798 textNodeKeys.push(textNode.getKey());
1801 editor.registerMutationListener(TestTextNode, textNodeMutationsB, {
1802 skipInitialization: false,
1805 expect(textNodeMutations.mock.calls.length).toBe(1);
1807 const [textNodeMutation1] = textNodeMutations.mock.calls;
1809 expect(textNodeMutation1[0].size).toBe(1);
1810 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1811 expect([...textNodeMutation1[1].updateTags]).toEqual([]);
1813 const [textNodeMutationB1] = textNodeMutationsB.mock.calls;
1815 expect(textNodeMutationB1[0].size).toBe(1);
1816 expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
1817 expect([...textNodeMutationB1[1].updateTags]).toEqual([
1818 'registerMutationListener',
1822 it('mutation listeners does not trigger when other node types are mutated', async () => {
1825 const paragraphNodeMutations = jest.fn();
1826 const textNodeMutations = jest.fn();
1827 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1828 skipInitialization: false,
1830 editor.registerMutationListener(TextNode, textNodeMutations, {
1831 skipInitialization: false,
1834 await editor.update(() => {
1835 $getRoot().append($createParagraphNode());
1838 expect(paragraphNodeMutations.mock.calls.length).toBe(1);
1839 expect(textNodeMutations.mock.calls.length).toBe(0);
1842 it('mutation listeners with normalization', async () => {
1845 const textNodeMutations = jest.fn();
1846 editor.registerMutationListener(TextNode, textNodeMutations, {
1847 skipInitialization: false,
1849 const textNodeKeys: string[] = [];
1851 await editor.update(() => {
1852 const root = $getRoot();
1853 const paragraph = $createParagraphNode();
1854 const textNode1 = $createTextNode('foo');
1855 const textNode2 = $createTextNode('bar');
1857 textNodeKeys.push(textNode1.getKey(), textNode2.getKey());
1858 root.append(paragraph);
1859 paragraph.append(textNode1, textNode2);
1862 await editor.update(() => {
1863 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1864 const textNode3 = $createTextNode('xyz').toggleFormat('bold');
1865 paragraph.append(textNode3);
1866 textNodeKeys.push(textNode3.getKey());
1869 await editor.update(() => {
1870 const textNode3 = $getNodeByKey(textNodeKeys[2]) as TextNode;
1871 textNode3.toggleFormat('bold'); // Normalize with foobar
1874 expect(textNodeMutations.mock.calls.length).toBe(3);
1876 const [textNodeMutation1, textNodeMutation2, textNodeMutation3] =
1877 textNodeMutations.mock.calls;
1879 expect(textNodeMutation1[0].size).toBe(1);
1880 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1881 expect(textNodeMutation2[0].size).toBe(2);
1882 expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('created');
1883 expect(textNodeMutation3[0].size).toBe(2);
1884 expect(textNodeMutation3[0].get(textNodeKeys[0])).toBe('updated');
1885 expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('destroyed');
1888 it('mutation "update" listener', async () => {
1891 const paragraphNodeMutations = jest.fn();
1892 const textNodeMutations = jest.fn();
1894 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1895 skipInitialization: false,
1897 editor.registerMutationListener(TextNode, textNodeMutations, {
1898 skipInitialization: false,
1901 const paragraphNodeKeys: string[] = [];
1902 const textNodeKeys: string[] = [];
1904 await editor.update(() => {
1905 const root = $getRoot();
1906 const paragraph = $createParagraphNode();
1907 const textNode1 = $createTextNode('foo');
1908 textNodeKeys.push(textNode1.getKey());
1909 paragraphNodeKeys.push(paragraph.getKey());
1910 root.append(paragraph);
1911 paragraph.append(textNode1);
1914 expect(paragraphNodeMutations.mock.calls.length).toBe(1);
1916 const [paragraphNodeMutation1] = paragraphNodeMutations.mock.calls;
1917 expect(textNodeMutations.mock.calls.length).toBe(1);
1919 const [textNodeMutation1] = textNodeMutations.mock.calls;
1921 expect(textNodeMutation1[0].size).toBe(1);
1922 expect(paragraphNodeMutation1[0].size).toBe(1);
1924 // Change first text node's content.
1925 await editor.update(() => {
1926 const textNode1 = $getNodeByKey(textNodeKeys[0]) as TextNode;
1927 textNode1.setTextContent('Test'); // Normalize with foobar
1930 // Append text node to paragraph.
1931 await editor.update(() => {
1932 const paragraphNode1 = $getNodeByKey(
1933 paragraphNodeKeys[0],
1935 const textNode1 = $createTextNode('foo');
1936 paragraphNode1.append(textNode1);
1939 expect(textNodeMutations.mock.calls.length).toBe(3);
1941 const textNodeMutation2 = textNodeMutations.mock.calls[1];
1943 // Show TextNode was updated when text content changed.
1944 expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('updated');
1945 expect(paragraphNodeMutations.mock.calls.length).toBe(2);
1947 const paragraphNodeMutation2 = paragraphNodeMutations.mock.calls[1];
1949 // Show ParagraphNode was updated when new text node was appended.
1950 expect(paragraphNodeMutation2[0].get(paragraphNodeKeys[0])).toBe('updated');
1952 let tableCellKey: string;
1953 let tableRowKey: string;
1955 const tableCellMutations = jest.fn();
1956 const tableRowMutations = jest.fn();
1958 editor.registerMutationListener(TableCellNode, tableCellMutations, {
1959 skipInitialization: false,
1961 editor.registerMutationListener(TableRowNode, tableRowMutations, {
1962 skipInitialization: false,
1966 await editor.update(() => {
1967 const root = $getRoot();
1968 const tableCell = $createTableCellNode(0);
1969 const tableRow = $createTableRowNode();
1970 const table = $createTableNode();
1972 tableRow.append(tableCell);
1973 table.append(tableRow);
1976 tableRowKey = tableRow.getKey();
1977 tableCellKey = tableCell.getKey();
1979 // Add New Table Cell To Row
1981 await editor.update(() => {
1982 const tableRow = $getNodeByKey(tableRowKey) as TableRowNode;
1983 const tableCell = $createTableCellNode(0);
1984 tableRow.append(tableCell);
1987 // Update Table Cell
1988 await editor.update(() => {
1989 const tableCell = $getNodeByKey(tableCellKey) as TableCellNode;
1990 tableCell.toggleHeaderStyle(1);
1993 expect(tableCellMutations.mock.calls.length).toBe(3);
1994 const tableCellMutation3 = tableCellMutations.mock.calls[2];
1996 // Show table cell is updated when header value changes.
1997 expect(tableCellMutation3[0].get(tableCellKey!)).toBe('updated');
1998 expect(tableRowMutations.mock.calls.length).toBe(2);
2000 const tableRowMutation2 = tableRowMutations.mock.calls[1];
2002 // Show row is updated when a new child is added.
2003 expect(tableRowMutation2[0].get(tableRowKey!)).toBe('updated');
2006 it('editable listener', () => {
2009 const editableFn = jest.fn();
2010 editor.registerEditableListener(editableFn);
2012 expect(editor.isEditable()).toBe(true);
2014 editor.setEditable(false);
2016 expect(editor.isEditable()).toBe(false);
2018 editor.setEditable(true);
2020 expect(editableFn.mock.calls).toEqual([[false], [true]]);
2023 it('does not add new listeners while triggering existing', async () => {
2024 const updateListener = jest.fn();
2025 const mutationListener = jest.fn();
2026 const nodeTransformListener = jest.fn();
2027 const textContentListener = jest.fn();
2028 const editableListener = jest.fn();
2029 const commandListener = jest.fn();
2030 const TEST_COMMAND = createCommand('TEST_COMMAND');
2034 editor.registerUpdateListener(() => {
2037 editor.registerUpdateListener(() => {
2042 editor.registerMutationListener(
2046 editor.registerMutationListener(
2051 {skipInitialization: true},
2054 {skipInitialization: false},
2057 editor.registerNodeTransform(ParagraphNode, () => {
2058 nodeTransformListener();
2059 editor.registerNodeTransform(ParagraphNode, () => {
2060 nodeTransformListener();
2064 editor.registerEditableListener(() => {
2066 editor.registerEditableListener(() => {
2071 editor.registerTextContentListener(() => {
2072 textContentListener();
2073 editor.registerTextContentListener(() => {
2074 textContentListener();
2078 editor.registerCommand(
2082 editor.registerCommand(
2085 COMMAND_PRIORITY_LOW,
2089 COMMAND_PRIORITY_LOW,
2092 await update(() => {
2094 $createParagraphNode().append($createTextNode('Hello world')),
2098 editor.dispatchCommand(TEST_COMMAND, false);
2100 editor.setEditable(false);
2102 expect(updateListener).toHaveBeenCalledTimes(1);
2103 expect(editableListener).toHaveBeenCalledTimes(1);
2104 expect(commandListener).toHaveBeenCalledTimes(1);
2105 expect(textContentListener).toHaveBeenCalledTimes(1);
2106 expect(nodeTransformListener).toHaveBeenCalledTimes(1);
2107 expect(mutationListener).toHaveBeenCalledTimes(1);
2110 it('calls mutation listener with initial state', async () => {
2111 // TODO add tests for node replacement
2112 const mutationListenerA = jest.fn();
2113 const mutationListenerB = jest.fn();
2114 const mutationListenerC = jest.fn();
2117 editor.registerMutationListener(TextNode, mutationListenerA, {
2118 skipInitialization: false,
2120 expect(mutationListenerA).toHaveBeenCalledTimes(0);
2122 await update(() => {
2124 $createParagraphNode().append($createTextNode('Hello world')),
2128 function asymmetricMatcher<T>(asymmetricMatch: (x: T) => boolean) {
2129 return {asymmetricMatch};
2132 expect(mutationListenerA).toHaveBeenCalledTimes(1);
2133 expect(mutationListenerA).toHaveBeenLastCalledWith(
2135 expect.objectContaining({
2136 updateTags: asymmetricMatcher(
2137 (s: Set<string>) => !s.has('registerMutationListener'),
2141 editor.registerMutationListener(TextNode, mutationListenerB, {
2142 skipInitialization: false,
2144 editor.registerMutationListener(TextNode, mutationListenerC, {
2145 skipInitialization: true,
2147 expect(mutationListenerA).toHaveBeenCalledTimes(1);
2148 expect(mutationListenerB).toHaveBeenCalledTimes(1);
2149 expect(mutationListenerB).toHaveBeenLastCalledWith(
2151 expect.objectContaining({
2152 updateTags: asymmetricMatcher((s: Set<string>) =>
2153 s.has('registerMutationListener'),
2157 expect(mutationListenerC).toHaveBeenCalledTimes(0);
2158 await update(() => {
2160 $createParagraphNode().append($createTextNode('Another update!')),
2163 expect(mutationListenerA).toHaveBeenCalledTimes(2);
2164 expect(mutationListenerB).toHaveBeenCalledTimes(2);
2165 expect(mutationListenerC).toHaveBeenCalledTimes(1);
2166 [mutationListenerA, mutationListenerB, mutationListenerC].forEach((fn) => {
2167 expect(fn).toHaveBeenLastCalledWith(
2169 expect.objectContaining({
2170 updateTags: asymmetricMatcher(
2171 (s: Set<string>) => !s.has('registerMutationListener'),
2178 it('can use discrete for synchronous updates', () => {
2180 const onUpdate = jest.fn();
2181 editor.registerUpdateListener(onUpdate);
2185 $createParagraphNode().append($createTextNode('Sync update')),
2193 const textContent = editor
2195 .read(() => $getRoot().getTextContent());
2196 expect(textContent).toBe('Sync update');
2197 expect(onUpdate).toHaveBeenCalledTimes(1);
2200 it('can use discrete after a non-discrete update to flush the entire queue', () => {
2201 const headless = createTestHeadlessEditor();
2202 const onUpdate = jest.fn();
2203 headless.registerUpdateListener(onUpdate);
2204 headless.update(() => {
2206 $createParagraphNode().append($createTextNode('Async update')),
2212 $createParagraphNode().append($createTextNode('Sync update')),
2220 const textContent = headless
2222 .read(() => $getRoot().getTextContent());
2223 expect(textContent).toBe('Async update\n\nSync update');
2224 expect(onUpdate).toHaveBeenCalledTimes(1);
2227 it('can use discrete after a non-discrete setEditorState to flush the entire queue', () => {
2232 $createParagraphNode().append($createTextNode('Async update')),
2240 const headless = createTestHeadlessEditor(editor.getEditorState());
2244 $createParagraphNode().append($createTextNode('Sync update')),
2251 const textContent = headless
2253 .read(() => $getRoot().getTextContent());
2254 expect(textContent).toBe('Async update\n\nSync update');
2257 it('can use discrete in a nested update to flush the entire queue', () => {
2259 const onUpdate = jest.fn();
2260 editor.registerUpdateListener(onUpdate);
2261 editor.update(() => {
2263 $createParagraphNode().append($createTextNode('Async update')),
2268 $createParagraphNode().append($createTextNode('Sync update')),
2277 const textContent = editor
2279 .read(() => $getRoot().getTextContent());
2280 expect(textContent).toBe('Async update\n\nSync update');
2281 expect(onUpdate).toHaveBeenCalledTimes(1);
2284 it('does not include linebreak into inline elements', async () => {
2287 await editor.update(() => {
2289 $createParagraphNode().append(
2290 $createTextNode('Hello'),
2291 $createTestInlineElementNode(),
2296 expect(container.firstElementChild?.innerHTML).toBe(
2297 '<p><span data-lexical-text="true">Hello</span><a></a></p>',
2301 it('reconciles state without root element', () => {
2302 editor = createTestEditor({});
2303 const state = editor.parseEditorState(
2304 `{"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}}`,
2306 editor.setEditorState(state);
2307 expect(editor._editorState).toBe(state);
2308 expect(editor._pendingEditorState).toBe(null);
2311 describe('node replacement', () => {
2312 it('should work correctly', async () => {
2313 const onError = jest.fn();
2315 const newEditor = createTestEditor({
2320 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2326 bold: 'editor-text-bold',
2327 italic: 'editor-text-italic',
2328 underline: 'editor-text-underline',
2333 newEditor.setRootElement(container);
2335 await newEditor.update(() => {
2336 const root = $getRoot();
2337 const paragraph = $createParagraphNode();
2338 const text = $createTextNode('123');
2339 root.append(paragraph);
2340 paragraph.append(text);
2341 expect(text instanceof TestTextNode).toBe(true);
2342 expect(text.getTextContent()).toBe('123');
2345 expect(onError).not.toHaveBeenCalled();
2348 it('should fail if node keys are re-used', async () => {
2349 const onError = jest.fn();
2351 const newEditor = createTestEditor({
2356 with: (node: TextNode) =>
2357 new TestTextNode(node.getTextContent(), node.getKey()),
2363 bold: 'editor-text-bold',
2364 italic: 'editor-text-italic',
2365 underline: 'editor-text-underline',
2370 newEditor.setRootElement(container);
2372 await newEditor.update(() => {
2374 $createTextNode('123');
2375 expect(false).toBe('unreachable');
2378 newEditor.commitUpdates();
2380 expect(onError).toHaveBeenCalledWith(
2381 expect.objectContaining({
2382 message: expect.stringMatching(/TestTextNode.*re-use key.*TextNode/),
2387 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 () => {
2388 const onError = jest.fn();
2390 const newEditor = createTestEditor({
2395 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2401 bold: 'editor-text-bold',
2402 italic: 'editor-text-italic',
2403 underline: 'editor-text-underline',
2408 newEditor.setRootElement(container);
2410 const mockTransform = jest.fn();
2411 const removeTransform = newEditor.registerNodeTransform(
2416 await newEditor.update(() => {
2417 const root = $getRoot();
2418 const paragraph = $createParagraphNode();
2419 const text = $createTextNode('123');
2420 root.append(paragraph);
2421 paragraph.append(text);
2422 expect(text instanceof TestTextNode).toBe(true);
2423 expect(text.getTextContent()).toBe('123');
2426 await newEditor.getEditorState().read(() => {
2427 expect(mockTransform).toHaveBeenCalledTimes(0);
2430 expect(onError).not.toHaveBeenCalled();
2434 it('node transform to the nodes specified by "replace" should be applied also to the nodes specified by "with" when "withKlass" is specified', async () => {
2435 const onError = jest.fn();
2437 const newEditor = createTestEditor({
2442 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2443 withKlass: TestTextNode,
2449 bold: 'editor-text-bold',
2450 italic: 'editor-text-italic',
2451 underline: 'editor-text-underline',
2456 newEditor.setRootElement(container);
2458 const mockTransform = jest.fn();
2459 const removeTransform = newEditor.registerNodeTransform(
2464 await newEditor.update(() => {
2465 const root = $getRoot();
2466 const paragraph = $createParagraphNode();
2467 const text = $createTextNode('123');
2468 root.append(paragraph);
2469 paragraph.append(text);
2470 expect(text instanceof TestTextNode).toBe(true);
2471 expect(text.getTextContent()).toBe('123');
2474 await newEditor.getEditorState().read(() => {
2475 expect(mockTransform).toHaveBeenCalledTimes(1);
2478 expect(onError).not.toHaveBeenCalled();
2483 it('recovers from reconciler failure and trigger proper prev editor state', async () => {
2484 const updateListener = jest.fn();
2485 const textListener = jest.fn();
2486 const onError = jest.fn();
2487 const updateError = new Error('Failed updateDOM');
2491 editor.registerUpdateListener(updateListener);
2492 editor.registerTextContentListener(textListener);
2494 await update(() => {
2496 $createParagraphNode().append($createTextNode('Hello')),
2500 // Cause reconciler error in update dom, so that it attempts to fallback by
2501 // reseting editor and rerendering whole content
2502 jest.spyOn(ParagraphNode.prototype, 'updateDOM').mockImplementation(() => {
2506 const editorState = editor.getEditorState();
2508 editor.registerUpdateListener(updateListener);
2510 await update(() => {
2512 $createParagraphNode().append($createTextNode('world')),
2516 expect(onError).toBeCalledWith(updateError);
2517 expect(textListener).toBeCalledWith('Hello\n\nworld');
2518 expect(updateListener.mock.lastCall[0].prevEditorState).toBe(editorState);
2521 it('should call importDOM methods only once', async () => {
2522 jest.spyOn(ParagraphNode, 'importDOM');
2524 class CustomParagraphNode extends ParagraphNode {
2526 return 'custom-paragraph';
2529 static clone(node: CustomParagraphNode) {
2530 return new CustomParagraphNode(node.__key);
2533 static importJSON() {
2534 return new CustomParagraphNode();
2538 return {...super.exportJSON(), type: 'custom-paragraph'};
2542 createTestEditor({nodes: [CustomParagraphNode]});
2544 expect(ParagraphNode.importDOM).toHaveBeenCalledTimes(1);
2547 it('root element count is always positive', () => {
2548 const newEditor1 = createTestEditor();
2549 const newEditor2 = createTestEditor();
2551 const container1 = document.createElement('div');
2552 const container2 = document.createElement('div');
2554 newEditor1.setRootElement(container1);
2555 newEditor1.setRootElement(null);
2557 newEditor1.setRootElement(container1);
2558 newEditor2.setRootElement(container2);
2559 newEditor1.setRootElement(null);
2560 newEditor2.setRootElement(null);
2563 describe('html config', () => {
2564 it('should override export output function', async () => {
2565 const onError = jest.fn();
2567 const newEditor = createTestEditor({
2573 invariant($isTextNode(target));
2576 element: target.hasFormat('bold')
2577 ? document.createElement('bor')
2578 : document.createElement('foo'),
2587 newEditor.setRootElement(container);
2589 newEditor.update(() => {
2590 const root = $getRoot();
2591 const paragraph = $createParagraphNode();
2592 const text = $createTextNode();
2593 root.append(paragraph);
2594 paragraph.append(text);
2596 const selection = $createNodeSelection();
2597 selection.add(text.getKey());
2599 const htmlFoo = $generateHtmlFromNodes(newEditor, selection);
2600 expect(htmlFoo).toBe('<foo></foo>');
2602 text.toggleFormat('bold');
2604 const htmlBold = $generateHtmlFromNodes(newEditor, selection);
2605 expect(htmlBold).toBe('<bor></bor>');
2608 expect(onError).not.toHaveBeenCalled();
2611 it('should override import conversion function', async () => {
2612 const onError = jest.fn();
2614 const newEditor = createTestEditor({
2618 conversion: () => ({node: $createTextNode('yolo')}),
2626 newEditor.setRootElement(container);
2628 newEditor.update(() => {
2629 const html = '<figure></figure>';
2631 const parser = new DOMParser();
2632 const dom = parser.parseFromString(html, 'text/html');
2633 const node = $generateNodesFromDOM(newEditor, dom)[0];
2635 expect(node).toEqual({
2638 __key: node.getKey(),
2649 expect(onError).not.toHaveBeenCalled();