From d18130c23f76a3297f86f5e075fd672e6e162806 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 24 Jan 2026 01:27:39 +1300 Subject: [PATCH] fix: resolve node selection bug where nodes with undefined bounds get incorrectly selected React Flow's internal bounds for nodes were undefined, causing certain nodes to be incorrectly included in drag selections regardless of their position. Added statistical outlier detection using IQR to identify and automatically deselect nodes that are clearly outside the actual selection area. Also includes: - Clear selected state and validate position coordinates on workflow load - Strip selected property when saving workflows (transient UI state) - Enable prompt nodes to receive text input connections Co-Authored-By: Claude Opus 4.5 --- src/components/WorkflowCanvas.tsx | 53 ++++++++++++++++++++++++++++++- src/store/workflowStore.ts | 23 +++++++++++--- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 35b6f223..929bf343 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -13,6 +13,7 @@ import { useReactFlow, OnConnectEnd, Node, + OnSelectionChangeParams, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; @@ -80,7 +81,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] case "annotation": return { inputs: ["image"], outputs: ["image"] }; case "prompt": - return { inputs: [], outputs: ["text"] }; + return { inputs: ["text"], outputs: ["text"] }; case "nanoBanana": return { inputs: ["image", "text"], outputs: ["image"] }; case "generateVideo": @@ -613,6 +614,8 @@ export function WorkflowCanvas() { sourceHandleIdForNewNode = "text"; } } else if (nodeType === "prompt") { + // prompt can receive and output text + targetHandleId = "text"; sourceHandleIdForNewNode = "text"; } } @@ -993,6 +996,53 @@ export function WorkflowCanvas() { return () => window.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); + + // Fix for React Flow selection bug where nodes with undefined bounds get incorrectly selected. + // Uses statistical outlier detection to identify and deselect nodes that are clearly + // outside the actual selection area. + const handleSelectionChange = useCallback(({ nodes: selectedNodes }: OnSelectionChangeParams) => { + if (selectedNodes.length <= 1) return; + + // Get positions of all selected nodes + const positions = selectedNodes.map(n => ({ + id: n.id, + x: n.position.x, + y: n.position.y, + })); + + // Calculate IQR-based bounds for outlier detection + const sortedX = [...positions].sort((a, b) => a.x - b.x); + const sortedY = [...positions].sort((a, b) => a.y - b.y); + + const q1X = sortedX[Math.floor(sortedX.length * 0.25)].x; + const q3X = sortedX[Math.floor(sortedX.length * 0.75)].x; + const q1Y = sortedY[Math.floor(sortedY.length * 0.25)].y; + const q3Y = sortedY[Math.floor(sortedY.length * 0.75)].y; + const iqrX = q3X - q1X; + const iqrY = q3Y - q1Y; + + // Outlier threshold: 3x IQR from quartiles + const minX = q1X - iqrX * 3; + const maxX = q3X + iqrX * 3; + const minY = q1Y - iqrY * 3; + const maxY = q3Y + iqrY * 3; + + // Find and deselect outliers + const outliers = positions.filter(p => + p.x < minX || p.x > maxX || p.y < minY || p.y > maxY + ); + + if (outliers.length > 0) { + onNodesChange( + outliers.map(o => ({ + type: 'select' as const, + id: o.id, + selected: false, + })) + ); + } + }, [onNodesChange]); + const handleDragOver = useCallback((event: DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; @@ -1198,6 +1248,7 @@ export function WorkflowCanvas() { onConnect={handleConnect} onConnectEnd={handleConnectEnd} onNodeDragStop={handleNodeDragStop} + onSelectionChange={handleSelectionChange} nodeTypes={nodeTypes} edgeTypes={edgeTypes} isValidConnection={isValidConnection} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 8ea6a2d1..7a959909 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1000,9 +1000,14 @@ export const useWorkflowStore = create((set, get) => ({ break; } - case "prompt": - // Nothing to execute, data is already set + case "prompt": { + // Check for connected text input and update prompt if connected + const { text: connectedText } = getConnectedInputs(node.id); + if (connectedText !== null) { + updateNodeData(node.id, { prompt: connectedText }); + } break; + } case "nanoBanana": { const { images, text, dynamicInputs } = getConnectedInputs(node.id); @@ -2368,7 +2373,8 @@ export const useWorkflowStore = create((set, get) => ({ const workflow: WorkflowFile = { version: 1, name: name || `workflow-${new Date().toISOString().slice(0, 10)}`, - nodes, + // Strip selected property - selection is transient UI state and should not be persisted + nodes: nodes.map(({ selected, ...rest }) => rest), edges, edgeStyle, groups: Object.keys(groups).length > 0 ? groups : undefined, @@ -2452,7 +2458,16 @@ export const useWorkflowStore = create((set, get) => ({ const costData = workflow.id ? loadWorkflowCostData(workflow.id) : null; set({ - nodes: hydratedWorkflow.nodes, + // Clear selected state - selection should not be persisted across sessions + // Also validate position to ensure coordinates are finite numbers + nodes: hydratedWorkflow.nodes.map(node => ({ + ...node, + selected: false, + position: { + x: isFinite(node.position?.x) ? node.position.x : 0, + y: isFinite(node.position?.y) ? node.position.y : 0, + }, + })), edges: hydratedWorkflow.edges, edgeStyle: hydratedWorkflow.edgeStyle || "angular", groups: hydratedWorkflow.groups || {},