diff --git a/app/globals.css b/app/globals.css index 7ef4c187..f860cc86 100644 --- a/app/globals.css +++ b/app/globals.css @@ -211,14 +211,20 @@ z-index: 20 !important; } -/* Increase hit area on mobile without changing visual size */ +/* Invisible hit area for desktop */ +.react-flow__handle::after { + content: "" !important; + position: absolute !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + width: 24px !important; + height: 24px !important; +} + +/* Invisible hit area for mobile with WCAG minimum target size */ @media (max-width: 768px) { .react-flow__handle::after { - content: "" !important; - position: absolute !important; - top: 50% !important; - left: 50% !important; - transform: translate(-50%, -50%) !important; width: 44px !important; height: 44px !important; } diff --git a/components/workflow/workflow-canvas.tsx b/components/workflow/workflow-canvas.tsx index c3f4faa9..2e76e881 100644 --- a/components/workflow/workflow-canvas.tsx +++ b/components/workflow/workflow-canvas.tsx @@ -101,6 +101,7 @@ export function WorkflowCanvas() { useReactFlow(); const connectingNodeId = useRef(null); + const connectingHandleType = useRef<"source" | "target" | null>(null); const justCreatedNodeFromConnection = useRef(false); const viewportInitialized = useRef(false); const [isCanvasReady, setIsCanvasReady] = useState(false); @@ -227,6 +228,27 @@ export function WorkflowCanvas() { [] ); + const nodeHasHandle = useCallback( + (nodeId: string, handleType: "source" | "target") => { + const node = nodes.find((n) => n.id === nodeId); + + if (!node) { + return false; + } + + if (node.type === "add") { + return false; + } + + if (handleType === "target") { + return node.type !== "trigger"; + } + + return true; + }, + [nodes] + ); + const isValidConnection = useCallback( (connection: XYFlowConnection | XYFlowEdge) => { // Ensure we have both source and target @@ -272,6 +294,7 @@ export function WorkflowCanvas() { const onConnectStart = useCallback( (_event: MouseEvent | TouchEvent, params: OnConnectStartParams) => { connectingNodeId.current = params.nodeId; + connectingHandleType.current = params.handleType; }, [] ); @@ -306,6 +329,125 @@ export function WorkflowCanvas() { [] ); + const handleConnectionToExistingNode = useCallback( + (nodeElement: Element) => { + const targetNodeId = nodeElement.getAttribute("data-id"); + const fromSource = connectingHandleType.current === "source"; + const requiredHandle = fromSource ? "target" : "source"; + const connectingId = connectingNodeId.current; + + if ( + targetNodeId && + connectingId && + targetNodeId !== connectingId && + nodeHasHandle(targetNodeId, requiredHandle) + ) { + const sourceId = fromSource ? connectingId : targetNodeId; + const targetId = fromSource ? targetNodeId : connectingId; + onConnect({ + source: sourceId, + target: targetId, + sourceHandle: null, + targetHandle: null, + }); + } + }, + [nodeHasHandle, onConnect] + ); + + const handleConnectionToNewNode = useCallback( + (event: MouseEvent | TouchEvent, clientX: number, clientY: number) => { + const sourceNodeId = connectingNodeId.current; + if (!sourceNodeId) { + return; + } + + const { adjustedX, adjustedY } = calculateMenuPosition( + event, + clientX, + clientY + ); + + // Get the action template + const actionTemplate = nodeTemplates.find((t) => t.type === "action"); + if (!actionTemplate) { + return; + } + + // Get the position in the flow coordinate system + const position = screenToFlowPosition({ + x: adjustedX, + y: adjustedY, + }); + + // Center the node vertically at the cursor position + // Node height is 192px (h-48 in Tailwind) + const nodeHeight = 192; + position.y -= nodeHeight / 2; + + const newNode: WorkflowNode = { + id: nanoid(), + type: actionTemplate.type, + position, + data: { + label: actionTemplate.label, + description: actionTemplate.description, + type: actionTemplate.type, + config: actionTemplate.defaultConfig, + status: "idle", + }, + selected: true, + }; + + addNode(newNode); + setSelectedNode(newNode.id); + setActiveTab("properties"); + + // Deselect all other nodes and select only the new node + // Need to do this after a delay because panOnDrag will clear selection + setTimeout(() => { + setNodes((currentNodes) => + currentNodes.map((n) => ({ + ...n, + selected: n.id === newNode.id, + })) + ); + }, 50); + + // Create connection from the source node to the new node + const fromSource = connectingHandleType.current === "source"; + + const newEdge = { + id: nanoid(), + source: fromSource ? sourceNodeId : newNode.id, + target: fromSource ? newNode.id : sourceNodeId, + type: "animated", + }; + setEdges([...edges, newEdge]); + setHasUnsavedChanges(true); + // Trigger immediate autosave for the new edge + triggerAutosave({ immediate: true }); + + // Set flag to prevent immediate deselection + justCreatedNodeFromConnection.current = true; + setTimeout(() => { + justCreatedNodeFromConnection.current = false; + }, 100); + }, + [ + calculateMenuPosition, + screenToFlowPosition, + addNode, + edges, + setEdges, + setNodes, + setSelectedNode, + setActiveTab, + setHasUnsavedChanges, + triggerAutosave, + ] + ); + const onConnectEnd = useCallback( (event: MouseEvent | TouchEvent) => { if (!connectingNodeId.current) { @@ -327,96 +469,28 @@ export function WorkflowCanvas() { return; } - const isNode = target.closest(".react-flow__node"); + const nodeElement = target.closest(".react-flow__node"); const isHandle = target.closest(".react-flow__handle"); - if (!(isNode || isHandle)) { - const { adjustedX, adjustedY } = calculateMenuPosition( - event, - clientX, - clientY - ); - - // Get the action template - const actionTemplate = nodeTemplates.find((t) => t.type === "action"); - if (!actionTemplate) { - return; - } - - // Get the position in the flow coordinate system - const position = screenToFlowPosition({ - x: adjustedX, - y: adjustedY, - }); - - // Center the node vertically at the cursor position - // Node height is 192px (h-48 in Tailwind) - const nodeHeight = 192; - position.y -= nodeHeight / 2; + // Create connection on edge dragged over node release + if (nodeElement && !isHandle && connectingHandleType.current) { + handleConnectionToExistingNode(nodeElement); + connectingNodeId.current = null; + connectingHandleType.current = null; + return; + } - // Create new action node - const newNode: WorkflowNode = { - id: nanoid(), - type: actionTemplate.type, - position, - data: { - label: actionTemplate.label, - description: actionTemplate.description, - type: actionTemplate.type, - config: actionTemplate.defaultConfig, - status: "idle", - }, - selected: true, - }; - - addNode(newNode); - setSelectedNode(newNode.id); - setActiveTab("properties"); - - // Deselect all other nodes and select only the new node - // Need to do this after a delay because panOnDrag will clear selection - setTimeout(() => { - setNodes((currentNodes) => - currentNodes.map((n) => ({ - ...n, - selected: n.id === newNode.id, - })) - ); - }, 50); - - // Create connection from the source node to the new node - const newEdge = { - id: nanoid(), - source: connectingNodeId.current, - target: newNode.id, - type: "animated", - }; - setEdges([...edges, newEdge]); - setHasUnsavedChanges(true); - // Trigger immediate autosave for the new edge - triggerAutosave({ immediate: true }); - - // Set flag to prevent immediate deselection - justCreatedNodeFromConnection.current = true; - setTimeout(() => { - justCreatedNodeFromConnection.current = false; - }, 100); + if (!(nodeElement || isHandle)) { + handleConnectionToNewNode(event, clientX, clientY); } connectingNodeId.current = null; + connectingHandleType.current = null; }, [ getClientPosition, - calculateMenuPosition, - screenToFlowPosition, - addNode, - edges, - setEdges, - setNodes, - setSelectedNode, - setActiveTab, - setHasUnsavedChanges, - triggerAutosave, + handleConnectionToExistingNode, + handleConnectionToNewNode, ] );