Add inline workflow quick connect bubbles
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-05 03:24:04 +08:00
parent 584579e987
commit d0c26e9fab

View File

@ -321,6 +321,40 @@ const VIDEO_CANVAS_INSERT_MENU_ITEMS: VideoCanvasInsertMenuItem[] = [
},
];
const VIDEO_WORKFLOW_QUICK_CONNECT_ICON_MAP: Partial<
Record<VideoWorkflowTemplateType, LucideIcon>
> = {
"text-node": FileText,
"input-image": FileImage,
"video-input": Film,
"novel-input": FileText,
"video-analyze": Scissors,
"storyboard-node": GitBranch,
"storyboard-chart-node": GitBranch,
"table-editor-node": Table2,
"gen-image": Palette,
"gen-video": Video,
"gen-music": Music2,
"motion-control": Clapperboard,
"camera-movement": Clapperboard,
"professional-camera": Wand2,
"inpaint-crop": Scissors,
"inpaint-stitch": Scissors,
"local-save": Save,
preview: Eye,
"custom-agent": Bot,
"rh-app": Box,
"rh-comfy": Box,
"jimeng-super-resolution": Sparkles,
"doodle-canvas": PenTool,
"canvas-node": Layers,
"image-compare": Layers,
};
function getVideoWorkflowQuickConnectIcon(type: VideoWorkflowTemplateType) {
return VIDEO_WORKFLOW_QUICK_CONNECT_ICON_MAP[type] || Plus;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@ -1004,6 +1038,41 @@ export default function CanvasEditorPage({ params }: PageProps) {
: [],
[selectedVideoWorkflowType]
);
const selectedWorkflowQuickConnectAnchor = useMemo(() => {
if (
!isVideoMode ||
!selectedVideoWorkflowNode ||
selectedVideoWorkflowQuickConnects.length === 0
) {
return null;
}
const nodeLeft = selectedVideoWorkflowNode.position.x * viewport.zoom + viewport.x;
const nodeTop = selectedVideoWorkflowNode.position.y * viewport.zoom + viewport.y;
const nodeWidth = selectedVideoWorkflowNode.size.width * viewport.zoom;
const anchorX = nodeLeft + nodeWidth;
const anchorY =
nodeTop + (selectedVideoWorkflowNode.size.height * viewport.zoom) / 2;
const openLeft = viewportWidth > 0 && anchorX + 260 > viewportWidth - 32;
return {
x: anchorX,
y: anchorY,
openLeft,
};
}, [
isVideoMode,
selectedVideoWorkflowNode,
selectedVideoWorkflowQuickConnects.length,
viewport.x,
viewport.y,
viewport.zoom,
viewportWidth,
]);
const selectedWorkflowQuickConnectSourceNode =
selectedWorkflowQuickConnectAnchor && selectedVideoWorkflowNode
? selectedVideoWorkflowNode
: null;
const workflowGraph = useMemo(
() => createWorkflowGraphContext(nodes, edges),
[edges, nodes]
@ -7343,40 +7412,97 @@ export default function CanvasEditorPage({ params }: PageProps) {
) : null}
{isVideoMode &&
selectedVideoWorkflowDefinition &&
(selectedVideoWorkflowQuickConnects.length > 0 || selectedWorkflowIsActionable) ? (
<div className="pointer-events-none absolute left-4 top-16 z-20 flex w-[min(480px,calc(100vw-32px))] flex-col gap-3">
{selectedVideoWorkflowQuickConnects.length > 0 ? (
<Card className={VIDEO_CANVAS_PANEL_CLASS}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{selectedVideoWorkflowDefinition.label}</CardTitle>
<CardDescription className="mt-1 line-clamp-2">
常用下游分支会放在这里,点击后自动放到右侧并连线。
</CardDescription>
</div>
<Badge variant="outline" className={VIDEO_CANVAS_PANEL_BADGE_CLASS}>
Quick Connect
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 pb-5">
{selectedVideoWorkflowQuickConnects.map((item) => (
<Button
key={`${selectedVideoWorkflowDefinition.type}-${item.type}`}
size="sm"
variant="secondary"
className="rounded-full border border-slate-200 bg-white hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900"
onClick={() => handleAddWorkflowNode(item.type)}
>
{item.label}
</Button>
))}
</CardContent>
</Card>
) : null}
selectedWorkflowQuickConnectAnchor &&
selectedWorkflowQuickConnectSourceNode &&
selectedVideoWorkflowQuickConnects.length > 0 &&
!videoCanvasInsertMenu ? (
<div
className="pointer-events-none absolute z-20"
style={{
left: selectedWorkflowQuickConnectAnchor.x,
top: selectedWorkflowQuickConnectAnchor.y,
}}
>
<svg
className="pointer-events-none absolute left-0 top-0 overflow-visible"
width="1"
height="1"
>
{selectedVideoWorkflowQuickConnects.map((item, index, items) => {
const total = items.length;
const y = (index - (total - 1) / 2) * 72;
const x = 96 + Math.abs(y) * 0.28;
const direction = selectedWorkflowQuickConnectAnchor.openLeft ? -1 : 1;
const targetX = x * direction;
const cp1X = 36 * direction;
const cp2X = (x - 26) * direction;
return (
<path
key={`quick-connect-line-${item.type}`}
d={`M 0 0 C ${cp1X} 0, ${cp2X} ${y}, ${targetX} ${y}`}
fill="none"
stroke="rgba(59,130,246,0.9)"
strokeWidth="2.5"
strokeLinecap="round"
/>
);
})}
</svg>
<button
type="button"
onClick={() =>
setVideoCanvasInsertMenu({
screenPosition: {
x: selectedWorkflowQuickConnectAnchor.x,
y: selectedWorkflowQuickConnectAnchor.y,
},
canvasPosition: {
x:
selectedWorkflowQuickConnectSourceNode.position.x +
selectedWorkflowQuickConnectSourceNode.size.width,
y:
selectedWorkflowQuickConnectSourceNode.position.y +
selectedWorkflowQuickConnectSourceNode.size.height / 2,
},
})
}
className="pointer-events-auto absolute left-0 top-0 flex h-16 w-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 shadow-[0_16px_32px_-18px_rgba(15,23,42,0.28)] transition-colors hover:bg-slate-50 hover:text-slate-700 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
aria-label="展开关联模块"
>
<Plus className="h-8 w-8" />
</button>
{selectedVideoWorkflowQuickConnects.map((item, index, items) => {
const total = items.length;
const y = (index - (total - 1) / 2) * 72;
const x = 96 + Math.abs(y) * 0.28;
const direction = selectedWorkflowQuickConnectAnchor.openLeft ? -1 : 1;
const Icon = getVideoWorkflowQuickConnectIcon(item.type);
return (
<button
key={`quick-connect-node-${item.type}`}
type="button"
onClick={() => handleAddWorkflowNode(item.type)}
className="pointer-events-auto absolute flex h-14 w-14 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 shadow-[0_14px_30px_-18px_rgba(15,23,42,0.24)] transition-all hover:scale-105 hover:bg-slate-50 hover:text-slate-800 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
style={{
left: `${x * direction}px`,
top: `${y}px`,
}}
aria-label={item.label}
title={item.label}
>
<Icon className="h-6 w-6" />
</button>
);
})}
</div>
) : null}
{isVideoMode &&
selectedVideoWorkflowDefinition &&
selectedWorkflowIsActionable ? (
<div className="pointer-events-none absolute left-4 top-16 z-20 flex w-[min(480px,calc(100vw-32px))] flex-col gap-3">
{selectedWorkflowIsActionable ? (
<Card className={VIDEO_CANVAS_PANEL_CLASS}>
<CardHeader className="pb-3">