diff --git a/plugins/tasks/editor/editor.test.tsx b/plugins/tasks/editor/editor.test.tsx index 3b8767eb0f..527a3ca062 100644 --- a/plugins/tasks/editor/editor.test.tsx +++ b/plugins/tasks/editor/editor.test.tsx @@ -71,6 +71,36 @@ describe("markdown round-trip", () => { it.each(cases)("preserves %s", (_name, markdown) => { expect(roundTrip(markdown)).toBe(markdown); }); + + it("preserves table rows and cells", () => { + const markdown = + "| Item | Owner |\n| --- | --- |\n| Parser | Ada |\n| Styles | Lin |"; + + expect(roundTrip(markdown).trimEnd()).toBe(markdown); + }); + + it.each([ + ["plain text", "left \\| right"], + ["inline code", "`left\\|right`"], + ])("preserves a pipe inside table-cell %s", (_name, cell) => { + const markdown = `| Value |\n| --- |\n| ${cell} |`; + + expect(roundTrip(markdown).trimEnd()).toBe(markdown); + }); + + it("renders table headers and cells", () => { + const screen = render( + undefined} + readOnly + />, + ); + + expect(screen.getByRole("table")).toBeTruthy(); + expect(screen.getAllByRole("columnheader")).toHaveLength(2); + expect(screen.getAllByRole("cell")).toHaveLength(2); + }); }); describe("mention extension", () => { diff --git a/plugins/tasks/editor/extensions.ts b/plugins/tasks/editor/extensions.ts index 8def24049d..85c979106f 100644 --- a/plugins/tasks/editor/extensions.ts +++ b/plugins/tasks/editor/extensions.ts @@ -1,13 +1,27 @@ -import { Extension, InputRule, Node, type Extensions } from "@tiptap/core"; +import { + Extension, + getHTMLFromFragment, + InputRule, + Node, + type Extensions, +} from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import Link from "@tiptap/extension-link"; import Image from "@tiptap/extension-image"; import TaskList from "@tiptap/extension-task-list"; import TaskItem from "@tiptap/extension-task-item"; import Placeholder from "@tiptap/extension-placeholder"; +import Table from "@tiptap/extension-table"; +import TableCell from "@tiptap/extension-table-cell"; +import TableHeader from "@tiptap/extension-table-header"; +import TableRow from "@tiptap/extension-table-row"; import { Markdown } from "tiptap-markdown"; import { Plugin, PluginKey } from "@tiptap/pm/state"; -import type { DOMOutputSpec } from "@tiptap/pm/model"; +import { + Fragment, + type DOMOutputSpec, + type Node as ProseMirrorNode, +} from "@tiptap/pm/model"; import { Suggestion, type SuggestionProps } from "@tiptap/suggestion"; import type { IconSvgElement } from "@hugeicons/react"; import { BubbleChatIcon } from "@hugeicons/core-free-icons"; @@ -95,6 +109,100 @@ const MarkdownTaskInput = Extension.create({ }, }); +interface TableMarkdownState { + out: string; + inTable: boolean; + write(value: string): void; + ensureNewLine(): void; + closeBlock(node: ProseMirrorNode): void; + renderInline(node: ProseMirrorNode): void; +} + +function tableChildren(node: ProseMirrorNode): readonly ProseMirrorNode[] { + return node.content.content; +} + +function hasMergedCell(node: ProseMirrorNode): boolean { + return node.attrs.colspan > 1 || node.attrs.rowspan > 1; +} + +function isMarkdownTable(node: ProseMirrorNode): boolean { + const [header, ...body] = tableChildren(node); + if (!header) return false; + if ( + tableChildren(header).some( + (cell) => + cell.type.name !== "tableHeader" || + hasMergedCell(cell) || + cell.childCount > 1, + ) + ) { + return false; + } + return !body.some((row) => + tableChildren(row).some( + (cell) => + cell.type.name === "tableHeader" || + hasMergedCell(cell) || + cell.childCount > 1, + ), + ); +} + +function renderTableCell( + state: TableMarkdownState, + cell: ProseMirrorNode, +): void { + const content = cell.firstChild; + if (!content?.textContent.trim()) return; + const start = state.out.length; + state.renderInline(content); + state.out = + state.out.slice(0, start) + + state.out.slice(start).replaceAll("|", "\\|"); +} + +// tiptap-markdown's table serializer does not escape pipes inside cells. +// Escaping every rendered pipe keeps text, code, and link destinations inside +// their original cell when an edited agent comment is serialized again. +const MarkdownTable = Table.extend({ + addStorage() { + return { + markdown: { + serialize(state: TableMarkdownState, node: ProseMirrorNode) { + if (!isMarkdownTable(node)) { + state.write( + getHTMLFromFragment(Fragment.from(node), node.type.schema), + ); + state.closeBlock(node); + return; + } + + state.inTable = true; + node.forEach((row, _offset, rowIndex) => { + state.write("| "); + row.forEach((cell, _cellOffset, cellIndex) => { + if (cellIndex > 0) state.write(" | "); + renderTableCell(state, cell); + }); + state.write(" |"); + state.ensureNewLine(); + if (rowIndex === 0) { + state.write( + `| ${Array.from({ length: row.childCount }, () => "---").join(" | ")} |`, + ); + state.ensureNewLine(); + } + }); + state.closeBlock(node); + state.inTable = false; + }, + parse: {}, + }, + }; + }, +}); + // Inline atom that renders as a pill and serializes to markdown as a // bbtask:// link: [TSK-42](bbtask://TSK-42). const TaskMention = Node.create({ @@ -325,6 +433,10 @@ export function createEditorExtensions(options?: { Image.configure({ allowBase64: false }), TightTaskList, TaskItem.configure({ nested: true }), + MarkdownTable.configure({ resizable: true, lastColumnResizable: false }), + TableRow, + TableHeader, + TableCell, MarkdownTaskInput, TaskMention, ThreadMention, diff --git a/plugins/tasks/editor/tasks-editor.tsx b/plugins/tasks/editor/tasks-editor.tsx index 4e368c2d8a..63bba09ad3 100644 --- a/plugins/tasks/editor/tasks-editor.tsx +++ b/plugins/tasks/editor/tasks-editor.tsx @@ -78,6 +78,16 @@ const EDITOR_CSS = ` .bb-tasks-editor .tiptap blockquote { border-left: 2px solid var(--border); padding-left: 0.85em; margin: 0.75em 0 0; color: var(--muted-foreground); } .bb-tasks-editor .tiptap hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0 0; } .bb-tasks-editor .tiptap img { display: block; max-width: 100%; max-height: 24rem; margin: 0.9em 0 0; border-radius: var(--radius); border: 1px solid var(--border); } +.bb-tasks-editor .tiptap .tableWrapper { margin: 0.9em 0 0; overflow-x: auto; } +.bb-tasks-editor .tiptap table { width: 100%; border-collapse: collapse; table-layout: fixed; } +.bb-tasks-editor .tiptap th, +.bb-tasks-editor .tiptap td { position: relative; min-width: 6rem; border: 1px solid var(--border); padding: 0.45em 0.6em; text-align: left; vertical-align: top; } +.bb-tasks-editor .tiptap th { background: var(--muted); font-weight: 600; } +.bb-tasks-editor .tiptap :is(th, td) > p { margin-top: 0; } +.bb-tasks-editor .tiptap :is(th, td) > p + p { margin-top: 0.5em; } +.bb-tasks-editor .tiptap .selectedCell::after { position: absolute; inset: 0; z-index: 2; pointer-events: none; content: ""; background: color-mix(in oklab, var(--primary) 14%, transparent); } +.bb-tasks-editor .tiptap .column-resize-handle { position: absolute; top: 0; right: -2px; bottom: -1px; width: 4px; z-index: 3; pointer-events: none; background: var(--primary); } +.bb-tasks-editor .tiptap.resize-cursor { cursor: col-resize; } .bb-tasks-editor .tiptap ul[data-type="taskList"] { list-style: none; padding-left: 0.25em; } .bb-tasks-editor .tiptap ul[data-type="taskList"] ul[data-type="taskList"] { margin-top: 0; } .bb-tasks-editor .tiptap ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5em; margin-top: 0.3em; padding-left: 0; } diff --git a/plugins/tasks/package.json b/plugins/tasks/package.json index 5644b91b99..d71b0906d1 100644 --- a/plugins/tasks/package.json +++ b/plugins/tasks/package.json @@ -70,6 +70,10 @@ "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.27.2", "@tiptap/extension-placeholder": "^2.27.2", + "@tiptap/extension-table": "^2.27.2", + "@tiptap/extension-table-cell": "^2.27.2", + "@tiptap/extension-table-header": "^2.27.2", + "@tiptap/extension-table-row": "^2.27.2", "@tiptap/extension-task-item": "^2.27.2", "@tiptap/extension-task-list": "^2.27.2", "@tiptap/pm": "^2.27.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2625dbe64..e119cebc3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3642,6 +3642,18 @@ importers: '@tiptap/extension-placeholder': specifier: ^2.27.2 version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) + '@tiptap/extension-table': + specifier: ^2.27.2 + version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) + '@tiptap/extension-table-cell': + specifier: ^2.27.2 + version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-table-header': + specifier: ^2.27.2 + version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-table-row': + specifier: ^2.27.2 + version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) '@tiptap/extension-task-item': specifier: ^2.27.2 version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)