mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Improve insertion algorithm for nodes with multiple main outputs (no-changelog) (#11213)
This commit is contained in:
parent
98759701e4
commit
c9628de72b
|
@ -285,10 +285,16 @@ describe('useCanvasOperations', () => {
|
||||||
|
|
||||||
it('should place the node at the last cancelled connection position', () => {
|
it('should place the node at the last cancelled connection position', () => {
|
||||||
const uiStore = mockedStore(useUIStore);
|
const uiStore = mockedStore(useUIStore);
|
||||||
|
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||||
const node = createTestNode({ id: '0' });
|
const node = createTestNode({ id: '0' });
|
||||||
const nodeTypeDescription = mockNodeTypeDescription();
|
const nodeTypeDescription = mockNodeTypeDescription();
|
||||||
|
|
||||||
vi.spyOn(uiStore, 'lastInteractedWithNode', 'get').mockReturnValue(node);
|
vi.spyOn(uiStore, 'lastInteractedWithNode', 'get').mockReturnValue(node);
|
||||||
|
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription);
|
||||||
|
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
||||||
|
createTestWorkflowObject(workflowsStore.workflow),
|
||||||
|
);
|
||||||
|
|
||||||
uiStore.lastInteractedWithNodeHandle = 'inputs/main/0';
|
uiStore.lastInteractedWithNodeHandle = 'inputs/main/0';
|
||||||
uiStore.lastCancelledConnectionPosition = [200, 200];
|
uiStore.lastCancelledConnectionPosition = [200, 200];
|
||||||
|
@ -307,6 +313,7 @@ describe('useCanvasOperations', () => {
|
||||||
|
|
||||||
const node = createTestNode({ id: '0' });
|
const node = createTestNode({ id: '0' });
|
||||||
const nodeTypeDescription = mockNodeTypeDescription();
|
const nodeTypeDescription = mockNodeTypeDescription();
|
||||||
|
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
|
||||||
|
|
||||||
uiStore.lastInteractedWithNode = createTestNode({
|
uiStore.lastInteractedWithNode = createTestNode({
|
||||||
position: [100, 100],
|
position: [100, 100],
|
||||||
|
@ -314,9 +321,8 @@ describe('useCanvasOperations', () => {
|
||||||
typeVersion: 1,
|
typeVersion: 1,
|
||||||
});
|
});
|
||||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription);
|
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription);
|
||||||
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject);
|
||||||
createTestWorkflowObject(workflowsStore.workflow),
|
workflowObject.getNode = vi.fn().mockReturnValue(node);
|
||||||
);
|
|
||||||
|
|
||||||
const { resolveNodePosition } = useCanvasOperations({ router });
|
const { resolveNodePosition } = useCanvasOperations({ router });
|
||||||
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription);
|
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription);
|
||||||
|
@ -331,6 +337,7 @@ describe('useCanvasOperations', () => {
|
||||||
|
|
||||||
const node = createTestNode({ id: '0' });
|
const node = createTestNode({ id: '0' });
|
||||||
const nodeTypeDescription = mockNodeTypeDescription();
|
const nodeTypeDescription = mockNodeTypeDescription();
|
||||||
|
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
|
||||||
|
|
||||||
uiStore.lastInteractedWithNode = createTestNode({
|
uiStore.lastInteractedWithNode = createTestNode({
|
||||||
position: [100, 100],
|
position: [100, 100],
|
||||||
|
@ -338,9 +345,8 @@ describe('useCanvasOperations', () => {
|
||||||
typeVersion: 1,
|
typeVersion: 1,
|
||||||
});
|
});
|
||||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription);
|
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription);
|
||||||
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject);
|
||||||
createTestWorkflowObject(workflowsStore.workflow),
|
workflowObject.getNode = vi.fn().mockReturnValue(node);
|
||||||
);
|
|
||||||
|
|
||||||
vi.spyOn(NodeHelpers, 'getNodeOutputs').mockReturnValueOnce([
|
vi.spyOn(NodeHelpers, 'getNodeOutputs').mockReturnValueOnce([
|
||||||
{ type: NodeConnectionType.AiTool },
|
{ type: NodeConnectionType.AiTool },
|
||||||
|
|
|
@ -68,7 +68,7 @@ import {
|
||||||
CONFIGURABLE_NODE_SIZE,
|
CONFIGURABLE_NODE_SIZE,
|
||||||
CONFIGURATION_NODE_SIZE,
|
CONFIGURATION_NODE_SIZE,
|
||||||
DEFAULT_NODE_SIZE,
|
DEFAULT_NODE_SIZE,
|
||||||
GRID_SIZE,
|
generateOffsets,
|
||||||
PUSH_NODES_OFFSET,
|
PUSH_NODES_OFFSET,
|
||||||
} from '@/utils/nodeViewUtils';
|
} from '@/utils/nodeViewUtils';
|
||||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||||
|
@ -926,6 +926,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
lastInteractedWithNode.type,
|
lastInteractedWithNode.type,
|
||||||
lastInteractedWithNode.typeVersion,
|
lastInteractedWithNode.typeVersion,
|
||||||
);
|
);
|
||||||
|
const lastInteractedWithNodeObject = editableWorkflowObject.value.getNode(
|
||||||
|
lastInteractedWithNode.name,
|
||||||
|
);
|
||||||
|
|
||||||
const newNodeInsertPosition = uiStore.lastCancelledConnectionPosition;
|
const newNodeInsertPosition = uiStore.lastCancelledConnectionPosition;
|
||||||
if (newNodeInsertPosition) {
|
if (newNodeInsertPosition) {
|
||||||
|
@ -939,12 +942,38 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
position = [newNodeInsertPosition[0] + xOffset, newNodeInsertPosition[1] + yOffset];
|
position = [newNodeInsertPosition[0] + xOffset, newNodeInsertPosition[1] + yOffset];
|
||||||
|
|
||||||
uiStore.lastCancelledConnectionPosition = undefined;
|
uiStore.lastCancelledConnectionPosition = undefined;
|
||||||
} else if (lastInteractedWithNodeTypeDescription) {
|
} else if (lastInteractedWithNodeTypeDescription && lastInteractedWithNodeObject) {
|
||||||
// When
|
// When
|
||||||
// - clicking the plus button of a node handle
|
// - clicking the plus button of a node handle
|
||||||
// - clicking the plus button of a node edge / connection
|
// - clicking the plus button of a node edge / connection
|
||||||
// - selecting a node, adding a node via the node creator
|
// - selecting a node, adding a node via the node creator
|
||||||
|
|
||||||
|
const lastInteractedWithNodeInputs = NodeHelpers.getNodeInputs(
|
||||||
|
editableWorkflowObject.value,
|
||||||
|
lastInteractedWithNodeObject,
|
||||||
|
lastInteractedWithNodeTypeDescription,
|
||||||
|
);
|
||||||
|
const lastInteractedWithNodeInputTypes = NodeHelpers.getConnectionTypes(
|
||||||
|
lastInteractedWithNodeInputs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastInteractedWithNodeScopedInputTypes = (
|
||||||
|
lastInteractedWithNodeInputTypes || []
|
||||||
|
).filter((input) => input !== NodeConnectionType.Main);
|
||||||
|
|
||||||
|
const lastInteractedWithNodeOutputs = NodeHelpers.getNodeOutputs(
|
||||||
|
editableWorkflowObject.value,
|
||||||
|
lastInteractedWithNodeObject,
|
||||||
|
lastInteractedWithNodeTypeDescription,
|
||||||
|
);
|
||||||
|
const lastInteractedWithNodeOutputTypes = NodeHelpers.getConnectionTypes(
|
||||||
|
lastInteractedWithNodeOutputs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastInteractedWithNodeMainOutputs = lastInteractedWithNodeOutputTypes.filter(
|
||||||
|
(output) => output === NodeConnectionType.Main,
|
||||||
|
);
|
||||||
|
|
||||||
let yOffset = 0;
|
let yOffset = 0;
|
||||||
if (lastInteractedWithNodeConnection) {
|
if (lastInteractedWithNodeConnection) {
|
||||||
// When clicking the plus button of a node edge / connection
|
// When clicking the plus button of a node edge / connection
|
||||||
|
@ -954,35 +983,16 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
shiftDownstreamNodesPosition(lastInteractedWithNode.name, PUSH_NODES_OFFSET, {
|
shiftDownstreamNodesPosition(lastInteractedWithNode.name, PUSH_NODES_OFFSET, {
|
||||||
trackHistory: true,
|
trackHistory: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const yOffsetValuesByOutputCount = [
|
if (lastInteractedWithNodeMainOutputs.length > 1) {
|
||||||
[-nodeSize[1], nodeSize[1]],
|
const yOffsetValues = generateOffsets(
|
||||||
[-nodeSize[1] - 2 * GRID_SIZE, 0, nodeSize[1] - 2 * GRID_SIZE],
|
lastInteractedWithNodeMainOutputs.length,
|
||||||
[
|
NodeViewUtils.NODE_SIZE,
|
||||||
-2 * nodeSize[1] - 2 * GRID_SIZE,
|
NodeViewUtils.GRID_SIZE,
|
||||||
-nodeSize[1],
|
|
||||||
nodeSize[1],
|
|
||||||
2 * nodeSize[1] - 2 * GRID_SIZE,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
const lastInteractedWithNodeOutputs = NodeHelpers.getNodeOutputs(
|
|
||||||
editableWorkflowObject.value,
|
|
||||||
lastInteractedWithNode,
|
|
||||||
lastInteractedWithNodeTypeDescription,
|
|
||||||
);
|
|
||||||
const lastInteractedWithNodeOutputTypes = NodeHelpers.getConnectionTypes(
|
|
||||||
lastInteractedWithNodeOutputs,
|
|
||||||
);
|
|
||||||
const lastInteractedWithNodeMainOutputs = lastInteractedWithNodeOutputTypes.filter(
|
|
||||||
(output) => output === NodeConnectionType.Main,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (lastInteractedWithNodeMainOutputs.length > 1) {
|
yOffset = yOffsetValues[connectionIndex];
|
||||||
const yOffsetValues =
|
|
||||||
yOffsetValuesByOutputCount[lastInteractedWithNodeMainOutputs.length - 2];
|
|
||||||
yOffset = yOffsetValues[connectionIndex];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputs: Array<NodeConnectionType | INodeOutputConfiguration> = [];
|
let outputs: Array<NodeConnectionType | INodeOutputConfiguration> = [];
|
||||||
|
@ -999,31 +1009,16 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
);
|
);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
||||||
const lastInteractedWithNodeObject = editableWorkflowObject.value.getNode(
|
|
||||||
lastInteractedWithNode.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
pushOffsets = [100, 0];
|
pushOffsets = [100, 0];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
outputTypes.length > 0 &&
|
outputTypes.length > 0 &&
|
||||||
outputTypes.every((outputName) => outputName !== NodeConnectionType.Main) &&
|
outputTypes.every((outputName) => outputName !== NodeConnectionType.Main)
|
||||||
lastInteractedWithNodeObject
|
|
||||||
) {
|
) {
|
||||||
// When the added node has only non-main outputs (configuration nodes)
|
// When the added node has only non-main outputs (configuration nodes)
|
||||||
// We want to place the new node directly below the last interacted with node.
|
// We want to place the new node directly below the last interacted with node.
|
||||||
|
|
||||||
const lastInteractedWithNodeInputs = NodeHelpers.getNodeInputs(
|
|
||||||
editableWorkflowObject.value,
|
|
||||||
lastInteractedWithNodeObject,
|
|
||||||
lastInteractedWithNodeTypeDescription,
|
|
||||||
);
|
|
||||||
const lastInteractedWithNodeInputTypes = NodeHelpers.getConnectionTypes(
|
|
||||||
lastInteractedWithNodeInputs,
|
|
||||||
);
|
|
||||||
const lastInteractedWithNodeScopedInputTypes = (
|
|
||||||
lastInteractedWithNodeInputTypes || []
|
|
||||||
).filter((input) => input !== NodeConnectionType.Main);
|
|
||||||
const scopedConnectionIndex = lastInteractedWithNodeScopedInputTypes.findIndex(
|
const scopedConnectionIndex = lastInteractedWithNodeScopedInputTypes.findIndex(
|
||||||
(inputType) => outputs[0] === inputType,
|
(inputType) => outputs[0] === inputType,
|
||||||
);
|
);
|
||||||
|
@ -1044,15 +1039,6 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
// When the node has only main outputs, mixed outputs, or no outputs at all
|
// When the node has only main outputs, mixed outputs, or no outputs at all
|
||||||
// We want to place the new node directly to the right of the last interacted with node.
|
// We want to place the new node directly to the right of the last interacted with node.
|
||||||
|
|
||||||
const lastInteractedWithNodeInputs = NodeHelpers.getNodeInputs(
|
|
||||||
editableWorkflowObject.value,
|
|
||||||
lastInteractedWithNode,
|
|
||||||
lastInteractedWithNodeTypeDescription,
|
|
||||||
);
|
|
||||||
const lastInteractedWithNodeInputTypes = NodeHelpers.getConnectionTypes(
|
|
||||||
lastInteractedWithNodeInputs,
|
|
||||||
);
|
|
||||||
|
|
||||||
let pushOffset = PUSH_NODES_OFFSET;
|
let pushOffset = PUSH_NODES_OFFSET;
|
||||||
if (
|
if (
|
||||||
!!lastInteractedWithNodeInputTypes.find((input) => input !== NodeConnectionType.Main)
|
!!lastInteractedWithNodeInputTypes.find((input) => input !== NodeConnectionType.Main)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { getGenericHints } from '../nodeViewUtils';
|
import { generateOffsets, getGenericHints } from '../nodeViewUtils';
|
||||||
import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow';
|
import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
import { NodeHelpers } from 'n8n-workflow';
|
import { NodeHelpers } from 'n8n-workflow';
|
||||||
|
@ -137,3 +137,35 @@ describe('getGenericHints', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('generateOffsets', () => {
|
||||||
|
it('should return correct offsets for 0 nodes', () => {
|
||||||
|
const result = generateOffsets(0, 100, 20);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct offsets for 1 node', () => {
|
||||||
|
const result = generateOffsets(1, 100, 20);
|
||||||
|
expect(result).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct offsets for 2 nodes', () => {
|
||||||
|
const result = generateOffsets(2, 100, 20);
|
||||||
|
expect(result).toEqual([-100, 100]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct offsets for 3 nodes', () => {
|
||||||
|
const result = generateOffsets(3, 100, 20);
|
||||||
|
expect(result).toEqual([-120, 0, 120]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct offsets for 4 nodes', () => {
|
||||||
|
const result = generateOffsets(4, 100, 20);
|
||||||
|
expect(result).toEqual([-220, -100, 100, 220]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -1289,3 +1289,34 @@ export function getGenericHints({
|
||||||
|
|
||||||
return nodeHints;
|
return nodeHints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate vertical insertion offsets for the given node count
|
||||||
|
*
|
||||||
|
* 2 nodes -> [-nodeSize, nodeSize],
|
||||||
|
* 3 nodes -> [-nodeSize - 2 * gridSize, 0, nodeSize + 2 * gridSize],
|
||||||
|
* 4 nodes -> [-2 * nodeSize - 2 * gridSize, -nodeSize, nodeSize, 2 * nodeSize + 2 * gridSize]
|
||||||
|
* 5 nodes -> [-2 * nodeSize - 2 * gridSize, -nodeSize, 0, nodeSize, 2 * nodeSize + 2 * gridSize]
|
||||||
|
*/
|
||||||
|
export function generateOffsets(nodeCount: number, nodeSize: number, gridSize: number) {
|
||||||
|
const offsets = [];
|
||||||
|
const half = Math.floor(nodeCount / 2);
|
||||||
|
const isOdd = nodeCount % 2 === 1;
|
||||||
|
|
||||||
|
if (nodeCount === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = -half; i <= half; i++) {
|
||||||
|
if (i === 0) {
|
||||||
|
if (isOdd) {
|
||||||
|
offsets.push(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const offset = i * nodeSize + Math.sign(i) * (Math.abs(i) - (isOdd ? 0 : 1)) * gridSize;
|
||||||
|
offsets.push(offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return offsets;
|
||||||
|
}
|
||||||
|
|
|
@ -161,6 +161,7 @@ import {
|
||||||
getConnectorPaintStyleData,
|
getConnectorPaintStyleData,
|
||||||
OVERLAY_ENDPOINT_ARROW_ID,
|
OVERLAY_ENDPOINT_ARROW_ID,
|
||||||
getEndpointScope,
|
getEndpointScope,
|
||||||
|
generateOffsets,
|
||||||
} from '@/utils/nodeViewUtils';
|
} from '@/utils/nodeViewUtils';
|
||||||
import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks';
|
import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
|
@ -2275,12 +2276,6 @@ export default defineComponent({
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sourceNodeType) {
|
if (sourceNodeType) {
|
||||||
const offsets = [
|
|
||||||
[-100, 100],
|
|
||||||
[-140, 0, 140],
|
|
||||||
[-240, -100, 100, 240],
|
|
||||||
];
|
|
||||||
|
|
||||||
const sourceNodeOutputs = NodeHelpers.getNodeOutputs(
|
const sourceNodeOutputs = NodeHelpers.getNodeOutputs(
|
||||||
workflow,
|
workflow,
|
||||||
lastSelectedNode,
|
lastSelectedNode,
|
||||||
|
@ -2293,7 +2288,11 @@ export default defineComponent({
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sourceNodeOutputMainOutputs.length > 1) {
|
if (sourceNodeOutputMainOutputs.length > 1) {
|
||||||
const offset = offsets[sourceNodeOutputMainOutputs.length - 2];
|
const offset = generateOffsets(
|
||||||
|
sourceNodeOutputMainOutputs.length,
|
||||||
|
NodeViewUtils.NODE_SIZE,
|
||||||
|
NodeViewUtils.GRID_SIZE,
|
||||||
|
);
|
||||||
const sourceOutputIndex = lastSelectedConnection.__meta
|
const sourceOutputIndex = lastSelectedConnection.__meta
|
||||||
? lastSelectedConnection.__meta.sourceOutputIndex
|
? lastSelectedConnection.__meta.sourceOutputIndex
|
||||||
: 0;
|
: 0;
|
||||||
|
|
Loading…
Reference in a new issue