auto-save 2026-05-14 01:28 (+5, ~3)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
@@ -34,9 +34,11 @@ export function HoverPreview({
|
||||
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) }, [])
|
||||
|
||||
@@ -64,30 +66,69 @@ export function HoverPreview({
|
||||
|
||||
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: anchorRect.top - 10,
|
||||
left: anchorRect.left + anchorRect.width / 2,
|
||||
transform: "translate(-50%, -100%)",
|
||||
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}`}>
|
||||
<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>
|
||||
{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>}
|
||||
|
||||
@@ -134,7 +134,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
onClick={(e) => { e.stopPropagation(); fileRef.current?.click() }}
|
||||
title="再上传一个视频"
|
||||
className="shrink-0 rounded-md border border-dashed border-white/30 hover:border-white/50 bg-white/[0.04] hover:bg-white/[0.08] inline-flex items-center justify-center text-white/60 hover:text-white transition"
|
||||
style={{ width: 44, height: 80 }}
|
||||
style={{ width: 88, height: 160 }}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -148,7 +148,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
className={`group relative shrink-0 rounded-md overflow-visible border shadow-lg transition hover:-translate-y-0.5 ${
|
||||
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
|
||||
}`}
|
||||
style={{ height: 80, aspectRatio: aspectStr }}
|
||||
style={{ height: 160, aspectRatio: aspectStr }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -432,7 +432,7 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
: "border-white/30 dark:border-white/20"
|
||||
}`}
|
||||
style={{
|
||||
height: 80,
|
||||
height: 160,
|
||||
aspectRatio: d.job && d.job.height > 0
|
||||
? `${d.job.width}/${d.job.height}`
|
||||
: "16/9",
|
||||
@@ -909,7 +909,7 @@ export function StoryboardNode({ data, selected }: any) {
|
||||
<div
|
||||
key={key}
|
||||
className="group relative shrink-0 rounded-md border border-violet-300/50 overflow-visible transition shadow-lg hover:-translate-y-0.5 bg-white"
|
||||
style={{ height: 80, aspectRatio: aspect }}
|
||||
style={{ height: 160, aspectRatio: aspect }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -1053,7 +1053,7 @@ export function VideoGenNode({ data, selected }: any) {
|
||||
className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 bg-black ${
|
||||
ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
|
||||
}`}
|
||||
style={{ height: 80, aspectRatio: aspect }}
|
||||
style={{ height: 160, aspectRatio: aspect }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user