feat: expand intelligent investigation workspace
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { Children, cloneElement, isValidElement, useCallback, useContext, useRef, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { Streamdown, StreamdownContext, extractTableDataFromElement, tableDataToCSV, tableDataToTSV, type CustomRendererProps } from "streamdown";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { Check, Copy, Download, Maximize2, Table2, Workflow, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/shared/ui/dialog";
|
||||
import { createReadingPreview } from "@/shared/ui/reading-preview";
|
||||
import { copySelectedText } from "./paragraph-copy";
|
||||
|
||||
/** Move the existing renderer into a bounded reader. Pan, selection and DOM state survive. */
|
||||
function MarkdownReading({ title, kind, children, expandedChildren, toolbar }: {
|
||||
title: string;
|
||||
kind: "table" | "diagram";
|
||||
children: ReactNode;
|
||||
expandedChildren?: ReactNode;
|
||||
toolbar?: (host: HTMLDivElement) => ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [host] = useState(() => document.createElement("div"));
|
||||
const inline = useRef<HTMLDivElement | null>(null);
|
||||
const snapshot = useRef<HTMLElement | null>(null);
|
||||
const { isAnimating } = useContext(StreamdownContext);
|
||||
const attachInline = useCallback((slot: HTMLDivElement | null) => {
|
||||
inline.current = slot;
|
||||
if (slot && !host.isConnected) slot.append(host);
|
||||
}, [host]);
|
||||
const attachExpanded = useCallback((slot: HTMLDivElement | null) => {
|
||||
if (!inline.current) return;
|
||||
if (slot) {
|
||||
if (kind === "diagram") {
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = createReadingPreview(host);
|
||||
inline.current.append(snapshot.current);
|
||||
}
|
||||
slot.append(host);
|
||||
} else {
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = null;
|
||||
inline.current.append(host);
|
||||
}
|
||||
}, [host, kind]);
|
||||
const Icon = kind === "table" ? Table2 : Workflow;
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<section className="markdown-reading-block" data-streamdown={kind === "table" ? "table-wrapper" : undefined} data-markdown-diagram={kind === "diagram" || undefined}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><Icon size={16} aria-hidden />{title}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{toolbar?.(host)}
|
||||
<DialogTrigger asChild><Button variant="ghost" size="icon-sm" disabled={isAnimating} aria-label={`放大查看${title}`} title={`放大查看${title}`}><Maximize2 size={15} aria-hidden /></Button></DialogTrigger>
|
||||
</div>
|
||||
</header>
|
||||
{kind === "table" ? <div className="min-w-0">{children}</div> : <div ref={attachInline} className="min-w-0" />}
|
||||
</section>
|
||||
{createPortal(expandedChildren ?? children, host)}
|
||||
<DialogContent onCopy={copySelectedText} className="markdown-reading-window motion-reduce:animate-none" data-reading-kind={kind} overlayClassName="markdown-reading-backdrop motion-reduce:animate-none" data-agent-theme="chat" showCloseButton={false}
|
||||
{...(kind === "table" ? { "aria-describedby": undefined } : {})}
|
||||
onEscapeKeyDown={(event) => { event.preventDefault(); event.stopPropagation(); setOpen(false); }}>
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="min-w-0"><DialogTitle className="text-base leading-6 tracking-normal">{title}</DialogTitle>
|
||||
{kind === "diagram" ? <DialogDescription className="mt-0.5 text-xs">可缩放和平移查看图中关系。</DialogDescription> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">{toolbar?.(host)}<DialogClose asChild><Button variant="ghost" size="icon-sm" aria-label="关闭放大查看"><X size={17} aria-hidden /></Button></DialogClose></div>
|
||||
</header>
|
||||
<div className="markdown-reading-scroll min-h-0 flex-1 overflow-auto overscroll-contain p-3 md:p-5" data-markdown-reading-scroll>
|
||||
<div ref={kind === "table" ? (slot) => { if (slot) slot.append(host); } : attachExpanded} className="min-w-0" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
function MarkdownTableActions({ host }: { host: HTMLDivElement }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const getData = () => {
|
||||
const table = host.querySelector("table");
|
||||
return table ? extractTableDataFromElement(table) : null;
|
||||
};
|
||||
return <><Button type="button" variant="ghost" size="icon-sm" aria-label={copied ? "表格已复制" : "复制表格"} onClick={async () => {
|
||||
const data = getData();
|
||||
if (!data) return;
|
||||
try { await navigator.clipboard.writeText(tableDataToTSV(data)); setCopied(true); setError(false); }
|
||||
catch { setError(true); }
|
||||
}}>{copied ? <Check size={15} aria-hidden /> : <Copy size={15} aria-hidden />}</Button>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="下载表格" onClick={() => {
|
||||
const data = getData();
|
||||
if (!data) return;
|
||||
const url = URL.createObjectURL(new Blob(["\uFEFF", tableDataToCSV(data)], { type: "text/csv;charset=utf-8" }));
|
||||
const anchor = document.createElement("a"); anchor.href = url; anchor.download = "table.csv"; anchor.click();
|
||||
const revoke = URL.revokeObjectURL.bind(URL);
|
||||
setTimeout(() => revoke(url), 1000);
|
||||
}}><Download size={15} aria-hidden /></Button>
|
||||
{error ? <span role="status" className="text-xs text-destructive">复制失败,请重试</span> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ReadableMarkdownTable({ children, className, node: _node, ...props }: ComponentProps<"table"> & { node?: unknown }) {
|
||||
const table = <div className="markdown-table-scroll" role="region" aria-label="表格数据" tabIndex={0}>
|
||||
<table {...props} className={className} data-streamdown="table">{children}</table>
|
||||
</div>;
|
||||
return <MarkdownReading title="表格" kind="table" expandedChildren={withoutExplicitIds(table)} toolbar={(host) => <MarkdownTableActions host={host} />}>
|
||||
{table}
|
||||
</MarkdownReading>;
|
||||
}
|
||||
|
||||
function withoutExplicitIds(children: ReactNode): ReactNode {
|
||||
return Children.map(children, (child) => {
|
||||
if (!isValidElement<{ id?: string; children?: ReactNode }>(child)) return child;
|
||||
return cloneElement(child, { id: undefined, ...(child.props.children !== undefined ? { children: withoutExplicitIds(child.props.children) } : {}) });
|
||||
});
|
||||
}
|
||||
|
||||
export function ReadableMarkdownDiagram({ code, isIncomplete }: CustomRendererProps) {
|
||||
return <MarkdownReading title="图示" kind="diagram">
|
||||
{isIncomplete ? <p className="p-4 text-sm text-muted-foreground">图示仍在生成…</p> : <Streamdown
|
||||
className="markdown-diagram-renderer" plugins={{ mermaid }}
|
||||
mermaid={{ config: { securityLevel: "strict", theme: "neutral", fontFamily: '"Noto Sans SC Variable", "PingFang SC", sans-serif', themeVariables: { fontSize: "18px" }, flowchart: { useMaxWidth: false, padding: 20 } } }}
|
||||
controls={{ mermaid: { fullscreen: false, panZoom: true, copy: true, download: true } }}
|
||||
>{`\`\`\`mermaid\n${code}\n\`\`\``}</Streamdown>}
|
||||
</MarkdownReading>;
|
||||
}
|
||||
Reference in New Issue
Block a user