diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index a7b04d476..2a1b698b1 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -36,6 +36,7 @@ import {$createListNode, $isListNode} from './LexicalListNode'; import {mergeLists} from './formatList'; import {isNestedListNode} from './utils'; import {el} from "../../utils/dom"; +import {$escapeListAtItem} from "../../utils/lists"; export type SerializedListItemNode = Spread< { @@ -273,21 +274,13 @@ export class ListItemNode extends ElementNode { restoreSelection = true, ): ListItemNode | ParagraphNode | null { - if (this.getTextContent().trim() === '' && this.isLastChild()) { - const list = this.getParentOrThrow(); - const parentListItem = list.getParent(); - if ($isListItemNode(parentListItem)) { - // Un-nest list item if empty nested item - parentListItem.insertAfter(this); - this.selectStart(); - return null; - } else { - // Insert empty paragraph after list if adding after last empty child - const paragraph = $createParagraphNode(); - list.insertAfter(paragraph, restoreSelection); - this.remove(); - return paragraph; - } + // If we're adding a new empty item, and coming from an empty item, + // take that as a desire to break from the current list level. + // Intended to be a bit more lenient on the last list item hence ignores whitespace, + // which allows empty items to be listed with just a space (except for last). + const textContent = this.getTextContent(); + if (textContent === '' || (textContent.trim() === '' && this.isLastChild())) { + return $escapeListAtItem(this); } const newElement = $createListItemNode( diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts index 10ff0fc66..48fa2faf8 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -9,12 +9,12 @@ import { $createParagraphNode, $createRangeSelection, - $getRoot, LexicalEditor, + $getRoot, LexicalEditor, LexicalNode, ParagraphNode, TextNode, } from 'lexical'; import { createTestContext, destroyFromContext, - expectHtmlToBeEqual, + expectHtmlToBeEqual, expectNodeShapeToMatch, html, } from 'lexical/__tests__/utils'; @@ -1200,6 +1200,70 @@ describe('LexicalListItemNode tests', () => { `, ); }); + + test('new items after empty top-level items splits the list in two, and inserts a paragraph inbetween', () => { + let newItem: LexicalNode|null = null; + const input = ``; + + editor.updateAndCommit(() => { + const root = $getRoot(); + root.append(...$htmlToBlockNodes(editor, input)); + const list = root.getFirstChild() as ListNode; + const itemB = list.getChildAtIndex(1) as ListItemNode; + + newItem = itemB.insertNewAfter($createRangeSelection()); + }); + + expect(newItem).toBeInstanceOf(ParagraphNode); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [{type: 'listitem', children: [{text: 'Item A'}]}], + }, + { + type: 'paragraph', + }, + { + type: 'list', + children: [{type: 'listitem', children: [{text: 'Item C'}]}], + } + ]) + }); + + test('new items after last empty top-level inserts a new paragraph below, and removes the list item', () => { + let newItem: LexicalNode|null = null; + const input = ``; + + editor.updateAndCommit(() => { + const root = $getRoot(); + root.append(...$htmlToBlockNodes(editor, input)); + const list = root.getFirstChild() as ListNode; + const itemC = list.getChildAtIndex(2) as ListItemNode; + + newItem = itemC.insertNewAfter($createRangeSelection()); + }); + + expect(newItem).toBeInstanceOf(ParagraphNode); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [{type: 'listitem', children: [{text: 'Item A'}]}, {type: 'listitem', children: [{text: 'Item B'}]}], + }, + { + type: 'paragraph', + }, + ]) + }); }); test('$createListItemNode()', async () => { diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 3deb9dfb6..cdbaba6fb 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -1,7 +1,7 @@ -import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical"; +import {$createParagraphNode, $createTextNode, $getSelection, BaseSelection, LexicalEditor, ParagraphNode, TextNode} from "lexical"; import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection"; import {$sortNodes, nodeHasInset} from "./nodes"; -import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; +import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode, ListNode} from "@lexical/list"; export function $nestListItem(node: ListItemNode): ListItemNode { @@ -186,4 +186,31 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo } $toggleSelection(editor); -} \ No newline at end of file +} + +export function $escapeListAtItem(item: ListItemNode): ParagraphNode|null { + const list = item.getParentOrThrow(); + const parentListItem = list.getParent(); + + // Un-nest list item if it's an empty nested item + if ($isListItemNode(parentListItem)) { + parentListItem.insertAfter(item); + item.selectStart(); + return null; + } + + // If we're anywhere but at the end, split the list, with following items + // moved into their own new list. + if (!item.isLastChild()) { + const afterSiblings = item.getNextSiblings(); + const newList = $createListNode(list.getListType()); + newList.append(...afterSiblings); + list.insertAfter(newList); + } + + // Insert a new empty paragraph to land on after our list + const paragraph = $createParagraphNode(); + list.insertAfter(paragraph, true); + item.remove(); + return paragraph; +}