From 071ffca034b354ec393f79450b9c87be8997260a Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 20 May 2026 08:20:50 +0800 Subject: [PATCH] fix(editor): exit list when Enter pressed on empty top-level item (MUL-2430) (#2861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: multica-agent --- packages/views/editor/extensions/index.ts | 7 + .../views/editor/extensions/list-item.test.ts | 181 ++++++++++++++++++ packages/views/editor/extensions/list-item.ts | 33 ++++ 3 files changed, 221 insertions(+) create mode 100644 packages/views/editor/extensions/list-item.test.ts create mode 100644 packages/views/editor/extensions/list-item.ts diff --git a/packages/views/editor/extensions/index.ts b/packages/views/editor/extensions/index.ts index cf55d307a..caa506cbe 100644 --- a/packages/views/editor/extensions/index.ts +++ b/packages/views/editor/extensions/index.ts @@ -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); diff --git a/packages/views/editor/extensions/list-item.test.ts b/packages/views/editor/extensions/list-item.test.ts new file mode 100644 index 000000000..843f5052b --- /dev/null +++ b/packages/views/editor/extensions/list-item.test.ts @@ -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 + 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 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"); + }); +}); diff --git a/packages/views/editor/extensions/list-item.ts b/packages/views/editor/extensions/list-item.ts new file mode 100644 index 000000000..93aaf8a08 --- /dev/null +++ b/packages/views/editor/extensions/list-item.ts @@ -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), + }; + }, +});