Lexical: Aligned new empty item behaviour for nested lists

- Makes enter on empty nested list item un-nest instead of just creating
  new list items.
- Also updated existing lists tests to use newer helper setup.
This commit is contained in:
Dan Brown 2024-12-17 16:50:03 +00:00
parent ace8af077d
commit fca8f928a3
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
4 changed files with 635 additions and 617 deletions

View File

@ -776,6 +776,7 @@ export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEd
key, key,
}); });
nodeDomEl?.dispatchEvent(event); nodeDomEl?.dispatchEvent(event);
editor.commitUpdates();
} }
export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) { export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {

View File

@ -271,11 +271,18 @@ export class ListItemNode extends ElementNode {
insertNewAfter( insertNewAfter(
_: RangeSelection, _: RangeSelection,
restoreSelection = true, restoreSelection = true,
): ListItemNode | ParagraphNode { ): ListItemNode | ParagraphNode | null {
if (this.getTextContent().trim() === '' && this.isLastChild()) { if (this.getTextContent().trim() === '' && this.isLastChild()) {
const list = this.getParentOrThrow<ListNode>(); const list = this.getParentOrThrow<ListNode>();
if (!$isListItemNode(list.getParent())) { 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(); const paragraph = $createParagraphNode();
list.insertAfter(paragraph, restoreSelection); list.insertAfter(paragraph, restoreSelection);
this.remove(); this.remove();

View File

@ -9,13 +9,13 @@
import { import {
$createParagraphNode, $createParagraphNode,
$createRangeSelection, $createRangeSelection,
$getRoot, $getRoot, LexicalEditor,
TextNode, TextNode,
} from 'lexical'; } from 'lexical';
import { import {
createTestContext, destroyFromContext,
expectHtmlToBeEqual, expectHtmlToBeEqual,
html, html,
initializeUnitTest,
} from 'lexical/__tests__/utils'; } from 'lexical/__tests__/utils';
import { import {
@ -24,23 +24,24 @@ import {
ListItemNode, ListItemNode,
ListNode, ListNode,
} from '../..'; } from '../..';
import {EditorUiContext} from "../../../../ui/framework/core";
const editorConfig = Object.freeze({ import {$htmlToBlockNodes} from "../../../../utils/nodes";
namespace: '',
theme: {
list: {
listitem: 'my-listItem-item-class',
nested: {
listitem: 'my-nested-list-listItem-class',
},
},
},
});
describe('LexicalListItemNode tests', () => { describe('LexicalListItemNode tests', () => {
initializeUnitTest((testEnv) => {
let context!: EditorUiContext;
let editor!: LexicalEditor;
beforeEach(() => {
context = createTestContext();
editor = context.editor;
});
afterEach(() => {
destroyFromContext(context);
});
test('ListItemNode.constructor', async () => { test('ListItemNode.constructor', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const listItemNode = new ListItemNode(); const listItemNode = new ListItemNode();
@ -54,13 +55,12 @@ describe('LexicalListItemNode tests', () => {
}); });
test('ListItemNode.createDOM()', async () => { test('ListItemNode.createDOM()', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const listItemNode = new ListItemNode(); const listItemNode = new ListItemNode();
expectHtmlToBeEqual( expectHtmlToBeEqual(
listItemNode.createDOM(editorConfig).outerHTML, listItemNode.createDOM(editor._config).outerHTML,
html` html`
<li value="1"></li> <li value="1"></li>
`, `,
@ -80,12 +80,11 @@ describe('LexicalListItemNode tests', () => {
describe('ListItemNode.updateDOM()', () => { describe('ListItemNode.updateDOM()', () => {
test('base', async () => { test('base', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const listItemNode = new ListItemNode(); const listItemNode = new ListItemNode();
const domElement = listItemNode.createDOM(editorConfig); const domElement = listItemNode.createDOM(editor._config);
expectHtmlToBeEqual( expectHtmlToBeEqual(
domElement.outerHTML, domElement.outerHTML,
@ -98,7 +97,7 @@ describe('LexicalListItemNode tests', () => {
const result = newListItemNode.updateDOM( const result = newListItemNode.updateDOM(
listItemNode, listItemNode,
domElement, domElement,
editorConfig, editor._config,
); );
expect(result).toBe(false); expect(result).toBe(false);
@ -113,14 +112,13 @@ describe('LexicalListItemNode tests', () => {
}); });
test('nested list', async () => { test('nested list', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const parentListNode = new ListNode('bullet', 1); const parentListNode = new ListNode('bullet', 1);
const parentlistItemNode = new ListItemNode(); const parentlistItemNode = new ListItemNode();
parentListNode.append(parentlistItemNode); parentListNode.append(parentlistItemNode);
const domElement = parentlistItemNode.createDOM(editorConfig); const domElement = parentlistItemNode.createDOM(editor._config);
expectHtmlToBeEqual( expectHtmlToBeEqual(
domElement.outerHTML, domElement.outerHTML,
@ -134,7 +132,7 @@ describe('LexicalListItemNode tests', () => {
const result = parentlistItemNode.updateDOM( const result = parentlistItemNode.updateDOM(
parentlistItemNode, parentlistItemNode,
domElement, domElement,
editorConfig, editor._config,
); );
expect(result).toBe(false); expect(result).toBe(false);
@ -156,7 +154,6 @@ describe('LexicalListItemNode tests', () => {
let listItemNode3: ListItemNode; let listItemNode3: ListItemNode;
beforeEach(async () => { beforeEach(async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const root = $getRoot(); const root = $getRoot();
@ -175,7 +172,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -198,7 +195,6 @@ describe('LexicalListItemNode tests', () => {
}); });
test('another list item node', async () => { test('another list item node', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const newListItemNode = new ListItemNode(); const newListItemNode = new ListItemNode();
@ -208,7 +204,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -231,14 +227,13 @@ describe('LexicalListItemNode tests', () => {
}); });
test('first list item with a non list item node', async () => { test('first list item with a non list item node', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
return; return;
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -265,7 +260,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -286,7 +281,6 @@ describe('LexicalListItemNode tests', () => {
}); });
test('last list item with a non list item node', async () => { test('last list item with a non list item node', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const paragraphNode = $createParagraphNode(); const paragraphNode = $createParagraphNode();
@ -294,7 +288,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -315,7 +309,6 @@ describe('LexicalListItemNode tests', () => {
}); });
test('middle list item with a non list item node', async () => { test('middle list item with a non list item node', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const paragraphNode = $createParagraphNode(); const paragraphNode = $createParagraphNode();
@ -323,7 +316,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -346,7 +339,6 @@ describe('LexicalListItemNode tests', () => {
}); });
test('the only list item with a non list item node', async () => { test('the only list item with a non list item node', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
listItemNode2.remove(); listItemNode2.remove();
@ -354,7 +346,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -375,7 +367,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -393,7 +385,6 @@ describe('LexicalListItemNode tests', () => {
// - x // - x
// - B // - B
test('siblings are not nested', async () => { test('siblings are not nested', async () => {
const {editor} = testEnv;
let x: ListItemNode; let x: ListItemNode;
await editor.update(() => { await editor.update(() => {
@ -414,7 +405,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -438,7 +429,7 @@ describe('LexicalListItemNode tests', () => {
await editor.update(() => x.remove()); await editor.update(() => x.remove());
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -461,7 +452,6 @@ describe('LexicalListItemNode tests', () => {
// - x // - x
// - B // - B
test('the previous sibling is nested', async () => { test('the previous sibling is nested', async () => {
const {editor} = testEnv;
let x: ListItemNode; let x: ListItemNode;
await editor.update(() => { await editor.update(() => {
@ -486,7 +476,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1" style="list-style: none;"> <li value="1" style="list-style: none;">
@ -509,7 +499,7 @@ describe('LexicalListItemNode tests', () => {
await editor.update(() => x.remove()); await editor.update(() => x.remove());
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1" style="list-style: none;"> <li value="1" style="list-style: none;">
@ -531,7 +521,6 @@ describe('LexicalListItemNode tests', () => {
// - x // - x
// - B // - B
test('the next sibling is nested', async () => { test('the next sibling is nested', async () => {
const {editor} = testEnv;
let x: ListItemNode; let x: ListItemNode;
await editor.update(() => { await editor.update(() => {
@ -556,7 +545,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1"> <li value="1">
@ -579,7 +568,7 @@ describe('LexicalListItemNode tests', () => {
await editor.update(() => x.remove()); await editor.update(() => x.remove());
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1"> <li value="1">
@ -601,7 +590,6 @@ describe('LexicalListItemNode tests', () => {
// - x // - x
// - B // - B
test('both siblings are nested', async () => { test('both siblings are nested', async () => {
const {editor} = testEnv;
let x: ListItemNode; let x: ListItemNode;
await editor.update(() => { await editor.update(() => {
@ -630,7 +618,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1" style="list-style: none;"> <li value="1" style="list-style: none;">
@ -657,7 +645,7 @@ describe('LexicalListItemNode tests', () => {
await editor.update(() => x.remove()); await editor.update(() => x.remove());
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1" style="list-style: none;"> <li value="1" style="list-style: none;">
@ -680,7 +668,6 @@ describe('LexicalListItemNode tests', () => {
// - x // - x
// - B // - B
test('the previous sibling is nested deeper than the next sibling', async () => { test('the previous sibling is nested deeper than the next sibling', async () => {
const {editor} = testEnv;
let x: ListItemNode; let x: ListItemNode;
await editor.update(() => { await editor.update(() => {
@ -716,7 +703,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1" style="list-style: none;"> <li value="1" style="list-style: none;">
@ -750,7 +737,7 @@ describe('LexicalListItemNode tests', () => {
await editor.update(() => x.remove()); await editor.update(() => x.remove());
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1" style="list-style: none;"> <li value="1" style="list-style: none;">
@ -780,7 +767,6 @@ describe('LexicalListItemNode tests', () => {
// - B1 // - B1
// - B2 // - B2
test('the next sibling is nested deeper than the previous sibling', async () => { test('the next sibling is nested deeper than the previous sibling', async () => {
const {editor} = testEnv;
let x: ListItemNode; let x: ListItemNode;
await editor.update(() => { await editor.update(() => {
@ -816,7 +802,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1" style="list-style: none;"> <li value="1" style="list-style: none;">
@ -850,7 +836,7 @@ describe('LexicalListItemNode tests', () => {
await editor.update(() => x.remove()); await editor.update(() => x.remove());
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1" style="list-style: none;"> <li value="1" style="list-style: none;">
@ -881,7 +867,6 @@ describe('LexicalListItemNode tests', () => {
// - B1 // - B1
// - B2 // - B2
test('both siblings are deeply nested', async () => { test('both siblings are deeply nested', async () => {
const {editor} = testEnv;
let x: ListItemNode; let x: ListItemNode;
await editor.update(() => { await editor.update(() => {
@ -924,7 +909,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1" style="list-style: none;"> <li value="1" style="list-style: none;">
@ -965,7 +950,7 @@ describe('LexicalListItemNode tests', () => {
await editor.update(() => x.remove()); await editor.update(() => x.remove());
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.innerHTML, context.editorDOM.innerHTML,
html` html`
<ul> <ul>
<li value="1" style="list-style: none;"> <li value="1" style="list-style: none;">
@ -1001,7 +986,6 @@ describe('LexicalListItemNode tests', () => {
let listItemNode3: ListItemNode; let listItemNode3: ListItemNode;
beforeEach(async () => { beforeEach(async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const root = $getRoot(); const root = $getRoot();
@ -1020,7 +1004,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -1043,14 +1027,13 @@ describe('LexicalListItemNode tests', () => {
}); });
test('first list item', async () => { test('first list item', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
listItemNode1.insertNewAfter($createRangeSelection()); listItemNode1.insertNewAfter($createRangeSelection());
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -1074,14 +1057,13 @@ describe('LexicalListItemNode tests', () => {
}); });
test('last list item', async () => { test('last list item', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
listItemNode3.insertNewAfter($createRangeSelection()); listItemNode3.insertNewAfter($createRangeSelection());
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -1105,14 +1087,13 @@ describe('LexicalListItemNode tests', () => {
}); });
test('middle list item', async () => { test('middle list item', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
listItemNode3.insertNewAfter($createRangeSelection()); listItemNode3.insertNewAfter($createRangeSelection());
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -1136,15 +1117,13 @@ describe('LexicalListItemNode tests', () => {
}); });
test('the only list item', async () => { test('the only list item', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
listItemNode2.remove(); listItemNode2.remove();
listItemNode3.remove(); listItemNode3.remove();
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -1164,7 +1143,7 @@ describe('LexicalListItemNode tests', () => {
}); });
expectHtmlToBeEqual( expectHtmlToBeEqual(
testEnv.outerHTML, context.editorDOM.outerHTML,
html` html`
<div <div
contenteditable="true" contenteditable="true"
@ -1182,9 +1161,48 @@ describe('LexicalListItemNode tests', () => {
}); });
}); });
test('$createListItemNode()', async () => { describe('ListItemNode.insertNewAfter()', () => {
const {editor} = testEnv; test('new items after empty nested items un-nests the current item instead of creating new', () => {
let nestedItem!: ListItemNode;
const input = `<ul>
<li>
Item A
<ul><li>Nested item A</li></ul>
</li>
<li>Item B</li>
</ul>`;
editor.updateAndCommit(() => {
const root = $getRoot();
root.append(...$htmlToBlockNodes(editor, input));
const list = root.getFirstChild() as ListNode;
const itemA = list.getFirstChild() as ListItemNode;
const nestedList = itemA.getLastChild() as ListNode;
nestedItem = nestedList.getFirstChild() as ListItemNode;
nestedList.selectEnd();
});
editor.updateAndCommit(() => {
nestedItem.insertNewAfter($createRangeSelection());
const newItem = nestedItem.getNextSibling() as ListItemNode;
newItem.insertNewAfter($createRangeSelection());
});
expectHtmlToBeEqual(
context.editorDOM.innerHTML,
html`<ul>
<li value="1">
<span data-lexical-text="true">Item A</span>
<ul><li value="1"><span data-lexical-text="true">Nested item A</span></li></ul>
</li>
<li value="2"><br></li>
<li value="3"><span data-lexical-text="true">Item B</span></li>
</ul>`,
);
});
});
test('$createListItemNode()', async () => {
await editor.update(() => { await editor.update(() => {
const listItemNode = new ListItemNode(); const listItemNode = new ListItemNode();
@ -1197,8 +1215,6 @@ describe('LexicalListItemNode tests', () => {
}); });
test('$isListItemNode()', async () => { test('$isListItemNode()', async () => {
const {editor} = testEnv;
await editor.update(() => { await editor.update(() => {
const listItemNode = new ListItemNode(); const listItemNode = new ListItemNode();
@ -1206,4 +1222,3 @@ describe('LexicalListItemNode tests', () => {
}); });
}); });
}); });
});

View File

@ -48,7 +48,6 @@ describe('Keyboard-handling service tests', () => {
expect(lastRootChild).toBeInstanceOf(DetailsNode); expect(lastRootChild).toBeInstanceOf(DetailsNode);
dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
editor.commitUpdates();
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
lastRootChild = $getRoot().getLastChild(); lastRootChild = $getRoot().getLastChild();
@ -79,10 +78,7 @@ describe('Keyboard-handling service tests', () => {
expect(lastRootChild).toBeInstanceOf(DetailsNode); expect(lastRootChild).toBeInstanceOf(DetailsNode);
dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); dispatchKeydownEventForNode(detailsPara, editor, 'Enter');
editor.commitUpdates();
dispatchKeydownEventForSelectedNode(editor, 'Enter'); dispatchKeydownEventForSelectedNode(editor, 'Enter');
editor.commitUpdates();
let detailsChildren!: LexicalNode[]; let detailsChildren!: LexicalNode[];
let lastDetailsText!: string; let lastDetailsText!: string;
@ -115,7 +111,6 @@ describe('Keyboard-handling service tests', () => {
}); });
dispatchKeydownEventForNode(listItemB, editor, 'Tab'); dispatchKeydownEventForNode(listItemB, editor, 'Tab');
editor.commitUpdates();
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
const list = $getRoot().getChildren()[0] as ListNode; const list = $getRoot().getChildren()[0] as ListNode;