Skip to content

Commit c7917ac

Browse files
committed
feat: flow runner (#16)
* feat: Introduce `BaseNode` component and `useNodeRun` hook to centralize node UI and execution logic, refactoring existing nodes. * feat: Add group node functionality with new component, button group UI, and canvas grouping logic. * feat: Introduce toast notifications using sonner and next-themes, and apply them to sidebar actions. * feat: Integrate NodeToolbar for base and group node controls and set default group padding to 0. * feat: Implement sequential group node execution with dependency resolution, input propagation, and run/stop controls.
1 parent 7e675ed commit c7917ac

18 files changed

+889
-329
lines changed

frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
"clsx": "^2.1.1",
1919
"cmdk": "^1.1.1",
2020
"lucide-react": "^0.562.0",
21+
"next-themes": "^0.4.6",
2122
"radix-ui": "^1.4.3",
2223
"react": "^19.2.0",
2324
"react-dom": "^19.2.0",
2425
"shadcn": "^3.6.2",
26+
"sonner": "^2.0.7",
2527
"tailwind-merge": "^3.4.0",
2628
"tailwindcss": "^4.1.17",
2729
"tw-animate-css": "^1.4.0"

frontend/package.json.md5

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
b638092a7de1ebaad7aeae60050a509c
1+
26a1cb68f011440ec834ec05278dcf9a

frontend/pnpm-lock.yaml

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/components/app-sidebar.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,15 @@ import {
1212
} from "@/components/ui/sidebar";
1313
import { NavUser } from "@/components/nav-user";
1414
import { Home, Image, Settings, Telescope } from "lucide-react";
15+
import { toast } from "sonner"
16+
1517

1618
export function AppSidebar({ onSettingsClick }: { onSettingsClick: () => void }) {
19+
20+
function wip() {
21+
toast("👷 Work in progress")
22+
}
23+
1724
return (
1825
<Sidebar className="border-none">
1926
<SidebarHeader>
@@ -32,13 +39,13 @@ export function AppSidebar({ onSettingsClick }: { onSettingsClick: () => void })
3239
</SidebarMenuButton>
3340
</SidebarMenuItem>
3441
<SidebarMenuItem>
35-
<SidebarMenuButton>
42+
<SidebarMenuButton onClick={() => wip()}>
3643
<Image />
3744
<span>素材库</span>
3845
</SidebarMenuButton>
3946
</SidebarMenuItem>
4047
<SidebarMenuItem>
41-
<SidebarMenuButton>
48+
<SidebarMenuButton onClick={() => wip()}>
4249
<Telescope />
4350
<span>浏览</span>
4451
</SidebarMenuButton>

frontend/src/components/canvas-view.tsx

Lines changed: 141 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useState, useCallback, useMemo } from "react";
21
import {
32
ReactFlow,
43
MiniMap,
@@ -13,7 +12,9 @@ import {
1312
type Edge,
1413
SelectionMode,
1514
ReactFlowProvider,
15+
useReactFlow,
1616
} from "@xyflow/react";
17+
import { useState, useCallback, useMemo, useRef } from "react";
1718
import "@xyflow/react/dist/style.css";
1819
import { Button } from "@/components/ui/button";
1920
import { Input } from "@/components/ui/input";
@@ -34,6 +35,7 @@ import {
3435
ImageNode,
3536
VideoNode,
3637
AudioNode,
38+
GroupNode,
3739
type NodeType,
3840
} from "./nodes";
3941

@@ -54,29 +56,83 @@ function CanvasEditor({ projectId, projectName, onBack }: CanvasViewProps) {
5456
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
5557
const [nodeIdCounter, setNodeIdCounter] = useState(1);
5658

59+
const [selectedNodes, setSelectedNodes] = useState<string[]>([]);
60+
5761
// Define custom node types
5862
const nodeTypes = useMemo(
5963
() => ({
6064
text: TextNode,
6165
image: ImageNode,
6266
video: VideoNode,
6367
audio: AudioNode,
68+
group: GroupNode,
6469
}),
6570
[]
6671
);
6772

73+
const onSelectionChange = useCallback(({ nodes: selectedNodes }: { nodes: Node[] }) => {
74+
if (selectedNodes.some((node) => node.type === "group")) {
75+
return;
76+
}
77+
setSelectedNodes(selectedNodes.map((node) => node.id));
78+
}, []);
79+
80+
const createGroup = useCallback(() => {
81+
if (selectedNodes.length === 0) return;
82+
83+
const selectedNodeObjects = nodes.filter((n) => selectedNodes.includes(n.id) && n.type !== "group");
84+
if (selectedNodeObjects.length === 0) return;
85+
86+
// Calculate bounding box
87+
let minX = Infinity;
88+
let minY = Infinity;
89+
let maxX = -Infinity;
90+
let maxY = -Infinity;
91+
92+
selectedNodeObjects.forEach((node) => {
93+
minX = Math.min(minX, node.position.x);
94+
minY = Math.min(minY, node.position.y);
95+
// Rough sizing if width/height missing (which happens if not measured yet)
96+
const width = node.measured?.width ?? 200;
97+
const height = node.measured?.height ?? 200;
98+
maxX = Math.max(maxX, node.position.x + width);
99+
maxY = Math.max(maxY, node.position.y + height);
100+
});
101+
102+
const padding = 50;
103+
const groupNode: Node = {
104+
id: `group-${nodeIdCounter}`,
105+
type: "group",
106+
position: {
107+
x: minX - padding,
108+
y: minY - padding,
109+
},
110+
style: {
111+
width: maxX - minX + padding * 2,
112+
height: maxY - minY + padding * 2,
113+
zIndex: -1, // Ensure group is behind
114+
border: "none",
115+
background: "transparent",
116+
padding: 0,
117+
},
118+
selectable: false, // Prevent box selection
119+
data: { label: "New Group" },
120+
};
121+
122+
setNodes((nds) => [...nds, groupNode]);
123+
setNodeIdCounter((c) => c + 1);
124+
setSelectedNodes([]); // Clear selection/hide button
125+
}, [selectedNodes, nodes, nodeIdCounter, setNodes]);
126+
68127
const onConnect = useCallback(
69128
(connection: Connection) => {
70129
// Create animated edge with data flow
71130
const newEdge = {
72131
...connection,
73-
animated: true,
132+
animated: false,
74133
style: { stroke: "#3b82f6" },
75134
};
76135
setEdges((eds) => addEdge(newEdge, eds));
77-
78-
// TODO: Trigger data processing from source to target node
79-
// This would involve calling your AI model API
80136
},
81137
[setEdges]
82138
);
@@ -108,6 +164,69 @@ function CanvasEditor({ projectId, projectName, onBack }: CanvasViewProps) {
108164
[nodeIdCounter, setNodes]
109165
);
110166

167+
const { getIntersectingNodes } = useReactFlow();
168+
const dragRef = useRef<{ id: string; position: { x: number; y: number } } | null>(null);
169+
170+
const onNodeDragStart = useCallback((_: React.MouseEvent, node: Node) => {
171+
if (node.type === "group") {
172+
dragRef.current = { id: node.id, position: { ...node.position } };
173+
}
174+
}, []);
175+
176+
177+
// group 子节点跟随拖动
178+
const onNodeDrag = useCallback(
179+
(_: React.MouseEvent, node: Node) => {
180+
if (node.type === "group" && dragRef.current && dragRef.current.id === node.id) {
181+
const dx = node.position.x - dragRef.current.position.x;
182+
const dy = node.position.y - dragRef.current.position.y;
183+
184+
// Update last position for next frame
185+
dragRef.current.position = { ...node.position };
186+
187+
if (dx === 0 && dy === 0) return;
188+
189+
// Find intersecting nodes
190+
// Note: We use the group node's current dimension (which React Flow tracks)
191+
const intersectingNodes = getIntersectingNodes(node).filter(
192+
(n) => n.type !== "group" && n.parentId !== node.id
193+
);
194+
195+
if (intersectingNodes.length > 0) {
196+
setNodes((nds) =>
197+
nds.map((n: Node) => {
198+
if (intersectingNodes.some((inNode: Node) => inNode.id === n.id)) {
199+
return {
200+
...n,
201+
position: {
202+
x: n.position.x + dx,
203+
y: n.position.y + dy,
204+
},
205+
// We must also update 'selected' to avoid weird selection artifacts?
206+
// No, usually just position is fine.
207+
};
208+
}
209+
return n;
210+
})
211+
);
212+
}
213+
}
214+
},
215+
[getIntersectingNodes, setNodes]
216+
);
217+
218+
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
219+
if (node.type === "group") {
220+
// Manual selection toggle for group nodes since they are not selectable by box
221+
setNodes((nds) => nds.map(n => {
222+
if (n.id === node.id) {
223+
return { ...n, selected: !n.selected };
224+
}
225+
return n;
226+
}));
227+
}
228+
}, [setNodes]);
229+
111230
return (
112231
<div className="flex h-full relative">
113232
{/* 主要内容区域 - 全屏 */}
@@ -138,7 +257,7 @@ function CanvasEditor({ projectId, projectName, onBack }: CanvasViewProps) {
138257
</div>
139258

140259
{/* 左侧悬浮工具栏 */}
141-
<div className="absolute left-4 top-20 z-10 flex flex-col gap-2">
260+
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-10 flex flex-col gap-2">
142261
<Card className="p-2 shadow-lg">
143262
<div className="flex flex-col gap-2">
144263
<Button
@@ -177,6 +296,18 @@ function CanvasEditor({ projectId, projectName, onBack }: CanvasViewProps) {
177296
</Card>
178297
</div>
179298

299+
{/* Create Group Button - Show when nodes are selected */}
300+
{selectedNodes.length > 1 && (
301+
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-10">
302+
<Button
303+
onClick={createGroup}
304+
className="shadow-lg animate-in fade-in zoom-in duration-200"
305+
>
306+
Create Group ({selectedNodes.length})
307+
</Button>
308+
</div>
309+
)}
310+
180311
{/* ReactFlow 画布 */}
181312
<div className="flex-1 h-full">
182313
<ReactFlow
@@ -186,14 +317,17 @@ function CanvasEditor({ projectId, projectName, onBack }: CanvasViewProps) {
186317
onEdgesChange={onEdgesChange}
187318
onConnect={onConnect}
188319
nodeTypes={nodeTypes}
320+
onSelectionChange={onSelectionChange}
189321
fitView
190322
panOnDrag={[1, 2]}
191323
selectionOnDrag
192324
selectionMode={SelectionMode.Partial}
193325
panOnScroll
194326
zoomOnScroll={false}
327+
onNodeDragStart={onNodeDragStart}
328+
onNodeDrag={onNodeDrag}
329+
onNodeClick={onNodeClick}
195330
>
196-
<Controls />
197331
<MiniMap />
198332
<Background variant={BackgroundVariant.Cross} gap={12} size={1} />
199333
</ReactFlow>

0 commit comments

Comments
 (0)