diff --git a/frontend/src/utils/nodeLayout.ts b/frontend/src/utils/nodeLayout.ts new file mode 100644 index 0000000..5c6cf93 --- /dev/null +++ b/frontend/src/utils/nodeLayout.ts @@ -0,0 +1,56 @@ +import type { WorkflowNode } from '../types/workflow' + +export interface NodePosition { + x: number + y: number +} + +// Horizontal gap between a source node and a newly connected node. +export const HORIZONTAL_STEP = 260 + +// Vertical spacing used both to stack nodes that have no source, and to +// cascade a new node further down when its preferred slot is already taken. +export const VERTICAL_STEP = 120 + +// Node cards are ~180px wide (see styles/canvas.css `min-width`); treat +// anything within this distance on either axis as "occupying" the slot. +const COLLISION_THRESHOLD_X = 180 +const COLLISION_THRESHOLD_Y = VERTICAL_STEP + +const isOccupied = (existingNodes: WorkflowNode[], candidate: NodePosition): boolean => + existingNodes.some( + (n) => + Math.abs(n.position.x - candidate.x) < COLLISION_THRESHOLD_X && + Math.abs(n.position.y - candidate.y) < COLLISION_THRESHOLD_Y + ) + +/** + * Computes where a newly created node should be placed so it doesn't + * overlap any existing node. When `sourceNode` is given, the candidate + * position starts directly to the right of it and cascades downward in + * `VERTICAL_STEP` increments until a free slot is found. When there is no + * source, nodes stack vertically from the top-left corner. + */ +export const computeNewNodePosition = ( + existingNodes: WorkflowNode[], + sourceNode: WorkflowNode | null | undefined +): NodePosition => { + if (!sourceNode) { + let candidate: NodePosition = { x: 40, y: 40 } + while (isOccupied(existingNodes, candidate)) { + candidate = { x: candidate.x, y: candidate.y + VERTICAL_STEP } + } + return candidate + } + + let candidate: NodePosition = { + x: sourceNode.position.x + HORIZONTAL_STEP, + y: sourceNode.position.y + } + + while (isOccupied(existingNodes, candidate)) { + candidate = { x: candidate.x, y: candidate.y + VERTICAL_STEP } + } + + return candidate +} diff --git a/frontend/src/views/WorkflowBuilder.vue b/frontend/src/views/WorkflowBuilder.vue index 1d04020..5d3596f 100644 --- a/frontend/src/views/WorkflowBuilder.vue +++ b/frontend/src/views/WorkflowBuilder.vue @@ -120,6 +120,7 @@ import { useAppConfig } from '../composables/useAppConfig' import { useAutomationConnect } from '../composables/useAutomationConnect' import { builderPath, listPath } from '../router' import { findNodeType, TRIGGER_CATEGORY, AI_CATEGORY, ACTION_CATEGORY, NOTIFICATION_CATEGORY } from '../nodeTypes' +import { computeNewNodePosition } from '../utils/nodeLayout' import type { TriggerType, WorkflowEdge, WorkflowNode, WorkflowNodeData } from '../types/workflow' const props = defineProps<{ id: string }>() @@ -199,9 +200,7 @@ const onPickNodeType = (typeId: string) => { if (!def) return const source = nodes.value.find((n) => n.id === pickerConnectFrom.value) - const position = source - ? { x: source.position.x + 260, y: source.position.y } - : { x: 40, y: 40 + nodes.value.length * 120 } + const position = computeNewNodePosition(nodes.value, source) const node: WorkflowNode = { id: def.nodeKind === 'trigger' ? 'trigger' : nextNodeId(def.nodeKind), diff --git a/frontend/tests/unit/nodeLayout.spec.ts b/frontend/tests/unit/nodeLayout.spec.ts new file mode 100644 index 0000000..344bc23 --- /dev/null +++ b/frontend/tests/unit/nodeLayout.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { computeNewNodePosition } from '../../src/utils/nodeLayout' +import type { WorkflowNode } from '../../src/types/workflow' + +const makeNode = (id: string, x: number, y: number): WorkflowNode => ({ + id, + type: 'action', + position: { x, y }, + data: {} +}) + +describe('computeNewNodePosition', () => { + it('returns the straightforward position when nothing occupies the candidate spot', () => { + const source = makeNode('source', 100, 200) + const existingNodes: WorkflowNode[] = [source] + + const position = computeNewNodePosition(existingNodes, source) + + expect(position).toEqual({ x: 360, y: 200 }) + }) + + it('moves the position down by one step when the candidate spot is already occupied', () => { + const source = makeNode('source', 100, 200) + const blocker = makeNode('blocker', 360, 200) + const existingNodes: WorkflowNode[] = [source, blocker] + + const position = computeNewNodePosition(existingNodes, source) + + expect(position.x).toBe(360) + expect(position.y).toBe(320) + }) + + it('cascades further down when two nodes are already stacked below the source', () => { + const source = makeNode('source', 100, 200) + const blocker1 = makeNode('blocker1', 360, 200) + const blocker2 = makeNode('blocker2', 360, 320) + const existingNodes: WorkflowNode[] = [source, blocker1, blocker2] + + const position = computeNewNodePosition(existingNodes, source) + + expect(position.x).toBe(360) + expect(position.y).toBe(440) + }) + + it('is not affected by nodes that are far away and do not overlap', () => { + const source = makeNode('source', 100, 200) + const farAway = makeNode('far', 900, 900) + const sameXDifferentY = makeNode('sameXFar', 360, 900) + const existingNodes: WorkflowNode[] = [source, farAway, sameXDifferentY] + + const position = computeNewNodePosition(existingNodes, source) + + expect(position).toEqual({ x: 360, y: 200 }) + }) +})