auto-save 2026-05-14 01:34 (+2, ~2)
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
"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 限制不超出屏幕
|
||||
* - **在 ReactFlow 节点 DOM 内**作为 absolute 元素,贴 thumb 上方边缘(bottom: calc(100% + 10px) + 居中)
|
||||
* - 跟随 ReactFlow 画布 pan/zoom 一起变化(属于"无限画布"的一部分)
|
||||
* - 媒体按"自然像素分辨率"渲染 + max-w/max-h 限制,避免占满整个画布
|
||||
* - 不 pinned 时:pointer-events-none,依赖 group-hover 显示/隐藏
|
||||
* - pinned=true:强制 visible,pointer-events 开启,可点 × 关闭
|
||||
* - 用法:父级容器要带 `group` class,HoverPreview 直接作为子元素
|
||||
*/
|
||||
interface Props {
|
||||
imgSrc?: string
|
||||
@@ -18,8 +18,8 @@ interface Props {
|
||||
label?: string
|
||||
caption?: string
|
||||
borderClass?: string
|
||||
maxW?: string
|
||||
maxH?: string
|
||||
maxW?: string // 默认 "min(70vw, 1200px)"
|
||||
maxH?: string // 默认 "min(70vh, 800px)"
|
||||
pinned?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -28,128 +28,53 @@ export function HoverPreview({
|
||||
imgSrc, videoSrc, poster, aspect,
|
||||
label, caption,
|
||||
borderClass = "border-violet-300/55",
|
||||
maxW = "min(80vw, 1400px)",
|
||||
maxH = "min(80vh, 880px)",
|
||||
maxW = "min(70vw, 1200px)",
|
||||
maxH = "min(70vh, 800px)",
|
||||
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])
|
||||
|
||||
const visibilityCls = pinned
|
||||
? "opacity-100 scale-100 pointer-events-auto"
|
||||
: "pointer-events-none opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100"
|
||||
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
|
||||
className={`absolute transition-all duration-150 z-[60] ${visibilityCls}`}
|
||||
style={{
|
||||
bottom: "calc(100% + 10px)",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
transformOrigin: "bottom center",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user