Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/components/MermaidBlock.tsx

tsx266 行查看原文
// Rendering queue, palette normalization and base theme adapted from ZCode 872ad960.
// Apache-2.0; desktop labels and theme tokens adapted for ZPI. See licenses/third-party-notices.txt.
import { createMermaidPlugin, type MermaidConfig } from "@streamdown/mermaid";
import { Maximize2, X } from "lucide-react";
import { memo, useEffect, useId, useMemo, useState } from "react";
import { useAppearance } from "../appearance.ts";
import { getCurrentMermaidDocumentVisibility, resolveMermaidAutoRenderDecision } from "./mermaid-budget.ts";

const mermaidPlugin = createMermaidPlugin();
let mermaidRenderQueue = Promise.resolve();
const MERMAID_COLOR_CANVAS_SENTINEL = "#010203";

// Mermaid 底层的 khroma 解析器不支持 Tailwind v4 常见的 oklab/color-mix 结果。
// 先让浏览器解析主题 token,再通过 canvas 采样成传统 rgb/rgba,避免把现代 CSS 颜色直接传给 Mermaid。
// Web 远程控制的启动测试只提供了最小 document mock,SSR/预渲染环境也可能没有 DOM 工厂;
// 颜色归一化是增强能力,不能让 MessageResponse 的静态导入在这些环境中直接崩溃。
const canCreateDomElements = typeof document !== "undefined" && typeof document.createElement === "function";
const mermaidColorResolverEl: HTMLSpanElement | null = canCreateDomElements
  ? document.createElement("span")
  : null;
const mermaidColorNormalizeCtx: CanvasRenderingContext2D | null = (() => {
  if (!canCreateDomElements) {
    return null;
  }

  const canvas = document.createElement("canvas");
  canvas.width = 1;
  canvas.height = 1;
  const ctx = canvas.getContext("2d", { willReadFrequently: true });
  if (ctx) {
    ctx.globalCompositeOperation = "copy";
  }
  return ctx;
})();

function enqueueMermaidRender<T>(task: () => Promise<T>): Promise<T> {
  const run = mermaidRenderQueue.then(task, task);
  mermaidRenderQueue = run.then(
    () => undefined,
    () => undefined,
  );
  return run;
}

function hashMermaidCode(code: string): string {
  let hash = 2166136261;
  for (let index = 0; index < code.length; index += 1) {
    hash ^= code.charCodeAt(index);
    hash = Math.imul(hash, 16777619);
  }

  return (hash >>> 0).toString(36);
}

