feat(editor): Improve canvas node insertion position (#14289)

This commit is contained in:
Alex Grozav
2025-05-13 19:38:10 +03:00
committed by GitHub
parent 3eb1c1c783
commit 102c67628c
10 changed files with 406 additions and 96 deletions

View File

@@ -218,6 +218,7 @@ describe('Undo/Redo', () => {
WorkflowPage.getters.nodeConnections().should('have.length', 1);
cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')).should('have.length', 1);
cy.wait(1000); // Clipboard paste is throttled
cy.fixture('Test_workflow_form_switch.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
});

View File

@@ -383,9 +383,12 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.zoomOutButton().click();
WorkflowPage.getters.zoomOutButton().click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
// At this point last added node should be off-screen
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters.canvasNodes().last().should('not.be.visible');
WorkflowPage.getters.zoomToFitButton().click();
WorkflowPage.getters.canvasNodes().last().should('be.visible');

View File

@@ -19,7 +19,7 @@ import type {
CanvasNodeData,
} from '@/types';
import { CanvasNodeRenderType } from '@/types';
import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { getMousePosition, GRID_SIZE } from '@/utils/nodeViewUtils';
import { isPresent } from '@/utils/typesUtils';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
@@ -27,9 +27,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import type {
Connection,
Dimensions,
GraphNode,
NodeDragEvent,
NodeMouseEvent,
ViewportTransform,
XYPosition,
} from '@vue-flow/core';
import { MarkerType, PanelPosition, useVueFlow, VueFlow } from '@vue-flow/core';
@@ -67,7 +69,7 @@ const emit = defineEmits<{
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
'update:node:inputs': [id: string];
'update:node:outputs': [id: string];
'click:node': [id: string];
'click:node': [id: string, position: XYPosition];
'click:node:add': [id: string, handle: string];
'run:node': [id: string];
'delete:node': [id: string];
@@ -95,6 +97,8 @@ const emit = defineEmits<{
'create:workflow': [];
'drag-and-drop': [position: XYPosition, event: DragEvent];
'tidy-up': [CanvasLayoutEvent];
'viewport:change': [viewport: ViewportTransform, dimensions: Dimensions];
'selection:end': [position: XYPosition];
'open:sub-workflow': [nodeId: string];
}>();
@@ -143,6 +147,7 @@ const {
onNodesInitialized,
findNode,
viewport,
dimensions,
nodesSelectionActive,
setViewport,
onEdgeMouseLeave,
@@ -351,7 +356,7 @@ function onNodeDragStop(event: NodeDragEvent) {
}
function onNodeClick({ event, node }: NodeMouseEvent) {
emit('click:node', node.id);
emit('click:node', node.id, getProjectedPosition(event));
if (event.ctrlKey || event.metaKey || selectedNodes.value.length < 2) {
return;
@@ -364,10 +369,12 @@ function onSelectionDragStop(event: NodeDragEvent) {
onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position })));
}
function onSelectionEnd() {
function onSelectionEnd(event: MouseEvent) {
if (selectedNodes.value.length === 1) {
nodesSelectionActive.value = false;
}
emit('selection:end', getProjectedPosition(event));
}
function onSetNodeActivated(id: string, event?: MouseEvent) {
@@ -543,10 +550,9 @@ const isPaneMoving = ref(false);
useViewportAutoAdjust(viewportRef, viewport, setViewport);
function getProjectedPosition(event?: Pick<MouseEvent, 'clientX' | 'clientY'>) {
function getProjectedPosition(event?: MouseEvent | TouchEvent) {
const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 };
const offsetX = event?.clientX ?? 0;
const offsetY = event?.clientY ?? 0;
const [offsetX, offsetY] = event ? getMousePosition(event) : [0, 0];
return project({
x: offsetX - bounds.left,
@@ -591,6 +597,10 @@ function onPaneMoveEnd() {
isPaneMoving.value = false;
}
function onViewportChange() {
emit('viewport:change', viewport.value, dimensions.value);
}
// #AI-716: Due to a bug in vue-flow reactivity, the node data is not updated when the node is added
// resulting in outdated data. We use this computed property as a workaround to get the latest node data.
const nodeDataById = computed(() => {
@@ -828,6 +838,7 @@ provide(CanvasKey, {
@selection-context-menu="onOpenSelectionContextMenu"
@dragover="onDragOver"
@drop="onDrop"
@viewport-change="onViewportChange"
>
<template #node-canvas-node="nodeProps">
<slot name="node" v-bind="{ nodeProps }">

View File

@@ -2990,13 +2990,13 @@ describe('useCanvasOperations', () => {
});
expect(workflowsStore.setConnections).toHaveBeenCalledWith(workflow.connections);
expect(workflowsStore.addNode).toHaveBeenCalledWith({
expect(workflowsStore.addNode).toHaveBeenNthCalledWith(1, {
...nodeA,
credentials: {},
disabled: false,
});
expect(workflowsStore.setNodePristine).toHaveBeenCalledWith(nodeA.name, true);
expect(workflowsStore.addNode).toHaveBeenCalledWith({
expect(workflowsStore.addNode).toHaveBeenNthCalledWith(2, {
...nodeB,
credentials: {},
disabled: false,

View File

@@ -58,6 +58,7 @@ import type {
CanvasConnectionPort,
CanvasNode,
CanvasNodeMoveEvent,
ViewportBoundaries,
} from '@/types';
import { CanvasConnectionMode } from '@/types';
import {
@@ -72,7 +73,9 @@ import {
CONFIGURABLE_NODE_SIZE,
CONFIGURATION_NODE_SIZE,
DEFAULT_NODE_SIZE,
DEFAULT_VIEWPORT_BOUNDARIES,
generateOffsets,
getNodesGroupSize,
PUSH_NODES_OFFSET,
} from '@/utils/nodeViewUtils';
import type { Connection } from '@vue-flow/core';
@@ -116,6 +119,7 @@ type AddNodesBaseOptions = {
trackHistory?: boolean;
keepPristine?: boolean;
telemetry?: boolean;
viewport?: ViewportBoundaries;
};
type AddNodesOptions = AddNodesBaseOptions & {
@@ -526,7 +530,10 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
);
}
async function addNodes(nodes: AddedNodesAndConnections['nodes'], options: AddNodesOptions = {}) {
async function addNodes(
nodes: AddedNodesAndConnections['nodes'],
{ viewport, ...options }: AddNodesOptions = {},
) {
let insertPosition = options.position;
let lastAddedNode: INodeUi | undefined;
const addedNodes: INodeUi[] = [];
@@ -546,7 +553,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
historyStore.startRecordingUndo();
}
for (const nodeAddData of nodesWithTypeVersion) {
for (const [index, nodeAddData] of nodesWithTypeVersion.entries()) {
const { isAutoAdd, openDetail: openNDV, ...node } = nodeAddData;
const position = node.position ?? insertPosition;
const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
@@ -560,6 +567,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
nodeTypeDescription,
{
...options,
...(index === 0 ? { viewport } : {}),
openNDV,
isAutoAdd,
},
@@ -634,7 +642,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
): INodeUi {
checkMaxNodesOfTypeReached(nodeTypeDescription);
const nodeData = resolveNodeData(node, nodeTypeDescription);
const nodeData = resolveNodeData(node, nodeTypeDescription, {
viewport: options.viewport,
});
if (!nodeData) {
throw new Error(i18n.baseText('nodeViewV2.showError.failedToCreateNode'));
}
@@ -797,12 +807,15 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
function resolveNodeData(
node: AddNodeDataWithTypeVersion,
nodeTypeDescription: INodeTypeDescription,
options: { viewport?: ViewportBoundaries } = {},
) {
const id = node.id ?? nodeHelpers.assignNodeId(node as INodeUi);
const name = node.name ?? (nodeTypeDescription.defaults.name as string);
const type = nodeTypeDescription.name;
const typeVersion = node.typeVersion;
const position = resolveNodePosition(node as INodeUi, nodeTypeDescription);
const position = resolveNodePosition(node as INodeUi, nodeTypeDescription, {
viewport: options.viewport,
});
const disabled = node.disabled ?? false;
const parameters = node.parameters ?? {};
@@ -883,14 +896,8 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
function resolveNodePosition(
node: Omit<INodeUi, 'position'> & { position?: INodeUi['position'] },
nodeTypeDescription: INodeTypeDescription,
options: { viewport?: ViewportBoundaries } = {},
) {
let position: XYPosition | undefined = node.position;
let pushOffsets: XYPosition = [40, 40];
if (position) {
return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position, pushOffsets);
}
// Available when
// - clicking the plus button of a node handle
// - dragging an edge / connection of a node handle
@@ -907,6 +914,17 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
const nodeSize =
connectionType === NodeConnectionTypes.Main ? DEFAULT_NODE_SIZE : CONFIGURATION_NODE_SIZE;
let pushOffsets: XYPosition = [nodeSize[0] / 2, nodeSize[1] / 2];
let position: XYPosition | undefined = node.position;
if (position) {
return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position, {
offset: pushOffsets,
size: nodeSize,
viewport: options.viewport,
normalize: false,
});
}
if (lastInteractedWithNode) {
const lastInteractedWithNodeTypeDescription = nodeTypesStore.getNodeType(
@@ -1056,7 +1074,11 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
}
}
return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position, pushOffsets);
return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position, {
offset: pushOffsets,
size: nodeSize,
viewport: options.viewport,
});
}
function resolveNodeName(node: INodeUi) {
@@ -1503,7 +1525,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
async function addImportedNodesToWorkflow(
data: IWorkflowDataUpdate,
{ trackBulk = true, trackHistory = false } = {},
{ trackBulk = true, trackHistory = false, viewport = DEFAULT_VIEWPORT_BOUNDARIES } = {},
): Promise<IWorkflowDataUpdate> {
// Because nodes with the same name maybe already exist, it could
// be needed that they have to be renamed. Also could it be possible
@@ -1650,7 +1672,11 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
historyStore.startRecordingUndo();
}
await addNodes(Object.values(tempWorkflow.nodes), { trackBulk: false, trackHistory });
await addNodes(Object.values(tempWorkflow.nodes), {
trackBulk: false,
trackHistory,
viewport,
});
await addConnections(
mapLegacyConnectionsToCanvasConnections(
tempWorkflow.connectionsBySourceNode,
@@ -1674,8 +1700,17 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
async function importWorkflowData(
workflowData: IWorkflowDataUpdate,
source: string,
importTags = true,
{ trackBulk = true, trackHistory = true } = {},
{
importTags = true,
trackBulk = true,
trackHistory = true,
viewport,
}: {
importTags?: boolean;
trackBulk?: boolean;
trackHistory?: boolean;
viewport?: ViewportBoundaries;
} = {},
): Promise<IWorkflowDataUpdate> {
uiStore.resetLastInteractedWith();
@@ -1767,10 +1802,19 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
// the user
workflowHelpers.updateNodePositions(
workflowData,
NodeViewUtils.getNewNodePosition(editableWorkflow.value.nodes, lastClickPosition.value),
NodeViewUtils.getNewNodePosition(editableWorkflow.value.nodes, lastClickPosition.value, {
...(workflowData.nodes && workflowData.nodes.length > 1
? { size: getNodesGroupSize(workflowData.nodes) }
: {}),
viewport,
}),
);
await addImportedNodesToWorkflow(workflowData, { trackBulk, trackHistory });
await addImportedNodesToWorkflow(workflowData, {
trackBulk,
trackHistory,
viewport,
});
if (importTags && settingsStore.areTagsEnabled && Array.isArray(workflowData.tags)) {
await importWorkflowTags(workflowData);
@@ -1920,9 +1964,12 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
return filteredConnections;
}
async function duplicateNodes(ids: string[]) {
async function duplicateNodes(ids: string[], options: { viewport?: ViewportBoundaries } = {}) {
const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids)));
const result = await importWorkflowData(workflowData, 'duplicate', false);
const result = await importWorkflowData(workflowData, 'duplicate', {
viewport: options.viewport,
importTags: false,
});
return result.nodes?.map((node) => node.id).filter(isPresent) ?? [];
}

View File

@@ -1,6 +1,5 @@
import { computed, inject, onBeforeUnmount, onMounted, ref, unref } from 'vue';
import { useClipboard as useClipboardCore } from '@vueuse/core';
import { useDebounce } from '@/composables/useDebounce';
import { useClipboard as useClipboardCore, useThrottleFn } from '@vueuse/core';
import { IsInPiPWindowSymbol } from '@/constants';
type ClipboardEventFn = (data: string, event?: ClipboardEvent) => void;
@@ -12,7 +11,6 @@ export function useClipboard(
onPaste() {},
},
) {
const { debounce } = useDebounce();
const isInPiPWindow = inject(IsInPiPWindowSymbol, false);
const { copy, copied, isSupported, text } = useClipboardCore({ legacy: true });
@@ -49,9 +47,7 @@ export function useClipboard(
}
}
const debouncedOnPaste = debounce(onPaste, {
debounceTime: 1000,
});
const throttledOnPaste = useThrottleFn(onPaste, 1000);
/**
* Initialize copy/paste elements and events
@@ -61,7 +57,7 @@ export function useClipboard(
return;
}
document.addEventListener('paste', debouncedOnPaste);
document.addEventListener('paste', throttledOnPaste);
initialized.value = true;
});
@@ -71,7 +67,7 @@ export function useClipboard(
*/
onBeforeUnmount(() => {
if (initialized.value) {
document.removeEventListener('paste', debouncedOnPaste);
document.removeEventListener('paste', throttledOnPaste);
}
});

View File

@@ -229,3 +229,10 @@ export type BoundingBox = {
width: number;
height: number;
};
export type ViewportBoundaries = {
xMin: number;
xMax: number;
yMin: number;
yMax: number;
};

View File

@@ -1,4 +1,14 @@
import { generateOffsets, getGenericHints, getNewNodePosition } from './nodeViewUtils';
import {
getLeftMostNode,
getTopMostNode,
getRightMostNode,
getBottomMostNode,
getNodesGroupSize,
generateOffsets,
getGenericHints,
getNewNodePosition,
NODE_SIZE,
} from './nodeViewUtils';
import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow';
import type { INodeUi, XYPosition } from '@/Interface';
import { NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
@@ -241,7 +251,7 @@ describe('getNewNodePosition', () => {
];
const newPosition: XYPosition = [100, 100];
const result = getNewNodePosition(nodes, newPosition);
expect(result).toEqual([180, 180]);
expect(result).toEqual([220, 220]);
});
it('should skip nodes in the conflict allowlist', () => {
@@ -259,8 +269,10 @@ describe('getNewNodePosition', () => {
];
const newPosition: XYPosition = [100, 100];
const movePosition: XYPosition = [50, 50];
const result = getNewNodePosition(nodes, newPosition, movePosition);
expect(result).toEqual([200, 200]);
const result = getNewNodePosition(nodes, newPosition, {
offset: movePosition,
});
expect(result).toEqual([220, 220]);
});
it('should handle multiple conflicts correctly', () => {
@@ -270,6 +282,118 @@ describe('getNewNodePosition', () => {
];
const newPosition: XYPosition = [100, 100];
const result = getNewNodePosition(nodes, newPosition);
expect(result).toEqual([220, 220]);
expect(result).toEqual([280, 280]);
});
});
const testNodes: INode[] = [
createTestNode({ id: 'a', position: [0, 0] }),
createTestNode({ id: 'b', position: [100, 50] }),
createTestNode({ id: 'c', position: [50, 100] }),
createTestNode({ id: 'd', position: [-20, -10] }),
];
describe('getLeftMostNode', () => {
it('should return the leftmost node', () => {
const left = getLeftMostNode(testNodes);
expect(left).toEqual(testNodes[3]);
});
it('should handle a single node', () => {
const single = [testNodes[0]];
expect(getLeftMostNode(single)).toEqual(testNodes[0]);
});
it('should handle nodes with equal positions', () => {
const equalNodes: INode[] = [
createTestNode({ id: 'x', position: [10, 20] }),
createTestNode({ id: 'y', position: [10, 20] }),
];
expect(getLeftMostNode(equalNodes)).toEqual(equalNodes[0]);
});
});
describe('getRightMostNode', () => {
it('should return the rightmost node', () => {
const right = getRightMostNode(testNodes);
expect(right).toEqual(testNodes[1]);
});
it('should handle a single node', () => {
const single = [testNodes[0]];
expect(getRightMostNode(single)).toEqual(testNodes[0]);
});
it('should handle nodes with equal positions', () => {
const equalNodes: INode[] = [
createTestNode({ id: 'x', position: [10, 20] }),
createTestNode({ id: 'y', position: [10, 20] }),
];
expect(getRightMostNode(equalNodes)).toEqual(equalNodes[0]);
});
});
describe('getTopMostNode', () => {
it('should return the topmost node', () => {
const top = getTopMostNode(testNodes);
expect(top).toEqual(testNodes[3]);
});
it('should handle a single node', () => {
const single = [testNodes[0]];
expect(getTopMostNode(single)).toEqual(testNodes[0]);
});
it('should handle nodes with equal positions', () => {
const equalNodes: INode[] = [
createTestNode({ id: 'x', position: [10, 20] }),
createTestNode({ id: 'y', position: [10, 20] }),
];
expect(getTopMostNode(equalNodes)).toEqual(equalNodes[0]);
});
});
describe('getBottomMostNode', () => {
it('should return the bottommost node', () => {
const bottom = getBottomMostNode(testNodes);
expect(bottom).toEqual(testNodes[2]);
});
it('should handle a single node', () => {
const single = [testNodes[0]];
expect(getBottomMostNode(single)).toEqual(testNodes[0]);
});
it('should handle nodes with equal positions', () => {
const equalNodes: INode[] = [
createTestNode({ id: 'x', position: [10, 20] }),
createTestNode({ id: 'y', position: [10, 20] }),
];
expect(getBottomMostNode(equalNodes)).toEqual(equalNodes[0]);
});
});
describe('getNodesGroupSize', () => {
it('calculates the group size correctly', () => {
const [width, height] = getNodesGroupSize(testNodes);
expect(width).toBe(Math.abs(100 - -20) + NODE_SIZE);
expect(height).toBe(Math.abs(-10 - 100) + NODE_SIZE);
});
it('should handle a single node', () => {
const single = [testNodes[0]];
const [w, h] = getNodesGroupSize(single);
expect(w).toBe(NODE_SIZE);
expect(h).toBe(NODE_SIZE);
});
it('should handle nodes with equal positions', () => {
const equalNodes: INode[] = [
createTestNode({ id: 'x', position: [10, 20] }),
createTestNode({ id: 'y', position: [10, 20] }),
];
const [we, he] = getNodesGroupSize(equalNodes);
expect(we).toBe(NODE_SIZE);
expect(he).toBe(NODE_SIZE);
});
});

View File

@@ -18,6 +18,7 @@ import type {
} from 'n8n-workflow';
import { NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type { RouteLocation } from 'vue-router';
import type { ViewportBoundaries } from '@/types';
/*
* Canvas constants and functions
@@ -26,18 +27,25 @@ import type { RouteLocation } from 'vue-router';
export const GRID_SIZE = 20;
export const NODE_SIZE = 100;
export const DEFAULT_NODE_SIZE = [100, 100];
export const CONFIGURATION_NODE_SIZE = [80, 80];
export const CONFIGURABLE_NODE_SIZE = [256, 100];
export const DEFAULT_NODE_SIZE: [number, number] = [100, 100];
export const CONFIGURATION_NODE_SIZE: [number, number] = [80, 80];
export const CONFIGURABLE_NODE_SIZE: [number, number] = [256, 100];
export const DEFAULT_START_POSITION_X = 180;
export const DEFAULT_START_POSITION_Y = 240;
export const HEADER_HEIGHT = 65;
export const MAX_X_TO_PUSH_DOWNSTREAM_NODES = 300;
export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE;
export const DEFAULT_VIEWPORT_BOUNDARIES: ViewportBoundaries = {
xMin: -Infinity,
yMin: -Infinity,
xMax: Infinity,
yMax: Infinity,
};
/**
* Returns the leftmost and topmost node from the given list of nodes
* Utility functions for returning nodes found at the edges of a group
*/
export const getLeftmostTopNode = <T extends { position: XYPosition }>(nodes: T[]): T => {
return nodes.reduce((leftmostTop, node) => {
if (node.position[0] > leftmostTop.position[0] || node.position[1] > leftmostTop.position[1]) {
@@ -48,12 +56,68 @@ export const getLeftmostTopNode = <T extends { position: XYPosition }>(nodes: T[
}, nodes[0]);
};
export const getLeftMostNode = <T extends { position: XYPosition }>(nodes: T[]): T => {
return nodes.reduce((leftmost, node) => {
if (node.position[0] < leftmost.position[0]) {
return node;
}
return leftmost;
}, nodes[0]);
};
export const getTopMostNode = <T extends { position: XYPosition }>(nodes: T[]): T => {
return nodes.reduce((topmost, node) => {
if (node.position[1] < topmost.position[1]) {
return node;
}
return topmost;
}, nodes[0]);
};
export const getRightMostNode = <T extends { position: XYPosition }>(nodes: T[]): T => {
return nodes.reduce((rightmost, node) => {
if (node.position[0] > rightmost.position[0]) {
return node;
}
return rightmost;
}, nodes[0]);
};
export const getBottomMostNode = <T extends { position: XYPosition }>(nodes: T[]): T => {
return nodes.reduce((bottommost, node) => {
if (node.position[1] > bottommost.position[1]) {
return node;
}
return bottommost;
}, nodes[0]);
};
export const getNodesGroupSize = (nodes: INodeUi[]): [number, number] => {
const leftMostNode = getLeftMostNode(nodes);
const topMostNode = getTopMostNode(nodes);
const rightMostNode = getRightMostNode(nodes);
const bottomMostNode = getBottomMostNode(nodes);
const width = Math.abs(rightMostNode.position[0] - leftMostNode.position[0]) + NODE_SIZE;
const height = Math.abs(bottomMostNode.position[1] - topMostNode.position[1]) + NODE_SIZE;
return [width, height];
};
/**
* Checks if the given position is available for a new node
*/
const canUsePosition = (position1: XYPosition, position2: XYPosition) => {
if (Math.abs(position1[0] - position2[0]) <= 100) {
if (Math.abs(position1[1] - position2[1]) <= 50) {
const canUsePosition = (
position1: XYPosition,
position2: XYPosition,
size: [number, number] = DEFAULT_NODE_SIZE,
) => {
if (Math.abs(position1[0] - position2[0]) <= size[0]) {
if (Math.abs(position1[1] - position2[1]) <= size[1]) {
return false;
}
}
@@ -88,52 +152,77 @@ const closestNumberDivisibleBy = (inputNumber: number, divisibleBy: number): num
*/
export const getNewNodePosition = (
nodes: INodeUi[],
newPosition: XYPosition,
movePosition?: XYPosition,
initialPosition: XYPosition,
{
offset = [DEFAULT_NODE_SIZE[0] / 2, DEFAULT_NODE_SIZE[1] / 2],
size = DEFAULT_NODE_SIZE,
viewport = DEFAULT_VIEWPORT_BOUNDARIES,
normalize = true,
}: {
offset?: XYPosition;
size?: [number, number];
viewport?: ViewportBoundaries;
normalize?: boolean;
} = {},
): XYPosition => {
const targetPosition: XYPosition = [...newPosition];
const resolvedOffset = [...offset];
resolvedOffset[0] = closestNumberDivisibleBy(resolvedOffset[0], GRID_SIZE);
resolvedOffset[1] = closestNumberDivisibleBy(resolvedOffset[1], GRID_SIZE);
targetPosition[0] = closestNumberDivisibleBy(targetPosition[0], GRID_SIZE);
targetPosition[1] = closestNumberDivisibleBy(targetPosition[1], GRID_SIZE);
const resolvedPosition: XYPosition = [...initialPosition];
resolvedPosition[0] = closestNumberDivisibleBy(resolvedPosition[0], GRID_SIZE);
resolvedPosition[1] = closestNumberDivisibleBy(resolvedPosition[1], GRID_SIZE);
if (!movePosition) {
movePosition = [40, 40];
if (normalize) {
let conflictFound = false;
let i, node;
do {
conflictFound = false;
for (i = 0; i < nodes.length; i++) {
node = nodes[i];
if (!node || NODE_POSITION_CONFLICT_ALLOWLIST.includes(node.type)) {
continue;
}
if (!canUsePosition(node.position, resolvedPosition, size)) {
conflictFound = true;
break;
}
}
if (conflictFound) {
resolvedPosition[0] += resolvedOffset[0];
resolvedPosition[1] += resolvedOffset[1];
}
} while (conflictFound);
if (resolvedPosition[0] < viewport.xMin + resolvedOffset[0]) {
resolvedPosition[0] = viewport.xMin + resolvedOffset[0];
}
if (resolvedPosition[1] < viewport.yMin + resolvedOffset[1]) {
resolvedPosition[1] = viewport.yMin + resolvedOffset[1];
}
if (resolvedPosition[0] > viewport.xMax - resolvedOffset[0]) {
resolvedPosition[0] = viewport.xMax - size[0] - resolvedOffset[0];
}
if (resolvedPosition[1] > viewport.yMax - resolvedOffset[1]) {
resolvedPosition[1] = viewport.yMax - size[1] - resolvedOffset[1];
}
}
let conflictFound = false;
let i, node;
do {
conflictFound = false;
for (i = 0; i < nodes.length; i++) {
node = nodes[i];
if (NODE_POSITION_CONFLICT_ALLOWLIST.includes(node.type)) {
continue;
}
if (!canUsePosition(node.position, targetPosition)) {
conflictFound = true;
break;
}
}
if (conflictFound) {
targetPosition[0] += movePosition[0];
targetPosition[1] += movePosition[1];
}
} while (conflictFound);
return targetPosition;
return resolvedPosition;
};
/**
* Returns the position of a mouse or touch event
*/
export const getMousePosition = (e: MouseEvent | TouchEvent): XYPosition => {
// @ts-ignore
const x = e.pageX !== undefined ? e.pageX : e.touches?.[0]?.pageX ? e.touches[0].pageX : 0;
// @ts-ignore
const y = e.pageY !== undefined ? e.pageY : e.touches?.[0]?.pageY ? e.touches[0].pageY : 0;
export const getMousePosition = (event: MouseEvent | TouchEvent): XYPosition => {
const x = (event && 'clientX' in event ? event.clientX : event?.touches?.[0]?.clientX) ?? 0;
const y = (event && 'clientY' in event ? event.clientY : event?.touches?.[0]?.clientY) ?? 0;
return [x, y];
};

View File

@@ -38,6 +38,7 @@ import type {
} from '@/Interface';
import type {
Connection,
Dimensions,
ViewportTransform,
XYPosition as VueFlowXYPosition,
} from '@vue-flow/core';
@@ -47,6 +48,7 @@ import type {
CanvasNode,
CanvasNodeMoveEvent,
ConnectStartEvent,
ViewportBoundaries,
} from '@/types';
import { CanvasNodeRenderType, CanvasConnectionMode } from '@/types';
import {
@@ -662,7 +664,8 @@ function onToggleNodesDisabled(ids: string[]) {
toggleNodesDisabled(ids);
}
function onClickNode() {
function onClickNode(_id: string, event: VueFlowXYPosition) {
lastClickPosition.value = [event.x, event.y];
closeNodeCreator();
}
@@ -740,7 +743,10 @@ async function onClipboardPaste(plainTextData: string): Promise<void> {
return;
}
const result = await importWorkflowData(workflowData, 'paste', false);
const result = await importWorkflowData(workflowData, 'paste', {
importTags: false,
viewport: viewportBoundaries.value,
});
selectNodes(result.nodes?.map((node) => node.id) ?? []);
}
@@ -757,7 +763,9 @@ async function onDuplicateNodes(ids: string[]) {
return;
}
const newIds = await duplicateNodes(ids);
const newIds = await duplicateNodes(ids, {
viewport: viewportBoundaries.value,
});
selectNodes(newIds);
}
@@ -980,7 +988,9 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: IWork
async function onImportWorkflowDataEvent(data: IDataObject) {
const workflowData = data.data as IWorkflowDataUpdate;
await importWorkflowData(workflowData, 'file');
await importWorkflowData(workflowData, 'file', {
viewport: viewportBoundaries.value,
});
fitView();
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
@@ -997,7 +1007,9 @@ async function onImportWorkflowUrlEvent(data: IDataObject) {
return;
}
await importWorkflowData(workflowData, 'url');
await importWorkflowData(workflowData, 'url', {
viewport: viewportBoundaries.value,
});
fitView();
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
@@ -1031,6 +1043,7 @@ async function onAddNodesAndConnections(
const addedNodes = await addNodes(nodes, {
dragAndDrop,
position,
viewport: viewportBoundaries.value,
trackHistory: true,
telemetry: true,
});
@@ -1571,10 +1584,24 @@ async function onSaveFromWithinExecutionDebug() {
*/
const viewportTransform = ref<ViewportTransform>({ x: 0, y: 0, zoom: 1 });
const viewportDimensions = ref<Dimensions>({ width: 0, height: 0 });
function onViewportChange(event: ViewportTransform) {
viewportTransform.value = event;
uiStore.nodeViewOffsetPosition = [event.x, event.y];
const viewportBoundaries = computed<ViewportBoundaries>(() => {
const { x, y, zoom } = viewportTransform.value;
const { width, height } = viewportDimensions.value;
const xMin = -x / zoom;
const yMin = -y / zoom;
const xMax = (width - x) / zoom;
const yMax = (height - y) / zoom;
return { xMin, yMin, xMax, yMax };
});
function onViewportChange(viewport: ViewportTransform, dimensions: Dimensions) {
viewportTransform.value = viewport;
viewportDimensions.value = dimensions;
uiStore.nodeViewOffsetPosition = [viewport.x, viewport.y];
}
function fitView() {
@@ -1589,11 +1616,15 @@ function selectNodes(ids: string[]) {
* Mouse events
*/
function onClickPane(position: CanvasNode['position']) {
function onClickPane(position: VueFlowXYPosition) {
lastClickPosition.value = [position.x, position.y];
onSetNodeSelected();
}
function onSelectionEnd(position: VueFlowXYPosition) {
lastClickPosition.value = [position.x, position.y];
}
/**
* Drag and Drop events
*/
@@ -1923,7 +1954,8 @@ onBeforeUnmount(() => {
@run:workflow="runEntireWorkflow('main')"
@save:workflow="onSaveWorkflow"
@create:workflow="onCreateWorkflow"
@viewport-change="onViewportChange"
@viewport:change="onViewportChange"
@selection:end="onSelectionEnd"
@drag-and-drop="onDragAndDrop"
@tidy-up="onTidyUp"
>