auto-save 2026-05-14 01:34 (+2, ~2)

This commit is contained in:
2026-05-14 01:34:43 +08:00
parent d05478812d
commit 9f706f796b
4 changed files with 539 additions and 126 deletions

View File

@@ -2771,6 +2771,19 @@
"message": "auto-save 2026-05-14 01:22 (+3, ~2)",
"hash": "9fc2442",
"files_changed": 5
},
{
"ts": "2026-05-14T01:29:00+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 01:28 (+5, ~3)",
"hash": "d054788",
"files_changed": 8
},
{
"ts": "2026-05-13T17:33:10Z",
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-14 01:28 (+5, ~3)",
"files_changed": 1
}
]
}

View File

@@ -0,0 +1,19 @@
- generic [active] [ref=e1]:
- main [ref=e3]:
- button "自动排版 · 保留每个节点的尺寸,重新排好间距和列布局" [ref=e5]:
- img [ref=e6]
- application [ref=e13]:
- img
- generic "Control Panel" [ref=e16]:
- button "Zoom In" [ref=e17] [cursor=pointer]:
- img [ref=e18]
- button "Zoom Out" [ref=e20] [cursor=pointer]:
- img [ref=e21]
- button "Fit View" [ref=e23] [cursor=pointer]:
- img [ref=e24]
- button "Toggle Interactivity" [ref=e26] [cursor=pointer]:
- img [ref=e27]
- img "Mini Map" [ref=e30]
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e37] [cursor=pointer]:
- img [ref=e38]

View File

