Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/components/MarkdownImage.tsx

tsx216 行查看原文
import { ChevronLeft, ChevronRight, Download, Image as ImageIcon, ImageOff, Minus, Plus } from "lucide-react";
import type { ComponentProps } from "react";
import { useEffect, useRef, useState } from "react";
import type { ExtraProps } from "streamdown";
import { resolveLinkTarget } from "../link-target.ts";
import { MarkdownPreview, useMarkdown } from "./MarkdownActions.tsx";

export function MarkdownImage({ src, alt = "图片预览", node }: ComponentProps<"img"> & ExtraProps) {
  const { onImage, onDownloadImage, workspace } = useMarkdown();
  const original = node?.properties?.dataZpiTarget;
  const target = typeof original === "string" ? original : typeof src === "string" ? src : "";
  const resolved = resolveLinkTarget(target, workspace);
  const path = resolved?.kind === "file" ? resolved.path : undefined;
  const relative = resolved?.kind === "file" && resolved.relative;
  const [local, setLocal] = useState<{ path: string; src?: string; failed?: boolean }>();
  const [status, setStatus] = useState<{ src: string; failed?: boolean }>();
  const [preview, setPreview] = useState<{ items: { src: string; alt: string }[]; index: number }>();
  const [downloadError, setDownloadError] = useState("");
  const [zoom, setZoom] = useState(1);
  const [pan, setPan] = useState({ x: 0, y: 0 });
  const pointer = useRef<{ x: number; y: number; pan: typeof pan } | undefined>(undefined);
  useEffect(() => {
    if (!path || !onImage) return;
    let active = true;
    void onImage(path, { relative }).then(
      (src) => {
        if (active) setLocal({ path, src });
      },
      () => {
        if (active) setLocal({ path, failed: true });
      },
    );
    return () => {
      active = false;
    };
  }, [target, path, relative, onImage]);
  const display = path
    ? local?.path === path
      ? local.src
      : undefined
    : resolved?.kind === "web" || /^data:image\//i.test(target)
      ? target
      : undefined;
  const failed = Boolean(
    (!resolved && !/^data:image\//i.test(target)) ||
      (path && ((local?.path === path && local.failed) || !onImage)) ||
      (status?.src === display && status?.failed),
  );
  const loaded = Boolean(display && status?.src === display && !status?.failed);
  const change = (index: number) => {
    setPreview((old) => (old ? { ...old, index } : old));
    setZoom(1);
    setPan({ x: 0, y: 0 });
    setDownloadError("");
  };
  if (!target) return null;
  return (
    <>
      <button
        type="button"
        className={`markdown-image-trigger ${loaded ? "" : "pending"}`}
        data-markdown-image-trigger=""
        aria-label={failed ? "图片无法显示" : "打开图片预览"}
        disabled={!loaded}
        onClick={(event) => {
          const group = event.currentTarget.closest(".markdown-image-gallery") ?? event.currentTarget;
          const items = Array.from(group.querySelectorAll<HTMLImageElement>("img"))
            .filter((image) => image.complete && image.naturalWidth)
            .map((image) => ({ src: image.src, alt: image.alt }));
          setPreview({
            items,
            index: Math.max(
              0,
              items.findIndex((item) => item.src === display),
            ),
          });
        }}
      >
        {!loaded && (
          <span role="status">
            <span className="sr-only">{failed ? "图片无法显示" : "正在加载图片"}</span>
            {failed ? <ImageOff size={24} /> : <ImageIcon size={24} />}
          </span>
        )}
        {display && !failed && (
          <img
            key={display}
            src={display}
            alt={alt}
            loading="lazy"
            draggable={false}
            data-streamdown="image"
            onLoad={() => setStatus({ src: display })}
            onError={() => setStatus({ src: display, failed: true })}
          />
        )}
      </button>
      {preview?.items[preview.index] && (
        <MarkdownPreview
          title="图片预览"
          className="markdown-image-preview"
          onClose={() => {
            setPreview(undefined);
            setDownloadError("");
            setZoom(1);
            setPan({ x: 0, y: 0 });
          }}
        >
          <div className="markdown-image-preview-toolbar">
            <button
              type="button"
              className="markdown-action"
              aria-label="上一张图片"
              disabled={preview.index === 0}
              onClick={() => change(preview.index - 1)}
            >
              <ChevronLeft size={16} />
            </button>
            <span>
              {preview.index + 1} / {preview.items.length}
            </span>
            <button
              type="button"
              className="markdown-action"
              aria-label="下一张图片"
              disabled={preview.index === preview.items.length - 1}
              onClick={() => change(preview.index + 1)}
            >
              <ChevronRight size={16} />
            </button>
            <button
              type="button"
              className="markdown-action"
              aria-label="缩小"
              disabled={zoom <= 0.25}
              onClick={() => {
                setZoom((value) => Math.max(0.25, value - 0.25));
                setPan({ x: 0, y: 0 });
              }}
            >
              <Minus size={16} />
            </button>
            <button
              type="button"
              className="markdown-action"
              aria-label="放大"
              disabled={zoom >= 4}
              onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
            >
              <Plus size={16} />
            </button>
            <a
              className="markdown-action"
              aria-label="下载图片"
              title="下载图片"
              href={preview.items[preview.index].src}
              download={alt || "image"}
              onClick={async (event) => {
                if (!onDownloadImage) return;
                event.preventDefault();
                setDownloadError("");
                try {
                  await onDownloadImage(preview.items[preview.index].src);
                } catch {
                  setDownloadError("图片下载失败,请重试");
                }
              }}
            >
              <Download size={16} />
            </a>
          </div>
          {downloadError && (
            <p role="alert" className="run-error">
              {downloadError}
            </p>
          )}
          <div
            className="markdown-image-canvas"
            onPointerDown={(event) => {
              if (event.button !== 0) return;
              event.currentTarget.setPointerCapture(event.pointerId);
              pointer.current = { x: event.clientX, y: event.clientY, pan };
            }}
            onPointerMove={(event) => {
              const start = pointer.current;
              const canvas = event.currentTarget;
              const image = canvas.querySelector("img");
              if (start && image) {
                const maxX = Math.max(0, (image.clientWidth * zoom - canvas.clientWidth) / 2);
                const maxY = Math.max(0, (image.clientHeight * zoom - canvas.clientHeight) / 2);
                setPan({
                  x: Math.max(-maxX, Math.min(maxX, start.pan.x + event.clientX - start.x)),
                  y: Math.max(-maxY, Math.min(maxY, start.pan.y + event.clientY - start.y)),
                });
              }
            }}
            onPointerUp={() => {
              pointer.current = undefined;
            }}
            onPointerCancel={() => {
              pointer.current = undefined;
            }}
          >
            <img
              src={preview.items[preview.index].src}
              alt={preview.items[preview.index].alt}
              draggable={false}
              style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
            />
          </div>
        </MarkdownPreview>
      )}
    </>
  );
}