mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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>
170 lines
5.7 KiB
TypeScript
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);
|
|
});
|
|
});
|