@ -283,6 +283,21 @@ const getNodeDimensions = (node: Node): { width: number; height: number } => {
} ;
} ;
} ;
} ;
const QUICK_ADD_AFFORDANCE_OFFSET_X = 18 ;
const QUICK_ADD_HOT_ZONE_WIDTH = 96 ;
const QUICK_ADD_HOT_ZONE_HEIGHT = 96 ;
export const isPointInQuickAddHotZone = ( node : Node , point : { x : number ; y : number } ) : boolean = > {
const { width , height } = getNodeDimensions ( node ) ;
const centerX = node . position . x + width + QUICK_ADD_AFFORDANCE_OFFSET_X ;
const centerY = node . position . y + height / 2 ;
return (
Math . abs ( point . x - centerX ) <= QUICK_ADD_HOT_ZONE_WIDTH / 2 &&
Math . abs ( point . y - centerY ) <= QUICK_ADD_HOT_ZONE_HEIGHT / 2
) ;
} ;
const getQuickAddSource = ( node : Node , edges : Edge [ ] ) : { handleId : string ; handleType : ConcreteHandleType } | null = > {
const getQuickAddSource = ( node : Node , edges : Edge [ ] ) : { handleId : string ; handleType : ConcreteHandleType } | null = > {
if ( node . type === "router" ) {
if ( node . type === "router" ) {
const activeEdge = edges . find ( ( edge ) = > edge . target === node . id && getHandleType ( edge . targetHandle ) ) ;
const activeEdge = edges . find ( ( edge ) = > edge . target === node . id && getHandleType ( edge . targetHandle ) ) ;
@ -703,16 +718,17 @@ export function WorkflowCanvas() {
regenerateNode ( nodeId ) ;
regenerateNode ( nodeId ) ;
} , [ regenerateNode , selectSingleNode ] ) ;
} , [ regenerateNode , selectSingleNode ] ) ;
const handleQuickAddNode = useCallback ( ( event : ReactMouseEvent < HTMLButtonElement > , node : Node ) = > {
const openQuickAddForNode = useCallback ( (
event . preventDefault ( ) ;
node : Node ,
event . stopPropagation ( ) ;
clientPosition : { x : number ; y : number } ,
sourceOverride ? : { handleId : string ; handleType : ConcreteHandleType } | null
const source = getQuickAddSource ( node , edges ) ;
) = > {
const source = sourceOverride ? ? getQuickAddSource ( node , edges ) ;
if ( ! source ) return ;
if ( ! source ) return ;
const { width } = getNodeDimensions ( node ) ;
const { width } = getNodeDimensions ( node ) ;
setConnectionDrop ( {
setConnectionDrop ( {
position : { x : event.clientX , y : event.clientY } ,
position : clientPosition ,
flowPosition : {
flowPosition : {
x : node.position.x + width + 160 ,
x : node.position.x + width + 160 ,
y : node.position.y ,
y : node.position.y ,
@ -728,8 +744,50 @@ export function WorkflowCanvas() {
if ( tutorialActive ) {
if ( tutorialActive ) {
useFTUXStore . getState ( ) . setConnectionMenuShown ( true ) ;
useFTUXStore . getState ( ) . setConnectionMenuShown ( true ) ;
}
}
return true ;
} , [ edges , tutorialActive ] ) ;
} , [ edges , tutorialActive ] ) ;
const handleQuickAddNode = useCallback ( ( event : ReactMouseEvent < HTMLButtonElement > , node : Node ) = > {
event . preventDefault ( ) ;
event . stopPropagation ( ) ;
openQuickAddForNode ( node , { x : event.clientX , y : event.clientY } ) ;
} , [ openQuickAddForNode ] ) ;
const handleQuickAddClickCapture = useCallback ( ( event : ReactMouseEvent < HTMLDivElement > ) = > {
if ( isModalOpen ) return ;
const target = event . target as HTMLElement | null ;
const handleElement = target ? . closest ( ".react-flow__handle-right" ) as HTMLElement | null ;
if ( ! handleElement || ! handleElement . classList . contains ( "source" ) ) return ;
const nodeWrapper = handleElement . closest ( ".react-flow__node" ) as HTMLElement | null ;
const nodeId = nodeWrapper ? . dataset . id ;
const node = nodeId ? nodes . find ( ( item ) = > item . id === nodeId ) : null ;
if ( ! node ) return ;
const flowPosition = screenToFlowPosition ( { x : event.clientX , y : event.clientY } ) ;
if ( ! isPointInQuickAddHotZone ( node , flowPosition ) ) return ;
const quickAddSource = getQuickAddSource ( node , edges ) ;
const clickedHandleId = handleElement . dataset . handleid || null ;
const clickedHandleType =
getHandleType ( clickedHandleId ) ||
getHandleType ( handleElement . dataset . handletype ) ||
quickAddSource ? . handleType ||
null ;
if ( ! clickedHandleType ) return ;
event . preventDefault ( ) ;
event . stopPropagation ( ) ;
openQuickAddForNode (
node ,
{ x : event.clientX , y : event.clientY } ,
{ handleId : clickedHandleId || quickAddSource ? . handleId || clickedHandleType , handleType : clickedHandleType }
) ;
} , [ edges , isModalOpen , nodes , openQuickAddForNode , screenToFlowPosition ] ) ;
// Inline parameters mode (for showing Browse in header)
// Inline parameters mode (for showing Browse in header)
const { inlineParametersEnabled } = useInlineParameters ( ) ;
const { inlineParametersEnabled } = useInlineParameters ( ) ;
@ -1222,6 +1280,20 @@ export function WorkflowCanvas() {
// No node under cursor or no compatible handle - show the drop menu
// No node under cursor or no compatible handle - show the drop menu
const flowPos = screenToFlowPosition ( { x : clientX , y : clientY } ) ;
const flowPos = screenToFlowPosition ( { x : clientX , y : clientY } ) ;
if ( isFromSource && fromHandleType && isPointInQuickAddHotZone ( connectionState . fromNode , flowPos ) ) {
const quickAddSource = getQuickAddSource ( connectionState . fromNode , edges ) ;
const opened = openQuickAddForNode (
connectionState . fromNode ,
{ x : clientX , y : clientY } ,
{
handleId : fromHandleId || quickAddSource ? . handleId || fromHandleType ,
handleType : fromHandleType ,
}
) ;
if ( opened ) return ;
}
setConnectionDrop ( {
setConnectionDrop ( {
position : { x : clientX , y : clientY } ,
position : { x : clientX , y : clientY } ,
flowPosition : flowPos ,
flowPosition : flowPos ,
@ -1237,7 +1309,7 @@ export function WorkflowCanvas() {
useFTUXStore . getState ( ) . setConnectionMenuShown ( true ) ;
useFTUXStore . getState ( ) . setConnectionMenuShown ( true ) ;
}
}
} ,
} ,
[ screenToFlowPosition , nodes , edges , handleConnect , tutorialActive ]
[ screenToFlowPosition , nodes , edges , handleConnect , openQuickAddForNode , tutorialActive ]
) ;
) ;
// Handle the splitGrid action - uses automated grid detection
// Handle the splitGrid action - uses automated grid detection
@ -2391,6 +2463,7 @@ export function WorkflowCanvas() {
< div
< div
ref = { reactFlowWrapper }
ref = { reactFlowWrapper }
className = { ` flex-1 bg-canvas-bg relative ${ isDragOver ? "ring-2 ring-inset ring-blue-500" : "" } ` }
className = { ` flex-1 bg-canvas-bg relative ${ isDragOver ? "ring-2 ring-inset ring-blue-500" : "" } ` }
onClickCapture = { handleQuickAddClickCapture }
onDragOver = { handleDragOver }
onDragOver = { handleDragOver }
onDragLeave = { handleDragLeave }
onDragLeave = { handleDragLeave }
onDrop = { handleDrop }
onDrop = { handleDrop }
@ -2608,28 +2681,31 @@ export function WorkflowCanvas() {
const isActive = ! ! node . selected || hoveredNodeId === node . id ;
const isActive = ! ! node . selected || hoveredNodeId === node . id ;
return (
return (
< div key = { ` quick-add- ${ node . id } ` } >
< button
< button
key = { ` quick-add- ${ node . id } ` }
type = "button"
type = "button"
aria - label = "Add connected node"
aria - label = "Add connected node"
data - testid = "node-quick-add"
data - testid = "node-quick-add"
className = { ` nodrag nopan pointer-events-auto absolute flex h-7 w-7 items-center justify-center rounded-full border border-neutral-500/80 bg-neutral-900/90 text-lg leading-none text-neutral-200 shadow-lg transition hover:border-neutral-300 hover:bg-neutral-800 hover:text-white ${
className = "sr-only"
onClick = { ( event ) = > handleQuickAddNode ( event , node ) }
/ >
< div
aria - hidden = "true"
data - testid = "node-quick-add-affordance"
className = { ` nodrag nopan pointer-events-none absolute flex h-7 w-7 items-center justify-center rounded-full border border-neutral-500/80 bg-neutral-900/90 text-lg leading-none text-neutral-200 shadow-lg transition ${
isActive ? "opacity-100" : "opacity-70"
isActive ? "opacity-100" : "opacity-70"
} ` }
} ` }
style = { {
style = { {
left : ` ${ node . position . x + width + 18 } px ` ,
left : ` ${ node . position . x + width + QUICK_ADD_AFFORDANCE_OFFSET_X } px ` ,
top : ` ${ node . position . y + height / 2 } px ` ,
top : ` ${ node . position . y + height / 2 } px ` ,
transform : "translate(-50%, -50%)" ,
transform : "translate(-50%, -50%)" ,
zIndex : node.selected ? 10002 : 9002 ,
zIndex : node.selected ? 10002 : 9002 ,
} }
} }
title = "Add connected node"
title = "Add connected node"
onPointerDown = { ( event ) = > {
event . stopPropagation ( ) ;
} }
onClick = { ( event ) = > handleQuickAddNode ( event , node ) }
>
>
+
+
< / button >
< / div >
< / div >
) ;
) ;
} ) }
} ) }
{ allNodes . map ( ( node ) = > {
{ allNodes . map ( ( node ) = > {