Files
20260512-skg-tk/web/components/nodes/hover-preview.tsx
2026-05-14 02:53:06 +08:00

201 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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强制 visiblepointer-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 metadataText = naturalSize
? `原始 ${naturalSize.width}×${naturalSize.height} · ${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-1.5 top-1.5 z-10 rounded-md bg-black/75 px-2 py-1 text-[10px] font-mono leading-none text-white shadow-lg backdrop-blur">
{metadataText}
</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>
)
}