@@ -34,6 +34,7 @@ import {
3434 Add24Regular ,
3535 ArrowReset24Regular ,
3636 Dismiss24Regular ,
37+ Link24Regular ,
3738 Play24Regular ,
3839} from '@fluentui/react-icons'
3940import { useCallback , useEffect , useRef , useState } from 'react'
@@ -151,7 +152,7 @@ function edgePt(node, tx, ty) {
151152 return { x : node . x + ( dx / d ) * ( NODE_R + 2 ) , y : node . y + ( dy / d ) * ( NODE_R + 2 ) }
152153}
153154
154- function renderCanvas ( ctx , canvas , nodes , edges , selectedId , hoveredId , connecting , mousePos , animProgress ) {
155+ function renderCanvas ( ctx , canvas , nodes , edges , selectedId , hoveredId , connecting , mousePos , animProgress , connectMode ) {
155156 const dpr = window . devicePixelRatio || 1
156157 const W = canvas . width / dpr , H = canvas . height / dpr
157158 ctx . clearRect ( 0 , 0 , canvas . width , canvas . height )
@@ -211,6 +212,13 @@ function renderCanvas(ctx, canvas, nodes, edges, selectedId, hoveredId, connecti
211212 ctx . font = 'bold 12px Inter, system-ui, sans-serif'
212213 ctx . fillStyle = '#1e293b' ; ctx . textAlign = 'center' ; ctx . textBaseline = 'top'
213214 lines . forEach ( ( line , i ) => ctx . fillText ( line , node . x , node . y + NODE_R + 10 + i * 15 ) )
215+
216+ // Connect mode: show a small "+" on hovered non-selected nodes
217+ if ( connectMode && connecting && node . id !== connecting && isHov ) {
218+ ctx . font = 'bold 18px Inter, system-ui, sans-serif'
219+ ctx . fillStyle = '#16a34a' ; ctx . textAlign = 'center' ; ctx . textBaseline = 'middle'
220+ ctx . fillText ( '+' , node . x , node . y )
221+ }
214222 } )
215223 ctx . restore ( )
216224}
@@ -257,6 +265,8 @@ export default function WorkflowPage() {
257265 const [ animProgress , setAnimProgress ] = useState ( null )
258266 const [ nodeDialog , setNodeDialog ] = useState ( null )
259267 const [ canvasReady , setCanvasReady ] = useState ( 0 )
268+ const [ connectMode , setConnectMode ] = useState ( false )
269+ const dragStartPos = useRef ( null ) // track if mouse actually moved (to distinguish click vs drag)
260270
261271 const switchWf = ( key ) => {
262272 const wf = WORKFLOWS [ key ] ; if ( ! wf ) return
@@ -273,19 +283,64 @@ export default function WorkflowPage() {
273283 } , [ ] )
274284
275285 useEffect ( ( ) => { resize ( ) ; window . addEventListener ( 'resize' , resize ) ; return ( ) => window . removeEventListener ( 'resize' , resize ) } , [ resize ] )
276- useEffect ( ( ) => { const c = canvasRef . current ; if ( ! c || ! canvasReady ) return ; renderCanvas ( c . getContext ( '2d' ) , c , nodes , edges , selectedId , hoveredId , connecting , mousePos , animProgress ) } , [ nodes , edges , selectedId , hoveredId , connecting , mousePos , animProgress , canvasReady ] )
286+ useEffect ( ( ) => { const c = canvasRef . current ; if ( ! c || ! canvasReady ) return ; renderCanvas ( c . getContext ( '2d' ) , c , nodes , edges , selectedId , hoveredId , connecting , mousePos , animProgress , connectMode ) } , [ nodes , edges , selectedId , hoveredId , connecting , mousePos , animProgress , canvasReady , connectMode ] )
277287 useEffect ( ( ) => { if ( ! animating ) { setAnimProgress ( null ) ; return } ; let ok = true ; const t = ( ) => { if ( ! ok ) return ; setAnimProgress ( ( p ) => ( ( p || 0 ) + 0.003 ) % 1 ) ; animRef . current = requestAnimationFrame ( t ) } ; animRef . current = requestAnimationFrame ( t ) ; return ( ) => { ok = false ; cancelAnimationFrame ( animRef . current ) } } , [ animating ] )
278288
279289 const gp = ( e ) => { const r = canvasRef . current . getBoundingClientRect ( ) ; return { x : e . clientX - r . left , y : e . clientY - r . top } }
280290 const fn = ( x , y ) => { for ( let i = nodes . length - 1 ; i >= 0 ; i -- ) if ( pointInNode ( x , y , nodes [ i ] ) ) return nodes [ i ] ; return null }
281291
282- const onDown = ( e ) => { const p = gp ( e ) , n = fn ( p . x , p . y ) ; if ( n ) { setSelectedId ( n . id ) ; e . shiftKey ? ( setConnecting ( n . id ) , setMousePos ( p ) ) : setDragging ( { id : n . id , ox : p . x - n . x , oy : p . y - n . y } ) } else setSelectedId ( null ) }
292+ const onDown = ( e ) => {
293+ const p = gp ( e ) , n = fn ( p . x , p . y )
294+ dragStartPos . current = p
295+ if ( n ) {
296+ setSelectedId ( n . id )
297+ if ( connectMode || e . shiftKey ) {
298+ setConnecting ( n . id ) ; setMousePos ( p )
299+ } else {
300+ setDragging ( { id : n . id , ox : p . x - n . x , oy : p . y - n . y } )
301+ }
302+ } else {
303+ setSelectedId ( null )
304+ }
305+ }
283306 const onMove = ( e ) => { const p = gp ( e ) ; setMousePos ( p ) ; dragging ? setNodes ( ( prev ) => prev . map ( ( n ) => n . id === dragging . id ? { ...n , x : p . x - dragging . ox , y : p . y - dragging . oy } : n ) ) : setHoveredId ( fn ( p . x , p . y ) ?. id || null ) }
284- const onUp = ( e ) => { if ( connecting ) { const t = fn ( gp ( e ) . x , gp ( e ) . y ) ; if ( t && t . id !== connecting && ! edges . some ( ( x ) => x . from === connecting && x . to === t . id ) ) setEdges ( ( p ) => [ ...p , { from : connecting , to : t . id } ] ) ; setConnecting ( null ) } ; setDragging ( null ) }
285- const onClick = ( e ) => { if ( ! dragging ) { const n = fn ( gp ( e ) . x , gp ( e ) . y ) ; if ( n ) setNodeDialog ( n . id ) } }
286- const onDbl = ( e ) => { const p = gp ( e ) , n = fn ( p . x , p . y ) ; if ( n ) { const r = canvasRef . current . getBoundingClientRect ( ) ; setEditing ( { nodeId : n . id , x : e . clientX - r . left , y : e . clientY - r . top } ) ; setEditText ( n . label . replace ( / \n / g, ' ' ) ) } }
307+ const onUp = ( e ) => {
308+ if ( connecting ) {
309+ const t = fn ( gp ( e ) . x , gp ( e ) . y )
310+ if ( t && t . id !== connecting && ! edges . some ( ( x ) => x . from === connecting && x . to === t . id ) ) {
311+ setEdges ( ( p ) => [ ...p , { from : connecting , to : t . id } ] )
312+ }
313+ setConnecting ( null )
314+ }
315+ setDragging ( null )
316+ }
317+ const onClick = ( e ) => {
318+ // Only open dialog if mouse didn't move (not a drag)
319+ const p = gp ( e )
320+ const start = dragStartPos . current
321+ if ( start && Math . abs ( p . x - start . x ) + Math . abs ( p . y - start . y ) > 5 ) return
322+ if ( ! connectMode ) {
323+ const n = fn ( p . x , p . y )
324+ if ( n ) setNodeDialog ( n . id )
325+ }
326+ }
327+ // Double-click: rename if on node, add new node if on empty canvas
328+ const onDbl = ( e ) => {
329+ const p = gp ( e ) , n = fn ( p . x , p . y )
330+ if ( n ) {
331+ const r = canvasRef . current . getBoundingClientRect ( )
332+ setEditing ( { nodeId : n . id , x : e . clientX - r . left , y : e . clientY - r . top } )
333+ setEditText ( n . label . replace ( / \n / g, ' ' ) )
334+ } else {
335+ // Add new node where user double-clicked
336+ const id = 'n' + ( _nid ++ )
337+ setNodes ( ( prev ) => [ ...prev , { id, x : p . x , y : p . y , label : 'New Step' , color : '#475569' , agent : 'none' } ] )
338+ setSelectedId ( id )
339+ setNodeDialog ( id )
340+ }
341+ }
287342 const commitEdit = ( ) => { if ( editing && editText . trim ( ) ) setNodes ( ( p ) => p . map ( ( n ) => n . id === editing . nodeId ? { ...n , label : editText . trim ( ) } : n ) ) ; setEditing ( null ) }
288- const addNode = ( ) => { const id = 'n' + ( _nid ++ ) ; setNodes ( ( p ) => [ ...p , { id, x : 150 + Math . random ( ) * 300 , y : 150 + Math . random ( ) * 150 , label : 'New Step' , color : '#475569' , agent : 'none' } ] ) ; setSelectedId ( id ) }
343+ const addNode = ( ) => { const id = 'n' + ( _nid ++ ) ; setNodes ( ( p ) => [ ...p , { id, x : 200 + Math . random ( ) * 300 , y : 100 + Math . random ( ) * 250 , label : 'New Step' , color : '#475569' , agent : 'none' } ] ) ; setSelectedId ( id ) ; setNodeDialog ( id ) }
289344 const delSel = ( ) => { if ( ! selectedId ) return ; setNodes ( ( p ) => p . filter ( ( n ) => n . id !== selectedId ) ) ; setEdges ( ( p ) => p . filter ( ( e ) => e . from !== selectedId && e . to !== selectedId ) ) ; setSelectedId ( null ) ; setNodeDialog ( null ) }
290345
291346 const dNode = nodeDialog ? nodes . find ( ( n ) => n . id === nodeDialog ) : null
@@ -296,10 +351,15 @@ export default function WorkflowPage() {
296351 < div className = { styles . header } >
297352 < div >
298353 < Subtitle2 > Support Workflow</ Subtitle2 >
299- < Caption1 style = { { display : 'block' , marginTop : '2px' } } > Click node to configure · Drag to move · Shift+drag to connect · Double-click to rename</ Caption1 >
354+ < Caption1 style = { { display : 'block' , marginTop : '2px' } } >
355+ { connectMode
356+ ? '🔗 Connect mode: click a node, then click another to draw an edge. Press Esc or toggle off to exit.'
357+ : 'Click node to configure · Drag to move · Double-click empty area to add node' }
358+ </ Caption1 >
300359 </ div >
301360 < div className = { styles . toolbar } >
302361 < Button appearance = { animating ? 'primary' : 'outline' } icon = { < Play24Regular /> } onClick = { ( ) => setAnimating ( ! animating ) } data-testid = "workflow-animate" > { animating ? 'Stop' : 'Animate' } </ Button >
362+ < Button appearance = { connectMode ? 'primary' : 'outline' } icon = { < Link24Regular /> } onClick = { ( ) => setConnectMode ( ! connectMode ) } data-testid = "workflow-connect-mode" > { connectMode ? 'Connecting…' : 'Connect' } </ Button >
303363 < Button appearance = "subtle" icon = { < Add24Regular /> } onClick = { addNode } data-testid = "workflow-add-node" > Add Node</ Button >
304364 < Button appearance = "subtle" icon = { < ArrowReset24Regular /> } onClick = { ( ) => switchWf ( activeWf ) } data-testid = "workflow-reset" > Reset</ Button >
305365 </ div >
@@ -319,7 +379,11 @@ export default function WorkflowPage() {
319379 < div className = { styles . canvasWrap } ref = { wrapRef } >
320380 < canvas ref = { canvasRef } onMouseDown = { onDown } onMouseMove = { onMove } onMouseUp = { onUp }
321381 onMouseLeave = { ( ) => { setDragging ( null ) ; setConnecting ( null ) ; setHoveredId ( null ) } }
322- onClick = { onClick } onDoubleClick = { onDbl } style = { { display : 'block' } } data-testid = "workflow-canvas" />
382+ onClick = { onClick } onDoubleClick = { onDbl }
383+ onKeyDown = { ( e ) => { if ( e . key === 'Escape' ) { setConnectMode ( false ) ; setConnecting ( null ) } } }
384+ tabIndex = { 0 }
385+ style = { { display : 'block' , cursor : connectMode ? 'crosshair' : 'default' , outline : 'none' } }
386+ data-testid = "workflow-canvas" />
323387 { editing && (
324388 < div className = { styles . editOverlay } style = { { left : editing . x - 80 , top : editing . y - 16 } } >
325389 < Input autoFocus value = { editText } onChange = { ( _ , d ) => setEditText ( d . value ) }
@@ -368,6 +432,20 @@ export default function WorkflowPage() {
368432 ) ) }
369433 </ Combobox >
370434 </ Field >
435+ < Field label = "Connect to…" >
436+ < div style = { { display : 'flex' , gap : '4px' , flexWrap : 'wrap' } } >
437+ { nodes . filter ( ( n ) => n . id !== dNode . id && ! edges . some ( ( e ) => e . from === dNode . id && e . to === n . id ) ) . map ( ( n ) => (
438+ < Button key = { n . id } size = "small" appearance = "outline"
439+ onClick = { ( ) => { setEdges ( ( p ) => [ ...p , { from : dNode . id , to : n . id } ] ) } }
440+ data-testid = { 'connect-to-' + n . id } >
441+ → { n . label . replace ( / \n / g, ' ' ) }
442+ </ Button >
443+ ) ) }
444+ { nodes . filter ( ( n ) => n . id !== dNode . id && ! edges . some ( ( e ) => e . from === dNode . id && e . to === n . id ) ) . length === 0 && (
445+ < Caption1 > Already connected to all nodes</ Caption1 >
446+ ) }
447+ </ div >
448+ </ Field >
371449 { dNode . agent && dNode . agent !== 'none' && (
372450 < Badge appearance = "filled" color = "brand" style = { { alignSelf : 'flex-start' } } >
373451 { '🤖 ' + ( AGENT_PRESETS . find ( ( a ) => a . id === dNode . agent ) ?. name || '' ) }
0 commit comments