diff --git a/packages/views/editor/bubble-menu.tsx b/packages/views/editor/bubble-menu.tsx index 3dc42d6bf7..a709d817e5 100644 --- a/packages/views/editor/bubble-menu.tsx +++ b/packages/views/editor/bubble-menu.tsx @@ -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 e.preventDefault()} /> + e.preventDefault()} /> }> @@ -349,6 +350,18 @@ function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: E {t(($) => $.bubble_menu.list_dropdown.ordered_list)} {isOrdered && } + ); @@ -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({ - + editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} /> diff --git a/packages/views/editor/extensions/index.ts b/packages/views/editor/extensions/index.ts index 24d449d149..e86ce85fa4 100644 --- a/packages/views/editor/extensions/index.ts +++ b/packages/views/editor/extensions/index.ts @@ -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); diff --git a/packages/views/editor/extensions/list-item.ts b/packages/views/editor/extensions/list-item.ts index 93aaf8a08e..11b2955773 100644 --- a/packages/views/editor/extensions/list-item.ts +++ b/packages/views/editor/extensions/list-item.ts @@ -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 }); diff --git a/packages/views/editor/extensions/task-list-markdown.test.ts b/packages/views/editor/extensions/task-list-markdown.test.ts new file mode 100644 index 0000000000..109db417d9 --- /dev/null +++ b/packages/views/editor/extensions/task-list-markdown.test.ts @@ -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; + 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); + }); +}); diff --git a/packages/views/editor/readonly-content.test.tsx b/packages/views/editor/readonly-content.test.tsx index 1e47c40b42..c2ca32b483 100644 --- a/packages/views/editor/readonly-content.test.tsx +++ b/packages/views/editor/readonly-content.test.tsx @@ -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( + , + ); + + const boxes = container.querySelectorAll( + '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( + , + ); + + // 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
  • — this is the structural + // assumption the readonly CSS depends on (no
    body wrapper, so the + // parent item must stay a block, not flex, or the nested
      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( + '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 by highlightToHtml; rehype-raw turns // it into an element and the sanitize schema must whitelist or it gets diff --git a/packages/views/editor/styles/prose.css b/packages/views/editor/styles/prose.css index c57181ec28..ba9e96a716 100644 --- a/packages/views/editor/styles/prose.css +++ b/packages/views/editor/styles/prose.css @@ -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
      , so the row is a flex layout and a + * nested task list inside that
      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
        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
      • (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
          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; diff --git a/packages/views/locales/en/editor.json b/packages/views/locales/en/editor.json index c6ab0d0f38..bddfea8b6d 100644 --- a/packages/views/locales/en/editor.json +++ b/packages/views/locales/en/editor.json @@ -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", diff --git a/packages/views/locales/ja/editor.json b/packages/views/locales/ja/editor.json index 950f44cadb..29fab8b953 100644 --- a/packages/views/locales/ja/editor.json +++ b/packages/views/locales/ja/editor.json @@ -18,7 +18,8 @@ }, "list_dropdown": { "bullet_list": "箇条書きリスト", - "ordered_list": "番号付きリスト" + "ordered_list": "番号付きリスト", + "task_list": "タスクリスト" }, "sub_issue": { "tooltip": "選択範囲からサブイシューを作成", diff --git a/packages/views/locales/ko/editor.json b/packages/views/locales/ko/editor.json index e2e608f736..4fe1238848 100644 --- a/packages/views/locales/ko/editor.json +++ b/packages/views/locales/ko/editor.json @@ -18,7 +18,8 @@ }, "list_dropdown": { "bullet_list": "글머리 기호 목록", - "ordered_list": "번호 목록" + "ordered_list": "번호 목록", + "task_list": "체크리스트" }, "sub_issue": { "tooltip": "선택 영역으로 하위 이슈 만들기", diff --git a/packages/views/locales/zh-Hans/editor.json b/packages/views/locales/zh-Hans/editor.json index 6672a72f3d..2a6c0d7106 100644 --- a/packages/views/locales/zh-Hans/editor.json +++ b/packages/views/locales/zh-Hans/editor.json @@ -18,7 +18,8 @@ }, "list_dropdown": { "bullet_list": "无序列表", - "ordered_list": "有序列表" + "ordered_list": "有序列表", + "task_list": "任务列表" }, "sub_issue": { "tooltip": "用选中内容创建子 issue",