Compare commits

...

4 Commits

Author SHA1 Message Date
J
c8e6fc67b5 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>
2026-06-04 16:00:50 +08:00
J
12dbfa8673 Merge remote-tracking branch 'origin/main' into agent/j/0daee49d
# Conflicts:
#	packages/views/editor/readonly-content.test.tsx
2026-06-04 12:35:02 +08:00
J
9a6e029ff3 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>
2026-06-02 17:54:01 +08:00
J
281faa7087 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>
2026-06-02 17:18:48 +08:00
10 changed files with 414 additions and 19 deletions

View File

@@ -60,6 +60,7 @@ import {
Link2,
List,
ListOrdered,
ListTodo,
Quote,
ChevronDown,
Check,
@@ -297,7 +298,7 @@ function HeadingDropdown({ editor, onOpenChange, activeLevel }: { editor: Editor
// List Dropdown
// ---------------------------------------------------------------------------
function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: Editor; onOpenChange: (open: boolean) => void; isBullet: boolean; isOrdered: boolean }) {
function ListDropdown({ editor, onOpenChange, isBullet, isOrdered, isTask }: { editor: Editor; onOpenChange: (open: boolean) => void; isBullet: boolean; isOrdered: boolean; isTask: boolean }) {
const { t } = useT("editor");
const [open, setOpen] = useState(false);
@@ -310,7 +311,7 @@ function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: E
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger render={
<PopoverTrigger className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted" aria-pressed={isBullet || isOrdered} onMouseDown={(e) => e.preventDefault()} />
<PopoverTrigger className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted" aria-pressed={isBullet || isOrdered || isTask} onMouseDown={(e) => e.preventDefault()} />
}>
<List className="size-3.5" />
<ChevronDown className="size-3" />
@@ -349,6 +350,18 @@ function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: E
<ListOrdered className="size-3.5" /> {t(($) => $.bubble_menu.list_dropdown.ordered_list)}
{isOrdered && <Check className="ml-auto size-3.5" />}
</button>
<button
type="button"
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
editor.chain().focus().toggleTaskList().run();
handleOpenChange(false);
}}
>
<ListTodo className="size-3.5" /> {t(($) => $.bubble_menu.list_dropdown.task_list)}
{isTask && <Check className="ml-auto size-3.5" />}
</button>
</PopoverContent>
</Popover>
);
@@ -478,6 +491,7 @@ function EditorBubbleMenu({
blockquote: e.isActive("blockquote"),
bulletList: e.isActive("bulletList"),
orderedList: e.isActive("orderedList"),
taskList: e.isActive("taskList"),
heading1: e.isActive("heading", { level: 1 }),
heading2: e.isActive("heading", { level: 2 }),
heading3: e.isActive("heading", { level: 3 }),
@@ -606,7 +620,7 @@ function EditorBubbleMenu({
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 h-5" />
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} activeLevel={fmt.heading1 ? 1 : fmt.heading2 ? 2 : fmt.heading3 ? 3 : undefined} />
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} isBullet={fmt.bulletList} isOrdered={fmt.orderedList} />
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} isBullet={fmt.bulletList} isOrdered={fmt.orderedList} isTask={fmt.taskList} />
<Tooltip>
<TooltipTrigger render={
<Toggle size="sm" pressed={fmt.blockquote} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />

View File

@@ -31,6 +31,7 @@ import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import { Table } from "@tiptap/extension-table";
import { TaskList } from "@tiptap/extension-list";
import { Markdown } from "@tiptap/markdown";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { AnyExtension } from "@tiptap/core";
@@ -41,7 +42,7 @@ import { createMentionSuggestion } from "./mention-suggestion";
import { SlashCommandExtension } from "./slash-command-extension";
import { createSlashCommandSuggestion } from "./slash-command-suggestion";
import { CodeBlockView } from "./code-block-view";
import { PatchedListItem } from "./list-item";
import { PatchedListItem, PatchedTaskItem } from "./list-item";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createMarkdownCopyExtension } from "./markdown-copy";
import { createSubmitExtension } from "./submit-shortcut";
@@ -129,6 +130,13 @@ export function createEditorExtensions(
listItem: false,
}),
PatchedListItem,
// Checkbox task lists: `- [ ]` / `- [x]`. TaskList + TaskItem ship their own
// markdown tokenizer / renderMarkdown, an input rule (typing `[] ` / `[x] `),
// 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 PatchedListItem's bullet list.
TaskList,
PatchedTaskItem,
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);

View File

