Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
d18130c23f
  1. 53
      src/components/WorkflowCanvas.tsx
  2. 23
      src/store/workflowStore.ts

53
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<HTMLDivElement>) => {
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}

23
src/store/workflowStore.ts

@ -1000,9 +1000,14 @@ export const useWorkflowStore = create<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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 || {},

Loading…
Cancel
Save