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" : ""}`}