Files
20260512-skg-tk/web/components/nodes/hover-preview.tsx

156 lines
6.0 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, useLayoutEffect, 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 portalRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
const [mounted, setMounted] = useState(false)
const [pos, setPos] = useState<{ top: number; left: number; placement: "above" | "below" } | null>(null)
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
// 测量 portal 实际尺寸 → 智能选 thumb 上方 / 下方,越界时 clamp 到 viewport 内
useLayoutEffect(() => {
if (!visible || !anchorRect || !portalRef.current) return
const el = portalRef.current
const measure = () => {
const previewRect = el.getBoundingClientRect()
const ph = previewRect.height
const pw = previewRect.width
const vw = window.innerWidth
const vh = window.innerHeight
const spaceAbove = anchorRect.top
const spaceBelow = vh - anchorRect.bottom
const placement: "above" | "below" = spaceAbove >= ph + 20 ? "above" : (spaceBelow >= spaceAbove ? "below" : "above")
let top = placement === "above"
? Math.max(10, anchorRect.top - 10 - ph)
: Math.min(vh - ph - 10, anchorRect.bottom + 10)
let left = anchorRect.left + anchorRect.width / 2 - pw / 2
left = Math.max(10, Math.min(vw - pw - 10, left))
setPos((prev) => {
if (prev && prev.top === top && prev.left === left && prev.placement === placement) return prev
return { top, left, placement }
})
}
measure()
// 视频 / 图片 loaded 后实际尺寸可能变化,再测一次
const media = el.querySelector("video, img") as HTMLVideoElement | HTMLImageElement | null
if (media) {
const reMeasure = () => measure()
media.addEventListener("loadedmetadata", reMeasure)
media.addEventListener("load", reMeasure)
return () => {
media.removeEventListener("loadedmetadata", reMeasure)
media.removeEventListener("load", reMeasure)
}
}
}, [visible, anchorRect, imgSrc, videoSrc])
return (
<>
<span ref={anchorFinderRef} className="hidden" />
{mounted && visible && anchorRect && createPortal(
<div
ref={portalRef}
className="fixed z-[9999]"
style={{
top: pos?.top ?? -9999,
left: pos?.left ?? -9999,
pointerEvents: pinned ? "auto" : "none",
opacity: pos ? 1 : 0,
transition: "opacity 150ms",
}}
>
<div className={`relative rounded-lg overflow-hidden border-2 bg-black shadow-2xl ${pinned ? "ring-2 ring-violet-400/70" : ""} ${borderClass}`}>
{videoSrc ? (
<video src={videoSrc} poster={poster} muted loop playsInline autoPlay
className="block object-contain"
style={{ maxWidth: maxW, maxHeight: maxH }} />
) : imgSrc ? (
<img src={imgSrc} alt="" className="block object-contain"
style={{ maxWidth: maxW, maxHeight: maxH }} />
) : (
<div className="w-40 h-40 bg-black/40" />
)}
{(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,
)}
</>
)
}