1- import { useState , useCallback , useMemo } from "react" ;
21import {
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" ;
1718import "@xyflow/react/dist/style.css" ;
1819import { Button } from "@/components/ui/button" ;
1920import { 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