Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions plugins/tasks/editor/editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TasksEditor
value={"| Item | Owner |\n| --- | --- |\n| Parser | Ada |"}
onChange={() => undefined}
readOnly
/>,
);

expect(screen.getByRole("table")).toBeTruthy();
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
expect(screen.getAllByRole("cell")).toHaveLength(2);
});
});

describe("mention extension", () => {
Expand Down
116 changes: 114 additions & 2 deletions plugins/tasks/editor/extensions.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions plugins/tasks/editor/tasks-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
4 changes: 4 additions & 0 deletions plugins/tasks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.