import { useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; const copyIcon = ( ); const downloadIcon = ( ); const checkIcon = ( ); const writeToClipboard = async (text: string): Promise => { if (!text) return false; try { await navigator.clipboard.writeText(text); return true; } catch { // Fallback for non-secure contexts. const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); let ok = false; try { ok = document.execCommand("copy"); } catch { ok = false; } finally { document.body.removeChild(textarea); } return ok; } }; const CodeBlock = ({ children }: { children?: ReactNode }) => { const [copied, setCopied] = useState(false); const codeRef = useRef(null); const timerRef = useRef(null); useEffect(() => { return () => { if (timerRef.current !== null) { window.clearTimeout(timerRef.current); } }; }, []); const handleCopy = async () => { const text = codeRef.current?.innerText ?? ""; await writeToClipboard(text); setCopied(true); if (timerRef.current !== null) { window.clearTimeout(timerRef.current); } timerRef.current = window.setTimeout(() => setCopied(false), 1500); }; const captureCodeRef = (element: HTMLElement | null) => { codeRef.current = element?.querySelector("code") ?? null; }; return (
{children}
); }; type TableCell = { text: string; isHeader: boolean }; const readTable = (table: HTMLTableElement): { headers: string[]; rows: string[][] } => { const rows = Array.from(table.querySelectorAll("tr")); const matrix: TableCell[][] = rows.map((row) => Array.from(row.querySelectorAll("th,td")).map((cell) => ({ text: (cell.textContent ?? "").replace(/\s+/g, " ").trim(), isHeader: cell.tagName.toLowerCase() === "th", })), ); if (matrix.length === 0) return { headers: [], rows: [] }; // If the first row is a header row, split it; otherwise synthesize generic headers. const firstRow = matrix[0] ?? []; const hasHeader = firstRow.length > 0 && firstRow.every((cell) => cell.isHeader); const headers = hasHeader ? firstRow.map((cell) => cell.text) : (matrix[0] ?? []).map((_, index) => `Column ${index + 1}`); const dataRows = hasHeader ? matrix.slice(1) : matrix; return { headers, rows: dataRows.map((row) => row.map((cell) => cell.text)), }; }; const toTsv = (headers: string[], rows: string[][]): string => { const escape = (value: string) => value.replace(/\t/g, " ").replace(/\n/g, " "); return [headers, ...rows].map((row) => row.map(escape).join("\t")).join("\n"); }; const toCsv = (headers: string[], rows: string[][]): string => { // RFC 4180: wrap fields containing comma, quote, or newline in double quotes; // escape internal double quotes by doubling them. const escape = (value: string) => { if (/[",\n\r]/.test(value)) { return `"${value.replace(/"/g, '""')}"`; } return value; }; return [headers, ...rows].map((row) => row.map(escape).join(",")).join("\r\n"); }; const downloadFile = (filename: string, content: string, mime: string) => { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.rel = "noreferrer"; document.body.appendChild(link); link.click(); document.body.removeChild(link); // Defer revoke so the download starts cleanly. window.setTimeout(() => URL.revokeObjectURL(url), 0); }; const TableBlock = ({ children }: { children?: ReactNode }) => { const [copied, setCopied] = useState(false); const tableRef = useRef(null); const timerRef = useRef(null); useEffect(() => { return () => { if (timerRef.current !== null) { window.clearTimeout(timerRef.current); } }; }, []); const flashCopied = () => { setCopied(true); if (timerRef.current !== null) { window.clearTimeout(timerRef.current); } timerRef.current = window.setTimeout(() => setCopied(false), 1500); }; const handleCopy = async () => { if (!tableRef.current) return; const { headers, rows } = readTable(tableRef.current); await writeToClipboard(toTsv(headers, rows)); flashCopied(); }; const handleDownloadCsv = () => { if (!tableRef.current) return; const { headers, rows } = readTable(tableRef.current); const filename = `table-${new Date().toISOString().replace(/[:.]/g, "-")}.csv`; // Prefix with UTF-8 BOM so Excel opens it correctly. downloadFile(filename, `\uFEFF${toCsv(headers, rows)}`, "text/csv;charset=utf-8"); }; return (
{children}
); }; export const normalizeMarkdown = (value: string) => value.replace(/\\n/g, "\n").replace(/\\t/g, "\t"); export const markdownComponents = { a: ({ href, children }: { href?: string; children?: ReactNode }) => ( {children} ), pre: ({ children }: { children?: ReactNode }) => {children}, table: ({ children }: { children?: ReactNode }) => {children}, }; export const MarkdownView = ({ source }: { source: string }) => ( {normalizeMarkdown(source)} ); export { CodeBlock };