diff --git a/packages/frontend/editor-ui/src/__tests__/data/canvas.ts b/packages/frontend/editor-ui/src/__tests__/data/canvas.ts index 520508fca1..5ee7a36189 100644 --- a/packages/frontend/editor-ui/src/__tests__/data/canvas.ts +++ b/packages/frontend/editor-ui/src/__tests__/data/canvas.ts @@ -74,8 +74,8 @@ export function createCanvasGraphNode({ id = '1', type = 'default', label = 'Node', - position = { x: 100, y: 100 }, - dimensions = { width: 100, height: 100 }, + position = { x: 96, y: 96 }, + dimensions = { width: 96, height: 96 }, data, ...rest }: Partial< diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/ActionItem.vue b/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/ActionItem.vue index 729e074eac..73a5139ae8 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/ActionItem.vue +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/ItemTypes/ActionItem.vue @@ -3,7 +3,7 @@ import { reactive, computed, toRefs } from 'vue'; import type { ActionTypeDescription, SimplifiedNodeType } from '@/Interface'; import { WEBHOOK_NODE_TYPE, DRAG_EVENT_DATA_KEY } from '@/constants'; -import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils'; +import { DEFAULT_NODE_SIZE, getNewNodePosition } from '@/utils/nodeViewUtils'; import NodeIcon from '@/components/NodeIcon.vue'; import { useViewStacks } from '../composables/useViewStacks'; @@ -76,7 +76,10 @@ function onDragOver(event: DragEvent): void { return; } - const [x, y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]); + const [x, y] = getNewNodePosition( + [], + [event.pageX - DEFAULT_NODE_SIZE[0] / 2, event.pageY - DEFAULT_NODE_SIZE[1] / 2], + ); state.draggablePosition = { x, y }; } diff --git a/packages/frontend/editor-ui/src/components/canvas/Canvas.test.ts b/packages/frontend/editor-ui/src/components/canvas/Canvas.test.ts index aafde953b2..2824c6c28c 100644 --- a/packages/frontend/editor-ui/src/components/canvas/Canvas.test.ts +++ b/packages/frontend/editor-ui/src/components/canvas/Canvas.test.ts @@ -127,7 +127,7 @@ describe('Canvas', () => { [ { id: '1', - position: { x: 120, y: 120 }, + position: { x: 112, y: 112 }, }, ], ], diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/background/__snapshots__/CanvasBackground.test.ts.snap b/packages/frontend/editor-ui/src/components/canvas/elements/background/__snapshots__/CanvasBackground.test.ts.snap index fa764fae2f..7c761b73cc 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/background/__snapshots__/CanvasBackground.test.ts.snap +++ b/packages/frontend/editor-ui/src/components/canvas/elements/background/__snapshots__/CanvasBackground.test.ts.snap @@ -2,7 +2,7 @@ exports[`CanvasBackground > should render the background with the correct gap 1`] = ` " - + diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts index 68614d1b31..27dc75b087 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts @@ -111,8 +111,8 @@ describe('CanvasNode', () => { const inputHandles = getAllByTestId('canvas-node-input-handle'); expect(inputHandles[1]).toHaveStyle('left: 40px'); - expect(inputHandles[2]).toHaveStyle('left: 160px'); - expect(inputHandles[3]).toHaveStyle('left: 200px'); + expect(inputHandles[2]).toHaveStyle('left: 168px'); + expect(inputHandles[3]).toHaveStyle('left: 232px'); }); }); diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue index 69723dd082..96c49131b8 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue @@ -34,7 +34,7 @@ import type { EventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus'; import isEqual from 'lodash/isEqual'; import CanvasNodeTrigger from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeTrigger.vue'; -import { GRID_SIZE } from '@/utils/nodeViewUtils'; +import { CONFIGURATION_NODE_OFFSET, GRID_SIZE } from '@/utils/nodeViewUtils'; type Props = NodeProps & { readOnly?: boolean; @@ -186,7 +186,7 @@ const createEndpointMappingFn = connectingHandle.value?.handleId === handleId; const offsetValue = position === Position.Bottom - ? `${GRID_SIZE * (2 + index * 2)}px` + ? `${GRID_SIZE * 2 * (1 + index * 2) + CONFIGURATION_NODE_OFFSET}px` : `${(100 / (endpoints.length + 1)) * (index + 1)}%`; return { diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts index d845b9917d..3683265105 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts @@ -35,13 +35,12 @@ describe('CanvasNodeDefault', () => { describe('inputs and outputs', () => { it.each([ - [1, 1, '100px'], - [3, 1, '100px'], - [4, 1, '140px'], - [1, 1, '100px'], - [1, 3, '100px'], - [1, 4, '140px'], - [4, 4, '140px'], + [1, 1, '96px'], + [1, 3, '128px'], + [1, 4, '160px'], + [3, 1, '128px'], + [4, 1, '160px'], + [4, 4, '160px'], ])( 'should adjust height css variable based on the number of inputs and outputs (%i inputs, %i outputs)', (inputCount, outputCount, expected) => { @@ -205,7 +204,7 @@ describe('CanvasNodeDefault', () => { [ '1 required', [{ type: NodeConnectionTypes.AiLanguageModel, index: 0, required: true }], - '240px', + '272px', ], [ '2 required, 1 optional', @@ -214,7 +213,7 @@ describe('CanvasNodeDefault', () => { { type: NodeConnectionTypes.AiDocument, index: 0, required: true }, { type: NodeConnectionTypes.AiMemory, index: 0, required: true }, ], - '240px', + '272px', ], [ '2 required, 2 optional', @@ -224,7 +223,7 @@ describe('CanvasNodeDefault', () => { { type: NodeConnectionTypes.AiDocument, index: 0, required: true }, { type: NodeConnectionTypes.AiMemory, index: 0, required: true }, ], - '240px', + '272px', ], [ '1 required, 4 optional', @@ -235,7 +234,7 @@ describe('CanvasNodeDefault', () => { { type: NodeConnectionTypes.AiMemory, index: 0 }, { type: NodeConnectionTypes.AiMemory, index: 0 }, ], - '280px', + '336px', ], ])( 'should adjust width css variable based on the number of non-main inputs (%s)', diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue index 781233673a..a65b3b2f59 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue @@ -234,7 +234,8 @@ function onActivate(event: MouseEvent) { &.configuration { .icon { - margin-left: calc((var(--canvas-node--height) - var(--node-icon-size)) / 2); + // 4px represents calc(var(--handle--indicator--width) - configuration node offset) / 2) + margin-left: calc((var(--canvas-node--height) - var(--node-icon-size) - 4px) / 2); } &:not(.running) { diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap index d56258febf..74281c1659 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap @@ -4,7 +4,7 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
configuration > should render configurable configur
should render node correctly 1`] = `
trigger > should render trigger node correctly 1`]
should layout a basic workflow 1`] = ` { "boundingBox": { - "height": 100, - "width": 540, + "height": 96, + "width": 544, "x": 0, "y": 0, }, "nodes": [ { "id": "node1", - "x": 100, - "y": 100, + "x": 96, + "y": 96, }, { "id": "node2", "x": 320, - "y": 100, + "y": 96, }, { "id": "node3", - "x": 540, - "y": 100, + "x": 544, + "y": 96, }, ], } @@ -31,26 +31,26 @@ exports[`useCanvasLayout > should layout a basic workflow 1`] = ` exports[`useCanvasLayout > should layout a basic workflow with selected nodes 1`] = ` { "boundingBox": { - "height": 100, - "width": 540, + "height": 96, + "width": 544, "x": 0, "y": 0, }, "nodes": [ { "id": "node1", - "x": 100, - "y": 100, + "x": 96, + "y": 96, }, { "id": "node2", "x": 320, - "y": 100, + "y": 96, }, { "id": "node3", - "x": 540, - "y": 100, + "x": 544, + "y": 96, }, ], } @@ -59,16 +59,16 @@ exports[`useCanvasLayout > should layout a basic workflow with selected nodes 1` exports[`useCanvasLayout > should layout a workflow with AI nodes 1`] = ` { "boundingBox": { - "height": 540, - "width": 820, + "height": 544, + "width": 832, "x": 0, - "y": 220, + "y": 224, }, "nodes": [ { "id": "node1", - "x": 100, - "y": 100, + "x": 96, + "y": 96, }, { "id": "aiTool1", @@ -77,28 +77,28 @@ exports[`useCanvasLayout > should layout a workflow with AI nodes 1`] = ` }, { "id": "aiTool2", - "x": 460, + "x": 464, "y": 320, }, { "id": "aiTool3", - "x": 600, - "y": 540, + "x": 608, + "y": 544, }, { "id": "aiAgent", - "x": 460, - "y": 100, + "x": 464, + "y": 96, }, { "id": "configurableAiTool", - "x": 600, + "x": 608, "y": 320, }, { "id": "node2", - "x": 820, - "y": 100, + "x": 832, + "y": 96, }, ], } @@ -107,8 +107,8 @@ exports[`useCanvasLayout > should layout a workflow with AI nodes 1`] = ` exports[`useCanvasLayout > should layout a workflow with sticky notes 1`] = ` { "boundingBox": { - "height": 100, - "width": 760, + "height": 96, + "width": 768, "x": 0, "y": 0, }, @@ -120,22 +120,22 @@ exports[`useCanvasLayout > should layout a workflow with sticky notes 1`] = ` }, { "id": "node2", - "x": 220, + "x": 224, "y": 0, }, { "id": "node3", - "x": 440, + "x": 448, "y": 0, }, { "id": "node4", - "x": 660, + "x": 672, "y": 0, }, { "id": "sticky", - "x": 130, + "x": 134, "y": -240, }, ], @@ -145,7 +145,7 @@ exports[`useCanvasLayout > should layout a workflow with sticky notes 1`] = ` exports[`useCanvasLayout > should not reorder nodes vertically as it affects execution order 1`] = ` { "boundingBox": { - "height": 300, + "height": 288, "width": 320, "x": 0, "y": 0, @@ -154,17 +154,17 @@ exports[`useCanvasLayout > should not reorder nodes vertically as it affects exe { "id": "node1", "x": 0, - "y": -100, + "y": -112, }, { "id": "node3", - "x": 220, - "y": -200, + "x": 224, + "y": -208, }, { "id": "node2", - "x": 220, - "y": 0, + "x": 224, + "y": -16, }, ], } diff --git a/packages/frontend/editor-ui/src/composables/useCanvasLayout.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasLayout.test.ts index f6f1671856..57cbb3c5d2 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasLayout.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasLayout.test.ts @@ -55,6 +55,7 @@ describe('useCanvasLayout', () => { const { layout } = createTestSetup(nodes, connections); const result = layout('all'); + expect(result).toMatchSnapshot(); expect(matchesGrid(result)).toBe(true); }); @@ -153,8 +154,8 @@ describe('useCanvasLayout', () => { test('should not reorder nodes vertically as it affects execution order', () => { const nodes = [ createCanvasGraphNode({ id: 'node1', position: { x: 0, y: 0 } }), - createCanvasGraphNode({ id: 'node2', position: { x: 400, y: 200 } }), - createCanvasGraphNode({ id: 'node3', position: { x: 400, y: -200 } }), + createCanvasGraphNode({ id: 'node2', position: { x: 400, y: 208 } }), + createCanvasGraphNode({ id: 'node3', position: { x: 400, y: -208 } }), ]; const connections: Array<[string, string]> = [ diff --git a/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts b/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts index 63879590fd..b919ce5c3a 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts @@ -9,7 +9,7 @@ import { type CanvasNodeData, } from '../types'; import { isPresent } from '../utils/typesUtils'; -import { GRID_SIZE, NODE_SIZE } from '../utils/nodeViewUtils'; +import { DEFAULT_NODE_SIZE, GRID_SIZE } from '../utils/nodeViewUtils'; export type CanvasLayoutOptions = { id?: string }; export type CanvasLayoutTarget = 'selection' | 'all'; @@ -40,12 +40,12 @@ export type CanvasLayoutEvent = { export type CanvasNodeDictionary = Record>; -const NODE_X_SPACING = GRID_SIZE * 6; -const NODE_Y_SPACING = GRID_SIZE * 5; +const NODE_X_SPACING = GRID_SIZE * 8; +const NODE_Y_SPACING = GRID_SIZE * 6; const SUBGRAPH_SPACING = GRID_SIZE * 8; -const AI_X_SPACING = GRID_SIZE * 2; -const AI_Y_SPACING = GRID_SIZE * 6; -const STICKY_BOTTOM_PADDING = GRID_SIZE * 3; +const AI_X_SPACING = GRID_SIZE * 3; +const AI_Y_SPACING = GRID_SIZE * 8; +const STICKY_BOTTOM_PADDING = GRID_SIZE * 4; export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) { const { @@ -113,7 +113,10 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) { function createDagreSubGraph({ nodeIds, parent, - }: { nodeIds: string[]; parent: dagre.graphlib.Graph }) { + }: { + nodeIds: string[]; + parent: dagre.graphlib.Graph; + }) { const subGraph = new dagre.graphlib.Graph(); subGraph.setGraph({ rankdir: 'LR', @@ -165,7 +168,10 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) { function createAiSubGraph({ parent, nodeIds, - }: { parent: dagre.graphlib.Graph; nodeIds: string[] }) { + }: { + parent: dagre.graphlib.Graph; + nodeIds: string[]; + }) { const subGraph = new dagre.graphlib.Graph(); subGraph.setGraph({ rankdir: 'TB', @@ -449,7 +455,7 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) { const aiGraphBoundingBox = compositeBoundingBox( aiNodes.map((nodeId) => boundingBoxByNodeId[nodeId]).filter(isPresent), ); - const aiNodeVerticalCorrection = aiGraphBoundingBox.height / 2 - NODE_SIZE / 2; + const aiNodeVerticalCorrection = aiGraphBoundingBox.height / 2 - DEFAULT_NODE_SIZE[0] / 2; aiGraphBoundingBox.y += aiNodeVerticalCorrection; const hasConflictingNodes = Object.entries(boundingBoxByNodeId) diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts index b09a125f7d..0f8e515ae2 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts @@ -200,12 +200,12 @@ describe('useCanvasOperations', () => { { type: 'type', typeVersion: 1, - position: [20, 20], + position: [32, 32], }, mockNodeTypeDescription({ name: 'type' }), ); - expect(result.position).toEqual([20, 20]); + expect(result.position).toEqual([32, 32]); }); it('should not assign credentials when multiple credentials are available', () => { @@ -274,13 +274,13 @@ describe('useCanvasOperations', () => { describe('resolveNodePosition', () => { it('should return the node position if it is already set', () => { - const node = createTestNode({ position: [100, 100] }); + const node = createTestNode({ position: [112, 112] }); const nodeTypeDescription = mockNodeTypeDescription(); const { resolveNodePosition } = useCanvasOperations(); const position = resolveNodePosition(node, nodeTypeDescription); - expect(position).toEqual([100, 100]); + expect(position).toEqual([112, 112]); }); it('should place the node at the last cancelled connection position', () => { @@ -302,7 +302,7 @@ describe('useCanvasOperations', () => { const { resolveNodePosition } = useCanvasOperations(); const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); - expect(position).toEqual([200, 160]); + expect(position).toEqual([208, 160]); expect(uiStore.lastCancelledConnectionPosition).toBeUndefined(); }); @@ -316,7 +316,7 @@ describe('useCanvasOperations', () => { const workflowObject = createTestWorkflowObject(workflowsStore.workflow); uiStore.lastInteractedWithNode = createTestNode({ - position: [100, 100], + position: [112, 112], type: 'test', typeVersion: 1, }); @@ -327,7 +327,7 @@ describe('useCanvasOperations', () => { const { resolveNodePosition } = useCanvasOperations(); const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); - expect(position).toEqual([320, 100]); + expect(position).toEqual([320, 112]); }); it('should place the node below the last interacted with node if it has non-main outputs', () => { @@ -340,7 +340,7 @@ describe('useCanvasOperations', () => { const workflowObject = createTestWorkflowObject(workflowsStore.workflow); uiStore.lastInteractedWithNode = createTestNode({ - position: [100, 100], + position: [96, 96], type: 'test', typeVersion: 1, }); @@ -358,7 +358,7 @@ describe('useCanvasOperations', () => { const { resolveNodePosition } = useCanvasOperations(); const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); - expect(position).toEqual([460, 100]); + expect(position).toEqual([448, 96]); }); it('should place the node at the last clicked position if no other position is set', () => { @@ -367,16 +367,14 @@ describe('useCanvasOperations', () => { const node = createTestNode({ id: '0' }); const nodeTypeDescription = mockNodeTypeDescription(); - workflowsStore.workflowTriggerNodes = [ - createTestNode({ id: 'trigger', position: [100, 100] }), - ]; + workflowsStore.workflowTriggerNodes = [createTestNode({ id: 'trigger', position: [96, 96] })]; const { resolveNodePosition, lastClickPosition } = useCanvasOperations(); lastClickPosition.value = [300, 300]; const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); - expect(position).toEqual([300, 300]); + expect(position).toEqual([304, 304]); // Snapped to grid }); it('should place the trigger node at the root if it is the first trigger node', () => { @@ -532,8 +530,8 @@ describe('useCanvasOperations', () => { it('records history for multiple node position updates when tracking is enabled', () => { const historyStore = useHistoryStore(); const events = [ - { id: 'node1', position: { x: 100, y: 100 } }, - { id: 'node2', position: { x: 200, y: 200 } }, + { id: 'node1', position: { x: 96, y: 96 } }, + { id: 'node2', position: { x: 208, y: 208 } }, ]; const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo'); const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo'); @@ -548,8 +546,8 @@ describe('useCanvasOperations', () => { it('updates positions for multiple nodes', () => { const workflowsStore = mockedStore(useWorkflowsStore); const events = [ - { id: 'node1', position: { x: 100, y: 100 } }, - { id: 'node2', position: { x: 200, y: 200 } }, + { id: 'node1', position: { x: 96, y: 96 } }, + { id: 'node2', position: { x: 208, y: 208 } }, ]; const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); workflowsStore.getNodeById @@ -570,13 +568,13 @@ describe('useCanvasOperations', () => { updateNodesPosition(events); expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2); - expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [100, 100]); - expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [200, 200]); + expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [96, 96]); + expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [208, 208]); }); it('does not record history when trackHistory is false', () => { const historyStore = useHistoryStore(); - const events = [{ id: 'node1', position: { x: 100, y: 100 } }]; + const events = [{ id: 'node1', position: { x: 96, y: 96 } }]; const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo'); const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo'); @@ -596,10 +594,10 @@ describe('useCanvasOperations', () => { target: 'all', result: { nodes: [ - { id: 'node1', x: 100, y: 100 }, - { id: 'node2', x: 200, y: 200 }, + { id: 'node1', x: 96, y: 96 }, + { id: 'node2', x: 208, y: 208 }, ], - boundingBox: { height: 100, width: 100, x: 0, y: 0 }, + boundingBox: { height: 96, width: 96, x: 0, y: 0 }, }, }; const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo'); @@ -619,10 +617,10 @@ describe('useCanvasOperations', () => { target: 'all', result: { nodes: [ - { id: 'node1', x: 100, y: 100 }, - { id: 'node2', x: 200, y: 200 }, + { id: 'node1', x: 96, y: 96 }, + { id: 'node2', x: 208, y: 208 }, ], - boundingBox: { height: 100, width: 100, x: 0, y: 0 }, + boundingBox: { height: 96, width: 96, x: 0, y: 0 }, }, }; const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); @@ -644,8 +642,8 @@ describe('useCanvasOperations', () => { tidyUp(event); expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2); - expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [100, 100]); - expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [200, 200]); + expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [96, 96]); + expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [208, 208]); }); it('should send a "User tidied up workflow" telemetry event', () => { @@ -654,10 +652,10 @@ describe('useCanvasOperations', () => { target: 'all', result: { nodes: [ - { id: 'node1', x: 100, y: 100 }, - { id: 'node2', x: 200, y: 200 }, + { id: 'node1', x: 96, y: 96 }, + { id: 'node2', x: 208, y: 208 }, ], - boundingBox: { height: 100, width: 100, x: 0, y: 0 }, + boundingBox: { height: 96, width: 96, x: 0, y: 0 }, }, }; @@ -738,8 +736,8 @@ describe('useCanvasOperations', () => { const nodeTypesStore = useNodeTypesStore(); const nodeTypeName = 'type'; const nodes = [ - mockNode({ name: 'Node 1', type: nodeTypeName, position: [30, 40] }), - mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }), + mockNode({ name: 'Node 1', type: nodeTypeName, position: [32, 32] }), + mockNode({ name: 'Node 2', type: nodeTypeName, position: [96, 256] }), ]; workflowsStore.getCurrentWorkflow.mockReturnValue( @@ -758,14 +756,14 @@ describe('useCanvasOperations', () => { name: nodes[0].name, type: nodeTypeName, typeVersion: 1, - position: [40, 40], + position: [32, 32], parameters: {}, }); expect(workflowsStore.addNode.mock.calls[1][0]).toMatchObject({ name: nodes[1].name, type: nodeTypeName, typeVersion: 1, - position: [100, 240], + position: [96, 256], parameters: {}, }); }); @@ -775,8 +773,8 @@ describe('useCanvasOperations', () => { const nodeTypesStore = mockedStore(useNodeTypesStore); const nodeTypeName = 'type'; const nodes = [ - mockNode({ name: 'Node 1', type: nodeTypeName, position: [120, 120] }), - mockNode({ name: 'Node 2', type: nodeTypeName, position: [180, 320] }), + mockNode({ name: 'Node 1', type: nodeTypeName, position: [128, 128] }), + mockNode({ name: 'Node 2', type: nodeTypeName, position: [192, 320] }), ]; workflowsStore.getCurrentWorkflow.mockReturnValue( @@ -804,9 +802,9 @@ describe('useCanvasOperations', () => { const nodeTypesStore = useNodeTypesStore(); const nodeTypeName = 'type'; const nodes = [ - mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }), - mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [40, 40] }), - mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [100, 240] }), + mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [32, 32] }), + mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [32, 32] }), + mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [96, 256] }), ]; const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); @@ -842,7 +840,7 @@ describe('useCanvasOperations', () => { const nodeTypeName = 'type'; const nodes = [ mockNode({ name: 'Node 1', type: nodeTypeName, position: [30, 40] }), - mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }), + mockNode({ name: 'Node 2', type: nodeTypeName, position: [96, 240] }), ]; workflowsStore.getCurrentWorkflow.mockReturnValue( @@ -3007,8 +3005,8 @@ describe('useCanvasOperations', () => { // Create three nodes in a sequence: A -> B -> C const nodeA = createTestNode({ id: 'A', name: 'Node A', position: [0, 0] }); - const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [100, 0] }); - const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [200, 0] }); + const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [96, 0] }); + const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [208, 0] }); const nodeTypeDescription = mockNodeTypeDescription({ name: nodeA.type, @@ -3077,8 +3075,8 @@ describe('useCanvasOperations', () => { // Create three nodes in a sequence: A -> B -> C const nodeA = createTestNode({ id: 'A', name: 'Node A', position: [0, 0] }); - const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [100, 0] }); - const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [200, 0] }); + const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [96, 0] }); + const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [208, 0] }); const nodeTypeDescription = mockNodeTypeDescription({ name: nodeA.type, @@ -3144,8 +3142,8 @@ describe('useCanvasOperations', () => { const workflowsStore = mockedStore(useWorkflowsStore); // Create nodes: B -> C (no incoming to B) - const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [100, 0] }); - const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [200, 0] }); + const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [96, 0] }); + const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [208, 0] }); workflowsStore.workflow.nodes = [nodeB, nodeC]; workflowsStore.workflow.connections = { @@ -3173,7 +3171,7 @@ describe('useCanvasOperations', () => { // Create nodes: A -> B (no outgoing from B) const nodeA = createTestNode({ id: 'A', name: 'Node A', position: [0, 0] }); - const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [100, 0] }); + const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [96, 0] }); workflowsStore.workflow.nodes = [nodeA, nodeB]; workflowsStore.workflow.connections = { @@ -3220,7 +3218,7 @@ describe('useCanvasOperations', () => { const nodeB: IWorkflowTemplateNode = createTestNode({ id: 'Y', name: 'Node Y', - position: [180, 80], + position: [192, 80], }); const workflow: IWorkflowTemplate['workflow'] = { diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts index d9c6f66d66..6132ee4695 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts @@ -71,6 +71,7 @@ import { } from '@/utils/canvasUtils'; import * as NodeViewUtils from '@/utils/nodeViewUtils'; import { + GRID_SIZE, CONFIGURABLE_NODE_SIZE, CONFIGURATION_NODE_SIZE, DEFAULT_NODE_SIZE, @@ -692,7 +693,7 @@ export function useCanvasOperations() { // When we're adding multiple nodes, increment the X position for the next one insertPosition = [ - lastAddedNode.position[0] + NodeViewUtils.NODE_SIZE * 2 + NodeViewUtils.GRID_SIZE, + lastAddedNode.position[0] + DEFAULT_NODE_SIZE[0] * 2 + GRID_SIZE, lastAddedNode.position[1], ]; } @@ -1108,8 +1109,8 @@ export function useCanvasOperations() { if (lastInteractedWithNodeMainOutputs.length > 1) { const yOffsetValues = generateOffsets( lastInteractedWithNodeMainOutputs.length, - NodeViewUtils.NODE_SIZE, - NodeViewUtils.GRID_SIZE, + DEFAULT_NODE_SIZE[1], + GRID_SIZE, ); yOffset = yOffsetValues[connectionIndex]; diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index c32ad5a28b..9a1cab4cb8 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -116,7 +116,7 @@ export const EXPRESSIONS_DOCS_URL = `https://${DOCS_DOMAIN}/code-examples/expres export const N8N_PRICING_PAGE_URL = 'https://n8n.io/pricing'; export const N8N_MAIN_GITHUB_REPO_URL = 'https://github.com/n8n-io/n8n'; -export const NODE_MIN_INPUT_ITEMS_COUNT = 5; +export const NODE_MIN_INPUT_ITEMS_COUNT = 4; // node types export const BAMBOO_HR_NODE_TYPE = 'n8n-nodes-base.bambooHr'; diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.ts b/packages/frontend/editor-ui/src/stores/workflows.store.ts index 2c30204aee..918de0d70b 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.ts @@ -102,6 +102,7 @@ import { updateCurrentUserSettings } from '@/api/users'; import { useExecutingNode } from '@/composables/useExecutingNode'; import type { NodeExecuteBefore } from '@n8n/api-types/push/execution'; import { isChatNode } from '@/utils/aiUtils'; +import { snapPositionToGrid } from '@/utils/nodeViewUtils'; const defaults: Omit & { settings: NonNullable } = { name: '', @@ -1290,6 +1291,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { node.type = getCredentialOnlyNodeTypeName(node.extendsCredential); } + if (node.position) { + node.position = snapPositionToGrid(node.position); + } + if (!nodeMetadata.value[node.name]) { nodeMetadata.value[node.name] = { pristine: true }; } diff --git a/packages/frontend/editor-ui/src/utils/canvasUtils.test.ts b/packages/frontend/editor-ui/src/utils/canvasUtils.test.ts index 7cf5a27535..77d4d6001d 100644 --- a/packages/frontend/editor-ui/src/utils/canvasUtils.test.ts +++ b/packages/frontend/editor-ui/src/utils/canvasUtils.test.ts @@ -992,7 +992,7 @@ describe('insertSpacersBetweenEndpoints', () => { const endpoints = [{ index: 0, required: true }]; const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length; const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount); - expect(result).toEqual([{ index: 0, required: true }, null, null, null, null]); + expect(result).toEqual([{ index: 0, required: true }, null, null, null]); }); it('should not insert spacers when there are at least min endpoints count', () => { @@ -1012,14 +1012,14 @@ describe('insertSpacersBetweenEndpoints', () => { const endpoints = [{ index: 0, required: false }]; const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length; const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount); - expect(result).toEqual([null, null, null, null, { index: 0, required: false }]); + expect(result).toEqual([null, null, null, { index: 0, required: false }]); }); it('should handle no endpoints', () => { const endpoints: Array<{ index: number; required: boolean }> = []; const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length; const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount); - expect(result).toEqual([null, null, null, null, null]); + expect(result).toEqual([null, null, null, null]); }); it('should handle required endpoints greater than NODE_MIN_INPUT_ITEMS_COUNT', () => { @@ -1040,7 +1040,6 @@ describe('insertSpacersBetweenEndpoints', () => { { index: 0, required: true }, { index: 1, required: true }, null, - null, { index: 2 }, ]); }); @@ -1049,6 +1048,6 @@ describe('insertSpacersBetweenEndpoints', () => { const endpoints = [{ index: 0, required: true }]; const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length; const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount); - expect(result).toEqual([{ index: 0, required: true }, null, null, null, null]); + expect(result).toEqual([{ index: 0, required: true }, null, null, null]); }); }); diff --git a/packages/frontend/editor-ui/src/utils/nodeViewUtils.test.ts b/packages/frontend/editor-ui/src/utils/nodeViewUtils.test.ts index af83e45de7..17e068e29a 100644 --- a/packages/frontend/editor-ui/src/utils/nodeViewUtils.test.ts +++ b/packages/frontend/editor-ui/src/utils/nodeViewUtils.test.ts @@ -7,8 +7,11 @@ import { generateOffsets, getGenericHints, getNewNodePosition, - NODE_SIZE, updateViewportToContainNodes, + DEFAULT_NODE_SIZE, + snapPositionToGrid, + calculateNodeSize, + GRID_SIZE, } from './nodeViewUtils'; import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow'; import type { INodeUi, XYPosition } from '@/Interface'; @@ -203,96 +206,112 @@ describe('getGenericHints', () => { describe('generateOffsets', () => { it('should return correct offsets for 0 nodes', () => { - const result = generateOffsets(0, 100, 20); + const result = generateOffsets(0, 96, GRID_SIZE); expect(result).toEqual([]); }); it('should return correct offsets for 1 node', () => { - const result = generateOffsets(1, 100, 20); + const result = generateOffsets(1, 96, GRID_SIZE); expect(result).toEqual([0]); }); it('should return correct offsets for 2 nodes', () => { - const result = generateOffsets(2, 100, 20); - expect(result).toEqual([-100, 100]); + const result = generateOffsets(2, 96, GRID_SIZE); + expect(result).toEqual([-96, 96]); }); it('should return correct offsets for 3 nodes', () => { - const result = generateOffsets(3, 100, 20); - expect(result).toEqual([-120, 0, 120]); + const result = generateOffsets(3, 96, GRID_SIZE); + expect(result).toEqual([-112, 0, 112]); }); it('should return correct offsets for 4 nodes', () => { - const result = generateOffsets(4, 100, 20); - expect(result).toEqual([-220, -100, 100, 220]); + const result = generateOffsets(4, 96, GRID_SIZE); + expect(result).toEqual([-208, -96, 96, 208]); }); it('should return correct offsets for large node count', () => { - const result = generateOffsets(10, 100, 20); - expect(result).toEqual([-580, -460, -340, -220, -100, 100, 220, 340, 460, 580]); + const result = generateOffsets(10, 96, GRID_SIZE); + expect(result).toEqual([-544, -432, -320, -208, -96, 96, 208, 320, 432, 544]); + }); +}); + +describe('snapPositionToGrid', () => { + it('should snap position to grid', () => { + const position: XYPosition = [105, 115]; + const snappedPosition = snapPositionToGrid(position); + expect(snappedPosition).toEqual([112, 128]); + }); + + it('should not change position if already on grid', () => { + const position: XYPosition = [96, 96]; + const snappedPosition = snapPositionToGrid(position); + expect(snappedPosition).toEqual([96, 96]); + }); + + it('should handle negative positions', () => { + const position: XYPosition = [-15, -25]; + const snappedPosition = snapPositionToGrid(position); + expect(snappedPosition).toEqual([-16, -32]); }); }); describe('getNewNodePosition', () => { it('should return the new position when there are no conflicts', () => { const nodes: INodeUi[] = []; - const newPosition: XYPosition = [100, 100]; + const newPosition: XYPosition = [96, 96]; const result = getNewNodePosition(nodes, newPosition); - expect(result).toEqual([100, 100]); + expect(result).toEqual([96, 96]); }); it('should adjust the position to the closest grid size', () => { const nodes: INodeUi[] = []; const newPosition: XYPosition = [105, 115]; const result = getNewNodePosition(nodes, newPosition); - expect(result).toEqual([120, 120]); + expect(result).toEqual([112, 128]); }); it('should move the position to avoid conflicts', () => { - const nodes: INodeUi[] = [ - createTestNode({ id: '1', position: [100, 100], type: SET_NODE_TYPE }), - ]; - const newPosition: XYPosition = [100, 100]; + const nodes: INodeUi[] = [createTestNode({ id: '1', position: [96, 96], type: SET_NODE_TYPE })]; + const newPosition: XYPosition = [96, 96]; const result = getNewNodePosition(nodes, newPosition); - expect(result).toEqual([220, 220]); + expect(result).toEqual([240, 240]); }); it('should skip nodes in the conflict allowlist', () => { const nodes: INodeUi[] = [ - createTestNode({ id: '1', position: [100, 100], type: STICKY_NODE_TYPE }), + createTestNode({ id: '1', position: [96, 96], type: STICKY_NODE_TYPE }), ]; - const newPosition: XYPosition = [100, 100]; + const newPosition: XYPosition = [96, 96]; const result = getNewNodePosition(nodes, newPosition); - expect(result).toEqual([100, 100]); + expect(result).toEqual([96, 96]); }); it('should use the provided move position to resolve conflicts', () => { - const nodes: INodeUi[] = [ - createTestNode({ id: '1', position: [100, 100], type: SET_NODE_TYPE }), - ]; - const newPosition: XYPosition = [100, 100]; - const movePosition: XYPosition = [50, 50]; + const nodes: INodeUi[] = [createTestNode({ id: '1', position: [96, 96], type: SET_NODE_TYPE })]; + const newPosition: XYPosition = [96, 96]; + const movePosition: XYPosition = [48, 48]; const result = getNewNodePosition(nodes, newPosition, { offset: movePosition, }); - expect(result).toEqual([220, 220]); + expect(result).toEqual([240, 240]); }); it('should handle multiple conflicts correctly', () => { const nodes: INodeUi[] = [ - createTestNode({ id: '1', position: [100, 100], type: SET_NODE_TYPE }), - createTestNode({ id: '2', position: [140, 140], type: SET_NODE_TYPE }), + createTestNode({ id: '1', position: [96, 96], type: SET_NODE_TYPE }), + createTestNode({ id: '2', position: [144, 144], type: SET_NODE_TYPE }), ]; - const newPosition: XYPosition = [100, 100]; + const newPosition: XYPosition = [96, 96]; const result = getNewNodePosition(nodes, newPosition); - expect(result).toEqual([280, 280]); + expect(result).toEqual([288, 288]); }); }); const testNodes: INode[] = [ createTestNode({ id: 'a', position: [0, 0] }), - createTestNode({ id: 'b', position: [100, 50] }), - createTestNode({ id: 'c', position: [50, 100] }), + createTestNode({ id: 'b', position: [96, 50] }), + createTestNode({ id: 'c', position: [50, 96] }), createTestNode({ id: 'd', position: [-20, -10] }), ]; @@ -379,15 +398,15 @@ describe('getBottomMostNode', () => { describe('getNodesGroupSize', () => { it('calculates the group size correctly', () => { const [width, height] = getNodesGroupSize(testNodes); - expect(width).toBe(Math.abs(100 - -20) + NODE_SIZE); - expect(height).toBe(Math.abs(-10 - 100) + NODE_SIZE); + expect(width).toBe(Math.abs(96 - -20) + DEFAULT_NODE_SIZE[0]); + expect(height).toBe(Math.abs(-10 - 96) + DEFAULT_NODE_SIZE[1]); }); it('should handle a single node', () => { const single = [testNodes[0]]; const [w, h] = getNodesGroupSize(single); - expect(w).toBe(NODE_SIZE); - expect(h).toBe(NODE_SIZE); + expect(w).toBe(DEFAULT_NODE_SIZE[0]); + expect(h).toBe(DEFAULT_NODE_SIZE[1]); }); it('should handle nodes with equal positions', () => { @@ -396,12 +415,12 @@ describe('getNodesGroupSize', () => { createTestNode({ id: 'y', position: [10, 20] }), ]; const [we, he] = getNodesGroupSize(equalNodes); - expect(we).toBe(NODE_SIZE); - expect(he).toBe(NODE_SIZE); + expect(we).toBe(DEFAULT_NODE_SIZE[0]); + expect(he).toBe(DEFAULT_NODE_SIZE[1]); }); }); -describe(updateViewportToContainNodes, () => { +describe('updateViewportToContainNodes', () => { it('should return the same viewport if given node is already in the viewport', () => { const result = updateViewportToContainNodes( { x: 0, y: 0, zoom: 2 }, @@ -446,6 +465,82 @@ describe(updateViewportToContainNodes, () => { }); }); +describe('calculateNodeSize', () => { + it('should return configuration node size when isConfiguration is true and isConfigurable is false', () => { + const result = calculateNodeSize( + true, // isConfiguration + false, // isConfigurable + 1, + 1, + 0, + ); + // width = GRID_SIZE * 5 = 16 * 5 = 80 + // height = GRID_SIZE * 5 = 16 * 5 = 80 + expect(result).toEqual({ width: 80, height: 80 }); + }); + + it('should return configurable node size when isConfigurable is true and isConfiguration is false', () => { + const nonMainInputCount = 5; + const mainInputCount = 3; + const mainOutputCount = 2; + // width = max(4, 5) * 2 * 16 * 2 = 5 * 2 * 16 * 2 + offset = 336 + // height = DEFAULT_NODE_SIZE[1] + max(0, max(3,2,1) - 2) * 16 * 2 + // maxVerticalHandles = 3 + // height = 96 + (3 - 2) * 32 = 96 + 32 = 128 + expect( + calculateNodeSize(false, true, mainInputCount, mainOutputCount, nonMainInputCount), + ).toEqual({ width: 336, height: 128 }); + }); + + it('should return configurable configuration node size when both isConfigurable and isConfiguration are true', () => { + const nonMainInputCount = 2; + // width = max(4, 2) * 2 * 16 * 2 = 4 * 2 * 16 * 2 + offset = 272 + // height = CONFIGURATION_NODE_SIZE[1] = 16 * 5 = 80 + expect(calculateNodeSize(true, true, 1, 1, nonMainInputCount)).toEqual({ + width: 272, + height: 80, + }); + }); + + it('should return default node size when neither isConfigurable nor isConfiguration are true', () => { + const mainInputCount = 3; + const mainOutputCount = 2; + // width = 96 + // maxVerticalHandles = 3 + // height = 96 + (3 - 2) * 32 = 128 + expect(calculateNodeSize(false, false, mainInputCount, mainOutputCount, 0)).toEqual({ + width: 96, + height: 128, + }); + }); + + it('should calculate height based on the max of mainInputCount and mainOutputCount', () => { + const mainInputCount = 6; + const mainOutputCount = 4; + // maxVerticalHandles = 6 + // height = 96 + (6 - 2) * 32 = 96 + 128 = 224 + expect(calculateNodeSize(false, false, mainInputCount, mainOutputCount, 0).height).toBe(224); + }); + + it('should respect the minimum width for configurable nodes', () => { + const nonMainInputCount = 2; // less than NODE_MIN_INPUT_ITEMS_COUNT + // width = 4 * 2 * 16 * 2 + offset = 272 + // height = default path, mainInputCount = 1, mainOutputCount = 1 + // maxVerticalHandles = 1 + // height = 96 + (1 - 2) * 32 = 96 + 0 = 96 + expect(calculateNodeSize(false, true, 1, 1, nonMainInputCount)).toEqual({ + width: 272, + height: 96, + }); + }); + + it('should handle edge case when mainInputCount and mainOutputCount are 0', () => { + // maxVerticalHandles = max(0,0,1) = 1 + // height = 96 + (1 - 2) * 32 = 96 + 0 = 96 + expect(calculateNodeSize(false, false, 0, 0, 0).height).toBe(96); + }); +}); + function createTestGraphNode(data: Partial = {}): GraphNode { return { computedPosition: { z: 0, ...(data.position ?? { x: 0, y: 0 }) }, diff --git a/packages/frontend/editor-ui/src/utils/nodeViewUtils.ts b/packages/frontend/editor-ui/src/utils/nodeViewUtils.ts index 27b659247b..8e2938c20d 100644 --- a/packages/frontend/editor-ui/src/utils/nodeViewUtils.ts +++ b/packages/frontend/editor-ui/src/utils/nodeViewUtils.ts @@ -32,17 +32,15 @@ import { * Canvas constants and functions */ -export const GRID_SIZE = 20; +export const GRID_SIZE = 16; -export const NODE_SIZE = GRID_SIZE * 5; -export const DEFAULT_NODE_SIZE: [number, number] = [GRID_SIZE * 5, GRID_SIZE * 5]; -export const CONFIGURATION_NODE_SIZE: [number, number] = [GRID_SIZE * 4, GRID_SIZE * 4]; -export const CONFIGURABLE_NODE_SIZE: [number, number] = [GRID_SIZE * 12, GRID_SIZE * 5]; -export const DEFAULT_START_POSITION_X = GRID_SIZE * 9; -export const DEFAULT_START_POSITION_Y = GRID_SIZE * 12; +export const DEFAULT_NODE_SIZE: [number, number] = [GRID_SIZE * 6, GRID_SIZE * 6]; +export const CONFIGURATION_NODE_SIZE: [number, number] = [GRID_SIZE * 5, GRID_SIZE * 5]; +export const CONFIGURABLE_NODE_SIZE: [number, number] = [GRID_SIZE * 16, GRID_SIZE * 6]; +export const DEFAULT_START_POSITION_X = GRID_SIZE * 11; +export const DEFAULT_START_POSITION_Y = GRID_SIZE * 15; export const HEADER_HEIGHT = 65; -export const MAX_X_TO_PUSH_DOWNSTREAM_NODES = GRID_SIZE * 15; -export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE; +export const PUSH_NODES_OFFSET = DEFAULT_NODE_SIZE[0] * 2 + GRID_SIZE; export const DEFAULT_VIEWPORT_BOUNDARIES: ViewportBoundaries = { xMin: -Infinity, yMin: -Infinity, @@ -50,6 +48,10 @@ export const DEFAULT_VIEWPORT_BOUNDARIES: ViewportBoundaries = { yMax: Infinity, }; +// The top-center of the configuration node is not a multiple of GRID_SIZE, +// therefore we need to offset non-main inputs to align with the nodes top-center +export const CONFIGURATION_NODE_OFFSET = (CONFIGURATION_NODE_SIZE[0] / 2) % GRID_SIZE; + /** * Utility functions for returning nodes found at the edges of a group */ @@ -110,8 +112,10 @@ export const getNodesGroupSize = (nodes: INodeUi[]): [number, number] => { const rightMostNode = getRightMostNode(nodes); const bottomMostNode = getBottomMostNode(nodes); - const width = Math.abs(rightMostNode.position[0] - leftMostNode.position[0]) + NODE_SIZE; - const height = Math.abs(bottomMostNode.position[1] - topMostNode.position[1]) + NODE_SIZE; + const width = + Math.abs(rightMostNode.position[0] - leftMostNode.position[0]) + DEFAULT_NODE_SIZE[0]; + const height = + Math.abs(bottomMostNode.position[1] - topMostNode.position[1]) + DEFAULT_NODE_SIZE[1]; return [width, height]; }; @@ -155,6 +159,13 @@ const closestNumberDivisibleBy = (inputNumber: number, divisibleBy: number): num return inputNumber2; }; +export function snapPositionToGrid(position: XYPosition): XYPosition { + return [ + closestNumberDivisibleBy(position[0], GRID_SIZE), + closestNumberDivisibleBy(position[1], GRID_SIZE), + ]; +} + /** * Returns the new position for a node based on the given position and the nodes in the workflow */ @@ -173,13 +184,8 @@ export const getNewNodePosition = ( normalize?: boolean; } = {}, ): XYPosition => { - const resolvedOffset = [...offset]; - resolvedOffset[0] = closestNumberDivisibleBy(resolvedOffset[0], GRID_SIZE); - resolvedOffset[1] = closestNumberDivisibleBy(resolvedOffset[1], GRID_SIZE); - - const resolvedPosition: XYPosition = [...initialPosition]; - resolvedPosition[0] = closestNumberDivisibleBy(resolvedPosition[0], GRID_SIZE); - resolvedPosition[1] = closestNumberDivisibleBy(resolvedPosition[1], GRID_SIZE); + const resolvedOffset = snapPositionToGrid(offset); + const resolvedPosition: XYPosition = snapPositionToGrid(initialPosition); if (normalize) { let conflictFound = false; @@ -290,7 +296,7 @@ export const getNodesWithNormalizedPosition = { - node.position[0] += diffX + NODE_SIZE * 2; + node.position[0] += diffX + DEFAULT_NODE_SIZE[0] * 2; node.position[1] += diffY; }); } @@ -610,18 +616,20 @@ export function calculateNodeSize( nonMainInputCount: number, ): { width: number; height: number } { const maxVerticalHandles = Math.max(mainInputCount, mainOutputCount, 1); - const height = 100 + Math.max(0, maxVerticalHandles - 3) * GRID_SIZE * 2; + const height = DEFAULT_NODE_SIZE[1] + Math.max(0, maxVerticalHandles - 2) * GRID_SIZE * 2; if (isConfigurable) { return { - width: (Math.max(NODE_MIN_INPUT_ITEMS_COUNT - 1, nonMainInputCount) * 2 + 4) * GRID_SIZE, - height: isConfiguration ? 75 : height, + width: + Math.max(NODE_MIN_INPUT_ITEMS_COUNT, nonMainInputCount) * GRID_SIZE * 4 + + CONFIGURATION_NODE_OFFSET * 2, + height: isConfiguration ? CONFIGURATION_NODE_SIZE[1] : height, }; } if (isConfiguration) { - return { width: GRID_SIZE * 4, height: GRID_SIZE * 4 }; + return { width: CONFIGURATION_NODE_SIZE[0], height: CONFIGURATION_NODE_SIZE[1] }; } - return { width: 100, height }; + return { width: DEFAULT_NODE_SIZE[0], height }; }