function normalizeCssColorForMermaid(raw: string, fallback: string): string {
  if (!raw || !mermaidColorResolverEl || !mermaidColorNormalizeCtx || !document.body) {
    return raw || fallback;
  }

  try {
    document.body.appendChild(mermaidColorResolverEl);
    mermaidColorResolverEl.style.color = "";
    mermaidColorResolverEl.style.color = raw;
    const resolved = getComputedStyle(mermaidColorResolverEl).color;
    if (!resolved) {
      return fallback;
    }

    mermaidColorNormalizeCtx.clearRect(0, 0, 1, 1);
    mermaidColorNormalizeCtx.fillStyle = MERMAID_COLOR_CANVAS_SENTINEL;
    const sentinelFillStyle = mermaidColorNormalizeCtx.fillStyle;
    mermaidColorNormalizeCtx.fillStyle = resolved;
    // 如果 canvas 也不支持该颜色格式,fillStyle 会停在哨兵色,直接回退到 Mermaid 可解析的安全色。
    if (mermaidColorNormalizeCtx.fillStyle === sentinelFillStyle) {
      return fallback;
    }

    mermaidColorNormalizeCtx.fillRect(0, 0, 1, 1);
    const [r = 0, g = 0, b = 0, a = 255] = mermaidColorNormalizeCtx.getImageData(0, 0, 1, 1).data;
    const roundedAlpha = +(a / 255).toFixed(3);
    return roundedAlpha >= 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${roundedAlpha})`;
  } catch {
    return fallback;
  } finally {
    mermaidColorResolverEl.remove();
  }
}

function resolveCssColor(variableName: string, fallback: string): string {
  if (typeof document === "undefined" || !document.body) {
    return fallback;
  }

  const probe = document.createElement("span");
  probe.style.color = `var(${variableName})`;
  probe.style.pointerEvents = "none";
  probe.style.position = "absolute";
  probe.style.visibility = "hidden";
  document.body.appendChild(probe);
  const color = getComputedStyle(probe).color;
  probe.remove();

  return normalizeCssColorForMermaid(color, fallback);
}

function createMermaidConfig(resolvedTheme: "light" | "dark", fontSize: string): MermaidConfig {
  const background = resolveCssColor("--color-surface", resolvedTheme === "dark" ? "#18181b" : "#ffffff");
  const surface = resolveCssColor("--color-surface-muted", resolvedTheme === "dark" ? "#27272a" : "#f4f4f5");
  const accent = resolveCssColor("--color-active", resolvedTheme === "dark" ? "#1f2937" : "#f0f9ff");
  const text = resolveCssColor("--color-text", resolvedTheme === "dark" ? "#f4f4f5" : "#27272a");
  const subtleText = resolveCssColor("--color-text-muted", resolvedTheme === "dark" ? "#a1a1aa" : "#52525b");
  const border = resolveCssColor("--color-border", resolvedTheme === "dark" ? "#3f3f46" : "#d4d4d8");

  return {
    fontFamily: "ui-sans-serif, system-ui, sans-serif",
    securityLevel: "strict",
    startOnLoad: false,
    suppressErrorRendering: true,
    theme: "base",
    themeVariables: {
      fontSize,
      actorBkg: background,
      actorBorder: border,
      actorTextColor: text,
      background,
      lineColor: subtleText,
      mainBkg: background,
      nodeBorder: border,
      noteBkgColor: accent,
      noteTextColor: text,
      primaryBorderColor: border,
      primaryColor: surface,
      primaryTextColor: text,
      secondaryBorderColor: border,
      secondaryColor: accent,
      secondaryTextColor: text,
      signalColor: subtleText,
      signalTextColor: text,
      tertiaryBorderColor: border,
      tertiaryColor: background,
      tertiaryTextColor: text,
      textColor: text,
    },
  };
}

function sizeInlineDiagram(element: HTMLDivElement | null) {
  const svg = element?.querySelector("svg");
  const width = svg?.viewBox.baseVal.width ?? 0;
  // Preserve natural diagram size in the scroll container. Percentage-width
  // SVGs otherwise shrink wide graphs under the desktop's flex layout.
  if (svg && Number.isFinite(width) && width > 0) svg.style.width = `${width}px`;
}

export const MermaidBlock = memo(function MermaidBlock({ code }: { code: string }) {
  const renderIdPrefix = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const { theme, fontSize } = useAppearance();
  const dark = theme === "dark";
  const [visibility, setVisibility] = useState(getCurrentMermaidDocumentVisibility);
  const [renderState, setRenderState] = useState<{ status: "loading" | "ready" | "plaintext"; svg?: string }>(
    { status: "loading" },
  );
  const [preview, setPreview] = useState(false);
  useEffect(() => {
    const visible = () => setVisibility(getCurrentMermaidDocumentVisibility());
    document.addEventListener("visibilitychange", visible);
    return () => {
      document.removeEventListener("visibilitychange", visible);
    };
  }, []);
  const config = useMemo(() => createMermaidConfig(theme, fontSize), [theme, fontSize]);
  const trimmedCode = code.trim();
  const decision = useMemo(
    () => resolveMermaidAutoRenderDecision(trimmedCode, { documentVisibilityState: visibility }),
    [trimmedCode, visibility],
  );
  useEffect(() => {
    let cancelled = false;
    setPreview(false);
    if (!trimmedCode || !decision.shouldRender) {
      setRenderState({ status: "plaintext" });
      return;
    }
    setRenderState({ status: "loading" });
    const renderId = `zpi-mermaid-${renderIdPrefix}-${hashMermaidCode(`${dark}:${trimmedCode}`)}`;
    void enqueueMermaidRender(async () => {
      if (cancelled) return undefined;
      return mermaidPlugin.getMermaid(config).render(renderId, trimmedCode);
    })
      .then((result) => {
        if (!cancelled && result) setRenderState({ status: "ready", svg: result.svg });
      })
      .catch(() => {
        if (!cancelled) setRenderState({ status: "plaintext" });
      });
    return () => {
      cancelled = true;
    };
  }, [trimmedCode, config, dark, decision, renderIdPrefix]);
  return (
    <div
      data-streamdown="mermaid-block"
      data-mermaid-block=""
      data-render-state={renderState.status}
      className="zpi-mermaid"
    >
      <div className="mermaid-toolbar">
        <span>mermaid</span>
        <button
          type="button"
          aria-label="放大图表"
          title="放大图表"
          disabled={renderState.status !== "ready"}
          onClick={() => setPreview(true)}
        >
          <Maximize2 size={13} />
        </button>
      </div>
      <div className="mermaid-scroll">
        {renderState.status === "loading" && (
          <div className="mermaid-pending" role="status">
            图表生成中…
          </div>
        )}
        {renderState.status === "plaintext" && <pre className="mermaid-source">{code}</pre>}
        {renderState.status === "ready" && (
          <div
            role="img"
            aria-label="Mermaid 图表"
            className="mermaid-diagram"
            ref={sizeInlineDiagram}
            // biome-ignore lint/security/noDangerouslySetInnerHtml: Mermaid strict mode sanitizes the generated SVG with DOMPurify.
            dangerouslySetInnerHTML={{ __html: renderState.svg ?? "" }}
            onDoubleClick={() => setPreview(true)}
          />
        )}
      </div>
      {preview && renderState.svg && (
        <dialog
          className="mermaid-preview"
          aria-label="图表预览"
          ref={(el) => {
            if (el && !el.open) el.showModal();
          }}
          onCancel={() => setPreview(false)}
        >
          <div className="mermaid-toolbar">
            <span>图表预览</span>
            <button type="button" aria-label="关闭图表预览" onClick={() => setPreview(false)}>
              <X size={18} />
            </button>
          </div>
          <div className="mermaid-scroll">
            <div
              role="img"
              aria-label="Mermaid 图表预览"
              // biome-ignore lint/security/noDangerouslySetInnerHtml: This is the same strict-mode sanitized SVG as the inline diagram.
              dangerouslySetInnerHTML={{ __html: renderState.svg }}
            />
          </div>
        </dialog>
      )}
    </div>
  );
});