@@ -0,0 +1,456 @@
- generic [active] [ref=e1]:
- generic [ref=e36] [cursor=pointer]:
- button "Open Next.js Dev Tools" [ref=e37]:
- img [ref=e38]
- generic [ref=e43]:
- button "Open issues overlay" [ref=e44]:
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: "1"
- generic [ref=e48]: Issue
- button "Collapse issues badge" [ref=e49]:
- img [ref=e50]
- main [ref=e53]:
- button "自动排版 · 保留每个节点的尺寸,重新排好间距和列布局" [ref=e55]:
- img [ref=e56]
- button "切到明亮主题" [ref=e62]:
- img [ref=e63]
- generic [ref=e69]:
- generic [ref=e72]:
- generic [ref=e73]:
- img [ref=e74]
- generic [ref=e79]: 分镜头编排
- generic [ref=e80]: 0 分镜 · 0 元素
- generic [ref=e81]: · 组织分镜画面 → 为生成视频做准备
- button "展开编排" [disabled] [ref=e83]:
- img [ref=e84]
- text: 展开编排
- application [ref=e87]:
- generic [ref=e89]:
- generic:
- generic:
- img:
- group "Edge from input to keyframe" [ref=e90] [cursor=pointer]
- img:
- group "Edge from input to asr" [ref=e93] [cursor=pointer]
- img:
- group "Edge from asr to translate" [ref=e96] [cursor=pointer]
- img:
- group "Edge from translate to rewrite" [ref=e99] [cursor=pointer]
- img:
- group "Edge from keyframe to storyboard" [ref=e102] [cursor=pointer]
- img:
- group "Edge from rewrite to storyboard" [ref=e105] [cursor=pointer]
- img:
- group "Edge from storyboard to videogen" [ref=e108] [cursor=pointer]
- img:
- group "Edge from videogen to compose" [ref=e111] [cursor=pointer]
- img:
- group "Edge from rewrite to compose" [ref=e114] [cursor=pointer]
- generic:
- group [ref=e117]:
- generic [ref=e118]:
- generic [ref=e119]:
- button "再上传一个视频" [ref=e120]:
- img [ref=e121]
- generic [ref=e122]:
- button "64.5s" [ref=e123]:
- generic [ref=e125]: 64.5s
- generic:
- generic:
- generic:
- generic: 1080×1920
- generic: 64.5s
- generic [ref=e126]:
- button "72.4s" [ref=e127]:
- generic [ref=e129]: 72.4s
- generic:
- generic:
- generic:
- generic: 576×1024
- generic: 72.4s
- generic [ref=e130]:
- button "64.5s" [ref=e131]:
- generic [ref=e133]: 64.5s
- generic:
- generic:
- generic:
- generic: 1080×1920
- generic: 64.5s
- generic [ref=e134]:
- button "71.4s" [ref=e135]:
- generic [ref=e137]: 71.4s
- generic:
- generic:
- generic:
- generic: 1080×1920
- generic: 71.4s
- generic [ref=e138]:
- button "72.4s" [ref=e139]:
- generic [ref=e141]: 72.4s
- generic:
- generic:
- generic:
- generic: 576×1024
- generic: 72.4s
- generic [ref=e142]:
- button "71.4s" [ref=e143]:
- generic [ref=e145]: 71.4s
- generic:
- generic:
- generic:
- generic: 1080×1920
- generic: 71.4s
- generic [ref=e146]:
- button "71.4s" [ref=e147]:
- generic [ref=e149]: 71.4s
- generic:
- generic:
- generic:
- generic: 1080×1920
- generic: 71.4s
- generic [ref=e150]:
- button "71.4s" [ref=e151]:
- generic [ref=e153]: 71.4s
- generic:
- generic:
- generic:
- generic: 1080×1920
- generic: 71.4s
- generic [ref=e154]:
- button "71.4s" [ref=e155]:
- generic [ref=e157]: 71.4s
- generic:
- generic:
- generic:
- generic: 1080×1920
- generic: 71.4s
- generic [ref=e158]:
- button "71.4s" [ref=e159]:
- generic [ref=e161]: 71.4s
- generic:
- generic:
- generic:
- generic: 1080×1920
- generic: 71.4s
- generic [ref=e162]:
- button "8.0s" [ref=e163]:
- generic [ref=e165]: 8.0s
- generic:
- generic:
- generic:
- generic: 640×360
- generic: 8.0s
- generic [ref=e166]:
- button "8.0s" [ref=e167]:
- generic [ref=e169]: 8.0s
- generic:
- generic:
- generic:
- generic: 640×360
- generic: 8.0s
- generic [ref=e170]:
- button "8.0s" [ref=e171]:
- generic [ref=e173]: 8.0s
- generic:
- generic:
- generic:
- generic: 640×360
- generic: 8.0s
- generic [ref=e174]:
- button "8.0s" [ref=e175]:
- generic [ref=e177]: 8.0s
- generic:
- generic:
- generic:
- generic: 640×360
- generic: 8.0s
- button "…" [ref=e179]:
- img [ref=e181]
- generic [ref=e183]:
- button "…" [ref=e185]:
- img [ref=e187]
- generic [ref=e189]:
- button "…" [ref=e191]:
- img [ref=e193]
- generic [ref=e195]:
- generic [ref=e196]:
- generic [ref=e197]:
- img [ref=e199]
- generic [ref=e202]: 输入 · Input
- generic [ref=e203]:
- img [ref=e204]
- button "钉住 · 锁定位置与尺寸" [ref=e208]:
- img [ref=e209]
- generic [ref=e212]:
- generic [ref=e213]: STEP 1 · 视频就绪 · 完成
- textbox "再加一个 TK 链接" [ref=e214]
- generic [ref=e215]:
- button "+ 加链接" [disabled] [ref=e216]
- button "再传一个" [ref=e217]:
- img [ref=e218]
- text: 再传一个
- generic [ref=e221]:
- generic [ref=e222]: 1080×1920 · 64.5s
- generic [ref=e223]: 🔗 链接
- button "重新解析" [ref=e224]
- generic "拖动调整宽度" [ref=e226]
- generic "拖动调整大小(宽 × 高)" [ref=e227]
- group [ref=e228]:
- generic [ref=e229]:
- generic [ref=e230]:
- generic [ref=e231]:
- button "frame 9 1.7s" [ref=e232]:
- img "frame 9" [ref=e233]
- generic [ref=e234]: 1.7s
- button "📋" [ref=e235]
- button "删除该关键帧" [ref=e236]:
- img [ref=e237]
- generic:
- generic:
- generic:
- generic: 分镜 10
- generic: 1.66s
- generic [ref=e240]:
- button "frame 0 ✨ 24.7s" [ref=e241]:
- img "frame 0" [ref=e242]
- generic "已清洗" [ref=e244]: ✨
- generic [ref=e245]: 24.7s
- button "📋" [ref=e246]
- button "删除该关键帧" [ref=e247]:
- img [ref=e248]
- generic:
- generic:
- generic:
- generic: 分镜 1
- generic: 24.73s
- generic [ref=e251]:
- button "frame 1 33.6s" [ref=e252]:
- img "frame 1" [ref=e253]
- generic [ref=e254]: 33.6s
- button "📋" [ref=e255]
- button "删除该关键帧" [ref=e256]:
- img [ref=e257]
- generic:
- generic:
- generic:
- generic: 分镜 2
- generic: 33.61s
- generic [ref=e260]:
- button "frame 2 37.7s" [ref=e261]:
- img "frame 2" [ref=e262]
- generic [ref=e263]: 37.7s
- button "📋" [ref=e264]
- button "删除该关键帧" [ref=e265]:
- img [ref=e266]
- generic:
- generic:
- generic:
- generic: 分镜 3
- generic: 37.70s
- generic [ref=e269]:
- button "frame 3 39.4s" [ref=e270]:
- img "frame 3" [ref=e271]
- generic [ref=e272]: 39.4s
- button "📋" [ref=e273]
- button "删除该关键帧" [ref=e274]:
- img [ref=e275]
- generic:
- generic:
- generic:
- generic: 分镜 4
- generic: 39.42s
- generic [ref=e278]:
- button "frame 4 1 43.1s" [ref=e279]:
- img "frame 4" [ref=e280]
- generic "1 个元素已抠图" [ref=e282]: "1"
- generic [ref=e283]: 43.1s
- button "📋" [ref=e284]
- button "删除该关键帧" [ref=e285]:
- img [ref=e286]
- generic:
- generic:
- generic:
- generic: 分镜 5
- generic: 43.13s
- generic [ref=e289]:
- button "frame 5 45.0s" [ref=e290]:
- img "frame 5" [ref=e291]
- generic [ref=e292]: 45.0s
- button "📋" [ref=e293]
- button "删除该关键帧" [ref=e294]:
- img [ref=e295]
- generic:
- generic:
- generic:
- generic: 分镜 6
- generic: 45.05s
- generic [ref=e298]:
- button "frame 6 53.6s" [ref=e299]:
- img "frame 6" [ref=e300]
- generic [ref=e301]: 53.6s
- button "📋" [ref=e302]
- button "删除该关键帧" [ref=e303]:
- img [ref=e304]
- generic:
- generic:
- generic:
- generic: 分镜 7
- generic: 53.60s
- generic [ref=e307]:
- button "frame 7 56.0s" [ref=e308]:
- img "frame 7" [ref=e309]
- generic [ref=e310]: 56.0s
- button "📋" [ref=e311]
- button "删除该关键帧" [ref=e312]:
- img [ref=e313]
- generic:
- generic:
- generic:
- generic: 分镜 8
- generic: 55.96s
- generic [ref=e316]:
- button "frame 8 58.4s" [ref=e317]:
- img "frame 8" [ref=e318]
- generic [ref=e319]: 58.4s
- button "📋" [ref=e320]
- button "删除该关键帧" [ref=e321]:
- img [ref=e322]
- generic:
- generic:
- generic:
- generic: 分镜 9
- generic: 58.39s
- generic [ref=e325]:
- generic [ref=e327]:
- img [ref=e329]
- generic [ref=e333]: 镜头拆解 · 元素提取
- generic [ref=e334]:
- img [ref=e335]
- button "钉住 · 锁定位置与尺寸" [ref=e339]:
- img [ref=e340]
- generic [ref=e343]:
- generic [ref=e344]: STEP 2 · 0/10 入编排 · 完成
- generic [ref=e345]:
- text: 自动 10 张 ·
- generic [ref=e346]: 1 已清洗
- text: ·
- generic [ref=e347]: 1/2 已抠图
- text: 点缩略图 → 清洗水印 / 提取可借鉴元素 → 改造成 SKG 画面素材
- generic "拖动调整宽度" [ref=e349]
- generic "拖动调整大小(宽 × 高)" [ref=e350]
- group [ref=e351]:
- generic [ref=e352]:
- generic [ref=e354]:
- img [ref=e356]
- generic [ref=e359]: 声音文案 · ASR
- button "钉住 · 锁定位置与尺寸" [ref=e362]:
- img [ref=e363]
- generic [ref=e366]:
- generic [ref=e367]: STEP 3 · 可选文案轨 · 待运行
- generic [ref=e368]: Gemini 2.5 · 英文带时间戳分段
- generic "拖动调整宽度" [ref=e370]
- generic "拖动调整大小(宽 × 高)" [ref=e371]
- group [ref=e372]:
- generic [ref=e373]:
- generic [ref=e375]:
- img [ref=e377]
- generic [ref=e381]: 翻译理解 · Translate
- button "钉住 · 锁定位置与尺寸" [ref=e384]:
- img [ref=e385]
- generic [ref=e388]:
- generic [ref=e389]: STEP 4 · EN → ZH · 待运行
- generic [ref=e390]: 中文翻译 · 段落级 · 实时输出
- generic "拖动调整宽度" [ref=e392]
- generic "拖动调整大小(宽 × 高)" [ref=e393]
- group [ref=e394]:
- generic [ref=e395]:
- generic [ref=e397]:
- button "透明骷髅" [ref=e398]:
- img "透明骷髅" [ref=e399]
- button "📋" [ref=e400]
- button "删除该提取图" [ref=e401]:
- img [ref=e402]
- generic:
- generic:
- generic:
- generic: 分镜 5 · 透明骷髅
- generic: 43.13s
- generic [ref=e405]:
- generic [ref=e407]:
- img [ref=e409]
- generic [ref=e414]: 元素改造 · Storyboard
- generic [ref=e415]:
- img [ref=e416]
- button "钉住 · 锁定位置与尺寸" [ref=e420]:
- img [ref=e421]
- generic [ref=e424]:
- generic [ref=e425]: STEP 6 · 参考元素 → SKG 画面 · 完成
- generic [ref=e426]:
- text: 不是复刻原视频:先把参考图里的主体 / 场景 / 动作 / 道具拆出来,再替换成 SKG 产品画面。
- generic [ref=e427]: 已有 1 个提取元素 · 0 个分镜进入编排
- button "进入分镜编排" [disabled] [ref=e428]
- generic "拖动调整宽度" [ref=e430]
- generic "拖动调整大小(宽 × 高)" [ref=e431]
- group [ref=e432]:
- generic [ref=e433]:
- generic [ref=e435]:
- img [ref=e437]
- generic [ref=e441]: 产品文案 · Rewrite
- button "钉住 · 锁定位置与尺寸" [ref=e444]:
- img [ref=e445]
- generic [ref=e448]:
- generic [ref=e449]: STEP 5 · 接 SKG 卖点 · 待运行
- textbox "粘贴 SKG 产品信息 / 关键卖点(可作为视频脚本和镜头动作参考)" [disabled] [ref=e450]
- generic [ref=e451]: 下一冲刺接入
- generic "拖动调整宽度" [ref=e453]
- generic "拖动调整大小(宽 × 高)" [ref=e454]
- group [ref=e455]:
- generic [ref=e457]:
- generic [ref=e459]:
- img [ref=e461]
- generic [ref=e463]: 生成视频 · Video Gen
- button "钉住 · 锁定位置与尺寸" [ref=e466]:
- img [ref=e467]
- generic [ref=e470]:
- generic [ref=e471]: STEP 7 · 首帧 + 动作 prompt · 待运行
- generic [ref=e472]:
- generic [ref=e473]: Seedance
- generic [ref=e474]: Kling
- generic [ref=e475]: Veo 3
- generic "拖动调整宽度" [ref=e477]
- generic "拖动调整大小(宽 × 高)" [ref=e478]
- group [ref=e479]:
- generic [ref=e480]:
- generic [ref=e482]:
- img [ref=e484]
- generic [ref=e488]: 合成成品 · Compose
- button "钉住 · 锁定位置与尺寸" [ref=e491]:
- img [ref=e492]
- generic [ref=e495]:
- generic [ref=e496]: STEP 8 · ffmpeg + 字幕 · 待运行
- generic [ref=e497]:
- text: 视频片段 + 字幕 / TTS
- text: → 最终 mp4 输出
- generic "拖动调整宽度" [ref=e498]
- generic "拖动调整大小(宽 × 高)" [ref=e499]
- img
- generic "Control Panel" [ref=e500]:
- button "Zoom In" [ref=e501] [cursor=pointer]:
- img [ref=e502]
- button "Zoom Out" [ref=e504] [cursor=pointer]:
- img [ref=e505]
- button "Fit View" [ref=e507] [cursor=pointer]:
- img [ref=e508]
- button "Toggle Interactivity" [ref=e510] [cursor=pointer]:
- img [ref=e511]
- img "Mini Map" [ref=e514]
- region "Notifications alt+T":
- list:
- listitem [ref=e524]:
- img [ref=e526]
- generic [ref=e529]: 已自动排版 · 保留每个节点的尺寸
- listitem [ref=e530]:
- img [ref=e532]
- generic [ref=e535]: 📥 视频已就绪 — 请点 Input 节点里的「点这里开始解析」按钮
- alert [ref=e536]

View File

@@ -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强制 visiblepointer-events 开启,可点 × 关闭
* - 用法:父级容器要带 `group` classHoverPreview 直接作为子元素
*/
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>
)
}