Skip to content
Merged
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
56 changes: 56 additions & 0 deletions frontend/src/utils/nodeLayout.ts
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 2 additions & 3 deletions frontend/src/views/WorkflowBuilder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>()
Expand Down Expand Up @@ -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),
Expand Down
56 changes: 56 additions & 0 deletions frontend/tests/unit/nodeLayout.spec.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
Loading