Lexical: Improved ability to break out of lists

Updates list handling so that you can break out of a list (or move down
a level) via enter on an existing empty list item at any point in the
list, not just the end.
Added test to cover.
This commit is contained in:
Dan Brown
2026-05-12 18:42:06 +01:00
parent 5d429ea9bb
commit 9f4afac7bc
3 changed files with 104 additions and 20 deletions

View File

@@ -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<ListNode>();
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(

View File

@@ -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', () => {
</ul>`,
);
});
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 = `<ul>
<li>Item A</li>
<li></li>
<li>Item C</li>
</ul>`;
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 = `<ul>
<li>Item A</li>
<li>Item B</li>
<li></li>
</ul>`;
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 () => {

View File

@@ -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);
}
}
export function $escapeListAtItem(item: ListItemNode): ParagraphNode|null {
const list = item.getParentOrThrow<ListNode>();
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;
}