@@ -1,7 +1,8 @@
import { ListItem } from "@tiptap/extension-list";
import { type Editor, InputRule } from "@tiptap/core";
import { ListItem, TaskItem } from "@tiptap/extension-list";
/**
* Patched ListItem with proper "double-Enter exits list" behaviour.
* Shared list keymap 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
@@ -17,17 +18,67 @@ import { ListItem } from "@tiptap/extension-list";
* 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.
*
* Tab / Shift-Tab indent / dedent the item.
*/
function listItemKeymap(editor: Editor, name: string) {
return {
Enter: () =>
editor.commands.first(({ commands }) => [
() => commands.splitListItem(name),
() => commands.liftListItem(name),
]),
Tab: () => editor.commands.sinkListItem(name),
"Shift-Tab": () => editor.commands.liftListItem(name),
};
}
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),
};
return listItemKeymap(this.editor, this.name);
},
});
/**
* Patched TaskItem — same "double-Enter exits list" fix as PatchedListItem,
* applied to checkbox task items so they behave identically to bullet/ordered
* lists. `nested: true` lets a task item hold nested lists (so Tab indents into
* a sub-task and nested markdown round-trips), matching GitHub / Notion.
*
* It also adds the GitHub-style `- [ ] ` typing flow. TaskItem's built-in input
* rule only converts `[ ] ` / `[x] ` typed at the start of a PLAIN paragraph;
* once a leading `- ` (or `1. `) has turned the line into a bullet/ordered
* item, that rule no longer fires and `[ ]` stays as literal text. The extra
* rule below catches the checkbox token when it is the entire content of a
* freshly-typed list item and converts just that item into a task item —
* sibling items in the same list are left untouched (lift then re-wrap).
*/
export const PatchedTaskItem = TaskItem.extend({
addKeyboardShortcuts() {
return listItemKeymap(this.editor, this.name);
},
addInputRules() {
return [
...(this.parent?.() ?? []),
new InputRule({
find: /^\[([ xX])?\]\s$/,
handler: ({ state, range, match, chain }) => {
// Only when the checkbox token is the whole content of a list item.
// Plain-paragraph typing is handled by the inherited rule above; the
// anchored regex guarantees there is no other text before it. When
// the guard fails the handler leaves the transaction untouched, so
// the rule is a no-op and ProseMirror falls through.
if (state.selection.$from.node(-1)?.type.name === "listItem") {
const checked = (match[1] ?? "").toLowerCase() === "x";
chain()
.deleteRange(range)
.liftListItem("listItem")
.toggleList("taskList", "taskItem")
.updateAttributes("taskItem", { checked })
.run();
}
},
}),
];
},
}).configure({ nested: true });

View File

@@ -0,0 +1,169 @@
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);
});
});

View File

