mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
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:
@@ -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);
|
||||
|
||||
181
packages/views/editor/extensions/list-item.test.ts
Normal file
181
packages/views/editor/extensions/list-item.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
33
packages/views/editor/extensions/list-item.ts
Normal file
33
packages/views/editor/extensions/list-item.ts
Normal 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),
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user