mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
603 lines
17 KiB
TypeScript
603 lines
17 KiB
TypeScript
import {
|
||
AI_MCP_TOOL_NODE_TYPE,
|
||
LIST_LIKE_NODE_OPERATIONS,
|
||
MAIN_HEADER_TABS,
|
||
NODE_POSITION_CONFLICT_ALLOWLIST,
|
||
SET_NODE_TYPE,
|
||
SPLIT_IN_BATCHES_NODE_TYPE,
|
||
VIEWS,
|
||
} from '@/constants';
|
||
import type { INodeUi, XYPosition } from '@/Interface';
|
||
import type {
|
||
AssignmentCollectionValue,
|
||
INode,
|
||
INodeExecutionData,
|
||
INodeTypeDescription,
|
||
NodeHint,
|
||
Workflow,
|
||
} from 'n8n-workflow';
|
||
import { NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
||
import type { RouteLocation } from 'vue-router';
|
||
import type { ViewportBoundaries } from '@/types';
|
||
import {
|
||
getRectOfNodes,
|
||
type Dimensions,
|
||
type GraphNode,
|
||
type Rect,
|
||
type ViewportTransform,
|
||
} from '@vue-flow/core';
|
||
|
||
/*
|
||
* Canvas constants and functions
|
||
*/
|
||
|
||
export const GRID_SIZE = 20;
|
||
|
||
export const NODE_SIZE = 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,
|
||
};
|
||
|
||
/**
|
||
* 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]) {
|
||
return leftmostTop;
|
||
}
|
||
|
||
return node;
|
||
}, 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,
|
||
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;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
/**
|
||
* Returns the closest number divisible by the given number
|
||
*/
|
||
const closestNumberDivisibleBy = (inputNumber: number, divisibleBy: number): number => {
|
||
const quotient = Math.ceil(inputNumber / divisibleBy);
|
||
|
||
// 1st possible closest number
|
||
const inputNumber1 = divisibleBy * quotient;
|
||
|
||
// 2nd possible closest number
|
||
const inputNumber2 =
|
||
inputNumber * divisibleBy > 0 ? divisibleBy * (quotient + 1) : divisibleBy * (quotient - 1);
|
||
|
||
// if true, then inputNumber1 is the required closest number
|
||
if (Math.abs(inputNumber - inputNumber1) < Math.abs(inputNumber - inputNumber2)) {
|
||
return inputNumber1;
|
||
}
|
||
|
||
// else inputNumber2 is the required closest number
|
||
return inputNumber2;
|
||
};
|
||
|
||
/**
|
||
* Returns the new position for a node based on the given position and the nodes in the workflow
|
||
*/
|
||
export const getNewNodePosition = (
|
||
nodes: INodeUi[],
|
||
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 resolvedOffset = [...offset];
|
||
resolvedOffset[0] = closestNumberDivisibleBy(resolvedOffset[0], GRID_SIZE);
|
||
resolvedOffset[1] = closestNumberDivisibleBy(resolvedOffset[1], GRID_SIZE);
|
||
|
||
const resolvedPosition: XYPosition = [...initialPosition];
|
||
resolvedPosition[0] = closestNumberDivisibleBy(resolvedPosition[0], GRID_SIZE);
|
||
resolvedPosition[1] = closestNumberDivisibleBy(resolvedPosition[1], GRID_SIZE);
|
||
|
||
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];
|
||
}
|
||
}
|
||
|
||
return resolvedPosition;
|
||
};
|
||
|
||
/**
|
||
* Returns the position of a mouse or touch event
|
||
*/
|
||
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];
|
||
};
|
||
|
||
/**
|
||
* Returns the relative position of a point on the canvas
|
||
*/
|
||
export const getRelativePosition = (
|
||
x: number,
|
||
y: number,
|
||
scale: number,
|
||
offset: XYPosition,
|
||
): XYPosition => {
|
||
return [(x - offset[0]) / scale, (y - offset[1]) / scale];
|
||
};
|
||
|
||
/**
|
||
* Returns the width and height of the node view content
|
||
*/
|
||
const getContentDimensions = (): { editorWidth: number; editorHeight: number } => {
|
||
let contentWidth = window.innerWidth;
|
||
let contentHeight = window.innerHeight;
|
||
const nodeViewRoot = document.getElementById('node-view-root');
|
||
|
||
if (nodeViewRoot) {
|
||
const contentBounds = nodeViewRoot.getBoundingClientRect();
|
||
contentWidth = contentBounds.width;
|
||
contentHeight = contentBounds.height;
|
||
}
|
||
return {
|
||
editorWidth: contentWidth,
|
||
editorHeight: contentHeight,
|
||
};
|
||
};
|
||
|
||
/**
|
||
* Returns the position of the canvas center
|
||
*/
|
||
export const getMidCanvasPosition = (scale: number, offset: XYPosition): XYPosition => {
|
||
const { editorWidth, editorHeight } = getContentDimensions();
|
||
|
||
return getRelativePosition(editorWidth / 2, (editorHeight - HEADER_HEIGHT) / 2, scale, offset);
|
||
};
|
||
|
||
/**
|
||
* Normalize node positions based on the leftmost top node
|
||
*/
|
||
export const getNodesWithNormalizedPosition = <T extends { position: XYPosition }>(
|
||
workflowNodes: T[],
|
||
): T[] => {
|
||
const nodes = [...workflowNodes];
|
||
|
||
if (nodes.length) {
|
||
const leftmostTop = getLeftmostTopNode(nodes);
|
||
|
||
const diffX = DEFAULT_START_POSITION_X - leftmostTop.position[0];
|
||
const diffY = DEFAULT_START_POSITION_Y - leftmostTop.position[1];
|
||
|
||
nodes.forEach((node) => {
|
||
node.position[0] += diffX + NODE_SIZE * 2;
|
||
node.position[1] += diffY;
|
||
});
|
||
}
|
||
|
||
return nodes;
|
||
};
|
||
|
||
/**
|
||
* Calculates the intersecting distances of the mouse event coordinates with the given element's boundaries,
|
||
* adjusted by the specified offset.
|
||
*
|
||
* @param {Element} element - The DOM element to check against.
|
||
* @param {MouseEvent | TouchEvent} mouseEvent - The mouse or touch event with the coordinates.
|
||
* @param {number} offset - Offset to adjust the element's boundaries.
|
||
* @returns { {x: number | null, y: number | null} | null } Object containing intersecting distances along x and y axes or null if no intersection.
|
||
*/
|
||
export function calculateElementIntersection(
|
||
element: Element,
|
||
mouseEvent: MouseEvent | TouchEvent,
|
||
offset: number,
|
||
): { x: number | null; y: number | null } | null {
|
||
const { top, left, right, bottom } = element.getBoundingClientRect();
|
||
const [x, y] = getMousePosition(mouseEvent);
|
||
|
||
let intersectX: number | null = null;
|
||
let intersectY: number | null = null;
|
||
|
||
if (x >= left - offset && x <= right + offset) {
|
||
intersectX = Math.min(x - (left - offset), right + offset - x);
|
||
}
|
||
if (y >= top - offset && y <= bottom + offset) {
|
||
intersectY = Math.min(y - (top - offset), bottom + offset - y);
|
||
}
|
||
|
||
if (intersectX === null && intersectY === null) return null;
|
||
|
||
return { x: intersectX, y: intersectY };
|
||
}
|
||
|
||
/**
|
||
* Checks if the mouse event coordinates intersect with the given element's boundaries,
|
||
* adjusted by the specified offset.
|
||
*
|
||
* @param {Element} element - The DOM element to check against.
|
||
* @param {MouseEvent | TouchEvent} mouseEvent - The mouse or touch event with the coordinates.
|
||
* @param {number} offset - Offset to adjust the element's boundaries.
|
||
* @returns {boolean} True if the mouse coordinates intersect with the element.
|
||
*/
|
||
export function isElementIntersection(
|
||
element: Element,
|
||
mouseEvent: MouseEvent | TouchEvent,
|
||
offset: number,
|
||
): boolean {
|
||
const intersection = calculateElementIntersection(element, mouseEvent, offset);
|
||
|
||
if (intersection === null) {
|
||
return false;
|
||
}
|
||
|
||
const isWithinVerticalBounds = intersection.y !== null;
|
||
const isWithinHorizontalBounds = intersection.x !== null;
|
||
|
||
return isWithinVerticalBounds && isWithinHorizontalBounds;
|
||
}
|
||
|
||
/**
|
||
* Returns the node hints based on the node type and execution data
|
||
*/
|
||
export function getGenericHints({
|
||
workflowNode,
|
||
node,
|
||
nodeType,
|
||
nodeOutputData,
|
||
hasMultipleInputItems,
|
||
workflow,
|
||
hasNodeRun,
|
||
}: {
|
||
workflowNode: INode;
|
||
node: INodeUi;
|
||
nodeType: INodeTypeDescription;
|
||
nodeOutputData: INodeExecutionData[];
|
||
hasMultipleInputItems: boolean;
|
||
workflow: Workflow;
|
||
hasNodeRun: boolean;
|
||
}) {
|
||
const nodeHints: NodeHint[] = [];
|
||
|
||
// tools hints
|
||
if (
|
||
node?.type.toLocaleLowerCase().includes('tool') &&
|
||
node?.type !== AI_MCP_TOOL_NODE_TYPE &&
|
||
hasNodeRun
|
||
) {
|
||
const stringifiedParameters = JSON.stringify(workflowNode.parameters);
|
||
if (!stringifiedParameters.includes('$fromAI')) {
|
||
nodeHints.push({
|
||
message:
|
||
'No parameters are set up to be filled by AI. Click on the ✨ button next to a parameter to allow AI to set its value.',
|
||
location: 'outputPane',
|
||
whenToDisplay: 'afterExecution',
|
||
});
|
||
}
|
||
}
|
||
|
||
// add limit reached hint
|
||
if (hasNodeRun && workflowNode.parameters.limit) {
|
||
if (nodeOutputData.length === workflowNode.parameters.limit) {
|
||
nodeHints.push({
|
||
message: `Limit of ${workflowNode.parameters.limit} items reached. There may be more items that aren't being returned. Tweak the 'Return All' or 'Limit' parameters to access more items.`,
|
||
location: 'outputPane',
|
||
whenToDisplay: 'afterExecution',
|
||
});
|
||
}
|
||
}
|
||
|
||
// add Execute Once hint
|
||
if (
|
||
hasMultipleInputItems &&
|
||
LIST_LIKE_NODE_OPERATIONS.includes((workflowNode.parameters.operation as string) || '')
|
||
) {
|
||
const executeOnce = workflow.getNode(node.name)?.executeOnce;
|
||
if (!executeOnce) {
|
||
nodeHints.push({
|
||
message:
|
||
'This node runs multiple times, once for each input item. Use ‘Execute Once’ in the node settings if you want to run it only once.',
|
||
location: 'outputPane',
|
||
});
|
||
}
|
||
}
|
||
|
||
// add sendAndWait hint
|
||
if (hasMultipleInputItems && workflowNode.parameters.operation === SEND_AND_WAIT_OPERATION) {
|
||
const executeOnce = workflow.getNode(node.name)?.executeOnce;
|
||
if (!executeOnce) {
|
||
nodeHints.push({
|
||
message: 'This action will run only once, for the first input item',
|
||
location: 'outputPane',
|
||
});
|
||
}
|
||
}
|
||
|
||
// add expression in field name hint for Set node
|
||
if (node.type === SET_NODE_TYPE && node.parameters.mode === 'manual') {
|
||
const rawParameters = NodeHelpers.getNodeParameters(
|
||
nodeType.properties,
|
||
node.parameters,
|
||
true,
|
||
false,
|
||
node,
|
||
nodeType,
|
||
);
|
||
|
||
const assignments =
|
||
((rawParameters?.assignments as AssignmentCollectionValue) || {})?.assignments || [];
|
||
const expressionInFieldName: number[] = [];
|
||
|
||
for (const [index, assignment] of assignments.entries()) {
|
||
if (assignment.name.startsWith('=')) {
|
||
expressionInFieldName.push(index + 1);
|
||
}
|
||
}
|
||
|
||
if (expressionInFieldName.length > 0) {
|
||
nodeHints.push({
|
||
message: `An expression is used in 'Fields to Set' in ${expressionInFieldName.length === 1 ? 'field' : 'fields'} ${expressionInFieldName.join(', ')}, did you mean to use it in the value instead?`,
|
||
whenToDisplay: 'beforeExecution',
|
||
location: 'outputPane',
|
||
});
|
||
}
|
||
}
|
||
|
||
// Split In Batches setup hints
|
||
if (node.type === SPLIT_IN_BATCHES_NODE_TYPE) {
|
||
const { connectionsBySourceNode } = workflow;
|
||
|
||
const firstNodesInLoop = connectionsBySourceNode[node.name]?.main[1] || [];
|
||
|
||
if (!firstNodesInLoop.length) {
|
||
nodeHints.push({
|
||
message: "No nodes connected to the 'loop' output of this node",
|
||
whenToDisplay: 'beforeExecution',
|
||
location: 'outputPane',
|
||
});
|
||
} else {
|
||
for (const nodeInConnection of firstNodesInLoop || []) {
|
||
const nodeChilds = workflow.getChildNodes(nodeInConnection.node) || [];
|
||
if (!nodeChilds.includes(node.name)) {
|
||
nodeHints.push({
|
||
message:
|
||
"The last node in the branch of the 'loop' output must be connected back to the input of this node to loop correctly",
|
||
whenToDisplay: 'beforeExecution',
|
||
location: 'outputPane',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Get the current NodeView tab based on the route
|
||
*/
|
||
export const getNodeViewTab = (route: RouteLocation): string | null => {
|
||
if (route.meta?.nodeView) {
|
||
return MAIN_HEADER_TABS.WORKFLOW;
|
||
} else if (
|
||
[VIEWS.WORKFLOW_EXECUTIONS, VIEWS.EXECUTION_PREVIEW, VIEWS.EXECUTION_HOME]
|
||
.map(String)
|
||
.includes(String(route.name))
|
||
) {
|
||
return MAIN_HEADER_TABS.EXECUTIONS;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
export function getBounds(
|
||
{ x, y, zoom }: ViewportTransform,
|
||
{ width, height }: Dimensions,
|
||
): ViewportBoundaries {
|
||
const xMin = -x / zoom;
|
||
const yMin = -y / zoom;
|
||
const xMax = (width - x) / zoom;
|
||
const yMax = (height - y) / zoom;
|
||
|
||
return { xMin, yMin, xMax, yMax };
|
||
}
|
||
|
||
function addPadding({ x, y, width, height }: Rect, amount: number): Rect {
|
||
return {
|
||
x: x - amount,
|
||
y: y - amount,
|
||
width: width + amount * 2,
|
||
height: height + amount * 2,
|
||
};
|
||
}
|
||
|
||
export function updateViewportToContainNodes(
|
||
viewport: ViewportTransform,
|
||
dimensions: Dimensions,
|
||
nodes: GraphNode[],
|
||
padding: number,
|
||
): ViewportTransform {
|
||
function computeDelta(start: number, end: number, min: number, max: number) {
|
||
if (start >= min && end <= max) {
|
||
// Both ends are already in the range, no need for adjustment
|
||
return 0;
|
||
}
|
||
|
||
if (start < min) {
|
||
if (end > max) {
|
||
// Neither end is in the range, in this case we don't make
|
||
// any adjustment (for now; we could adjust zoom to fit in viewport)
|
||
return 0;
|
||
}
|
||
|
||
return min - start;
|
||
}
|
||
|
||
return max - end;
|
||
}
|
||
|
||
if (nodes.length === 0) {
|
||
return viewport;
|
||
}
|
||
|
||
const zoom = viewport.zoom;
|
||
const rect = addPadding(getRectOfNodes(nodes), padding / zoom);
|
||
const { xMax, xMin, yMax, yMin } = getBounds(viewport, dimensions);
|
||
const dx = computeDelta(rect.x, rect.x + rect.width, xMin, xMax);
|
||
const dy = computeDelta(rect.y, rect.y + rect.height, yMin, yMax);
|
||
|
||
return {
|
||
x: viewport.x + dx * zoom,
|
||
y: viewport.y + dy * zoom,
|
||
zoom,
|
||
};
|
||
}
|