auto-save 2026-05-14 01:28 (+5, ~3)

This commit is contained in:
2026-05-14 01:29:00 +08:00
parent 9fc24427c5
commit d05478812d
8 changed files with 1088 additions and 19 deletions

View File

@@ -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>}

View File

@@ -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"