Browse Source

Make node continuation and image upload gestures predictable

The canvas now treats the visible plus affordance as part of the output handle hit zone, so users can click or drag from the same right-side target. Empty image input nodes also handle double-click locally before the canvas node double-click centering logic sees the event.

Constraint: The plus affordance must not block React Flow handle drag events
Rejected: Keep the plus as the primary pointer target | it prevented drag-to-connect from the same visual area
Confidence: high
Scope-risk: moderate
Directive: Keep quick-add visuals non-interactive unless handle dragging is reworked at the React Flow layer
Tested: npm run test:run -- src/components/__tests__/ImageInputNode.test.tsx src/components/__tests__/WorkflowCanvas.test.tsx
Tested: npm run build
Not-tested: npm run lint | existing Next 16 next lint script resolves to nonexistent /lint directory
feature/gateway
jiajia 2 months ago
parent
commit
e9b04fd836
  1. 6
      src/app/globals.css
  2. 134
      src/components/WorkflowCanvas.tsx
  3. 21
      src/components/__tests__/ImageInputNode.test.tsx
  4. 34
      src/components/__tests__/WorkflowCanvas.test.tsx
  5. 5
      src/components/nodes/ImageInputNode.tsx

6
src/app/globals.css

@ -115,6 +115,12 @@ body {
right: -7px; right: -7px;
} }
/* Right-side output handles also own the quick-add affordance hit zone. */
.react-flow__handle-right::before {
width: 96px;
height: 96px;
}
/* Image handles - soft green */ /* Image handles - soft green */
.react-flow__handle[data-handletype="image"] { .react-flow__handle[data-handletype="image"] {
background: #10b981; background: #10b981;

134
src/components/WorkflowCanvas.tsx

@ -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 (
<button <div key={`quick-add-${node.id}`}>
key={`quick-add-${node.id}`} <button
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"
isActive ? "opacity-100" : "opacity-70" onClick={(event) => handleQuickAddNode(event, node)}
}`} />
style={{ <div
left: `${node.position.x + width + 18}px`, aria-hidden="true"
top: `${node.position.y + height / 2}px`, data-testid="node-quick-add-affordance"
transform: "translate(-50%, -50%)", 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 ${
zIndex: node.selected ? 10002 : 9002, isActive ? "opacity-100" : "opacity-70"
}} }`}
title="Add connected node" style={{
onPointerDown={(event) => { left: `${node.position.x + width + QUICK_ADD_AFFORDANCE_OFFSET_X}px`,
event.stopPropagation(); top: `${node.position.y + height / 2}px`,
}} transform: "translate(-50%, -50%)",
onClick={(event) => handleQuickAddNode(event, node)} zIndex: node.selected ? 10002 : 9002,
> }}
+ title="Add connected node"
</button> >
+
</div>
</div>
); );
})} })}
{allNodes.map((node) => { {allNodes.map((node) => {

21
src/components/__tests__/ImageInputNode.test.tsx

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, createEvent } from "@testing-library/react";
import { ImageInputNode } from "@/components/nodes/ImageInputNode"; import { ImageInputNode } from "@/components/nodes/ImageInputNode";
import { ReactFlowProvider } from "@xyflow/react"; import { ReactFlowProvider } from "@xyflow/react";
@ -490,6 +490,25 @@ describe("ImageInputNode", () => {
expect(clickSpy).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled();
}); });
it("should trigger file input click and stop canvas double-click handling when drop zone is double-clicked", () => {
render(
<TestWrapper>
<ImageInputNode {...defaultProps} />
</TestWrapper>
);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, "click");
const dropZone = screen.getByText("Drop image").parentElement!;
const doubleClickEvent = createEvent.dblClick(dropZone);
const stopPropagationSpy = vi.spyOn(doubleClickEvent, "stopPropagation");
fireEvent(dropZone, doubleClickEvent);
expect(clickSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
});
it("should not trigger file input click when existing image preview is clicked", () => { it("should not trigger file input click when existing image preview is clicked", () => {
render( render(
<TestWrapper> <TestWrapper>

34
src/components/__tests__/WorkflowCanvas.test.tsx

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { WorkflowCanvas } from "@/components/WorkflowCanvas"; import { WorkflowCanvas, isPointInQuickAddHotZone } from "@/components/WorkflowCanvas";
import { ReactFlowProvider } from "@xyflow/react"; import { ReactFlowProvider } from "@xyflow/react";
// Mock the workflow store // Mock the workflow store
@ -412,6 +412,38 @@ describe("WorkflowCanvas", () => {
expect(screen.queryByTitle("Set fallback model (runs if primary fails)")).not.toBeInTheDocument(); expect(screen.queryByTitle("Set fallback model (runs if primary fails)")).not.toBeInTheDocument();
}); });
it("treats the plus affordance region as a quick-add hot zone", () => {
const node = createMockNode("image-1", "imageInput", {
measured: { width: 300, height: 220 },
});
expect(isPointInQuickAddHotZone(node as any, { x: 418, y: 210 })).toBe(true);
expect(isPointInQuickAddHotZone(node as any, { x: 510, y: 210 })).toBe(false);
});
it("renders the visible quick-add affordance without taking pointer events", async () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
nodes: [
createMockNode("image-1", "imageInput", {
selected: true,
measured: { width: 300, height: 220 },
}),
],
}));
});
render(
<TestWrapper>
<WorkflowCanvas />
</TestWrapper>
);
const affordance = await screen.findByTestId("node-quick-add-affordance");
expect(affordance).toHaveClass("pointer-events-none");
expect(affordance).toHaveAttribute("aria-hidden", "true");
});
it("should open the connection menu from a node quick-add button and create a connected node", async () => { it("should open the connection menu from a node quick-add button and create a connected node", async () => {
mockUseWorkflowStore.mockImplementation((selector) => { mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({ return selector(createDefaultState({

5
src/components/nodes/ImageInputNode.tsx

@ -229,6 +229,11 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
handleOpenFileDialog(); handleOpenFileDialog();
} }
}} }}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleOpenFileDialog();
}}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
className={`w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center cursor-pointer hover:bg-neutral-900/60 transition-colors ${nodeData.isOptional ? "border-2 border-dashed border-neutral-600" : ""}`} className={`w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center cursor-pointer hover:bg-neutral-900/60 transition-colors ${nodeData.isOptional ? "border-2 border-dashed border-neutral-600" : ""}`}

Loading…
Cancel
Save