Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
236 changes: 155 additions & 81 deletions components/workflow/workflow-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function WorkflowCanvas() {
useReactFlow();

const connectingNodeId = useRef<string | null>(null);
const connectingHandleType = useRef<"source" | "target" | null>(null);
const justCreatedNodeFromConnection = useRef(false);
const viewportInitialized = useRef(false);
const [isCanvasReady, setIsCanvasReady] = useState(false);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -272,6 +294,7 @@ export function WorkflowCanvas() {
const onConnectStart = useCallback(
(_event: MouseEvent | TouchEvent, params: OnConnectStartParams) => {
connectingNodeId.current = params.nodeId;
connectingHandleType.current = params.handleType;
},
[]
);
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
]
);

Expand Down