"use client" import { useEffect, useMemo, useRef, useState } from "react" import { Maximize2, Minimize2, X } from "lucide-react" /** * 视觉类节点统一大预览: * - **在 ReactFlow 节点 DOM 内**作为 absolute 元素,底边贴当前缩略图上方边缘 * - 跟随 ReactFlow 画布 pan/zoom 一起变化(属于"无限画布"的一部分) * - 默认按原比例 fit 到可视区域,并标注原始分辨率 / 当前缩放比例 * - pinned 后可切换到 1:1;大图在预览框内滚动查看局部 * - 不 pinned 时:pointer-events-none,依赖调用方传入 visible * - pinned=true:强制 visible,pointer-events 开启,可点 × 关闭 * - 用法:渲染在节点根层,不要放进 overflow-x-auto 缩略图滚动条里 */ interface Props { imgSrc?: string videoSrc?: string poster?: string aspect?: string label?: string caption?: string borderClass?: string visible?: boolean anchorX?: number anchorY?: number pinned?: boolean onClose?: () => void } export function HoverPreview({ imgSrc, videoSrc, poster, aspect, label, caption, borderClass = "border-violet-300/55", visible = false, anchorX, anchorY, pinned = false, onClose, }: Props) { const shown = pinned || visible const imgRef = useRef(null) const videoRef = useRef(null) const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null) const [actualSize, setActualSize] = useState(false) const [viewport, setViewport] = useState({ width: 1200, height: 800 }) useEffect(() => { setNaturalSize(null) setActualSize(false) }, [imgSrc, videoSrc]) useEffect(() => { if (!pinned) setActualSize(false) }, [pinned]) useEffect(() => { const update = () => setViewport({ width: window.innerWidth || 1200, height: window.innerHeight || 800, }) update() window.addEventListener("resize", update) return () => window.removeEventListener("resize", update) }, []) useEffect(() => { if (!shown) return const raf = window.requestAnimationFrame(() => { const img = imgRef.current if (img?.complete && img.naturalWidth && img.naturalHeight) { setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight }) return } const video = videoRef.current if (video?.videoWidth && video.videoHeight) { setNaturalSize({ width: video.videoWidth, height: video.videoHeight }) } }) return () => window.cancelAnimationFrame(raf) }, [imgSrc, shown, videoSrc]) const fitScale = useMemo(() => { if (!naturalSize) return null const maxWidth = Math.min(920, viewport.width * 0.7) const maxHeight = Math.min(820, viewport.height * 0.72) return Math.min(1, maxWidth / naturalSize.width, maxHeight / naturalSize.height) }, [naturalSize, viewport.height, viewport.width]) const dimensionText = naturalSize ? `${naturalSize.width} × ${naturalSize.height}` : "读取中" const scaleText = naturalSize ? actualSize ? "1:1 原寸" : `Fit ${Math.round((fitScale ?? 1) * 100)}%` : actualSize ? "1:1 原寸" : "Fit" const visibilityCls = shown ? pinned ? "opacity-100 pointer-events-auto" : "opacity-100 pointer-events-none" : "pointer-events-none opacity-0" const previewFrameStyle = actualSize ? { width: "max-content", maxWidth: "min(78vw, 1040px)", maxHeight: "min(76vh, 860px)", overflow: "auto", } : { width: "max-content", maxWidth: "min(70vw, 920px)", maxHeight: "min(72vh, 820px)", overflow: "hidden", } const mediaStyle = actualSize ? { width: "auto", height: "auto", maxWidth: "none", maxHeight: "none" } : { width: "auto", height: "auto", maxWidth: "min(70vw, 920px)", maxHeight: "min(72vh, 820px)" } return (
{shown && (
原始尺寸
{dimensionText} {scaleText}
)} {videoSrc ? (
) }