refactor(table): 表格复制按钮复用 CopyButton,下载改为 markdown+BOM
- MarkdownTable 导出为公共组件 - 复制按钮直接复用 CopyButton,行为与 iframe 复制一致 - 表格数据通过 tableRef 在 render 阶段同步计算 - useLayoutEffect 确保首次渲染后即可获取正确数据 - 下载按钮改为 markdown 格式 (.md),UTF-8 with BOM - 移除废弃的 escapeCsvCell / toCsvTable
This commit is contained in:
parent
1637a0e71c
commit
407618baf0
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react";
|
import { DownloadIcon } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState, type MouseEvent } from "react";
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
AnchorHTMLAttributes,
|
AnchorHTMLAttributes,
|
||||||
ComponentPropsWithoutRef,
|
ComponentPropsWithoutRef,
|
||||||
@ -14,7 +14,9 @@ import {
|
|||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import { cn, copyToClipboard } from "@/lib/utils";
|
import { CopyButton } from "@/components/workspace/copy-button";
|
||||||
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
|
|
||||||
@ -56,21 +58,9 @@ function toMarkdownTable(data: TableData): string {
|
|||||||
return [headerLine, dividerLine, ...rowLines].join("\n");
|
return [headerLine, dividerLine, ...rowLines].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeCsvCell(value: string): string {
|
function downloadMarkdownFile(content: string, filename: string) {
|
||||||
if (!/[",\n\r]/.test(value)) return value;
|
|
||||||
return `"${value.replaceAll('"', '""')}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCsvTable(data: TableData): string {
|
|
||||||
if (data.headers.length === 0) return "";
|
|
||||||
return [data.headers, ...data.rows]
|
|
||||||
.map((row) => row.map(escapeCsvCell).join(","))
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadCsvFile(content: string, filename: string) {
|
|
||||||
const blob = new Blob(["\uFEFF", content], {
|
const blob = new Blob(["\uFEFF", content], {
|
||||||
type: "text/csv;charset=utf-8",
|
type: "text/markdown;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
@ -80,58 +70,43 @@ function downloadCsvFile(content: string, filename: string) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownTable({
|
export function MarkdownTable({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
copyLabel,
|
copyLabel: _copyLabel,
|
||||||
downloadLabel,
|
downloadLabel,
|
||||||
...props
|
...props
|
||||||
}: ComponentPropsWithoutRef<"table"> & {
|
}: ComponentPropsWithoutRef<"table"> & {
|
||||||
copyLabel: string;
|
copyLabel: string;
|
||||||
downloadLabel: string;
|
downloadLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false);
|
const tableRef = useRef<HTMLTableElement>(null);
|
||||||
|
const [, forceUpdate] = useState(0);
|
||||||
|
|
||||||
const getTableData = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
// 首次 mount 后 tableRef 才被赋值,用 useLayoutEffect 在 paint 前强制刷新
|
||||||
const wrapper = event.currentTarget.closest(
|
useLayoutEffect(() => {
|
||||||
'[data-streamdown="table-wrapper"]',
|
forceUpdate((n) => n + 1);
|
||||||
);
|
|
||||||
const table = wrapper?.querySelector("table");
|
|
||||||
if (!(table instanceof HTMLTableElement)) return null;
|
|
||||||
return parseTableData(table);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCopy = useCallback(
|
// 在 render 阶段直接从 DOM ref 计算,不依赖 effect 异步更新
|
||||||
async (event: MouseEvent<HTMLButtonElement>) => {
|
// tableRef 在上一次渲染的 commit 阶段已设置,本次渲染可用
|
||||||
const data = getTableData(event);
|
const clipboardData = (() => {
|
||||||
if (!data) return;
|
const table = tableRef.current;
|
||||||
|
if (!table) return "";
|
||||||
|
const data = parseTableData(table);
|
||||||
|
if (!data) return "";
|
||||||
|
return toMarkdownTable(data);
|
||||||
|
})();
|
||||||
|
|
||||||
const markdown = toMarkdownTable(data);
|
const handleDownload = useCallback(() => {
|
||||||
if (!markdown) return;
|
const table = tableRef.current;
|
||||||
|
if (!table) return;
|
||||||
try {
|
const data = parseTableData(table);
|
||||||
await copyToClipboard(markdown);
|
if (!data) return;
|
||||||
setCopied(true);
|
const markdown = toMarkdownTable(data);
|
||||||
window.setTimeout(() => setCopied(false), 2000);
|
if (!markdown) return;
|
||||||
} catch {
|
downloadMarkdownFile(markdown, "table.md");
|
||||||
// no-op
|
}, []);
|
||||||
}
|
|
||||||
},
|
|
||||||
[getTableData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(
|
|
||||||
(event: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
const data = getTableData(event);
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
const csv = toCsvTable(data);
|
|
||||||
if (!csv) return;
|
|
||||||
|
|
||||||
downloadCsvFile(csv, "table.csv");
|
|
||||||
},
|
|
||||||
[getTableData],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -139,25 +114,20 @@ function MarkdownTable({
|
|||||||
data-streamdown="table-wrapper"
|
data-streamdown="table-wrapper"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<button
|
<CopyButton className="text-muted-foreground hover:bg-transparent hover:text-foreground cursor-pointer p-1 transition-all" clipboardData={clipboardData} />
|
||||||
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
|
<Tooltip content={downloadLabel}>
|
||||||
onClick={handleCopy}
|
<button
|
||||||
title={copyLabel}
|
className="h-[32px] w-[32px] text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
|
||||||
type="button"
|
onClick={handleDownload}
|
||||||
>
|
type="button"
|
||||||
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
|
>
|
||||||
</button>
|
<DownloadIcon size={16} />
|
||||||
<button
|
</button>
|
||||||
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
|
</Tooltip>
|
||||||
onClick={handleDownload}
|
|
||||||
title={downloadLabel}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<DownloadIcon size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table
|
<table
|
||||||
|
ref={tableRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-border w-full border-collapse border",
|
"border-border w-full border-collapse border",
|
||||||
className,
|
className,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user