115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
"use client"
|
||
import { useEffect, useRef, useState } from "react"
|
||
import { createPortal } from "react-dom"
|
||
import { X } from "lucide-react"
|
||
|
||
/**
|
||
* 视觉类节点统一大预览:
|
||
* - 用 portal 渲染到 document.body,**脱离 ReactFlow viewport transform**,图片/视频按真实像素显示不被画布缩放影响
|
||
* - 自动锚定到 HoverPreview 在 DOM 树中的 parent(也就是 thumb 容器),mouseenter/leave 控制显示
|
||
* - pinned=true:常驻显示,pointer-events 开启,可点 × 关闭;点击节点外由调用方负责 unpin
|
||
* - 图片/视频按 natural 像素尺寸渲染,max-w/max-h 限制不超出屏幕
|
||
*/
|
||
interface Props {
|
||
imgSrc?: string
|
||
videoSrc?: string
|
||
poster?: string
|
||
aspect: string
|
||
label?: string
|
||
caption?: string
|
||
borderClass?: string
|
||
maxW?: string
|
||
maxH?: string
|
||
pinned?: boolean
|
||
onClose?: () => void
|
||
}
|
||
|
||
export function HoverPreview({
|
||
imgSrc, videoSrc, poster, aspect,
|
||
label, caption,
|
||
borderClass = "border-violet-300/55",
|
||
maxW = "min(80vw, 1400px)",
|
||
maxH = "min(80vh, 880px)",
|
||
pinned = false,
|
||
onClose,
|
||
}: Props) {
|
||
const anchorFinderRef = useRef<HTMLSpanElement>(null)
|
||
const [hovered, setHovered] = useState(false)
|
||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
|
||
const [mounted, setMounted] = useState(false)
|
||
|
||
useEffect(() => { setMounted(true) }, [])
|
||
|
||
useEffect(() => {
|
||
const finder = anchorFinderRef.current
|
||
const anchor = finder?.parentElement
|
||
if (!anchor) return
|
||
const update = () => setAnchorRect(anchor.getBoundingClientRect())
|
||
const onEnter = () => { update(); setHovered(true) }
|
||
const onLeave = () => setHovered(false)
|
||
anchor.addEventListener("mouseenter", onEnter)
|
||
anchor.addEventListener("mouseleave", onLeave)
|
||
// 当 pinned 状态下,定时刷新位置(节点可能被拖动 / 缩放)
|
||
let pinnedRaf: number | undefined
|
||
if (pinned) {
|
||
const tick = () => { update(); pinnedRaf = requestAnimationFrame(tick) }
|
||
pinnedRaf = requestAnimationFrame(tick)
|
||
}
|
||
return () => {
|
||
anchor.removeEventListener("mouseenter", onEnter)
|
||
anchor.removeEventListener("mouseleave", onLeave)
|
||
if (pinnedRaf !== undefined) cancelAnimationFrame(pinnedRaf)
|
||
}
|
||
}, [pinned])
|
||
|
||
const visible = pinned || hovered
|
||
|
||
return (
|
||
<>
|
||
<span ref={anchorFinderRef} className="hidden" />
|
||
{mounted && visible && anchorRect && createPortal(
|
||
<div
|
||
className="fixed z-[9999]"
|
||
style={{
|
||
top: anchorRect.top - 10,
|
||
left: anchorRect.left + anchorRect.width / 2,
|
||
transform: "translate(-50%, -100%)",
|
||
pointerEvents: pinned ? "auto" : "none",
|
||
}}
|
||
>
|
||
<div className={`relative rounded-lg overflow-hidden border-2 bg-black shadow-2xl ${pinned ? "ring-2 ring-violet-400/70" : ""} ${borderClass}`}>
|
||
<div style={{ display: "inline-block", maxWidth: maxW, maxHeight: maxH }}>
|
||
{videoSrc ? (
|
||
<video src={videoSrc} poster={poster} muted loop playsInline autoPlay
|
||
className="block max-w-full max-h-full object-contain" />
|
||
) : imgSrc ? (
|
||
<img src={imgSrc} alt="" className="block max-w-full max-h-full object-contain" />
|
||
) : (
|
||
<div className="w-40 h-40 bg-black/40" />
|
||
)}
|
||
</div>
|
||
{(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 && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onClose() }}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
title="取消固定预览"
|
||
className="absolute right-1 top-1 h-7 w-7 rounded-full bg-black/75 text-white shadow-lg hover:bg-rose-500 inline-flex items-center justify-center z-10"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</>
|
||
)
|
||
}
|