fix: Center pasted nodes in the canvas viewport (#39995)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-09-30 13:00:49 +00:00 committed by GitHub
parent 902b039c4d
commit bd9e1d8947
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 79 additions and 26 deletions

View File

@ -6258,6 +6258,48 @@ describe('useCanvasOperations', () => {
connections: {},
};
it('centers a pasted node in the viewport instead of using the last click', async () => {
const nodes = [createTestNode({ name: 'Pasted', position: [100, 100] })];
vi.mocked(workflowDocumentStoreInstance.createWorkflowObject).mockImplementation(
(importedNodes, connections) =>
createTestWorkflowObject({ nodes: importedNodes, connections }),
);
const canvasOperations = useCanvasOperations();
canvasOperations.lastClickPosition.value = [900, 900];
const result = await canvasOperations.importWorkflowData(
{ nodes, connections: {} },
'paste',
{ viewport: { xMin: 0, yMin: 0, xMax: 1000, yMax: 1000 }, trackEvents: false },
);
expect(result.nodes?.[0].position).toEqual([464, 464]);
});
it('centers the bounding box when pasting nodes with different leftmost and topmost nodes', async () => {
const nodes = [
createTestNode({ name: 'Top', position: [400, 0] }),
createTestNode({ name: 'Left', position: [0, 200] }),
];
vi.mocked(workflowDocumentStoreInstance.createWorkflowObject).mockImplementation(
(importedNodes, connections) =>
createTestWorkflowObject({ nodes: importedNodes, connections }),
);
const canvasOperations = useCanvasOperations();
canvasOperations.lastClickPosition.value = [900, 900];
const result = await canvasOperations.importWorkflowData(
{ nodes, connections: {} },
'paste',
{ viewport: { xMin: 0, yMin: 0, xMax: 1000, yMax: 1000 }, trackEvents: false },
);
expect(result.nodes?.map((node) => node.position)).toEqual([
[656, 352],
[256, 552],
]);
});
it.each(['paste', 'file', 'url'] as const)(
'strips empty groups before importing from %s when the feature is disabled',
async (source) => {

View File

@ -3378,18 +3378,23 @@ export function useCanvasOperations() {
// Fix the node position as it could be totally offscreen
// and the pasted nodes would so not be directly visible to
// the user
const groupSize =
workflowData.nodes && workflowData.nodes.length > 1
? getNodesGroupSize(workflowData.nodes)
: DEFAULT_NODE_SIZE;
const pastePosition: XYPosition =
source === 'paste' && viewport
? [
(viewport.xMin + viewport.xMax - groupSize[0]) / 2,
(viewport.yMin + viewport.yMax - groupSize[1]) / 2,
]
: lastClickPosition.value;
workflowHelpers.updateNodePositions(
workflowData,
NodeViewUtils.getNewNodePosition(
workflowDocumentStore.value.allNodes,
lastClickPosition.value,
{
...(workflowData.nodes && workflowData.nodes.length > 1
? { size: getNodesGroupSize(workflowData.nodes) }
: {}),
viewport,
},
),
NodeViewUtils.getNewNodePosition(workflowDocumentStore.value.allNodes, pastePosition, {
size: groupSize,
viewport,
}),
);
if (ownsImportBulk) {

View File

@ -61,6 +61,22 @@ describe('useWorkflowHelpers', () => {
workflowsStore.workflowId = '';
});
describe('updateNodePositions', () => {
it('places the bounding-box top-left at the requested position', () => {
const nodes = [
createTestNode({ name: 'Top', position: [400, 0] }),
createTestNode({ name: 'Left', position: [0, 200] }),
];
useWorkflowHelpers().updateNodePositions({ nodes, connections: {} }, [256, 352]);
expect(nodes.map((node) => node.position)).toEqual([
[656, 352],
[256, 552],
]);
});
});
describe('getNodeParametersWithResolvedExpressions', () => {
it('should correctly detect and resolve expressions in a regular node ', async () => {
const nodeParameters = {

View File

@ -738,32 +738,22 @@ export function useWorkflowHelpers() {
}
}
// Updates the position of all the nodes that the top-left node
// is at the given position
// Updates all node positions so the group starts at the given position
function updateNodePositions(
workflowData: WorkflowData | WorkflowDataUpdate,
position: XYPosition,
): void {
if (workflowData.nodes === undefined) {
if (!workflowData.nodes?.length) {
return;
}
// Find most top-left node
const minPosition = [99999999, 99999999];
const minPosition = [Infinity, Infinity];
for (const node of workflowData.nodes) {
if (node.position[1] < minPosition[1]) {
minPosition[0] = node.position[0];
minPosition[1] = node.position[1];
} else if (node.position[1] === minPosition[1]) {
if (node.position[0] < minPosition[0]) {
minPosition[0] = node.position[0];
minPosition[1] = node.position[1];
}
}
minPosition[0] = Math.min(minPosition[0], node.position[0]);
minPosition[1] = Math.min(minPosition[1], node.position[1]);
}
// Update the position on all nodes so that the
// most top-left one is at given position
// Shift the group's bounding-box top-left to the given position
const offsetPosition = [position[0] - minPosition[0], position[1] - minPosition[1]];
for (const node of workflowData.nodes) {
node.position[0] += offsetPosition[0];