Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/components/Markdown.tsx

tsx158 行查看原文
import type { ComponentProps, ReactNode } from "react";
import { Children, Component, isValidElement, memo, useMemo } from "react";
import type { ExtraProps, StreamdownProps } from "streamdown";
import { Streamdown, useIsCodeFenceIncomplete } from "streamdown";
import { resolveMarkdownLinkTarget, type WebOpenOptions } from "../link-target.ts";
import { MarkdownContext, type MarkdownServices, useMarkdown } from "./MarkdownActions.tsx";
import { MarkdownCode } from "./MarkdownCode.tsx";
import { MarkdownImage } from "./MarkdownImage.tsx";
import { MarkdownLink } from "./MarkdownLink.tsx";
import { MarkdownTable } from "./MarkdownTable.tsx";
import { MermaidBlock } from "./MermaidBlock.tsx";
import {
  markdownCodeLanguage,
  markdownPlugins,
  markdownRehypePlugins,
  markdownRemarkPlugins,
  markdownSource,
} from "./markdown-configuration.ts";
import { shouldRenderMermaidCodeBlock } from "./mermaid-language.ts";
import { Reference } from "./Reference.tsx";

function rawText(value: unknown): string {
  if (typeof value === "string" || typeof value === "number") return String(value);
  if (Array.isArray(value)) return value.map(rawText).join("");
  if (isValidElement<{ children?: unknown }>(value)) return rawText(value.props.children);
  return "";
}
function RenderCode(props: ComponentProps<"code"> & ExtraProps) {
  const incomplete = useIsCodeFenceIncomplete();
  const language = markdownCodeLanguage(props.className);
  if (!("data-block" in props)) {
    const { node: _node, className: _className, ...codeProps } = props;
    return <code {...codeProps} className="markdown-inline-code" />;
  }
  const source = rawText(props.children);
  if (shouldRenderMermaidCodeBlock(language, source))
    return incomplete ? (
      <div data-streamdown="mermaid-block" className="mermaid-pending" role="status">
        图表生成中…
      </div>
    ) : (
      <MermaidBlock code={source} />
    );
  return <MarkdownCode source={source} language={language} incomplete={incomplete} />;
}

function ImageParagraph({ children, node: _node, ...props }: ComponentProps<"p"> & ExtraProps) {
  const elements = Children.toArray(children).filter((child) => typeof child !== "string" || child.trim());
  const images =
    elements.length > 0 &&
    elements.every(
      (child) =>
        isValidElement<{ node?: { tagName?: string } }>(child) && child.props.node?.tagName === "img",
    );
  return images ? (
    <div className={elements.length > 1 ? "markdown-image-gallery" : "markdown-image-single"}>{elements}</div>
  ) : (
    <p {...props}>{children}</p>
  );
}

function RenderLink({ href, children, node }: ComponentProps<"a"> & ExtraProps) {
  const { workspace, onLink, onFile, onFileAction } = useMarkdown();
  const original = node?.properties?.dataZpiTarget;
  const destination = typeof original === "string" ? original : href;
  const target = destination ? resolveMarkdownLinkTarget(destination, workspace) : null;
  if (target?.kind === "file" && onFile) {
    const { kind: _kind, path, ...location } = target;
    return (
      <Reference
        kind="file"
        className="message-file-link"
        path={path}
        label={rawText(children) || path.split("/").at(-1) || path}
        onOpen={() => onFile(path, location, "markdown-link")}
        onAction={onFileAction ? (path, action) => onFileAction(path, action, location) : undefined}
      />
    );
  }
  if (target?.kind !== "web" || !onLink) return <span>{children}</span>;
  return (
    <MarkdownLink url={target.url} onOpen={onLink}>
      {children}
    </MarkdownLink>
  );
}

// Stable component types preserve native selection anchors when routing callbacks change.
const components: NonNullable<StreamdownProps["components"]> = {
  a: RenderLink,
  strong: ({ node: _node, className: _className, ...props }) => <strong {...props} />,
  code: RenderCode,
  table: MarkdownTable,
  p: ImageParagraph,
  img: MarkdownImage,
};

class MarkdownBoundary extends Component<{ text: string; children: ReactNode }, { failed: boolean }> {
  state = { failed: false };
  static getDerivedStateFromError() {
    return { failed: true };
  }
  componentDidUpdate(previous: Readonly<{ text: string; children: ReactNode }>) {
    if (this.state.failed && previous.text !== this.props.text) this.setState({ failed: false });
  }
  render() {
    return this.state.failed ? (
      <div className="markdown-fallback" data-find-rendered>
        {this.props.text}
      </div>
    ) : (
      this.props.children
    );
  }
}

export const Markdown = memo(function Markdown({
  text,
  streaming,
  workspace,
  onLink,
  onCopy,
  onFile,
  onFileAction,
  onImage,
  onDownloadImage,
}: MarkdownServices & {
  text: string;
  onLink: (url: string, options?: WebOpenOptions) => void;
}) {
  const markdown = useMemo(() => markdownSource(text, streaming), [text, streaming]);
  const remarks = useMemo(() => markdownRemarkPlugins(workspace), [workspace]);
  return (
    <MarkdownContext.Provider
      value={{ streaming, workspace, onLink, onFile, onFileAction, onCopy, onImage, onDownloadImage }}
    >
      <MarkdownBoundary text={text}>
        <Streamdown
          className="markdown-body"
          mode={streaming ? "streaming" : "static"}
          parseIncompleteMarkdown={streaming}
          skipHtml
          components={components}
          plugins={markdownPlugins}
          rehypePlugins={markdownRehypePlugins}
          remarkPlugins={remarks}
          shikiTheme={["github-light", "github-dark"]}
          animated={false}
          isAnimating={streaming}
          controls={{ code: false, mermaid: false, table: false, image: false }}
        >
          {markdown}
        </Streamdown>
      </MarkdownBoundary>
    </MarkdownContext.Provider>
  );
});