feat(editor): Improve insertion algorithm for nodes with multiple main outputs (no-changelog) (#11213)

This commit is contained in:
Alex Grozav 2024-10-11 17:03:58 +03:00 committed by GitHub
parent 98759701e4
commit c9628de72b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 121 additions and 67 deletions

View file

@ -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 },

View file

@ -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,35 +983,16 @@ 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 = generateOffsets(
lastInteractedWithNodeMainOutputs.length,
NodeViewUtils.NODE_SIZE,
NodeViewUtils.GRID_SIZE,
);
if (lastInteractedWithNodeMainOutputs.length > 1) {
const yOffsetValues =
yOffsetValuesByOutputCount[lastInteractedWithNodeMainOutputs.length - 2];
yOffset = yOffsetValues[connectionIndex];
}
yOffset = yOffsetValues[connectionIndex];
}
let outputs: Array<NodeConnectionType | INodeOutputConfiguration> = [];
@ -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)

View file

@ -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]);
});
});

View file

@ -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;
}

View file

@ -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;