diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx index ab5de7ea..5f970bcb 100644 --- a/frontend/src/components/workspace/messages/markdown-content.tsx +++ b/frontend/src/components/workspace/messages/markdown-content.tsx @@ -1,14 +1,20 @@ "use client"; -import { useMemo } from "react"; -import type { AnchorHTMLAttributes } from "react"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useCallback, useMemo, useState, type MouseEvent } from "react"; +import type { + AnchorHTMLAttributes, + ComponentPropsWithoutRef, + ReactNode, +} from "react"; import { MessageResponse, type MessageResponseProps, } from "@/components/ai-elements/message"; +import { useI18n } from "@/core/i18n/hooks"; import { streamdownPlugins } from "@/core/streamdown"; -import { cn } from "@/lib/utils"; +import { cn, copyToClipboard } from "@/lib/utils"; import { CitationLink } from "../citations/citation-link"; @@ -25,6 +31,89 @@ export type MarkdownContentProps = { components?: MessageResponseProps["components"]; }; +type TableData = { + headers: string[]; + rows: string[][]; +}; + +function parseTableData(table: HTMLTableElement): TableData { + const headers = Array.from(table.querySelectorAll("thead th")).map((cell) => + (cell.textContent ?? "").trim(), + ); + const rows = Array.from(table.querySelectorAll("tbody tr")).map((row) => + Array.from(row.querySelectorAll("td")).map( + (cell) => (cell.textContent ?? "").trim(), + ), + ); + return { headers, rows }; +} + +function toMarkdownTable(data: TableData): string { + if (data.headers.length === 0) return ""; + const headerLine = `| ${data.headers.join(" | ")} |`; + const dividerLine = `| ${data.headers.map(() => "---").join(" | ")} |`; + const rowLines = data.rows.map((row) => `| ${row.join(" | ")} |`); + return [headerLine, dividerLine, ...rowLines].join("\n"); +} + +function MarkdownTable({ + className, + children, + isLoading, + copyLabel, + ...props +}: ComponentPropsWithoutRef<"table"> & { + isLoading: boolean; + copyLabel: string; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback( + async (event: MouseEvent) => { + const wrapper = event.currentTarget.closest('[data-streamdown="table-wrapper"]'); + const table = wrapper?.querySelector("table"); + if (!(table instanceof HTMLTableElement)) return; + + const markdown = toMarkdownTable(parseTableData(table)); + if (!markdown) return; + + try { + await copyToClipboard(markdown); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + // no-op + } + }, + [], + ); + + return ( +
+
+ +
+
+ + {children} +
+
+
+ ); +} + /** Renders markdown content. */ export function MarkdownContent({ content, @@ -34,6 +123,8 @@ export function MarkdownContent({ remarkPlugins = streamdownPlugins.remarkPlugins, components: componentsFromProps, }: MarkdownContentProps) { + const { t } = useI18n(); + const components = useMemo(() => { return { a: (props: AnchorHTMLAttributes) => { @@ -58,9 +149,23 @@ export function MarkdownContent({ /> ); }, + table: ({ + children, + className, + ...props + }: ComponentPropsWithoutRef<"table"> & { children?: ReactNode }) => ( + + {children} + + ), ...componentsFromProps, }; - }, [componentsFromProps]); + }, [componentsFromProps, isLoading, t.clipboard.copyToClipboard]); if (!content) return null; @@ -68,6 +173,7 @@ export function MarkdownContent({