@@ -126,6 +126,52 @@ describe("ReadonlyContent line breaks", () => {
});
});
describe("ReadonlyContent task lists", () => {
it("renders `- [ ]` / `- [x]` as checkboxes and preserves the checked state", () => {
const { container } = render(
<ReadonlyContent content={"- [ ] todo\n- [x] done"} />,
);
const boxes = container.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"]',
);
expect(boxes).toHaveLength(2);
// The completed item must render checked, not just present.
expect(boxes[0]!.checked).toBe(false);
expect(boxes[1]!.checked).toBe(true);
// Checkboxes are display-only in readonly mode.
expect(boxes[1]!.disabled).toBe(true);
});
it("nests a child task list inside its parent item (not as a sibling)", () => {
const { container } = render(
<ReadonlyContent content={"- [ ] parent\n - [x] child\n - [ ] child2"} />,
);
// One top-level list with a single parent item.
const root = container.querySelector("ul.contains-task-list");
expect(root).not.toBeNull();
const topItems = root!.querySelectorAll(":scope > li.task-list-item");
expect(topItems).toHaveLength(1);
// The child list lives INSIDE the parent <li> — this is the structural
// assumption the readonly CSS depends on (no <div> body wrapper, so the
// parent item must stay a block, not flex, or the nested <ul> shares the
// parent's row). If remark-gfm ever wrapped the body, this fails loudly.
const parent = topItems[0]!;
const nested = parent.querySelector(":scope > ul.contains-task-list");
expect(nested).not.toBeNull();
const childItems = nested!.querySelectorAll(":scope > li.task-list-item");
expect(childItems).toHaveLength(2);
const childBoxes = nested!.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"]',
);
expect(childBoxes[0]!.checked).toBe(true);
expect(childBoxes[1]!.checked).toBe(false);
});
});
describe("ReadonlyContent highlight Markdown", () => {
// `==text==` is lowered to a raw <mark> by highlightToHtml; rehype-raw turns
// it into an element and the sanitize schema must whitelist <mark> or it gets

View File

@@ -149,6 +149,109 @@
list-style-type: lower-roman;
}
/*
* Task lists (checkboxes) — `- [ ]` / `- [x]`.
*
* The editor and readonly views emit DIFFERENT DOM, so they are styled
* separately rather than sharing a flex rule:
*
* - Editor (Tiptap NodeView):
* ul[data-type="taskList"] > li[data-checked]
* > label(contenteditable) > input + span
* > div(content) > p, [nested ul]
* The item body is wrapped in a <div>, so the row is a flex layout and a
* nested task list inside that <div> still stacks below the text.
*
* - Readonly (remark-gfm):
* ul.contains-task-list > li.task-list-item
* > input[disabled] + text [ + nested ul ]
* There is NO body wrapper — a nested <ul> is a direct sibling of the
* checkbox and text. Flex here would pull the nested list onto the same
* row, so the item stays a block and the checkbox is positioned inline.
*/
/* Shared: checkbox appearance. */
.rich-text-editor input[type="checkbox"] {
width: 0.95rem;
height: 0.95rem;
accent-color: var(--brand);
cursor: pointer;
}
/* --- Editor (Tiptap) --- */
.rich-text-editor ul[data-type="taskList"] {
list-style: none;
padding-inline: 0;
margin: 0.5rem 0;
}
/* Child combinators keep these rules off nested plain bullet/ordered lists. */
.rich-text-editor ul[data-type="taskList"] > li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0;
}
/* Checkbox column. The top margin nudges the box onto the first text line
(line-height 1.625). */
.rich-text-editor ul[data-type="taskList"] > li > label {
flex: 0 0 auto;
margin: 0.28rem 0 0;
user-select: none;
}
/* Content column fills the remaining width and may wrap/scroll. */
.rich-text-editor ul[data-type="taskList"] > li > div {
flex: 1 1 auto;
min-width: 0;
}
.rich-text-editor ul[data-type="taskList"] > li > div > p {
margin: 0;
}
/* Completed editor item: muted text (no strikethrough), matching Linear. The
NodeView keeps `data-checked` on the <li> (but not data-type). */
.rich-text-editor ul[data-type="taskList"] > li[data-checked="true"] > div {
color: var(--muted-foreground);
}
/* --- Readonly (remark-gfm) --- */
.rich-text-editor ul.contains-task-list {
list-style: none;
padding-inline: 0;
margin: 0.5rem 0;
}
.rich-text-editor li.task-list-item {
padding-inline: 0;
}
/* Inline checkbox on the first text line; a nested <ul> stays block and drops
below the item instead of sharing the row. */
.rich-text-editor li.task-list-item > input[type="checkbox"] {
margin: 0 0.5rem 0 0;
vertical-align: -0.1em;
}
/* Indent nested readonly task lists under their parent item. */
.rich-text-editor li.task-list-item .contains-task-list {
padding-inline-start: 1.45rem;
margin: 0.25rem 0;
}
/* Completed readonly item: muted text, matched via :has(input:checked). The
reset keeps a completed parent from bleeding its muted color into the
(independently evaluated) nested items below it. */
.rich-text-editor li.task-list-item:has(> input:checked) {
color: var(--muted-foreground);
}
.rich-text-editor li.task-list-item:has(> input:checked) .contains-task-list {
color: var(--foreground);
}
/* Tables */
.rich-text-editor .tableWrapper {
overflow-x: auto;

View File

@@ -18,7 +18,8 @@
},
"list_dropdown": {
"bullet_list": "Bullet List",
"ordered_list": "Ordered List"
"ordered_list": "Ordered List",
"task_list": "Task List"
},
"sub_issue": {
"tooltip": "Create sub-issue from selection",

View File

@@ -18,7 +18,8 @@
},
"list_dropdown": {
"bullet_list": "箇条書きリスト",
"ordered_list": "番号付きリスト"
"ordered_list": "番号付きリスト",
"task_list": "タスクリスト"
},
"sub_issue": {
"tooltip": "選択範囲からサブイシューを作成",

View File

@@ -18,7 +18,8 @@
},
"list_dropdown": {
"bullet_list": "글머리 기호 목록",
"ordered_list": "번호 목록"
"ordered_list": "번호 목록",
"task_list": "체크리스트"
},
"sub_issue": {
"tooltip": "선택 영역으로 하위 이슈 만들기",

View File

@@ -18,7 +18,8 @@
},
"list_dropdown": {
"bullet_list": "无序列表",
"ordered_list": "有序列表"
"ordered_list": "有序列表",
"task_list": "任务列表"
},
"sub_issue": {
"tooltip": "用选中内容创建子 issue",