feat(editor): Update grid size to 16px for better alignment (#16869)

This commit is contained in:
Alex Grozav
2025-07-02 13:19:26 +03:00
committed by GitHub
parent 657e5a3b3a
commit 7ebde66eed
19 changed files with 315 additions and 199 deletions

View File

@@ -74,8 +74,8 @@ export function createCanvasGraphNode({
id = '1', id = '1',
type = 'default', type = 'default',
label = 'Node', label = 'Node',
position = { x: 100, y: 100 }, position = { x: 96, y: 96 },
dimensions = { width: 100, height: 100 }, dimensions = { width: 96, height: 96 },
data, data,
...rest ...rest
}: Partial< }: Partial<

View File

@@ -3,7 +3,7 @@ import { reactive, computed, toRefs } from 'vue';
import type { ActionTypeDescription, SimplifiedNodeType } from '@/Interface'; import type { ActionTypeDescription, SimplifiedNodeType } from '@/Interface';
import { WEBHOOK_NODE_TYPE, DRAG_EVENT_DATA_KEY } from '@/constants'; 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 NodeIcon from '@/components/NodeIcon.vue';
import { useViewStacks } from '../composables/useViewStacks'; import { useViewStacks } from '../composables/useViewStacks';
@@ -76,7 +76,10 @@ function onDragOver(event: DragEvent): void {
return; 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 }; state.draggablePosition = { x, y };
} }

View File

@@ -127,7 +127,7 @@ describe('Canvas', () => {
[ [
{ {
id: '1', id: '1',
position: { x: 120, y: 120 }, position: { x: 112, y: 112 },
}, },
], ],
], ],

View File

@@ -2,7 +2,7 @@
exports[`CanvasBackground > should render the background with the correct gap 1`] = ` exports[`CanvasBackground > should render the background with the correct gap 1`] = `
"<svg class="vue-flow__background vue-flow__container" style="height: 100%; width: 100%;" data-test-id="canvas-background"> "<svg class="vue-flow__background vue-flow__container" style="height: 100%; width: 100%;" data-test-id="canvas-background">
<pattern id="pattern-vue-flow-0" x="0" y="0" width="20" height="20" patternTransform="translate(-11,-11)" patternUnits="userSpaceOnUse"> <pattern id="pattern-vue-flow-0" x="0" y="0" width="16" height="16" patternTransform="translate(-9,-9)" patternUnits="userSpaceOnUse">
<circle cx="0.5" cy="0.5" r="0.5" fill="#aaa"></circle> <circle cx="0.5" cy="0.5" r="0.5" fill="#aaa"></circle>
<!----> <!---->
</pattern> </pattern>

View File

@@ -111,8 +111,8 @@ describe('CanvasNode', () => {
const inputHandles = getAllByTestId('canvas-node-input-handle'); const inputHandles = getAllByTestId('canvas-node-input-handle');
expect(inputHandles[1]).toHaveStyle('left: 40px'); expect(inputHandles[1]).toHaveStyle('left: 40px');
expect(inputHandles[2]).toHaveStyle('left: 160px'); expect(inputHandles[2]).toHaveStyle('left: 168px');
expect(inputHandles[3]).toHaveStyle('left: 200px'); expect(inputHandles[3]).toHaveStyle('left: 232px');
}); });
}); });

View File

