mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -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', () => {
|
||||
const uiStore = mockedStore(useUIStore);
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
const node = createTestNode({ id: '0' });
|
||||
const nodeTypeDescription = mockNodeTypeDescription();
|
||||
|
||||
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.lastCancelledConnectionPosition = [200, 200];
|
||||
|
@ -307,6 +313,7 @@ describe('useCanvasOperations', () => {
|
|||
|
||||
const node = createTestNode({ id: '0' });
|
||||
const nodeTypeDescription = mockNodeTypeDescription();
|
||||
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
|
||||
|
||||
uiStore.lastInteractedWithNode = createTestNode({
|
||||
position: [100, 100],
|
||||
|
@ -314,9 +321,8 @@ describe('useCanvasOperations', () => {
|
|||
typeVersion: 1,
|
||||
});
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription);
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
||||
createTestWorkflowObject(workflowsStore.workflow),
|
||||
);
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject);
|
||||
workflowObject.getNode = vi.fn().mockReturnValue(node);
|
||||
|
||||
const { resolveNodePosition } = useCanvasOperations({ router });
|
||||
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription);
|
||||
|
@ -331,6 +337,7 @@ describe('useCanvasOperations', () => {
|
|||
|
||||
const node = createTestNode({ id: '0' });
|
||||
const nodeTypeDescription = mockNodeTypeDescription();
|
||||
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
|
||||
|
||||
uiStore.lastInteractedWithNode = createTestNode({
|
||||
position: [100, 100],
|
||||
|
@ -338,9 +345,8 @@ describe('useCanvasOperations', () => {
|
|||
typeVersion: 1,
|
||||
});
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription);
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
||||
createTestWorkflowObject(workflowsStore.workflow),
|
||||
);
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject);
|
||||
workflowObject.getNode = vi.fn().mockReturnValue(node);
|
||||
|
||||
vi.spyOn(NodeHelpers, 'getNodeOutputs').mockReturnValueOnce([
|
||||
{ type: NodeConnectionType.AiTool },
|
||||
|
|
|
@ -68,7 +68,7 @@ import {
|
|||
CONFIGURABLE_NODE_SIZE,
|
||||
CONFIGURATION_NODE_SIZE,
|
||||
DEFAULT_NODE_SIZE,
|
||||
GRID_SIZE,
|
||||
generateOffsets,
|
||||
PUSH_NODES_OFFSET,
|
||||
} from '@/utils/nodeViewUtils';
|
||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||
|
@ -926,6 +926,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||
lastInteractedWithNode.type,
|
||||
lastInteractedWithNode.typeVersion,
|
||||
);
|
||||
const lastInteractedWithNodeObject = editableWorkflowObject.value.getNode(
|
||||
lastInteractedWithNode.name,
|
||||
);
|
||||
|
||||
const newNodeInsertPosition = uiStore.lastCancelledConnectionPosition;
|
||||
if (newNodeInsertPosition) {
|
||||
|
@ -939,12 +942,38 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||
position = [newNodeInsertPosition[0] + xOffset, newNodeInsertPosition[1] + yOffset];
|
||||
|
||||
uiStore.lastCancelledConnectionPosition = undefined;
|
||||
} else if (lastInteractedWithNodeTypeDescription) {
|
||||
} else if (lastInteractedWithNodeTypeDescription && lastInteractedWithNodeObject) {
|
||||
// When
|
||||
// - clicking the plus button of a node handle
|
||||
// - clicking the plus button of a node edge / connection
|
||||
// - 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;
|
||||
if (lastInteractedWithNodeConnection) {
|
||||
// When clicking the plus button of a node edge / connection
|
||||
|
@ -954,36 +983,17 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||
shiftDownstreamNodesPosition(lastInteractedWithNode.name, PUSH_NODES_OFFSET, {
|
||||
trackHistory: true,
|
||||
});
|
||||
|
||||
const yOffsetValuesByOutputCount = [
|
||||
[-nodeSize[1], nodeSize[1]],
|
||||
[-nodeSize[1] - 2 * GRID_SIZE, 0, nodeSize[1] - 2 * GRID_SIZE],
|
||||
[
|
||||
-2 * nodeSize[1] - 2 * 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) {
|
||||
const yOffsetValues =
|
||||
yOffsetValuesByOutputCount[lastInteractedWithNodeMainOutputs.length - 2];
|
||||
const yOffsetValues = generateOffsets(
|
||||
lastInteractedWithNodeMainOutputs.length,
|
||||
NodeViewUtils.NODE_SIZE,
|
||||
NodeViewUtils.GRID_SIZE,
|
||||
);
|
||||
|
||||
yOffset = yOffsetValues[connectionIndex];
|
||||
}
|
||||
}
|
||||
|
||||
let outputs: Array<NodeConnectionType | INodeOutputConfiguration> = [];
|
||||
try {
|
||||
|
@ -999,31 +1009,16 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||
);
|
||||
} catch (e) {}
|
||||
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
||||
const lastInteractedWithNodeObject = editableWorkflowObject.value.getNode(
|
||||
lastInteractedWithNode.name,
|
||||
);
|
||||
|
||||
pushOffsets = [100, 0];
|
||||
|
||||
if (
|
||||
outputTypes.length > 0 &&
|
||||
outputTypes.every((outputName) => outputName !== NodeConnectionType.Main) &&
|
||||
lastInteractedWithNodeObject
|
||||
outputTypes.every((outputName) => outputName !== NodeConnectionType.Main)
|
||||
) {
|
||||
// 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.
|
||||
|
||||
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(
|
||||
(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
|
||||
// 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;
|
||||
if (
|
||||
!!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 { INodeUi } from '@/Interface';
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
OVERLAY_ENDPOINT_ARROW_ID,
|
||||
getEndpointScope,
|
||||
generateOffsets,
|
||||
} from '@/utils/nodeViewUtils';
|
||||
import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
|
@ -2275,12 +2276,6 @@ export default defineComponent({
|
|||
);
|
||||
|
||||
if (sourceNodeType) {
|
||||
const offsets = [
|
||||
[-100, 100],
|
||||
[-140, 0, 140],
|
||||
[-240, -100, 100, 240],
|
||||
];
|
||||
|
||||
const sourceNodeOutputs = NodeHelpers.getNodeOutputs(
|
||||
workflow,
|
||||
lastSelectedNode,
|
||||
|
@ -2293,7 +2288,11 @@ export default defineComponent({
|
|||
);
|
||||
|
||||
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
|
||||
? lastSelectedConnection.__meta.sourceOutputIndex
|
||||
: 0;
|
||||
|
|
Loading…
Reference in a new issue