Files
multica/packages/views/editor/extensions/task-list-markdown.test.ts
Bohan Jiang 0d38288dbd MUL-2926 feat(editor): support markdown checkbox task lists (#3593) (#3657)
* feat(editor): support markdown checkbox task lists (#3593)

Render `- [ ]` / `- [x]` as interactive checkboxes in the issue content
editor, matching GitHub / Notion.

- Register TaskList + a patched TaskItem in the shared extension factory.
  Both ship their own markdown tokenizer / renderMarkdown, input rules, and
  a checkbox NodeView; the taskList tokenizer is consulted before marked's
  built-in list tokenizer, so `- [ ]` becomes a task while a plain `- ` still
  falls through to the bullet list.
- Patch TaskItem's keymap to share PatchedListItem's split -> lift Enter
  chain (double-Enter on an empty item exits the list); nested: true enables
  sub-tasks and nested round-trips.
- Add a "Task list" entry to the bubble-menu list dropdown (+ i18n for en /
  zh-Hans / ja / ko).
- Style task lists in prose.css for both the editor ([data-type="taskList"])
  and the readonly remark-gfm output (.contains-task-list); completed items
  render muted.

Readonly already rendered task lists via remark-gfm; this brings the editable
view to parity. Adds markdown round-trip and readonly checked-state tests.

MUL-2926

Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): keep readonly nested task lists block-laid-out (#3593)

The shared `display: flex` rule on task-list items broke nested task lists in
the readonly view. remark-gfm renders a task item as
`<li><input> text <ul>…</ul></li>` — no body wrapper — so a nested list is a
direct sibling of the checkbox and text, and flex pulled it onto the same row.
The editor's Tiptap NodeView wraps the body in a `<div>`, so it was unaffected.

Split the task-list CSS into separate editor and readonly blocks: the editor
keeps the flex row; readonly stays a block list item with an inline checkbox so
a nested `<ul>` drops below and indents under its parent. Adds a readonly test
that pins the nested DOM shape (nested `<ul>` inside the parent `<li>`), so a
future remark-gfm change that wraps the body fails loudly.

MUL-2926

Co-authored-by: multica-agent <github@multica.ai>

* feat(editor): convert `- [ ] ` typing into a task list (#3593)

TaskItem's built-in input rule only converts `[ ] ` / `[x] ` typed at the start
of a plain paragraph. When the user types the GitHub-style `- [ ] ` the leading
`- ` first turns the line into a bullet, and the built-in rule no longer fires —
so `[ ]` stayed as literal text and nothing became a checkbox.

Add an input rule on PatchedTaskItem that catches the checkbox token when it is
the entire content of a freshly-typed list item (bullet or ordered) and converts
just that item into a task item (deleteRange → liftListItem → toggleList). The
anchored regex means it only fires on an item whose whole content is `[ ] ` /
`[x] `, so sibling items in the same list are left untouched.

Adds typing-level tests (real input-rule simulation) covering `[ ] `, `[x] `,
`- [ ] `, `- [x] `, the mixed-list split case, and the plain-bullet no-op.

MUL-2926

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 16:36:46 +08:00

170 lines
5.7 KiB
TypeScript

import { describe, it, expect, afterEach } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
import { PatchedListItem, PatchedTaskItem } from "./list-item";
import { TaskList } from "@tiptap/extension-list";
// A minimal editor mirroring the production list config: StarterKit's stock
// ListItem disabled in favor of PatchedListItem, plus the checkbox TaskList /
// TaskItem pair, all serialized through @tiptap/markdown.
function makeEditor() {
const element = document.createElement("div");
document.body.appendChild(element);
return new Editor({
element,
extensions: [
StarterKit.configure({ listItem: false }),
PatchedListItem,
TaskList,
PatchedTaskItem,
Markdown.configure({ indentation: { style: "space", size: 3 } }),
],
});
}
interface JsonNode {
type?: string;
text?: string;
attrs?: Record<string, unknown>;
content?: JsonNode[];
}
function findAll(node: JsonNode, type: string, acc: JsonNode[] = []): JsonNode[] {
if (node.type === type) acc.push(node);
for (const child of node.content ?? []) findAll(child, type, acc);
return acc;
}
function nodeText(node: JsonNode): string {
if (node.text !== undefined) return node.text;
return (node.content ?? []).map(nodeText).join("");
}
function loadMarkdown(editor: Editor, md: string) {
editor.commands.setContent(md, { contentType: "markdown" });
}
// Faithfully simulate typing: each character gets a chance to fire an input
// rule (handleTextInput) before falling back to a plain insert — exactly how
// ProseMirror processes keyboard input. Lets us exercise the live `[ ] ` /
// `- [ ] ` shortcuts, which setContent (the markdown path) bypasses.
function typeText(ed: Editor, text: string) {
for (const ch of text) {
const { from, to } = ed.state.selection;
const handled = ed.view.someProp("handleTextInput", (f) =>
f(ed.view, from, to, ch, () => ed.state.tr),
);
if (!handled) ed.view.dispatch(ed.state.tr.insertText(ch, from, to));
}
}
let editor: Editor;
afterEach(() => editor?.destroy());
describe("task list markdown parsing", () => {
it("parses `- [ ]` / `- [x]` into a taskList with checked flags", () => {
editor = makeEditor();
loadMarkdown(editor, "- [ ] todo\n- [x] done");
const json = editor.getJSON() as JsonNode;
const taskLists = findAll(json, "taskList");
expect(taskLists).toHaveLength(1);
const items = findAll(json, "taskItem");
expect(items).toHaveLength(2);
expect(items[0]!.attrs?.checked).toBe(false);
expect(nodeText(items[0]!)).toBe("todo");
expect(items[1]!.attrs?.checked).toBe(true);
expect(nodeText(items[1]!)).toBe("done");
});
it("accepts an uppercase `- [X]` as checked", () => {
editor = makeEditor();
loadMarkdown(editor, "- [X] done");
const items = findAll(editor.getJSON() as JsonNode, "taskItem");
expect(items).toHaveLength(1);
expect(items[0]!.attrs?.checked).toBe(true);
});
it("leaves a plain bullet as a bulletList, not a taskList", () => {
editor = makeEditor();
loadMarkdown(editor, "- just a bullet");
const json = editor.getJSON() as JsonNode;
expect(findAll(json, "taskList")).toHaveLength(0);
expect(findAll(json, "bulletList")).toHaveLength(1);
});
});
describe("task list markdown serialization", () => {
it("round-trips checked and unchecked items", () => {
editor = makeEditor();
loadMarkdown(editor, "- [ ] todo\n- [x] done");
expect(editor.getMarkdown().trim()).toBe("- [ ] todo\n- [x] done");
});
it("serializes a checkbox toggled in the editor back to `- [x]`", () => {
editor = makeEditor();
loadMarkdown(editor, "- [ ] todo");
// Flip the single task item's checked attr the way the checkbox NodeView does.
editor.commands.command(({ tr, state }) => {
state.doc.descendants((node, pos) => {
if (node.type.name === "taskItem") {
tr.setNodeMarkup(pos, undefined, { ...node.attrs, checked: true });
}
return true;
});
return true;
});
expect(editor.getMarkdown().trim()).toBe("- [x] todo");
});
});
describe("task list typing input rules", () => {
it("converts `[ ] ` typed in a plain paragraph into an unchecked task", () => {
editor = makeEditor();
typeText(editor, "[ ] buy milk");
expect(editor.getMarkdown().trim()).toBe("- [ ] buy milk");
});
it("converts `[x] ` into a checked task", () => {
editor = makeEditor();
typeText(editor, "[x] shipped");
expect(editor.getMarkdown().trim()).toBe("- [x] shipped");
});
// The GitHub-style flow the feature request showed: `- ` makes a bullet, then
// `[ ] ` turns that item into a checkbox.
it("converts `- [ ] ` typed as a bullet into a task", () => {
editor = makeEditor();
typeText(editor, "- [ ] write tests");
expect(editor.getMarkdown().trim()).toBe("- [ ] write tests");
});
it("converts `- [x] ` typed as a bullet into a checked task", () => {
editor = makeEditor();
typeText(editor, "- [x] done");
expect(editor.getMarkdown().trim()).toBe("- [x] done");
});
it("only converts the current bullet item, leaving siblings as bullets", () => {
editor = makeEditor();
typeText(editor, "- apple");
editor.commands.enter();
typeText(editor, "[ ] task");
expect(editor.getMarkdown().trim()).toBe("- apple\n\n- [ ] task");
});
it("leaves a plain `- ` bullet alone (no false conversion)", () => {
editor = makeEditor();
typeText(editor, "- hello");
const json = editor.getJSON() as JsonNode;
expect(findAll(json, "taskList")).toHaveLength(0);
expect(findAll(json, "bulletList")).toHaveLength(1);
});
});