From e9b04fd8368af01cffd6f4db6214300835b96e7b Mon Sep 17 00:00:00 2001 From: jiajia Date: Sat, 23 May 2026 14:40:50 +0800 Subject: [PATCH] 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 --- src/app/globals.css | 6 + src/components/WorkflowCanvas.tsx | 134 ++++++++++++++---- .../__tests__/ImageInputNode.test.tsx | 21 ++- .../__tests__/WorkflowCanvas.test.tsx | 34 ++++- src/components/nodes/ImageInputNode.tsx | 5 + 5 files changed, 169 insertions(+), 31 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 15f2ac3a..c464f513 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -115,6 +115,12 @@ body { 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 */ .react-flow__handle[data-handletype="image"] { background: #10b981; diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 979222ce..dd5f42d6 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/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 => { if (node.type === "router") { const activeEdge = edges.find((edge) => edge.target === node.id && getHandleType(edge.targetHandle)); @@ -703,16 +718,17 @@ export function WorkflowCanvas() { regenerateNode(nodeId); }, [regenerateNode, selectSingleNode]); - const handleQuickAddNode = useCallback((event: ReactMouseEvent, node: Node) => { - event.preventDefault(); - event.stopPropagation(); - - const source = getQuickAddSource(node, edges); + const openQuickAddForNode = useCallback(( + node: Node, + clientPosition: { x: number; y: number }, + sourceOverride?: { handleId: string; handleType: ConcreteHandleType } | null + ) => { + const source = sourceOverride ?? getQuickAddSource(node, edges); if (!source) return; const { width } = getNodeDimensions(node); setConnectionDrop({ - position: { x: event.clientX, y: event.clientY }, + position: clientPosition, flowPosition: { x: node.position.x + width + 160, y: node.position.y, @@ -728,8 +744,50 @@ export function WorkflowCanvas() { if (tutorialActive) { useFTUXStore.getState().setConnectionMenuShown(true); } + + return true; }, [edges, tutorialActive]); + const handleQuickAddNode = useCallback((event: ReactMouseEvent, node: Node) => { + event.preventDefault(); + event.stopPropagation(); + openQuickAddForNode(node, { x: event.clientX, y: event.clientY }); + }, [openQuickAddForNode]); + + const handleQuickAddClickCapture = useCallback((event: ReactMouseEvent) => { + 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) const { inlineParametersEnabled } = useInlineParameters(); @@ -1222,6 +1280,20 @@ export function WorkflowCanvas() { // No node under cursor or no compatible handle - show the drop menu 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({ position: { x: clientX, y: clientY }, flowPosition: flowPos, @@ -1237,7 +1309,7 @@ export function WorkflowCanvas() { useFTUXStore.getState().setConnectionMenuShown(true); } }, - [screenToFlowPosition, nodes, edges, handleConnect, tutorialActive] + [screenToFlowPosition, nodes, edges, handleConnect, openQuickAddForNode, tutorialActive] ); // Handle the splitGrid action - uses automated grid detection @@ -2391,6 +2463,7 @@ export function WorkflowCanvas() {
{ - event.stopPropagation(); - }} - onClick={(event) => handleQuickAddNode(event, node)} - > - + - +
+
); })} {allNodes.map((node) => { diff --git a/src/components/__tests__/ImageInputNode.test.tsx b/src/components/__tests__/ImageInputNode.test.tsx index ecd75bd7..365358a3 100644 --- a/src/components/__tests__/ImageInputNode.test.tsx +++ b/src/components/__tests__/ImageInputNode.test.tsx @@ -1,5 +1,5 @@ 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 { ReactFlowProvider } from "@xyflow/react"; @@ -490,6 +490,25 @@ describe("ImageInputNode", () => { expect(clickSpy).toHaveBeenCalled(); }); + it("should trigger file input click and stop canvas double-click handling when drop zone is double-clicked", () => { + render( + + + + ); + + 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", () => { render( diff --git a/src/components/__tests__/WorkflowCanvas.test.tsx b/src/components/__tests__/WorkflowCanvas.test.tsx index 2bd06b43..3a2706a5 100644 --- a/src/components/__tests__/WorkflowCanvas.test.tsx +++ b/src/components/__tests__/WorkflowCanvas.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 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"; // Mock the workflow store @@ -412,6 +412,38 @@ describe("WorkflowCanvas", () => { 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( + + + + ); + + 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 () => { mockUseWorkflowStore.mockImplementation((selector) => { return selector(createDefaultState({ diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index c85717fc..49c0db93 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -229,6 +229,11 @@ export function ImageInputNode({ id, data, selected }: NodeProps { + e.preventDefault(); + e.stopPropagation(); + handleOpenFileDialog(); + }} onDrop={handleDrop} 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" : ""}`}