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` +
    + +
    + `, ); }); @@ -162,8 +201,26 @@ describe('LexicalListItemNode tests', () => { listItemNode1.replace(newListItemNode); }); - expect(testEnv.outerHTML).toBe( - '
    ', + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    + +
    + `, ); }); @@ -174,8 +231,26 @@ describe('LexicalListItemNode tests', () => { return; }); - expect(testEnv.outerHTML).toBe( - '
    ', + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    + +
    + `, ); 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` +
    + +
    + `, + ); + + 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` +
    + +
    + `, ); }); }); @@ -262,8 +1073,26 @@ describe('LexicalListItemNode tests', () => { listItemNode3.append(new TextNode('three')); }); - expect(testEnv.outerHTML).toBe( - '
    ', + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    + +
    + `, ); }); @@ -274,8 +1103,27 @@ describe('LexicalListItemNode tests', () => { listItemNode1.insertNewAfter(); }); - expect(testEnv.outerHTML).toBe( - '
    ', + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    + +
    + `, ); }); @@ -286,8 +1134,27 @@ describe('LexicalListItemNode tests', () => { listItemNode3.insertNewAfter(); }); - expect(testEnv.outerHTML).toBe( - '
    ', + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    + +
    + `, ); }); @@ -298,8 +1165,27 @@ describe('LexicalListItemNode tests', () => { listItemNode3.insertNewAfter(); }); - expect(testEnv.outerHTML).toBe( - '
    ', + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    + +
    + `, ); }); @@ -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();