auto-save 2026-05-14 00:25 (+6, ~5)
This commit is contained in:
@@ -103,7 +103,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
const inputLocked = isDownloading || d.submitting
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: "100%" }}>
|
||||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),统一高度 64,宽度按视频原比例,一行横滚。
|
||||
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
|
||||
{!videoExpanded && d.jobs.length > 0 && (
|
||||
@@ -222,7 +222,6 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
icon={<Link2 className="h-4 w-4" />}
|
||||
title="输入 · Input"
|
||||
subtitle={isDownloading ? "STEP 1 · 下载中" : hasVideo ? "STEP 1 · 视频就绪" : "STEP 1"}
|
||||
width={320}
|
||||
selected={selected}
|
||||
hasTarget={false}
|
||||
>
|
||||
@@ -367,7 +366,7 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
const aspectStr = d.job && d.job.height > 0 ? `${d.job.width}/${d.job.height}` : "9/16"
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: "100%" }}>
|
||||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{/* 缩略图浮条(节点上方,最多 5 个一行,多行向上扩展) */}
|
||||
{frames.length > 0 && jobId && (
|
||||
<div
|
||||
@@ -488,7 +487,6 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
title="镜头拆解 · 元素提取"
|
||||
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 入编排` : "等待抽取"}`}
|
||||
width={KEYFRAME_WIDTH}
|
||||
selected={selected}
|
||||
>
|
||||
{frames.length > 0 ? (() => {
|
||||
@@ -836,7 +834,7 @@ export function StoryboardNode({ data, selected }: any) {
|
||||
const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: "100%" }}>
|
||||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{/* 节点上方:所有元素 crop 图(编排输入素材)· 跟 keyframe 节点样式一致 */}
|
||||
{elementCrops.length > 0 && job && (
|
||||
<div
|
||||
@@ -919,7 +917,6 @@ export function StoryboardNode({ data, selected }: any) {
|
||||
icon={<LayoutGrid className="h-4 w-4" />}
|
||||
title="元素改造 · Storyboard"
|
||||
subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`}
|
||||
width={IMAGEGEN_WIDTH}
|
||||
selected={selected}
|
||||
>
|
||||
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
|
||||
@@ -972,7 +969,7 @@ export function VideoGenNode({ data, selected }: any) {
|
||||
return e
|
||||
}
|
||||
return (
|
||||
<div className="relative" style={{ width: "100%" }}>
|
||||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{videos.length > 0 && (
|
||||
<div
|
||||
className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { Handle, Position } from "@xyflow/react"
|
||||
import { CheckCircle2, Loader2, AlertCircle } from "lucide-react"
|
||||
import { ResizeRight } from "./resize-handle"
|
||||
import { ResizeRight, ResizeBR } from "./resize-handle"
|
||||
|
||||
export type NodeKind = "input" | "process" | "ai" | "output"
|
||||
export type NodeStatus = "pending" | "running" | "done" | "failed"
|
||||
@@ -50,7 +50,7 @@ export function NodeShell({
|
||||
<div
|
||||
className={`glass-node ${selected ? "glass-node--selected" : ""} ${status === "running" ? "glass-node--running" : ""}`}
|
||||
data-type={type}
|
||||
style={{ width }}
|
||||
style={{ width, height: "100%" }}
|
||||
>
|
||||
{hasTarget && <Handle type="target" position={Position.Left} />}
|
||||
|
||||
@@ -76,6 +76,7 @@ export function NodeShell({
|
||||
|
||||
{hasSource && <Handle type="source" position={Position.Right} />}
|
||||
<ResizeRight />
|
||||
<ResizeBR />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,49 +2,92 @@
|
||||
import { type PointerEvent } from "react"
|
||||
import { useReactFlow } from "@xyflow/react"
|
||||
|
||||
/** 节点右边缘 resize 把手:手写 pointer 事件改 node.width,自适应 viewport zoom,不走 xyflow NodeResizeControl(在 glass-node overflow:hidden 下不工作)。 */
|
||||
export function ResizeRight({ minWidth = 240, maxWidth = 1200 }: { minWidth?: number; maxWidth?: number }) {
|
||||
const { setNodes, getZoom } = useReactFlow()
|
||||
interface Bounds {
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const target = e.currentTarget
|
||||
const nodeEl = target.closest(".react-flow__node") as HTMLElement | null
|
||||
const nodeId = nodeEl?.getAttribute("data-id")
|
||||
if (!nodeId || !nodeEl) return
|
||||
target.setPointerCapture(e.pointerId)
|
||||
/** 拖动期间持续更新 ReactFlow node 的 width/height,同时写到 style.width/height 让 wrapper 真实渲染。
|
||||
* 自适应 viewport zoom。不走 xyflow NodeResizeControl(glass-node overflow:hidden + reserved-type CSS 干扰下不响应)。 */
|
||||
function startResize(
|
||||
e: PointerEvent<HTMLDivElement>,
|
||||
setNodes: ReturnType<typeof useReactFlow>["setNodes"],
|
||||
getZoom: ReturnType<typeof useReactFlow>["getZoom"],
|
||||
axis: "x" | "y" | "xy",
|
||||
bounds: Required<Bounds>,
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const target = e.currentTarget
|
||||
const nodeEl = target.closest(".react-flow__node") as HTMLElement | null
|
||||
const nodeId = nodeEl?.getAttribute("data-id")
|
||||
if (!nodeId || !nodeEl) return
|
||||
target.setPointerCapture(e.pointerId)
|
||||
|
||||
const startX = e.clientX
|
||||
const zoom = getZoom() || 1
|
||||
const startWidth = parseFloat(getComputedStyle(nodeEl).width) / zoom
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const zoom = getZoom() || 1
|
||||
const startWidth = parseFloat(getComputedStyle(nodeEl).width) / zoom
|
||||
const startHeight = parseFloat(getComputedStyle(nodeEl).height) / zoom
|
||||
|
||||
const onMove = (ev: globalThis.PointerEvent) => {
|
||||
const dx = (ev.clientX - startX) / zoom
|
||||
const next = Math.max(minWidth, Math.min(maxWidth, startWidth + dx))
|
||||
setNodes((nodes) => nodes.map((n) => (n.id === nodeId ? { ...n, width: next, style: { ...n.style, width: next } } : n)))
|
||||
}
|
||||
const onUp = () => {
|
||||
window.removeEventListener("pointermove", onMove)
|
||||
window.removeEventListener("pointerup", onUp)
|
||||
try { target.releasePointerCapture(e.pointerId) } catch {}
|
||||
}
|
||||
window.addEventListener("pointermove", onMove)
|
||||
window.addEventListener("pointerup", onUp)
|
||||
const onMove = (ev: globalThis.PointerEvent) => {
|
||||
const dx = (ev.clientX - startX) / zoom
|
||||
const dy = (ev.clientY - startY) / zoom
|
||||
const wantW = axis === "y" ? null : Math.max(bounds.minWidth, Math.min(bounds.maxWidth, startWidth + dx))
|
||||
const wantH = axis === "x" ? null : Math.max(bounds.minHeight, Math.min(bounds.maxHeight, startHeight + dy))
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => {
|
||||
if (n.id !== nodeId) return n
|
||||
const nextStyle = { ...n.style }
|
||||
const patch: Record<string, unknown> = {}
|
||||
if (wantW !== null) { patch.width = wantW; nextStyle.width = wantW }
|
||||
if (wantH !== null) { patch.height = wantH; nextStyle.height = wantH }
|
||||
return { ...n, ...patch, style: nextStyle }
|
||||
}),
|
||||
)
|
||||
}
|
||||
const onUp = () => {
|
||||
window.removeEventListener("pointermove", onMove)
|
||||
window.removeEventListener("pointerup", onUp)
|
||||
try { target.releasePointerCapture(e.pointerId) } catch {}
|
||||
}
|
||||
window.addEventListener("pointermove", onMove)
|
||||
window.addEventListener("pointerup", onUp)
|
||||
}
|
||||
|
||||
/** 右边缘把手 — 只改宽度 */
|
||||
export function ResizeRight({ minWidth = 240, maxWidth = 1400 }: Bounds) {
|
||||
const { setNodes, getZoom } = useReactFlow()
|
||||
return (
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerDown={(e) => startResize(e, setNodes, getZoom, "x", { minWidth, maxWidth, minHeight: 0, maxHeight: 0 })}
|
||||
title="拖动调整宽度"
|
||||
className="nodrag absolute z-20 hover:bg-violet-400/60 active:bg-violet-400/80 transition rounded-r"
|
||||
style={{ right: 0, top: 12, bottom: 12, width: 6, cursor: "ew-resize", touchAction: "none" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** 右下角把手 — 同时改宽和高 */
|
||||
export function ResizeBR({
|
||||
minWidth = 240, maxWidth = 1400, minHeight = 120, maxHeight = 1400,
|
||||
}: Bounds) {
|
||||
const { setNodes, getZoom } = useReactFlow()
|
||||
return (
|
||||
<div
|
||||
onPointerDown={(e) => startResize(e, setNodes, getZoom, "xy", { minWidth, maxWidth, minHeight, maxHeight })}
|
||||
title="拖动调整大小(宽 × 高)"
|
||||
className="nodrag absolute z-30 hover:bg-violet-400/80 active:bg-violet-400 transition"
|
||||
style={{
|
||||
right: 0,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
width: 6,
|
||||
cursor: "ew-resize",
|
||||
bottom: 0,
|
||||
width: 14,
|
||||
height: 14,
|
||||
cursor: "nwse-resize",
|
||||
touchAction: "none",
|
||||
background: "linear-gradient(135deg, transparent 50%, rgba(167,139,250,0.55) 50%)",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user