Files
multica/packages/views/editor/extensions/list-item.test.ts
Naiyuan Qing 714f9b1ab7 fix(editor): keep Tab inside lists instead of escaping focus (MUL-3697) (#4605)
In a list item that cannot indent (first child / max depth), Tab was
returned unhandled, so the browser's native Tab moved focus out of the
editor onto adjacent controls. Decouple "swallow the key" from "did the
indent move anything": best-effort indent, then swallow whenever the
caret is inside the list (editor.isActive(name)) and only fall through
to focus navigation when not in a list. Covers bullet, ordered and task
lists via the shared keymap.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:06:28 +08:00

430 lines
13 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { TaskList } from "@tiptap/extension-list";
import { PatchedListItem, PatchedTaskItem } 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,
TaskList,
PatchedTaskItem,
],
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 an editor keymap by invoking a bound shortcut directly. We can't drive
* real key events reliably in jsdom, so we resolve the keymap an extension
* registers and call the entry for `key`. The shared list keymap closes over
* `editor` (not `this`), so the rebind only needs a faithful `this`.
*/
function pressShortcut(editor: Editor, extName: string, key: string): boolean {
const ext = editor.extensionManager.extensions.find((e) => e.name === extName);
if (!ext) throw new Error(`${extName} extension not registered`);
const shortcuts = (
ext.config.addKeyboardShortcuts as
| (() => Record<string, () => boolean>)
| undefined
)?.bind({
editor,
name: extName,
options: ext.options,
type: editor.schema.nodes[extName],
storage: ext.storage,
} as never)();
const fn = shortcuts?.[key];
if (!fn) throw new Error(`${key} shortcut not bound on ${extName}`);
return fn();
}
/** Mimic the editor's Enter keymap: invoke the bound Enter shortcut directly. */
function pressEnter(editor: Editor): boolean {
return pressShortcut(editor, "listItem", "Enter");
}
/** Indented bullet outline of the doc — nesting depth, item text only. */
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
const ITEM_TYPES = ["listItem", "taskItem"];
function outline(json: JsonNode): string {
const lines: string[] = [];
function rec(node: JsonNode, depth: number) {
for (const child of node.content ?? []) {
if (LIST_TYPES.includes(child.type)) {
rec(child, depth + 1);
} else if (ITEM_TYPES.includes(child.type)) {
const text = child.content?.[0]?.content?.[0]?.text ?? "";
lines.push(" ".repeat(Math.max(0, depth - 1)) + "- " + text);
for (const gc of child.content ?? []) {
if (LIST_TYPES.includes(gc.type)) rec(gc, depth + 1);
}
} else {
rec(child, depth);
}
}
}
rec(json, 0);
return lines.join("\n");
}
/** Inside-paragraph position of the index-th item of `typeName` (doc order). */
function itemPos(editor: Editor, typeName: string, index: number): number {
const positions: number[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name === typeName) positions.push(pos + 2);
return true;
});
const pos = positions[index];
if (pos === undefined) throw new Error(`no ${typeName} at index ${index}`);
return pos;
}
/** Select from the start of item `fromIdx`'s text to the end of item `toIdx`'s. */
function selectItemRange(
editor: Editor,
typeName: string,
fromIdx: number,
toIdx: number,
itemLen = 3,
) {
editor.commands.setTextSelection({
from: itemPos(editor, typeName, fromIdx),
to: itemPos(editor, typeName, toIdx) + itemLen,
});
}
/** A flat three-item list ("aaa","bbb","ccc") of the given node types. */
function flatList(listType: string, itemType: string): JsonNode {
return {
type: "doc",
content: [
{
type: listType,
content: ["aaa", "bbb", "ccc"].map((t) => ({
type: itemType,
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
})),
},
],
};
}
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");
});
});
describe("PatchedListItem Tab indent (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
const pressTab = (e: Editor) => pressShortcut(e, "listItem", "Tab");
it("leaves the doc unchanged but swallows Tab in the first item (stay put, do not escape focus)", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
editor.commands.setTextSelection(itemPos(editor, "listItem", 0));
// Nothing to nest under, so the structural indent is a no-op — but the caret
// is in a list, so Tab is swallowed (true) instead of leaking to the browser
// and moving focus to other controls. The doc must be untouched.
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
it("indents a single non-first item under its predecessor", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
editor.commands.setTextSelection(itemPos(editor, "listItem", 1));
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n- ccc",
);
});
it("indents a whole-list selection (items 1..3): first stays, 2 and 3 nest", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
expect(pressTab(editor)).toBe(true);
// The reported bug: this used to be a no-op because range.startIndex === 0.
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
it("indents a mid-list selection (items 2..3) under the first", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 1, 2);
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
it("returns false cleanly when the selection is not in a list (C2: no range)", () => {
editor = makeEditor({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "plain" }] },
],
});
editor.commands.setTextSelection(3);
expect(pressTab(editor)).toBe(false);
});
it("indents in a single, undoable transaction (C3)", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
const view = editor.view;
const original = view.dispatch.bind(view);
let dispatches = 0;
view.dispatch = (tr) => {
dispatches += 1;
original(tr);
};
try {
expect(pressTab(editor)).toBe(true);
} finally {
view.dispatch = original;
}
// One dispatch -> one transaction -> one undo step.
expect(dispatches).toBe(1);
editor.commands.undo();
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
});
describe("Tab indent across list types (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
it("indents an ordered-list whole selection (not only unordered)", () => {
editor = makeEditor(flatList("orderedList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
expect(pressShortcut(editor, "listItem", "Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
expect((editor.getJSON() as JsonNode).content?.[0]?.type).toBe(
"orderedList",
);
});
it("indents a task-list whole selection via the taskItem keymap", () => {
editor = makeEditor(flatList("taskList", "taskItem"));
selectItemRange(editor, "taskItem", 0, 2);
expect(pressShortcut(editor, "taskItem", "Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
});
describe("Shift-Tab dedent regression (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
it("lifts a multi-item nested selection back to the top level (unchanged)", () => {
editor = makeEditor({
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "aaa" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "bbb" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "ccc" }],
},
],
},
],
},
],
},
],
},
],
});
// Select the two nested items (bbb, ccc) and dedent.
selectItemRange(editor, "listItem", 1, 2);
expect(pressShortcut(editor, "listItem", "Shift-Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
});