fix(editor): exit list when Enter pressed on empty top-level item (MUL-2430) (#2861)

Tiptap's stock ListItem keymap binds Enter only to splitListItem. When the
cursor sits in an empty top-level list item, splitListItem returns false
(without dispatching) with a code comment saying "let next command handle
lifting" — but no next command is chained. Enter then falls through to
ProseMirror's baseKeymap which inserts another empty paragraph inside the
list item, trapping the user.

Replace StarterKit's ListItem with PatchedListItem whose Enter binding
chains splitListItem → liftListItem via commands.first. The lift fallback
only runs when splitListItem returns false (top-level empty case),
restoring the standard "double-Enter exits the list" behaviour seen in
every other rich-text editor. Non-empty and nested-empty items are
unaffected because splitListItem already handles them correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-05-20 08:20:50 +08:00
committed by GitHub
parent 2ad1cd8ff8
commit 071ffca034
3 changed files with 221 additions and 0 deletions

View File

@@ -38,6 +38,7 @@ import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { BaseMentionExtension } from "./mention-extension";
import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import { PatchedListItem } from "./list-item";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createMarkdownCopyExtension } from "./markdown-copy";
import { createSubmitExtension } from "./submit-shortcut";
@@ -106,7 +107,13 @@ export function createEditorExtensions(
heading: { levels: [1, 2, 3] },
link: false,
codeBlock: false,
// Disable StarterKit's stock ListItem — its Enter keybind binds only
// `splitListItem`, which leaves the user stuck inside an empty top-level
// list item (see list-item.ts). PatchedListItem below restores the
// standard split → lift fallback chain.
listItem: false,
}),
PatchedListItem,
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);

View File

@@ -0,0 +1,181 @@
import { afterEach, describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { PatchedListItem } from "./list-item";
interface JsonNode {
type: string;
text?: string;
content?: JsonNode[];
}
function makeEditor(content: JsonNode) {
const element = document.createElement("div");
document.body.appendChild(element);
return new Editor({
element,
extensions: [StarterKit.configure({ listItem: false }), PatchedListItem],
content,
});
}
/** Walk the doc and return the inside-paragraph position of the i-th listItem. */
function listItemTextPos(editor: Editor, index: number): number {
let count = 0;
let pos = -1;
editor.state.doc.descendants((node, p) => {
if (node.type.name === "listItem") {
if (count === index) {
pos = p + 2; // step over <listItem> + <paragraph> open
return false;
}
count += 1;
}
return true;
});
if (pos < 0) throw new Error(`no listItem at index ${index}`);
return pos;
}
/** Mimic the editor's Enter keymap: invoke the bound Enter shortcut directly. */
function pressEnter(editor: Editor): boolean {
const listItemExt = editor.extensionManager.extensions.find(
(e) => e.name === "listItem",
);
if (!listItemExt) throw new Error("listItem extension not registered");
const shortcuts = (
listItemExt.config.addKeyboardShortcuts as
| (() => Record<string, () => boolean>)
| undefined
)?.bind({
editor,
name: "listItem",
options: listItemExt.options,
type: editor.schema.nodes.listItem,
storage: listItemExt.storage,
} as never)();
const enter = shortcuts?.Enter;
if (!enter) throw new Error("Enter shortcut not bound");
return enter();
}
describe("PatchedListItem Enter behaviour", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
it("splits a non-empty list item into two", () => {
editor = makeEditor({
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "hello" }] },
],
},
],
},
],
});
// Cursor at end of "hello"
editor.commands.setTextSelection(listItemTextPos(editor, 0) + 5);
expect(pressEnter(editor)).toBe(true);
const json = editor.getJSON() as JsonNode;
const list = json.content?.[0];
expect(list?.type).toBe("bulletList");
expect(list?.content).toHaveLength(2);
const firstLiText =
list?.content?.[0]?.content?.[0]?.content?.[0]?.text ?? "";
expect(firstLiText).toBe("hello");
});
it("lifts an empty top-level list item out of the list (double-Enter exits)", () => {
editor = makeEditor({
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "first" }] },
],
},
{ type: "listItem", content: [{ type: "paragraph" }] },
],
},
],
});
// Cursor inside the empty second listItem
editor.commands.setTextSelection(listItemTextPos(editor, 1));
expect(pressEnter(editor)).toBe(true);
const json = editor.getJSON() as JsonNode;
// After lift, the bulletList holds only the first item; the empty li
// becomes a sibling paragraph after the list.
const list = json.content?.[0];
const trailing = json.content?.[1];
expect(list?.type).toBe("bulletList");
expect(list?.content).toHaveLength(1);
expect(trailing?.type).toBe("paragraph");
expect(trailing?.content ?? []).toHaveLength(0);
});
it("splits a nested empty list item correctly (does not lift outer list)", () => {
// doc > bulletList > listItem("outer") > bulletList > listItem("")
editor = makeEditor({
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "outer" }],
},
{
type: "bulletList",
content: [
{ type: "listItem", content: [{ type: "paragraph" }] },
],
},
],
},
],
},
],
});
// Cursor in the inner empty list item (second listItem in doc order)
editor.commands.setTextSelection(listItemTextPos(editor, 1));
expect(pressEnter(editor)).toBe(true);
// Behaviour: splitListItem's nested branch lifts the inner empty item
// up one level — it becomes a new top-level listItem after the outer.
// The outer listItem still exists with its "outer" text.
const json = editor.getJSON() as JsonNode;
const list = json.content?.[0];
expect(list?.type).toBe("bulletList");
const outer = list?.content?.[0];
const outerText = outer?.content?.[0]?.content?.[0]?.text ?? "";
expect(outerText).toBe("outer");
});
});

View File

@@ -0,0 +1,33 @@
import { ListItem } from "@tiptap/extension-list";
/**
* Patched ListItem with proper "double-Enter exits list" behaviour.
*
* Tiptap's stock `Enter: splitListItem` is incomplete. `splitListItem` itself
* returns false (without dispatching) when the cursor sits in an empty
* TOP-LEVEL list item, with a code comment saying "bail out and let next
* command handle lifting" — but the stock keymap has no next command.
* The empty Enter then falls through to ProseMirror's baseKeymap (`splitBlock`),
* which just inserts another empty paragraph inside the list item, trapping
* the user.
*
* Fix: chain `splitListItem` → `liftListItem` via `commands.first`. The lift
* fallback only runs when `splitListItem` returns false (top-level empty
* item), matching the universal editor behaviour where a second Enter on an
* empty bullet exits the list as a plain paragraph. Non-empty and nested
* empty items are unaffected because `splitListItem` handles them correctly
* and returns true.
*/
export const PatchedListItem = ListItem.extend({
addKeyboardShortcuts() {
return {
Enter: () =>
this.editor.commands.first(({ commands }) => [
() => commands.splitListItem(this.name),
() => commands.liftListItem(this.name),
]),
Tab: () => this.editor.commands.sinkListItem(this.name),
"Shift-Tab": () => this.editor.commands.liftListItem(this.name),
};
},
});