100 lines
3.5 KiB
TypeScript
100 lines
3.5 KiB
TypeScript
"use client"
|
||
import { X } from "lucide-react"
|
||
|
||
/**
|
||
* 视觉类节点统一大预览:
|
||
* - **在 ReactFlow 节点 DOM 内**作为 absolute 元素,底边贴当前缩略图上方边缘
|
||
* - 跟随 ReactFlow 画布 pan/zoom 一起变化(属于"无限画布"的一部分)
|
||
* - 媒体按"自然像素分辨率"渲染,不做 max 尺寸限制
|
||
* - 不 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 visibilityCls = shown
|
||
? pinned ? "opacity-100 pointer-events-auto" : "opacity-100 pointer-events-none"
|
||
: "pointer-events-none opacity-0"
|
||
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={{ width: "max-content" }}>
|
||
{videoSrc ? (
|
||
<video
|
||
src={videoSrc}
|
||
poster={poster}
|
||
muted
|
||
loop
|
||
playsInline
|
||
autoPlay
|
||
preload="auto"
|
||
className="block"
|
||
style={{ width: "auto", height: "auto", maxWidth: "none", maxHeight: "none" }}
|
||
onLoadedMetadata={(e) => { e.currentTarget.play().catch(() => {}) }}
|
||
onCanPlay={(e) => { e.currentTarget.play().catch(() => {}) }}
|
||
/>
|
||
) : imgSrc ? (
|
||
<img
|
||
src={imgSrc}
|
||
alt=""
|
||
className="block"
|
||
style={{ width: "auto", height: "auto", maxWidth: "none", maxHeight: "none" }}
|
||
/>
|
||
) : (
|
||
<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 && (
|
||
<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>
|
||
)
|
||
}
|