212 lines
7.9 KiB
TypeScript
212 lines
7.9 KiB
TypeScript
"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<HTMLImageElement>(null)
|
||
const videoRef = useRef<HTMLVideoElement>(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 (
|
||
<div
|
||
className={`absolute transition-all duration-150 z-[120] ${visibilityCls}`}
|
||
style={{
|
||
top: typeof anchorY === "number" ? `${anchorY - 8}px` : undefined,
|
||
bottom: typeof anchorY === "number" ? undefined : "calc(100% + 8px)",
|
||
left: typeof anchorX === "number" ? `${anchorX}px` : "50%",
|
||
transform: `${typeof anchorY === "number" ? "translate(-50%, -100%)" : "translateX(-50%)"} scale(${shown ? 1 : 0.96})`,
|
||
transformOrigin: "bottom center",
|
||
}}
|
||
>
|
||
<div className={`relative rounded-lg overflow-hidden border-2 bg-black shadow-2xl ${pinned ? "ring-2 ring-violet-400/70" : ""} ${borderClass}`}
|
||
style={previewFrameStyle}>
|
||
{shown && (
|
||
<div className="absolute left-2 top-2 z-10 overflow-hidden rounded-lg border border-white/25 bg-black/88 text-white shadow-[0_10px_30px_rgba(0,0,0,0.42)] backdrop-blur">
|
||
<div className="border-b border-white/12 bg-white/10 px-2.5 py-1 text-[9.5px] font-semibold leading-none text-white/72">
|
||
原始尺寸
|
||
</div>
|
||
<div className="flex items-baseline gap-2 px-2.5 py-1.5">
|
||
<span className="font-mono text-[15px] font-bold leading-none text-white">
|
||
{dimensionText}
|
||
</span>
|
||
<span className="rounded bg-violet-500/28 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-violet-100">
|
||
{scaleText}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{videoSrc ? (
|
||
<video
|
||
ref={videoRef}
|
||
src={videoSrc}
|
||
poster={poster}
|
||
muted
|
||
loop
|
||
playsInline
|
||
autoPlay
|
||
preload="auto"
|
||
className="block"
|
||
style={mediaStyle}
|
||
onLoadedMetadata={(e) => {
|
||
const video = e.currentTarget
|
||
if (video.videoWidth && video.videoHeight) {
|
||
setNaturalSize({ width: video.videoWidth, height: video.videoHeight })
|
||
}
|
||
video.play().catch(() => {})
|
||
}}
|
||
onCanPlay={(e) => { e.currentTarget.play().catch(() => {}) }}
|
||
/>
|
||
) : imgSrc ? (
|
||
<img
|
||
ref={imgRef}
|
||
src={imgSrc}
|
||
alt=""
|
||
className="block"
|
||
style={mediaStyle}
|
||
onLoad={(e) => {
|
||
const img = e.currentTarget
|
||
if (img.naturalWidth && img.naturalHeight) {
|
||
setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight })
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="w-40 bg-black/40" style={{ aspectRatio: aspect ?? "1/1" }} />
|
||
)}
|
||
{(label || caption) && (
|
||
<div className="px-2 py-1 bg-black/80 text-white text-[11px] flex items-center justify-between">
|
||
{label && <span>{label}</span>}
|
||
{caption && <span className="text-white/60 font-mono">{caption}</span>}
|
||
</div>
|
||
)}
|
||
{pinned && onClose && (
|
||
<div className="absolute right-1 top-1 z-10 flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); setActualSize((x) => !x) }}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
title={actualSize ? "适应预览" : "1:1 查看"}
|
||
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/75 text-white shadow-lg backdrop-blur hover:bg-violet-500"
|
||
>
|
||
{actualSize ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onClose() }}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
title="取消固定预览"
|
||
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/75 text-white shadow-lg backdrop-blur hover:bg-rose-500"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|