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), + }; + }, +});