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
93 changes: 93 additions & 0 deletions apps/web/src/components/chat/MarkdownCopy.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,96 @@ describe("markdown copy without the async clipboard", () => {
expect(clipboard.execCommand).toHaveBeenCalledWith("copy");
});
});

describe("copying a table as HTML", () => {
it("writes the markup as plain text and as the rich HTML flavor", async () => {
await using screen = await render(
<MarkdownTable>
<thead>
<tr>
<th>Name</th>
<th style={{ textAlign: "right" }}>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ada &amp; co</td>
<td style={{ textAlign: "right" }}>1,200</td>
</tr>
<tr>
<td>
first
<br />
second
</td>
<td>
{/* The wrapper MarkdownMath renders around a formula. */}
<span data-markdown-math="inline" data-markdown-copy="\(x^2\)">
x²
</span>
</td>
</tr>
<tr>
<td>zero</td>
<td>garbage</td>
</tr>
</tbody>
</MarkdownTable>,
// The table renders no links and reads no route.
{ router: false },
);
// Raw span values, so the copy reads the DOM's own rowSpan/colSpan normalization.
const sourceCell = (text: string) =>
[...document.querySelectorAll("td")].find((cell) => cell.textContent === text);
sourceCell("zero")?.setAttribute("rowspan", "0");
sourceCell("garbage")?.setAttribute("colspan", "2garbage");
sourceCell("Ada & co")?.setAttribute("rowspan", "-1");
const items: ClipboardItem[] = [];
const write = vi.spyOn(navigator.clipboard, "write").mockImplementation(async (data) => {
items.push(...data);
});

try {
await userEvent.click(screen.getByRole("button", { name: "Copy table" }));
await userEvent.click(page.getByRole("menuitem", { name: "Copy as HTML" }));
await expect.element(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();

const [item] = items;
expect(item?.types).toEqual(["text/plain", "text/html"]);
const markup = await (await item!.getType("text/plain")).text();
expect(markup).toMatch(/^<table[\s>]/);
expect(await (await item!.getType("text/html")).text()).toBe(
`<meta charset="utf-8">${markup}`,
);

const pasted = new DOMParser().parseFromString(markup, "text/html").querySelector("table");
const cells = [...(pasted?.querySelectorAll("th, td") ?? [])] as HTMLElement[];
expect(cells.map((cell) => [cell.tagName, cell.innerHTML, cell.style.textAlign])).toEqual([
["TH", "Name", "left"],
["TH", "Amount", "right"],
["TD", "Ada &amp; co", ""],
["TD", "1,200", "right"],
["TD", "first<br>second", ""],
["TD", "\\(x^2\\)", ""],
["TD", "zero", ""],
["TD", "garbage", ""],
]);
const span = (cell: HTMLElement | undefined) => [
cell?.getAttribute("rowspan") ?? null,
cell?.getAttribute("colspan") ?? null,
];
// rowspan="0" runs to the section end, "2garbage" reads as 2, a negative span is dropped.
expect([span(cells[6]), span(cells[7]), span(cells[2])]).toEqual([
["0", null],
[null, "2"],
[null, null],
]);
// Fixed light colors: the copy reads the same whichever theme it came from.
expect(cells[0]?.style.backgroundColor).toBe("rgb(246, 248, 250)");
expect(cells[0]?.style.color).toBe("rgb(31, 35, 40)");
expect(cells.every((cell) => cell.style.borderStyle === "solid")).toBe(true);
} finally {
write.mockRestore();
Comment on lines +133 to +162

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats the serializer's cell-content, alignment, span, and style checks in the browser layer. Could you keep the clipboard-flavor assertions here and remove the duplicated serialization assertions?

Posted via Macroscope — Test Signal

}
});
});
21 changes: 15 additions & 6 deletions apps/web/src/components/chat/MarkdownTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,39 @@ import { useRef, type ComponentProps } from "react";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import {
serializeTableElementToCsv,
serializeTableElementToHtml,
serializeTableElementToMarkdown,
} from "../../markdown-clipboard";
import { Button } from "../ui/button";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
import { ScrollArea } from "../ui/scroll-area";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { reportMarkdownActionFailure } from "./markdownActionFailure";
import { reportMarkdownActionFailure, type TableCopyFormat } from "./markdownActionFailure";

const TABLE_COPY_SERIALIZERS: Record<TableCopyFormat, (table: Element) => string> = {
markdown: serializeTableElementToMarkdown,
csv: serializeTableElementToCsv,
html: serializeTableElementToHtml,
};

export function MarkdownTable({ children, ...props }: ComponentProps<"table">) {
const containerRef = useRef<HTMLDivElement | null>(null);
const { copyToClipboard, isCopied: copied } = useCopyToClipboard<"markdown" | "csv">({
const { copyToClipboard, isCopied: copied } = useCopyToClipboard<TableCopyFormat>({
timeout: 1200,
onError: (cause, format) =>
reportMarkdownActionFailure({ operation: "copy-table", format }, cause),
});
const copyLabel = copied ? "Copied" : "Copy table";

const handleCopy = (format: "markdown" | "csv") => {
const handleCopy = (format: TableCopyFormat) => {
const table = containerRef.current?.querySelector("table");
if (!table) return;
const text = TABLE_COPY_SERIALIZERS[format](table);
// Plain-text targets get the markup; rich targets (mail, docs, wikis) paste a real table.
copyToClipboard(
format === "markdown"
? serializeTableElementToMarkdown(table)
: serializeTableElementToCsv(table),
text,
format,
format === "html" ? { "text/html": `<meta charset="utf-8">${text}` } : undefined,
);
};

Expand Down Expand Up @@ -62,6 +70,7 @@ export function MarkdownTable({ children, ...props }: ComponentProps<"table">) {
<MenuPopup align="end">
<MenuItem onClick={() => handleCopy("markdown")}>Copy as Markdown</MenuItem>
<MenuItem onClick={() => handleCopy("csv")}>Copy as CSV</MenuItem>
<MenuItem onClick={() => handleCopy("html")}>Copy as HTML</MenuItem>
</MenuPopup>
</Menu>
</div>
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/markdownActionFailure.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export type TableCopyFormat = "markdown" | "csv" | "html";

interface MarkdownActionFailureContext {
readonly operation: string;
readonly target?: string;
readonly format?: "markdown" | "csv";
readonly format?: TableCopyFormat;
readonly language?: string;
readonly fenceTitle?: string;
readonly copyTarget?: string;
Expand Down
59 changes: 36 additions & 23 deletions apps/web/src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,33 +204,46 @@ export function useCopyToClipboard<TContext = void>({
onCopy?: (ctx: TContext) => void;
onError?: (error: Error, ctx: TContext) => void;
extraFlavors?: Readonly<Record<string, string>>;
} = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } {
} = {}): {
copyToClipboard: (
value: string,
ctx: TContext,
flavors?: Readonly<Record<string, string>>,
) => void;
isCopied: boolean;
} {
const [isCopied, setIsCopied] = React.useState(false);
const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null);
const copyToClipboard = useStableCallback((value: string, ctx: TContext): void => {
void writeTextToClipboard(value, target, extraFlavors).then(
(didCopy) => {
if (!didCopy) return;
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
}
setIsCopied(true);
const copyToClipboard = useStableCallback(
(
value: string,
ctx: TContext,
flavors: Readonly<Record<string, string>> | undefined = extraFlavors,
): void => {
void writeTextToClipboard(value, target, flavors).then(
(didCopy) => {
if (!didCopy) return;
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
}
setIsCopied(true);

onCopy?.(ctx);
onCopy?.(ctx);

if (timeout !== 0) {
timeoutIdRef.current = setTimeout(() => {
setIsCopied(false);
timeoutIdRef.current = null;
}, timeout);
}
},
(error) => {
console.error(error);
onError?.(error, ctx);
},
);
});
if (timeout !== 0) {
timeoutIdRef.current = setTimeout(() => {
setIsCopied(false);
timeoutIdRef.current = null;
}, timeout);
}
},
(error) => {
console.error(error);
onError?.(error, ctx);
},
);
},
);

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the copied-state reset timer on unmount
React.useEffect(() => {
Expand Down
Loading
Loading