mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Improve canvas node insertion position (#14289)
This commit is contained in:
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ?? [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -229,3 +229,10 @@ export type BoundingBox = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type ViewportBoundaries = {
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user