diff --git a/packages/lexical-list/src/LexicalListItemNode.ts b/packages/lexical-list/src/LexicalListItemNode.ts
index e6e5968c431..42d36975dd9 100644
--- a/packages/lexical-list/src/LexicalListItemNode.ts
+++ b/packages/lexical-list/src/LexicalListItemNode.ts
@@ -41,8 +41,10 @@ import {$createListNode, $isListNode} from './';
import {
$handleIndent,
$handleOutdent,
+ mergeLists,
updateChildrenListItemValue,
} from './formatList';
+import {isNestedListNode} from './utils';
export type SerializedListItemNode = Spread<
{
@@ -243,10 +245,19 @@ export class ListItemNode extends ElementNode {
}
remove(preserveEmptyParent?: boolean): void {
+ const prevSibling = this.getPreviousSibling();
const nextSibling = this.getNextSibling();
super.remove(preserveEmptyParent);
- if (nextSibling !== null) {
+ if (
+ prevSibling &&
+ nextSibling &&
+ isNestedListNode(prevSibling) &&
+ isNestedListNode(nextSibling)
+ ) {
+ mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
+ nextSibling.remove();
+ } else if (nextSibling) {
const parent = nextSibling.getParent();
if ($isListNode(parent)) {
diff --git a/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts b/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts
index 303b730af33..b948d319b1f 100644
--- a/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts
+++ b/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts
@@ -15,6 +15,7 @@ import {
ListItemNode,
ListNode,
} from '../..';
+import {expectHtmlToBeEqual, html} from '../utils';
const editorConfig = Object.freeze({
namespace: '',
@@ -50,16 +51,22 @@ describe('LexicalListItemNode tests', () => {
await editor.update(() => {
const listItemNode = new ListItemNode();
- expect(listItemNode.createDOM(editorConfig).outerHTML).toBe(
- '
',
+ expectHtmlToBeEqual(
+ listItemNode.createDOM(editorConfig).outerHTML,
+ html`
+
+ `,
);
- expect(
+ expectHtmlToBeEqual(
listItemNode.createDOM({
namespace: '',
theme: {},
}).outerHTML,
- ).toBe('');
+ html`
+
+ `,
+ );
});
});
@@ -72,8 +79,11 @@ describe('LexicalListItemNode tests', () => {
const domElement = listItemNode.createDOM(editorConfig);
- expect(domElement.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ domElement.outerHTML,
+ html`
+
+ `,
);
const newListItemNode = new ListItemNode();
@@ -85,8 +95,11 @@ describe('LexicalListItemNode tests', () => {
expect(result).toBe(false);
- expect(domElement.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ domElement.outerHTML,
+ html`
+
+ `,
);
});
});
@@ -101,8 +114,11 @@ describe('LexicalListItemNode tests', () => {
parentListNode.append(parentlistItemNode);
const domElement = parentlistItemNode.createDOM(editorConfig);
- expect(domElement.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ domElement.outerHTML,
+ html`
+
+ `,
);
const nestedListNode = new ListNode('bullet', 1);
nestedListNode.append(new ListItemNode());
@@ -115,8 +131,13 @@ describe('LexicalListItemNode tests', () => {
expect(result).toBe(false);
- expect(domElement.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ domElement.outerHTML,
+ html`
+
+ `,
);
});
});
@@ -147,8 +168,26 @@ describe('LexicalListItemNode tests', () => {
listNode.append(listItemNode1, listItemNode2, listItemNode3);
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+
+ -
+ one
+
+ -
+ two
+
+ -
+ three
+
+
+
+ `,
);
});
@@ -162,8 +201,26 @@ describe('LexicalListItemNode tests', () => {
listItemNode1.replace(newListItemNode);
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+
+ -
+ bar
+
+ -
+ two
+
+ -
+ three
+
+
+
+ `,
);
});
@@ -174,8 +231,26 @@ describe('LexicalListItemNode tests', () => {
return;
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+
+ -
+ one
+
+ -
+ two
+
+ -
+ three
+
+
+
+ `,
);
await editor.update(() => {
@@ -183,8 +258,24 @@ describe('LexicalListItemNode tests', () => {
listItemNode1.replace(paragraphNode);
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
);
});
@@ -196,8 +287,24 @@ describe('LexicalListItemNode tests', () => {
listItemNode3.replace(paragraphNode);
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
);
});
@@ -209,8 +316,26 @@ describe('LexicalListItemNode tests', () => {
listItemNode2.replace(paragraphNode);
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
);
});
@@ -222,8 +347,20 @@ describe('LexicalListItemNode tests', () => {
listItemNode3.remove();
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
);
await editor.update(() => {
@@ -231,8 +368,682 @@ describe('LexicalListItemNode tests', () => {
listItemNode1.replace(paragraphNode);
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+ });
+ });
+
+ describe('ListItemNode.remove()', () => {
+ // - A
+ // - x
+ // - B
+ test('siblings are not nested', async () => {
+ const {editor} = testEnv;
+ let x;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ A_listItem.append(new TextNode('A'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ B_listItem.append(new TextNode('B'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+
+ -
+ A
+
+ -
+ x
+
+ -
+ B
+
+
+
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+ });
+
+ // - A
+ // - x
+ // - B
+ test('the previous sibling is nested', async () => {
+ const {editor} = testEnv;
+ let x;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ const A_nestedList = new ListNode('bullet', 1);
+ const A_nestedListItem = new ListItemNode();
+ A_listItem.append(A_nestedList);
+ A_nestedList.append(A_nestedListItem);
+ A_nestedListItem.append(new TextNode('A'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ B_listItem.append(new TextNode('B'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+ });
+
+ // - A
+ // - x
+ // - B
+ test('the next sibling is nested', async () => {
+ const {editor} = testEnv;
+ let x;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ A_listItem.append(new TextNode('A'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ const B_nestedList = new ListNode('bullet', 1);
+ const B_nestedListItem = new ListItemNode();
+ B_listItem.append(B_nestedList);
+ B_nestedList.append(B_nestedListItem);
+ B_nestedListItem.append(new TextNode('B'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+ });
+
+ // - A
+ // - x
+ // - B
+ test('both siblings are nested', async () => {
+ const {editor} = testEnv;
+ let x;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ const A_nestedList = new ListNode('bullet', 1);
+ const A_nestedListItem = new ListItemNode();
+ A_listItem.append(A_nestedList);
+ A_nestedList.append(A_nestedListItem);
+ A_nestedListItem.append(new TextNode('A'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ const B_nestedList = new ListNode('bullet', 1);
+ const B_nestedListItem = new ListItemNode();
+ B_listItem.append(B_nestedList);
+ B_nestedList.append(B_nestedListItem);
+ B_nestedListItem.append(new TextNode('B'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+ });
+
+ // - A1
+ // - A2
+ // - x
+ // - B
+ test('the previous sibling is nested deeper than the next sibling', async () => {
+ const {editor} = testEnv;
+ let x;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ const A_nestedList = new ListNode('bullet', 1);
+ const A_nestedListItem1 = new ListItemNode();
+ const A_nestedListItem2 = new ListItemNode();
+ const A_deeplyNestedList = new ListNode('bullet', 1);
+ const A_deeplyNestedListItem = new ListItemNode();
+ A_listItem.append(A_nestedList);
+ A_nestedList.append(A_nestedListItem1);
+ A_nestedList.append(A_nestedListItem2);
+ A_nestedListItem1.append(new TextNode('A1'));
+ A_nestedListItem2.append(A_deeplyNestedList);
+ A_deeplyNestedList.append(A_deeplyNestedListItem);
+ A_deeplyNestedListItem.append(new TextNode('A2'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ const B_nestedList = new ListNode('bullet', 1);
+ const B_nestedlistItem = new ListItemNode();
+ B_listItem.append(B_nestedList);
+ B_nestedList.append(B_nestedlistItem);
+ B_nestedlistItem.append(new TextNode('B'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+ });
+
+ // - A
+ // - x
+ // - B1
+ // - B2
+ test('the next sibling is nested deeper than the previous sibling', async () => {
+ const {editor} = testEnv;
+ let x;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ const A_nestedList = new ListNode('bullet', 1);
+ const A_nestedListItem = new ListItemNode();
+ A_listItem.append(A_nestedList);
+ A_nestedList.append(A_nestedListItem);
+ A_nestedListItem.append(new TextNode('A'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ const B_nestedList = new ListNode('bullet', 1);
+ const B_nestedListItem1 = new ListItemNode();
+ const B_nestedListItem2 = new ListItemNode();
+ const B_deeplyNestedList = new ListNode('bullet', 1);
+ const B_deeplyNestedListItem = new ListItemNode();
+ B_listItem.append(B_nestedList);
+ B_nestedList.append(B_nestedListItem1);
+ B_nestedList.append(B_nestedListItem2);
+ B_nestedListItem1.append(B_deeplyNestedList);
+ B_nestedListItem2.append(new TextNode('B2'));
+ B_deeplyNestedList.append(B_deeplyNestedListItem);
+ B_deeplyNestedListItem.append(new TextNode('B1'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+ });
+
+ // - A1
+ // - A2
+ // - x
+ // - B1
+ // - B2
+ test('both siblings are deeply nested', async () => {
+ const {editor} = testEnv;
+ let x;
+
+ await editor.update(() => {
+ const root = $getRoot();
+ const parent = new ListNode('bullet', 1);
+
+ const A_listItem = new ListItemNode();
+ const A_nestedList = new ListNode('bullet', 1);
+ const A_nestedListItem1 = new ListItemNode();
+ const A_nestedListItem2 = new ListItemNode();
+ const A_deeplyNestedList = new ListNode('bullet', 1);
+ const A_deeplyNestedListItem = new ListItemNode();
+ A_listItem.append(A_nestedList);
+ A_nestedList.append(A_nestedListItem1);
+ A_nestedList.append(A_nestedListItem2);
+ A_nestedListItem1.append(new TextNode('A1'));
+ A_nestedListItem2.append(A_deeplyNestedList);
+ A_deeplyNestedList.append(A_deeplyNestedListItem);
+ A_deeplyNestedListItem.append(new TextNode('A2'));
+
+ x = new ListItemNode();
+ x.append(new TextNode('x'));
+
+ const B_listItem = new ListItemNode();
+ const B_nestedList = new ListNode('bullet', 1);
+ const B_nestedListItem1 = new ListItemNode();
+ const B_nestedListItem2 = new ListItemNode();
+ const B_deeplyNestedList = new ListNode('bullet', 1);
+ const B_deeplyNestedListItem = new ListItemNode();
+ B_listItem.append(B_nestedList);
+ B_nestedList.append(B_nestedListItem1);
+ B_nestedList.append(B_nestedListItem2);
+ B_nestedListItem1.append(B_deeplyNestedList);
+ B_nestedListItem2.append(new TextNode('B2'));
+ B_deeplyNestedList.append(B_deeplyNestedListItem);
+ B_deeplyNestedListItem.append(new TextNode('B1'));
+
+ parent.append(A_listItem, x, B_listItem);
+ root.append(parent);
+ });
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
+ );
+
+ await editor.update(() => x.remove());
+
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+
+ -
+
+ -
+ A1
+
+ -
+
+
+ -
+ B2
+
+
+
+
+
+ `,
);
});
});
@@ -262,8 +1073,26 @@ describe('LexicalListItemNode tests', () => {
listItemNode3.append(new TextNode('three'));
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+
+ -
+ one
+
+ -
+ two
+
+ -
+ three
+
+
+
+ `,
);
});
@@ -274,8 +1103,27 @@ describe('LexicalListItemNode tests', () => {
listItemNode1.insertNewAfter();
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+
+ -
+ one
+
+
+ -
+ two
+
+ -
+ three
+
+
+
+ `,
);
});
@@ -286,8 +1134,27 @@ describe('LexicalListItemNode tests', () => {
listItemNode3.insertNewAfter();
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+
+ -
+ one
+
+ -
+ two
+
+ -
+ three
+
+
+
+
+ `,
);
});
@@ -298,8 +1165,27 @@ describe('LexicalListItemNode tests', () => {
listItemNode3.insertNewAfter();
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+
+ -
+ one
+
+ -
+ two
+
+ -
+ three
+
+
+
+
+ `,
);
});
@@ -311,16 +1197,41 @@ describe('LexicalListItemNode tests', () => {
listItemNode3.remove();
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
);
await editor.update(() => {
listItemNode1.insertNewAfter();
});
- expect(testEnv.outerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ testEnv.outerHTML,
+ html`
+
+ `,
);
});
});
@@ -381,8 +1292,30 @@ describe('LexicalListItemNode tests', () => {
expect(listItemNode1.getIndent()).toBe(3);
});
- expect(editor.getRootElement().innerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ editor.getRootElement().innerHTML,
+ html`
+
+ `,
);
await editor.update(() => {
@@ -393,8 +1326,18 @@ describe('LexicalListItemNode tests', () => {
expect(listItemNode1.getIndent()).toBe(0);
});
- expect(editor.getRootElement().innerHTML).toBe(
- '',
+ expectHtmlToBeEqual(
+ editor.getRootElement().innerHTML,
+ html`
+
+ `,
);
});
});
diff --git a/packages/lexical-list/src/__tests__/utils.ts b/packages/lexical-list/src/__tests__/utils.ts
new file mode 100644
index 00000000000..bb92f7caf97
--- /dev/null
+++ b/packages/lexical-list/src/__tests__/utils.ts
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import {expect} from '@playwright/test';
+import prettier from 'prettier';
+
+// This tag function is just used to trigger prettier auto-formatting.
+// (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
+export function html(
+ partials: TemplateStringsArray,
+ ...params: string[]
+): string {
+ let output = '';
+ for (let i = 0; i < partials.length; i++) {
+ output += partials[i];
+ if (i < partials.length - 1) {
+ output += params[i];
+ }
+ }
+ return output;
+}
+
+export function expectHtmlToBeEqual(expected: string, actual: string): void {
+ expect(prettifyHtml(expected)).toBe(prettifyHtml(actual));
+}
+
+function prettifyHtml(s: string): string {
+ return prettier.format(s.replace(/\n/g, ''), {parser: 'html'});
+}
diff --git a/packages/lexical-list/src/formatList.ts b/packages/lexical-list/src/formatList.ts
index f206f9d9556..7b377c1bbdb 100644
--- a/packages/lexical-list/src/formatList.ts
+++ b/packages/lexical-list/src/formatList.ts
@@ -207,6 +207,29 @@ function createListOrMerge(node: ElementNode, listType: ListType): ListNode {
}
}
+export function mergeLists(list1: ListNode, list2: ListNode): void {
+ const listItem1 = list1.getLastChild();
+ const listItem2 = list2.getFirstChild();
+
+ if (
+ listItem1 &&
+ listItem2 &&
+ isNestedListNode(listItem1) &&
+ isNestedListNode(listItem2)
+ ) {
+ mergeLists(listItem1.getFirstChild(), listItem2.getFirstChild());
+ listItem2.remove();
+ }
+
+ const toMerge = list2.getChildren();
+ if (toMerge.length > 0) {
+ list1.append(...toMerge);
+ updateChildrenListItemValue(list1);
+ }
+
+ list2.remove();
+}
+
export function removeList(editor: LexicalEditor): void {
editor.update(() => {
const selection = $getSelection();