Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/components/MarkdownTable.tsx

tsx154 行查看原文
import { ArrowLeftFromLine, ArrowRightToLine, Download, Maximize2 } from "lucide-react";
import type { ComponentProps, CSSProperties } from "react";
import { useEffect, useRef, useState } from "react";
import type { ExtraProps } from "streamdown";
import { downloadBlob, MarkdownCopy, MarkdownPreview } from "./MarkdownActions.tsx";
import { tableCsv, tableMarkdown } from "./markdown-table-text.ts";

export function MarkdownTable({ children, node: _node, ...props }: ComponentProps<"table"> & ExtraProps) {
  const table = useRef<HTMLTableElement>(null);
  const frame = useRef<HTMLDivElement>(null);
  const viewport = useRef<HTMLDivElement>(null);
  const [preview, setPreview] = useState(false);
  const [expanded, setExpanded] = useState(false);
  const [metrics, setMetrics] = useState({ left: 0, max: 0, width: 0, extra: 0, thumb: 48 });
  const measure = () => {
    const element = viewport.current;
    const root = frame.current;
    if (!element || !root) return;
    const bounds = root.getBoundingClientRect();
    const container = root.closest(".conversation, .file-pane-content")?.getBoundingClientRect();
    const extra = container ? Math.max(0, container.right - bounds.right - 16) : 0;
    const next = {
      left: element.scrollLeft,
      max: Math.max(0, element.scrollWidth - element.clientWidth),
      width: bounds.width,
      extra,
      thumb: Math.max(48, element.clientWidth ** 2 / Math.max(1, element.scrollWidth)),
    };
    setMetrics((old) =>
      Object.keys(next).every((key) => old[key as keyof typeof old] === next[key as keyof typeof next])
        ? old
        : next,
    );
  };
  useEffect(() => {
    const observer = new ResizeObserver(measure);
    if (table.current) observer.observe(table.current);
    if (viewport.current) observer.observe(viewport.current);
    if (frame.current) observer.observe(frame.current);
    const container = frame.current?.closest(".conversation, .file-pane-content");
    if (container) observer.observe(container);
    return () => observer.disconnect();
  }, []);
  const rows = () =>
    Array.from(table.current?.rows ?? [], (row) =>
      Array.from(row.cells, (cell) => (cell.textContent ?? "").replace(/\s+/g, " ").trim()),
    );
  const scroll = (left: number) => {
    if (viewport.current) viewport.current.scrollLeft = left;
  };
  const tableProps = { ...props, className: "markdown-table", children };
  return (
    <div className="markdown-table-block">
      <div className="markdown-toolbar" data-markdown-table-toolbar="">
        <MarkdownCopy
          text={() => tableMarkdown(rows())}
          label="复制 Markdown"
          success="已复制 Markdown 表格"
        />
        <button
          type="button"
          className="markdown-action"
          aria-label="下载 CSV"
          title="下载 CSV"
          onClick={() =>
            downloadBlob(new Blob([tableCsv(rows())], { type: "text/csv;charset=utf-8" }), "table.csv")
          }
        >
          <Download size={14} />
        </button>
        <button
          type="button"
          className="markdown-action"
          aria-label="预览表格"
          title="预览表格"
          onClick={() => setPreview(true)}
        >
          <Maximize2 size={14} />
        </button>
        {metrics.extra > 1 && (metrics.max > 1 || expanded) && (
          <button
            type="button"
            className="markdown-action"
            aria-label={expanded ? "收回表格滚动区域" : "展开表格滚动区域"}
            title={expanded ? "收回表格滚动区域" : "展开表格滚动区域"}
            onClick={() => setExpanded((value) => !value)}
          >
            {expanded ? <ArrowLeftFromLine size={14} /> : <ArrowRightToLine size={14} />}
          </button>
        )}
      </div>
      <div ref={frame} className="markdown-table-frame" data-markdown-table-frame="">
        <div
          className="markdown-table-border"
          style={expanded ? { width: metrics.width + metrics.extra } : undefined}
        >
          <div ref={viewport} className="markdown-table-scroll" onScroll={measure}>
            <table ref={table} {...tableProps} data-streamdown="table" />
          </div>
          {metrics.left > 1 && (
            <div
              aria-hidden="true"
              className="markdown-table-shadow left"
              data-markdown-table-edge-shadow="left"
            />
          )}
          {metrics.max - metrics.left > 1 && (
            <div
              aria-hidden="true"
              className="markdown-table-shadow right"
              data-markdown-table-edge-shadow="right"
            />
          )}
        </div>
        {metrics.max > 1 && (
          <div
            className="markdown-table-scrollbar"
            style={
              {
                width: expanded ? metrics.width + metrics.extra : undefined,
                "--markdown-table-thumb-width": `${metrics.thumb}px`,
              } as CSSProperties
            }
          >
            <input
              type="range"
              aria-label="表格横向滚动"
              min={0}
              max={metrics.max}
              value={metrics.left}
              onChange={(event) => scroll(Number(event.target.value))}
              onWheel={(event) => {
                const delta = event.deltaX || (event.shiftKey ? event.deltaY : 0);
                if (delta) scroll(metrics.left + delta);
              }}
            />
          </div>
        )}
      </div>
      {preview && (
        <MarkdownPreview
          title="表格预览"
          description="在更大的可滚动视图中查看表格。"
          onClose={() => setPreview(false)}
        >
          <div className="markdown-table-preview-scroll">
            <table {...tableProps} data-streamdown="table-preview" />
          </div>
        </MarkdownPreview>
      )}
    </div>
  );
}