@@ -34,7 +34,7 @@ import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import CanvasNodeTrigger from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeTrigger.vue'; 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<CanvasNodeData> & { type Props = NodeProps<CanvasNodeData> & {
readOnly?: boolean; readOnly?: boolean;
@@ -186,7 +186,7 @@ const createEndpointMappingFn =
connectingHandle.value?.handleId === handleId; connectingHandle.value?.handleId === handleId;
const offsetValue = const offsetValue =
position === Position.Bottom 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)}%`; : `${(100 / (endpoints.length + 1)) * (index + 1)}%`;
return { return {

View File

@@ -35,13 +35,12 @@ describe('CanvasNodeDefault', () => {
describe('inputs and outputs', () => { describe('inputs and outputs', () => {
it.each([ it.each([
[1, 1, '100px'], [1, 1, '96px'],
[3, 1, '100px'], [1, 3, '128px'],
[4, 1, '140px'], [1, 4, '160px'],
[1, 1, '100px'], [3, 1, '128px'],
[1, 3, '100px'], [4, 1, '160px'],
[1, 4, '140px'], [4, 4, '160px'],
[4, 4, '140px'],
])( ])(
'should adjust height css variable based on the number of inputs and outputs (%i inputs, %i outputs)', 'should adjust height css variable based on the number of inputs and outputs (%i inputs, %i outputs)',
(inputCount, outputCount, expected) => { (inputCount, outputCount, expected) => {
@@ -205,7 +204,7 @@ describe('CanvasNodeDefault', () => {
[ [
'1 required', '1 required',
[{ type: NodeConnectionTypes.AiLanguageModel, index: 0, required: true }], [{ type: NodeConnectionTypes.AiLanguageModel, index: 0, required: true }],
'240px', '272px',
], ],
[ [
'2 required, 1 optional', '2 required, 1 optional',
@@ -214,7 +213,7 @@ describe('CanvasNodeDefault', () => {
{ type: NodeConnectionTypes.AiDocument, index: 0, required: true }, { type: NodeConnectionTypes.AiDocument, index: 0, required: true },
{ type: NodeConnectionTypes.AiMemory, index: 0, required: true }, { type: NodeConnectionTypes.AiMemory, index: 0, required: true },
], ],
'240px', '272px',
], ],
[ [
'2 required, 2 optional', '2 required, 2 optional',
@@ -224,7 +223,7 @@ describe('CanvasNodeDefault', () => {
{ type: NodeConnectionTypes.AiDocument, index: 0, required: true }, { type: NodeConnectionTypes.AiDocument, index: 0, required: true },
{ type: NodeConnectionTypes.AiMemory, index: 0, required: true }, { type: NodeConnectionTypes.AiMemory, index: 0, required: true },
], ],
'240px', '272px',
], ],
[ [
'1 required, 4 optional', '1 required, 4 optional',
@@ -235,7 +234,7 @@ describe('CanvasNodeDefault', () => {
{ type: NodeConnectionTypes.AiMemory, index: 0 }, { type: NodeConnectionTypes.AiMemory, index: 0 },
{ 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)', 'should adjust width css variable based on the number of non-main inputs (%s)',

View File

@@ -234,7 +234,8 @@ function onActivate(event: MouseEvent) {
&.configuration { &.configuration {
.icon { .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) { &:not(.running) {

View File

@@ -4,7 +4,7 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
<div <div
class="node configurable" class="node configurable"
data-test-id="canvas-configurable-node" data-test-id="canvas-configurable-node"
style="--canvas-node--width: 240px; --canvas-node--height: 100px; --node-icon-size: 40px;" style="--canvas-node--width: 272px; --canvas-node--height: 96px; --node-icon-size: 40px;"
> >
<!--v-if--> <!--v-if-->
<div <div
@@ -49,7 +49,7 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
<div <div
class="node configurable configuration" class="node configurable configuration"
data-test-id="canvas-configurable-node" data-test-id="canvas-configurable-node"
style="--canvas-node--width: 240px; --canvas-node--height: 75px; --node-icon-size: 30px;" style="--canvas-node--width: 272px; --canvas-node--height: 80px; --node-icon-size: 30px;"
> >
<!--v-if--> <!--v-if-->
<div <div
@@ -139,7 +139,7 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
<div <div
class="node" class="node"
data-test-id="canvas-default-node" data-test-id="canvas-default-node"
style="--canvas-node--width: 100px; --canvas-node--height: 100px; --node-icon-size: 40px;" style="--canvas-node--width: 96px; --canvas-node--height: 96px; --node-icon-size: 40px;"
> >
<!--v-if--> <!--v-if-->
<div <div
@@ -184,7 +184,7 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
<div <div
class="node trigger" class="node trigger"
data-test-id="canvas-trigger-node" data-test-id="canvas-trigger-node"
style="--canvas-node--width: 100px; --canvas-node--height: 100px; --node-icon-size: 40px;" style="--canvas-node--width: 96px; --canvas-node--height: 96px; --node-icon-size: 40px;"
> >
<!--v-if--> <!--v-if-->
<div <div

View File

@@ -3,26 +3,26 @@
exports[`useCanvasLayout > should layout a basic workflow 1`] = ` exports[`useCanvasLayout > should layout a basic workflow 1`] = `
{ {
"boundingBox": { "boundingBox": {
"height": 100, "height": 96,
"width": 540, "width": 544,
"x": 0, "x": 0,
"y": 0, "y": 0,
}, },
"nodes": [ "nodes": [
{ {
"id": "node1", "id": "node1",
"x": 100, "x": 96,
"y": 100, "y": 96,
}, },
{ {
"id": "node2", "id": "node2",
"x": 320, "x": 320,
"y": 100, "y": 96,
}, },
{ {
"id": "node3", "id": "node3",
"x": 540, "x": 544,
"y": 100, "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`] = ` exports[`useCanvasLayout > should layout a basic workflow with selected nodes 1`] = `
{ {
"boundingBox": { "boundingBox": {
"height": 100, "height": 96,
"width": 540, "width": 544,
"x": 0, "x": 0,
"y": 0, "y": 0,
}, },
"nodes": [ "nodes": [
{ {
"id": "node1", "id": "node1",
"x": 100, "x": 96,
"y": 100, "y": 96,
}, },
{ {
"id": "node2", "id": "node2",
"x": 320, "x": 320,
"y": 100, "y": 96,
}, },
{ {
"id": "node3", "id": "node3",
"x": 540, "x": 544,
"y": 100, "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`] = ` exports[`useCanvasLayout > should layout a workflow with AI nodes 1`] = `
{ {
"boundingBox": { "boundingBox": {
"height": 540, "height": 544,
"width": 820, "width": 832,
"x": 0, "x": 0,
"y": 220, "y": 224,
}, },
"nodes": [ "nodes": [
{ {
"id": "node1", "id": "node1",
"x": 100, "x": 96,
"y": 100, "y": 96,
}, },
{ {
"id": "aiTool1", "id": "aiTool1",
@@ -77,28 +77,28 @@ exports[`useCanvasLayout > should layout a workflow with AI nodes 1`] = `
}, },
{ {
"id": "aiTool2", "id": "aiTool2",
"x": 460, "x": 464,
"y": 320, "y": 320,
}, },
{ {
"id": "aiTool3", "id": "aiTool3",
"x": 600, "x": 608,
"y": 540, "y": 544,
}, },
{ {
"id": "aiAgent", "id": "aiAgent",
"x": 460, "x": 464,
"y": 100, "y": 96,
}, },
{ {
"id": "configurableAiTool", "id": "configurableAiTool",
"x": 600, "x": 608,
"y": 320, "y": 320,
}, },
{ {
"id": "node2", "id": "node2",
"x": 820, "x": 832,
"y": 100, "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`] = ` exports[`useCanvasLayout > should layout a workflow with sticky notes 1`] = `
{ {
"boundingBox": { "boundingBox": {
"height": 100, "height": 96,
"width": 760, "width": 768,
"x": 0, "x": 0,
"y": 0, "y": 0,
}, },
@@ -120,22 +120,22 @@ exports[`useCanvasLayout > should layout a workflow with sticky notes 1`] = `
}, },
{ {
"id": "node2", "id": "node2",
"x": 220, "x": 224,
"y": 0, "y": 0,
}, },
{ {
"id": "node3", "id": "node3",
"x": 440, "x": 448,
"y": 0, "y": 0,
}, },
{ {
"id": "node4", "id": "node4",
"x": 660, "x": 672,
"y": 0, "y": 0,
}, },
{ {
"id": "sticky", "id": "sticky",
"x": 130, "x": 134,
"y": -240, "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`] = ` exports[`useCanvasLayout > should not reorder nodes vertically as it affects execution order 1`] = `
{ {
"boundingBox": { "boundingBox": {
"height": 300, "height": 288,
"width": 320, "width": 320,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -154,17 +154,17 @@ exports[`useCanvasLayout > should not reorder nodes vertically as it affects exe
{ {
"id": "node1", "id": "node1",
"x": 0, "x": 0,
"y": -100, "y": -112,
}, },
{ {
"id": "node3", "id": "node3",
"x": 220, "x": 224,
"y": -200, "y": -208,
}, },
{ {
"id": "node2", "id": "node2",
"x": 220, "x": 224,
"y": 0, "y": -16,
}, },
], ],
} }

View File

@@ -55,6 +55,7 @@ describe('useCanvasLayout', () => {
const { layout } = createTestSetup(nodes, connections); const { layout } = createTestSetup(nodes, connections);
const result = layout('all'); const result = layout('all');
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
expect(matchesGrid(result)).toBe(true); expect(matchesGrid(result)).toBe(true);
}); });
@@ -153,8 +154,8 @@ describe('useCanvasLayout', () => {
test('should not reorder nodes vertically as it affects execution order', () => { test('should not reorder nodes vertically as it affects execution order', () => {
const nodes = [ const nodes = [
createCanvasGraphNode({ id: 'node1', position: { x: 0, y: 0 } }), createCanvasGraphNode({ id: 'node1', position: { x: 0, y: 0 } }),
createCanvasGraphNode({ id: 'node2', position: { x: 400, y: 200 } }), createCanvasGraphNode({ id: 'node2', position: { x: 400, y: 208 } }),
createCanvasGraphNode({ id: 'node3', position: { x: 400, y: -200 } }), createCanvasGraphNode({ id: 'node3', position: { x: 400, y: -208 } }),
]; ];
const connections: Array<[string, string]> = [ const connections: Array<[string, string]> = [

View File

@@ -9,7 +9,7 @@ import {
type CanvasNodeData, type CanvasNodeData,
} from '../types'; } from '../types';
import { isPresent } from '../utils/typesUtils'; 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 CanvasLayoutOptions = { id?: string };
export type CanvasLayoutTarget = 'selection' | 'all'; export type CanvasLayoutTarget = 'selection' | 'all';
@@ -40,12 +40,12 @@ export type CanvasLayoutEvent = {
export type CanvasNodeDictionary = Record<string, GraphNode<CanvasNodeData>>; export type CanvasNodeDictionary = Record<string, GraphNode<CanvasNodeData>>;
const NODE_X_SPACING = GRID_SIZE * 6; const NODE_X_SPACING = GRID_SIZE * 8;
const NODE_Y_SPACING = GRID_SIZE * 5; const NODE_Y_SPACING = GRID_SIZE * 6;
const SUBGRAPH_SPACING = GRID_SIZE * 8; const SUBGRAPH_SPACING = GRID_SIZE * 8;
const AI_X_SPACING = GRID_SIZE * 2; const AI_X_SPACING = GRID_SIZE * 3;
const AI_Y_SPACING = GRID_SIZE * 6; const AI_Y_SPACING = GRID_SIZE * 8;
const STICKY_BOTTOM_PADDING = GRID_SIZE * 3; const STICKY_BOTTOM_PADDING = GRID_SIZE * 4;
export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) { export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
const { const {
@@ -113,7 +113,10 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
function createDagreSubGraph({ function createDagreSubGraph({
nodeIds, nodeIds,
parent, parent,
}: { nodeIds: string[]; parent: dagre.graphlib.Graph }) { }: {
nodeIds: string[];
parent: dagre.graphlib.Graph;
}) {
const subGraph = new dagre.graphlib.Graph(); const subGraph = new dagre.graphlib.Graph();
subGraph.setGraph({ subGraph.setGraph({
rankdir: 'LR', rankdir: 'LR',
@@ -165,7 +168,10 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
function createAiSubGraph({ function createAiSubGraph({
parent, parent,
nodeIds, nodeIds,
}: { parent: dagre.graphlib.Graph; nodeIds: string[] }) { }: {
parent: dagre.graphlib.Graph;
nodeIds: string[];
}) {
const subGraph = new dagre.graphlib.Graph(); const subGraph = new dagre.graphlib.Graph();
subGraph.setGraph({ subGraph.setGraph({
rankdir: 'TB', rankdir: 'TB',
@@ -449,7 +455,7 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
const aiGraphBoundingBox = compositeBoundingBox( const aiGraphBoundingBox = compositeBoundingBox(
aiNodes.map((nodeId) => boundingBoxByNodeId[nodeId]).filter(isPresent), 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; aiGraphBoundingBox.y += aiNodeVerticalCorrection;
const hasConflictingNodes = Object.entries(boundingBoxByNodeId) const hasConflictingNodes = Object.entries(boundingBoxByNodeId)

View File

@@ -200,12 +200,12 @@ describe('useCanvasOperations', () => {
{ {
type: 'type', type: 'type',
typeVersion: 1, typeVersion: 1,
position: [20, 20], position: [32, 32],
}, },
mockNodeTypeDescription({ name: 'type' }), 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', () => { it('should not assign credentials when multiple credentials are available', () => {
@@ -274,13 +274,13 @@ describe('useCanvasOperations', () => {
describe('resolveNodePosition', () => { describe('resolveNodePosition', () => {
it('should return the node position if it is already set', () => { 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 nodeTypeDescription = mockNodeTypeDescription();
const { resolveNodePosition } = useCanvasOperations(); const { resolveNodePosition } = useCanvasOperations();
const position = resolveNodePosition(node, nodeTypeDescription); 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', () => { it('should place the node at the last cancelled connection position', () => {
@@ -302,7 +302,7 @@ describe('useCanvasOperations', () => {
const { resolveNodePosition } = useCanvasOperations(); const { resolveNodePosition } = useCanvasOperations();
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription);
expect(position).toEqual([200, 160]); expect(position).toEqual([208, 160]);
expect(uiStore.lastCancelledConnectionPosition).toBeUndefined(); expect(uiStore.lastCancelledConnectionPosition).toBeUndefined();
}); });
@@ -316,7 +316,7 @@ describe('useCanvasOperations', () => {
const workflowObject = createTestWorkflowObject(workflowsStore.workflow); const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
uiStore.lastInteractedWithNode = createTestNode({ uiStore.lastInteractedWithNode = createTestNode({
position: [100, 100], position: [112, 112],
type: 'test', type: 'test',
typeVersion: 1, typeVersion: 1,
}); });
@@ -327,7 +327,7 @@ describe('useCanvasOperations', () => {
const { resolveNodePosition } = useCanvasOperations(); const { resolveNodePosition } = useCanvasOperations();
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); 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', () => { 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); const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
uiStore.lastInteractedWithNode = createTestNode({ uiStore.lastInteractedWithNode = createTestNode({
position: [100, 100], position: [96, 96],
type: 'test', type: 'test',
typeVersion: 1, typeVersion: 1,
}); });
@@ -358,7 +358,7 @@ describe('useCanvasOperations', () => {
const { resolveNodePosition } = useCanvasOperations(); const { resolveNodePosition } = useCanvasOperations();
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); 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', () => { 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 node = createTestNode({ id: '0' });
const nodeTypeDescription = mockNodeTypeDescription(); const nodeTypeDescription = mockNodeTypeDescription();
workflowsStore.workflowTriggerNodes = [ workflowsStore.workflowTriggerNodes = [createTestNode({ id: 'trigger', position: [96, 96] })];
createTestNode({ id: 'trigger', position: [100, 100] }),
];
const { resolveNodePosition, lastClickPosition } = useCanvasOperations(); const { resolveNodePosition, lastClickPosition } = useCanvasOperations();
lastClickPosition.value = [300, 300]; lastClickPosition.value = [300, 300];
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); 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', () => { 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', () => { it('records history for multiple node position updates when tracking is enabled', () => {
const historyStore = useHistoryStore(); const historyStore = useHistoryStore();
const events = [ const events = [
{ id: 'node1', position: { x: 100, y: 100 } }, { id: 'node1', position: { x: 96, y: 96 } },
{ id: 'node2', position: { x: 200, y: 200 } }, { id: 'node2', position: { x: 208, y: 208 } },
]; ];
const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo'); const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo');
const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo'); const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo');
@@ -548,8 +546,8 @@ describe('useCanvasOperations', () => {
it('updates positions for multiple nodes', () => { it('updates positions for multiple nodes', () => {
const workflowsStore = mockedStore(useWorkflowsStore); const workflowsStore = mockedStore(useWorkflowsStore);
const events = [ const events = [
{ id: 'node1', position: { x: 100, y: 100 } }, { id: 'node1', position: { x: 96, y: 96 } },
{ id: 'node2', position: { x: 200, y: 200 } }, { id: 'node2', position: { x: 208, y: 208 } },
]; ];
const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById');
workflowsStore.getNodeById workflowsStore.getNodeById
@@ -570,13 +568,13 @@ describe('useCanvasOperations', () => {
updateNodesPosition(events); updateNodesPosition(events);
expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2); expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2);
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [100, 100]); expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [96, 96]);
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [200, 200]); expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [208, 208]);
}); });
it('does not record history when trackHistory is false', () => { it('does not record history when trackHistory is false', () => {
const historyStore = useHistoryStore(); 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 startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo');
const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo'); const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo');
@@ -596,10 +594,10 @@ describe('useCanvasOperations', () => {
target: 'all', target: 'all',
result: { result: {
nodes: [ nodes: [
{ id: 'node1', x: 100, y: 100 }, { id: 'node1', x: 96, y: 96 },
{ id: 'node2', x: 200, y: 200 }, { 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'); const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo');
@@ -619,10 +617,10 @@ describe('useCanvasOperations', () => {
target: 'all', target: 'all',
result: { result: {
nodes: [ nodes: [
{ id: 'node1', x: 100, y: 100 }, { id: 'node1', x: 96, y: 96 },
{ id: 'node2', x: 200, y: 200 }, { 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'); const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById');
@@ -644,8 +642,8 @@ describe('useCanvasOperations', () => {
tidyUp(event); tidyUp(event);
expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2); expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2);
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [100, 100]); expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [96, 96]);
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [200, 200]); expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [208, 208]);
}); });
it('should send a "User tidied up workflow" telemetry event', () => { it('should send a "User tidied up workflow" telemetry event', () => {
@@ -654,10 +652,10 @@ describe('useCanvasOperations', () => {
target: 'all', target: 'all',
result: { result: {
nodes: [ nodes: [
{ id: 'node1', x: 100, y: 100 }, { id: 'node1', x: 96, y: 96 },
{ id: 'node2', x: 200, y: 200 }, { 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 nodeTypesStore = useNodeTypesStore();
const nodeTypeName = 'type'; const nodeTypeName = 'type';
const nodes = [ const nodes = [
mockNode({ name: 'Node 1', type: nodeTypeName, position: [30, 40] }), mockNode({ name: 'Node 1', type: nodeTypeName, position: [32, 32] }),
mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }), mockNode({ name: 'Node 2', type: nodeTypeName, position: [96, 256] }),
]; ];
workflowsStore.getCurrentWorkflow.mockReturnValue( workflowsStore.getCurrentWorkflow.mockReturnValue(
@@ -758,14 +756,14 @@ describe('useCanvasOperations', () => {
name: nodes[0].name, name: nodes[0].name,
type: nodeTypeName, type: nodeTypeName,
typeVersion: 1, typeVersion: 1,
position: [40, 40], position: [32, 32],
parameters: {}, parameters: {},
}); });
expect(workflowsStore.addNode.mock.calls[1][0]).toMatchObject({ expect(workflowsStore.addNode.mock.calls[1][0]).toMatchObject({
name: nodes[1].name, name: nodes[1].name,
type: nodeTypeName, type: nodeTypeName,
typeVersion: 1, typeVersion: 1,
position: [100, 240], position: [96, 256],
parameters: {}, parameters: {},
}); });
}); });
@@ -775,8 +773,8 @@ describe('useCanvasOperations', () => {
const nodeTypesStore = mockedStore(useNodeTypesStore); const nodeTypesStore = mockedStore(useNodeTypesStore);
const nodeTypeName = 'type'; const nodeTypeName = 'type';
const nodes = [ const nodes = [
mockNode({ name: 'Node 1', type: nodeTypeName, position: [120, 120] }), mockNode({ name: 'Node 1', type: nodeTypeName, position: [128, 128] }),
mockNode({ name: 'Node 2', type: nodeTypeName, position: [180, 320] }), mockNode({ name: 'Node 2', type: nodeTypeName, position: [192, 320] }),
]; ];
workflowsStore.getCurrentWorkflow.mockReturnValue( workflowsStore.getCurrentWorkflow.mockReturnValue(
@@ -804,9 +802,9 @@ describe('useCanvasOperations', () => {
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const nodeTypeName = 'type'; const nodeTypeName = 'type';
const nodes = [ const nodes = [
mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }), mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [32, 32] }),
mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [40, 40] }), mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [32, 32] }),
mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [100, 240] }), mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [96, 256] }),
]; ];
const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById');
@@ -842,7 +840,7 @@ describe('useCanvasOperations', () => {
const nodeTypeName = 'type'; const nodeTypeName = 'type';
const nodes = [ const nodes = [
mockNode({ name: 'Node 1', type: nodeTypeName, position: [30, 40] }), 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( workflowsStore.getCurrentWorkflow.mockReturnValue(
@@ -3007,8 +3005,8 @@ describe('useCanvasOperations', () => {
// Create three nodes in a sequence: A -> B -> C // Create three nodes in a sequence: A -> B -> C
const nodeA = createTestNode({ id: 'A', name: 'Node A', position: [0, 0] }); 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] });
const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [200, 0] }); const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [208, 0] });
const nodeTypeDescription = mockNodeTypeDescription({ const nodeTypeDescription = mockNodeTypeDescription({
name: nodeA.type, name: nodeA.type,
@@ -3077,8 +3075,8 @@ describe('useCanvasOperations', () => {
// Create three nodes in a sequence: A -> B -> C // Create three nodes in a sequence: A -> B -> C
const nodeA = createTestNode({ id: 'A', name: 'Node A', position: [0, 0] }); 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] });
const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [200, 0] }); const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [208, 0] });
const nodeTypeDescription = mockNodeTypeDescription({ const nodeTypeDescription = mockNodeTypeDescription({
name: nodeA.type, name: nodeA.type,
@@ -3144,8 +3142,8 @@ describe('useCanvasOperations', () => {
const workflowsStore = mockedStore(useWorkflowsStore); const workflowsStore = mockedStore(useWorkflowsStore);
// Create nodes: B -> C (no incoming to B) // Create nodes: B -> C (no incoming to B)
const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [100, 0] }); const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [96, 0] });
const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [200, 0] }); const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [208, 0] });
workflowsStore.workflow.nodes = [nodeB, nodeC]; workflowsStore.workflow.nodes = [nodeB, nodeC];
workflowsStore.workflow.connections = { workflowsStore.workflow.connections = {
@@ -3173,7 +3171,7 @@ describe('useCanvasOperations', () => {
// Create nodes: A -> B (no outgoing from B) // Create nodes: A -> B (no outgoing from B)
const nodeA = createTestNode({ id: 'A', name: 'Node A', position: [0, 0] }); 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.nodes = [nodeA, nodeB];
workflowsStore.workflow.connections = { workflowsStore.workflow.connections = {
@@ -3220,7 +3218,7 @@ describe('useCanvasOperations', () => {
const nodeB: IWorkflowTemplateNode = createTestNode({ const nodeB: IWorkflowTemplateNode = createTestNode({
id: 'Y', id: 'Y',
name: 'Node Y', name: 'Node Y',
position: [180, 80], position: [192, 80],
}); });
const workflow: IWorkflowTemplate['workflow'] = { const workflow: IWorkflowTemplate['workflow'] = {

View File

@@ -71,6 +71,7 @@ import {
} from '@/utils/canvasUtils'; } from '@/utils/canvasUtils';
import * as NodeViewUtils from '@/utils/nodeViewUtils'; import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { import {
GRID_SIZE,
CONFIGURABLE_NODE_SIZE, CONFIGURABLE_NODE_SIZE,
CONFIGURATION_NODE_SIZE, CONFIGURATION_NODE_SIZE,
DEFAULT_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 // When we're adding multiple nodes, increment the X position for the next one
insertPosition = [ insertPosition = [
lastAddedNode.position[0] + NodeViewUtils.NODE_SIZE * 2 + NodeViewUtils.GRID_SIZE, lastAddedNode.position[0] + DEFAULT_NODE_SIZE[0] * 2 + GRID_SIZE,
lastAddedNode.position[1], lastAddedNode.position[1],
]; ];
} }
@@ -1108,8 +1109,8 @@ export function useCanvasOperations() {
if (lastInteractedWithNodeMainOutputs.length > 1) { if (lastInteractedWithNodeMainOutputs.length > 1) {
const yOffsetValues = generateOffsets( const yOffsetValues = generateOffsets(
lastInteractedWithNodeMainOutputs.length, lastInteractedWithNodeMainOutputs.length,
NodeViewUtils.NODE_SIZE, DEFAULT_NODE_SIZE[1],
NodeViewUtils.GRID_SIZE, GRID_SIZE,
); );
yOffset = yOffsetValues[connectionIndex]; yOffset = yOffsetValues[connectionIndex];

View File

@@ -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_PRICING_PAGE_URL = 'https://n8n.io/pricing';
export const N8N_MAIN_GITHUB_REPO_URL = 'https://github.com/n8n-io/n8n'; 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 // node types
export const BAMBOO_HR_NODE_TYPE = 'n8n-nodes-base.bambooHr'; export const BAMBOO_HR_NODE_TYPE = 'n8n-nodes-base.bambooHr';

View File

@@ -102,6 +102,7 @@ import { updateCurrentUserSettings } from '@/api/users';
import { useExecutingNode } from '@/composables/useExecutingNode'; import { useExecutingNode } from '@/composables/useExecutingNode';
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution'; import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
import { isChatNode } from '@/utils/aiUtils'; import { isChatNode } from '@/utils/aiUtils';
import { snapPositionToGrid } from '@/utils/nodeViewUtils';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = { const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '', name: '',
@@ -1290,6 +1291,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
node.type = getCredentialOnlyNodeTypeName(node.extendsCredential); node.type = getCredentialOnlyNodeTypeName(node.extendsCredential);
} }
if (node.position) {
node.position = snapPositionToGrid(node.position);
}
if (!nodeMetadata.value[node.name]) { if (!nodeMetadata.value[node.name]) {
nodeMetadata.value[node.name] = { pristine: true }; nodeMetadata.value[node.name] = { pristine: true };
} }

View File

@@ -992,7 +992,7 @@ describe('insertSpacersBetweenEndpoints', () => {
const endpoints = [{ index: 0, required: true }]; const endpoints = [{ index: 0, required: true }];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length; const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount); 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', () => { 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 endpoints = [{ index: 0, required: false }];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length; const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount); 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', () => { it('should handle no endpoints', () => {
const endpoints: Array<{ index: number; required: boolean }> = []; const endpoints: Array<{ index: number; required: boolean }> = [];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length; const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount); 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', () => { it('should handle required endpoints greater than NODE_MIN_INPUT_ITEMS_COUNT', () => {
@@ -1040,7 +1040,6 @@ describe('insertSpacersBetweenEndpoints', () => {
{ index: 0, required: true }, { index: 0, required: true },
{ index: 1, required: true }, { index: 1, required: true },
null, null,
null,
{ index: 2 }, { index: 2 },
]); ]);
}); });
@@ -1049,6 +1048,6 @@ describe('insertSpacersBetweenEndpoints', () => {
const endpoints = [{ index: 0, required: true }]; const endpoints = [{ index: 0, required: true }];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length; const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount); 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]);
}); });
}); });

View File

@@ -7,8 +7,11 @@ import {
generateOffsets, generateOffsets,
getGenericHints, getGenericHints,
getNewNodePosition, getNewNodePosition,
NODE_SIZE,
updateViewportToContainNodes, updateViewportToContainNodes,
DEFAULT_NODE_SIZE,
snapPositionToGrid,
calculateNodeSize,
GRID_SIZE,
} from './nodeViewUtils'; } from './nodeViewUtils';
import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow'; import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow';
import type { INodeUi, XYPosition } from '@/Interface'; import type { INodeUi, XYPosition } from '@/Interface';
@@ -203,96 +206,112 @@ describe('getGenericHints', () => {
describe('generateOffsets', () => { describe('generateOffsets', () => {
it('should return correct offsets for 0 nodes', () => { it('should return correct offsets for 0 nodes', () => {
const result = generateOffsets(0, 100, 20); const result = generateOffsets(0, 96, GRID_SIZE);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
it('should return correct offsets for 1 node', () => { 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]); expect(result).toEqual([0]);
}); });
it('should return correct offsets for 2 nodes', () => { it('should return correct offsets for 2 nodes', () => {
const result = generateOffsets(2, 100, 20); const result = generateOffsets(2, 96, GRID_SIZE);
expect(result).toEqual([-100, 100]); expect(result).toEqual([-96, 96]);
}); });
it('should return correct offsets for 3 nodes', () => { it('should return correct offsets for 3 nodes', () => {
const result = generateOffsets(3, 100, 20); const result = generateOffsets(3, 96, GRID_SIZE);
expect(result).toEqual([-120, 0, 120]); expect(result).toEqual([-112, 0, 112]);
}); });
it('should return correct offsets for 4 nodes', () => { it('should return correct offsets for 4 nodes', () => {
const result = generateOffsets(4, 100, 20); const result = generateOffsets(4, 96, GRID_SIZE);
expect(result).toEqual([-220, -100, 100, 220]); expect(result).toEqual([-208, -96, 96, 208]);
}); });
it('should return correct offsets for large node count', () => { it('should return correct offsets for large node count', () => {
const result = generateOffsets(10, 100, 20); const result = generateOffsets(10, 96, GRID_SIZE);
expect(result).toEqual([-580, -460, -340, -220, -100, 100, 220, 340, 460, 580]); 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', () => { describe('getNewNodePosition', () => {
it('should return the new position when there are no conflicts', () => { it('should return the new position when there are no conflicts', () => {
const nodes: INodeUi[] = []; const nodes: INodeUi[] = [];
const newPosition: XYPosition = [100, 100]; const newPosition: XYPosition = [96, 96];
const result = getNewNodePosition(nodes, newPosition); 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', () => { it('should adjust the position to the closest grid size', () => {
const nodes: INodeUi[] = []; const nodes: INodeUi[] = [];
const newPosition: XYPosition = [105, 115]; const newPosition: XYPosition = [105, 115];
const result = getNewNodePosition(nodes, newPosition); const result = getNewNodePosition(nodes, newPosition);
expect(result).toEqual([120, 120]); expect(result).toEqual([112, 128]);
}); });
it('should move the position to avoid conflicts', () => { it('should move the position to avoid conflicts', () => {
const nodes: INodeUi[] = [ const nodes: INodeUi[] = [createTestNode({ id: '1', position: [96, 96], type: SET_NODE_TYPE })];
createTestNode({ id: '1', position: [100, 100], type: SET_NODE_TYPE }), const newPosition: XYPosition = [96, 96];
];
const newPosition: XYPosition = [100, 100];
const result = getNewNodePosition(nodes, newPosition); const result = getNewNodePosition(nodes, newPosition);
expect(result).toEqual([220, 220]); expect(result).toEqual([240, 240]);
}); });
it('should skip nodes in the conflict allowlist', () => { it('should skip nodes in the conflict allowlist', () => {
const nodes: INodeUi[] = [ 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); 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', () => { it('should use the provided move position to resolve conflicts', () => {
const nodes: INodeUi[] = [ const nodes: INodeUi[] = [createTestNode({ id: '1', position: [96, 96], type: SET_NODE_TYPE })];
createTestNode({ id: '1', position: [100, 100], type: SET_NODE_TYPE }), const newPosition: XYPosition = [96, 96];
]; const movePosition: XYPosition = [48, 48];
const newPosition: XYPosition = [100, 100];
const movePosition: XYPosition = [50, 50];
const result = getNewNodePosition(nodes, newPosition, { const result = getNewNodePosition(nodes, newPosition, {
offset: movePosition, offset: movePosition,
}); });
expect(result).toEqual([220, 220]); expect(result).toEqual([240, 240]);
}); });
it('should handle multiple conflicts correctly', () => { it('should handle multiple conflicts correctly', () => {
const nodes: INodeUi[] = [ const nodes: INodeUi[] = [
createTestNode({ id: '1', position: [100, 100], type: SET_NODE_TYPE }), createTestNode({ id: '1', position: [96, 96], type: SET_NODE_TYPE }),
createTestNode({ id: '2', position: [140, 140], 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); const result = getNewNodePosition(nodes, newPosition);
expect(result).toEqual([280, 280]); expect(result).toEqual([288, 288]);
}); });
}); });
const testNodes: INode[] = [ const testNodes: INode[] = [
createTestNode({ id: 'a', position: [0, 0] }), createTestNode({ id: 'a', position: [0, 0] }),
createTestNode({ id: 'b', position: [100, 50] }), createTestNode({ id: 'b', position: [96, 50] }),
createTestNode({ id: 'c', position: [50, 100] }), createTestNode({ id: 'c', position: [50, 96] }),
createTestNode({ id: 'd', position: [-20, -10] }), createTestNode({ id: 'd', position: [-20, -10] }),
]; ];
@@ -379,15 +398,15 @@ describe('getBottomMostNode', () => {
describe('getNodesGroupSize', () => { describe('getNodesGroupSize', () => {
it('calculates the group size correctly', () => { it('calculates the group size correctly', () => {
const [width, height] = getNodesGroupSize(testNodes); const [width, height] = getNodesGroupSize(testNodes);
expect(width).toBe(Math.abs(100 - -20) + NODE_SIZE); expect(width).toBe(Math.abs(96 - -20) + DEFAULT_NODE_SIZE[0]);
expect(height).toBe(Math.abs(-10 - 100) + NODE_SIZE); expect(height).toBe(Math.abs(-10 - 96) + DEFAULT_NODE_SIZE[1]);
}); });
it('should handle a single node', () => { it('should handle a single node', () => {
const single = [testNodes[0]]; const single = [testNodes[0]];
const [w, h] = getNodesGroupSize(single); const [w, h] = getNodesGroupSize(single);
expect(w).toBe(NODE_SIZE); expect(w).toBe(DEFAULT_NODE_SIZE[0]);
expect(h).toBe(NODE_SIZE); expect(h).toBe(DEFAULT_NODE_SIZE[1]);
}); });
it('should handle nodes with equal positions', () => { it('should handle nodes with equal positions', () => {
@@ -396,12 +415,12 @@ describe('getNodesGroupSize', () => {
createTestNode({ id: 'y', position: [10, 20] }), createTestNode({ id: 'y', position: [10, 20] }),
]; ];
const [we, he] = getNodesGroupSize(equalNodes); const [we, he] = getNodesGroupSize(equalNodes);
expect(we).toBe(NODE_SIZE); expect(we).toBe(DEFAULT_NODE_SIZE[0]);
expect(he).toBe(NODE_SIZE); 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', () => { it('should return the same viewport if given node is already in the viewport', () => {
const result = updateViewportToContainNodes( const result = updateViewportToContainNodes(
{ x: 0, y: 0, zoom: 2 }, { 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> = {}): GraphNode { function createTestGraphNode(data: Partial<GraphNode> = {}): GraphNode {
return { return {
computedPosition: { z: 0, ...(data.position ?? { x: 0, y: 0 }) }, computedPosition: { z: 0, ...(data.position ?? { x: 0, y: 0 }) },

View File

@@ -32,17 +32,15 @@ import {
* Canvas constants and functions * 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 * 6, GRID_SIZE * 6];
export const DEFAULT_NODE_SIZE: [number, number] = [GRID_SIZE * 5, GRID_SIZE * 5]; export const CONFIGURATION_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 * 16, GRID_SIZE * 6];
export const CONFIGURABLE_NODE_SIZE: [number, number] = [GRID_SIZE * 12, GRID_SIZE * 5]; export const DEFAULT_START_POSITION_X = GRID_SIZE * 11;
export const DEFAULT_START_POSITION_X = GRID_SIZE * 9; export const DEFAULT_START_POSITION_Y = GRID_SIZE * 15;
export const DEFAULT_START_POSITION_Y = GRID_SIZE * 12;
export const HEADER_HEIGHT = 65; export const HEADER_HEIGHT = 65;
export const MAX_X_TO_PUSH_DOWNSTREAM_NODES = GRID_SIZE * 15; export const PUSH_NODES_OFFSET = DEFAULT_NODE_SIZE[0] * 2 + GRID_SIZE;
export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE;
export const DEFAULT_VIEWPORT_BOUNDARIES: ViewportBoundaries = { export const DEFAULT_VIEWPORT_BOUNDARIES: ViewportBoundaries = {
xMin: -Infinity, xMin: -Infinity,
yMin: -Infinity, yMin: -Infinity,
@@ -50,6 +48,10 @@ export const DEFAULT_VIEWPORT_BOUNDARIES: ViewportBoundaries = {
yMax: Infinity, 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 * 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 rightMostNode = getRightMostNode(nodes);
const bottomMostNode = getBottomMostNode(nodes); const bottomMostNode = getBottomMostNode(nodes);
const width = Math.abs(rightMostNode.position[0] - leftMostNode.position[0]) + NODE_SIZE; const width =
const height = Math.abs(bottomMostNode.position[1] - topMostNode.position[1]) + NODE_SIZE; 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]; return [width, height];
}; };
@@ -155,6 +159,13 @@ const closestNumberDivisibleBy = (inputNumber: number, divisibleBy: number): num
return inputNumber2; 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 * 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; normalize?: boolean;
} = {}, } = {},
): XYPosition => { ): XYPosition => {
const resolvedOffset = [...offset]; const resolvedOffset = snapPositionToGrid(offset);
resolvedOffset[0] = closestNumberDivisibleBy(resolvedOffset[0], GRID_SIZE); const resolvedPosition: XYPosition = snapPositionToGrid(initialPosition);
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);
if (normalize) { if (normalize) {
let conflictFound = false; let conflictFound = false;
@@ -290,7 +296,7 @@ export const getNodesWithNormalizedPosition = <T extends { position: XYPosition
const diffY = DEFAULT_START_POSITION_Y - leftmostTop.position[1]; const diffY = DEFAULT_START_POSITION_Y - leftmostTop.position[1];
nodes.forEach((node) => { nodes.forEach((node) => {
node.position[0] += diffX + NODE_SIZE * 2; node.position[0] += diffX + DEFAULT_NODE_SIZE[0] * 2;
node.position[1] += diffY; node.position[1] += diffY;
}); });
} }
@@ -610,18 +616,20 @@ export function calculateNodeSize(
nonMainInputCount: number, nonMainInputCount: number,
): { width: number; height: number } { ): { width: number; height: number } {
const maxVerticalHandles = Math.max(mainInputCount, mainOutputCount, 1); 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) { if (isConfigurable) {
return { return {
width: (Math.max(NODE_MIN_INPUT_ITEMS_COUNT - 1, nonMainInputCount) * 2 + 4) * GRID_SIZE, width:
height: isConfiguration ? 75 : height, Math.max(NODE_MIN_INPUT_ITEMS_COUNT, nonMainInputCount) * GRID_SIZE * 4 +
CONFIGURATION_NODE_OFFSET * 2,
height: isConfiguration ? CONFIGURATION_NODE_SIZE[1] : height,
}; };
} }
if (isConfiguration) { 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 };
} }