n8n/packages/editor-ui/src/utils/nodeViewUtils.ts

1323 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { isNumber, isValidNodeConnectionType } from '@/utils/typeGuards';
import {
LIST_LIKE_NODE_OPERATIONS,
NODE_OUTPUT_DEFAULT_KEY,
SET_NODE_TYPE,
SPLIT_IN_BATCHES_NODE_TYPE,
STICKY_NODE_TYPE,
} from '@/constants';
import type { EndpointStyle, IBounds, INodeUi, XYPosition } from '@/Interface';
import type { ArrayAnchorSpec, ConnectorSpec, OverlaySpec, PaintStyle } from '@jsplumb/common';
import type { Connection, Endpoint, SelectOptions } from '@jsplumb/core';
import { N8nConnector } from '@/plugins/connectors/N8nCustomConnector';
import type {
AssignmentCollectionValue,
IConnection,
INode,
INodeExecutionData,
INodeTypeDescription,
ITaskData,
NodeHint,
NodeInputConnections,
Workflow,
} from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import { EVENT_CONNECTION_MOUSEOUT, EVENT_CONNECTION_MOUSEOVER } from '@jsplumb/browser-ui';
import { useUIStore } from '@/stores/ui.store';
import type { StyleValue } from 'vue';
/*
Canvas constants and functions.
These utils are not exported with main `utils`package because they need to be used
on-demand (when jsplumb instance is ready) by components (mainly the NodeView).
*/
export const OVERLAY_DROP_NODE_ID = 'drop-add-node';
export const OVERLAY_MIDPOINT_ARROW_ID = 'midpoint-arrow';
export const OVERLAY_ENDPOINT_ARROW_ID = 'endpoint-arrow';
export const OVERLAY_RUN_ITEMS_ID = 'run-items-label';
export const OVERLAY_CONNECTION_ACTIONS_ID = 'connection-actions';
export const JSPLUMB_FLOWCHART_STUB = 26;
export const OVERLAY_INPUT_NAME_LABEL = 'input-name-label';
export const OVERLAY_INPUT_NAME_MOVED_CLASS = 'node-input-endpoint-label--moved';
export const OVERLAY_OUTPUT_NAME_LABEL = 'output-name-label';
export const GRID_SIZE = 20;
const MIN_X_TO_SHOW_OUTPUT_LABEL = 90;
const MIN_Y_TO_SHOW_OUTPUT_LABEL = 100;
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 PLACEHOLDER_TRIGGER_NODE_SIZE = 100;
export const DEFAULT_START_POSITION_X = 180;
export const DEFAULT_START_POSITION_Y = 240;
export const HEADER_HEIGHT = 65;
export const SIDEBAR_WIDTH = 65;
export const INNER_SIDEBAR_WIDTH = 310;
export const SIDEBAR_WIDTH_EXPANDED = 200;
export const MAX_X_TO_PUSH_DOWNSTREAM_NODES = 300;
export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE;
const LOOPBACK_MINIMUM = 140;
export const INPUT_UUID_KEY = '-input';
export const OUTPUT_UUID_KEY = '-output';
export const PLACEHOLDER_BUTTON = 'PlaceholderTriggerButton';
export const DEFAULT_PLACEHOLDER_TRIGGER_BUTTON = {
name: 'Choose a Trigger...',
type: PLACEHOLDER_BUTTON,
typeVersion: 1,
position: [],
parameters: {
height: PLACEHOLDER_TRIGGER_NODE_SIZE,
width: PLACEHOLDER_TRIGGER_NODE_SIZE,
},
};
export const CONNECTOR_FLOWCHART_TYPE: ConnectorSpec = {
type: N8nConnector.type,
options: {
cornerRadius: 12,
stub: JSPLUMB_FLOWCHART_STUB + 10,
targetGap: 4,
alwaysRespectStubs: false,
loopbackVerticalLength: NODE_SIZE + GRID_SIZE, // height of vertical segment when looping
loopbackMinimum: LOOPBACK_MINIMUM, // minimum length before flowchart loops around
getEndpointOffset(endpoint: Endpoint) {
const indexOffset = 10; // stub offset between different endpoints of same node
const index = endpoint?.__meta ? endpoint.__meta.index : 0;
const totalEndpoints = endpoint?.__meta ? endpoint.__meta.totalEndpoints : 0;
const outputOverlay = getOverlay(endpoint, OVERLAY_OUTPUT_NAME_LABEL);
const outputOverlayLabel =
outputOverlay && 'label' in outputOverlay ? `${outputOverlay?.label}` : '';
const labelOffset = outputOverlayLabel.length > 1 ? 10 : 0;
const outputsOffset = totalEndpoints > 3 ? 24 : 0; // avoid intersecting plus
return index * indexOffset + labelOffset + outputsOffset;
},
},
};
export const CONNECTOR_PAINT_STYLE_DEFAULT: PaintStyle = {
stroke: 'var(--color-foreground-dark)',
strokeWidth: 2,
outlineWidth: 12,
outlineStroke: 'transparent',
};
export const CONNECTOR_PAINT_STYLE_PULL: PaintStyle = {
...CONNECTOR_PAINT_STYLE_DEFAULT,
stroke: 'var(--color-foreground-xdark)',
};
export const CONNECTOR_PAINT_STYLE_PRIMARY = {
...CONNECTOR_PAINT_STYLE_DEFAULT,
stroke: 'var(--color-primary)',
};
export const CONNECTOR_PAINT_STYLE_DATA: PaintStyle = {
...CONNECTOR_PAINT_STYLE_DEFAULT,
...{
dashstyle: '5 3',
},
stroke: 'var(--color-foreground-dark)',
};
export function isCanvasAugmentedType<T>(overlay: T): overlay is T & { canvas: HTMLElement } {
return typeof overlay === 'object' && overlay !== null && 'canvas' in overlay && !!overlay.canvas;
}
export const getConnectorColor = (type: NodeConnectionType, category?: string): string => {
if (category === 'error') {
return '--color-node-error-output-text-color';
}
if (type === NodeConnectionType.Main) {
return '--node-type-main-color';
}
return '--node-type-supplemental-connector-color';
};
export const getConnectorPaintStylePull = (connection: Connection): PaintStyle => {
const connectorColor = getConnectorColor(
connection.parameters.type as NodeConnectionType,
connection.parameters.category,
);
const additionalStyles: PaintStyle = {};
if (connection.parameters.type !== NodeConnectionType.Main) {
additionalStyles.dashstyle = '5 3';
}
return {
...CONNECTOR_PAINT_STYLE_PULL,
...(connectorColor ? { stroke: `var(${connectorColor})` } : {}),
...additionalStyles,
};
};
export const getConnectorPaintStyleDefault = (connection: Connection): PaintStyle => {
const connectorColor = getConnectorColor(
connection.parameters.type as NodeConnectionType,
connection.parameters.category,
);
return {
...CONNECTOR_PAINT_STYLE_DEFAULT,
...(connectorColor ? { stroke: `var(${connectorColor})` } : {}),
};
};
export const getConnectorPaintStyleData = (
connection: Connection,
category?: string,
): PaintStyle => {
const connectorColor = getConnectorColor(
connection.parameters.type as NodeConnectionType,
category,
);
return {
...CONNECTOR_PAINT_STYLE_DATA,
...(connectorColor ? { stroke: `var(${connectorColor})` } : {}),
};
};
export const CONNECTOR_ARROW_OVERLAYS: OverlaySpec[] = [
{
type: 'Arrow',
options: {
id: OVERLAY_ENDPOINT_ARROW_ID,
location: 1,
width: 12,
foldback: 1,
length: 10,
visible: true,
},
},
{
type: 'Arrow',
options: {
id: OVERLAY_MIDPOINT_ARROW_ID,
location: 0.5,
width: 12,
foldback: 1,
length: 10,
visible: false,
},
},
];
export const getAnchorPosition = (
connectionType: NodeConnectionType,
type: 'input' | 'output',
amount: number,
spacerIndexes: number[] = [],
): ArrayAnchorSpec[] => {
if (connectionType === NodeConnectionType.Main) {
const anchors: ArrayAnchorSpec[] = [];
const x = type === 'input' ? 0.01 : 0.99;
const ox = type === 'input' ? -1 : 1;
const oy = 0;
const stepSize = 1 / (amount + 1); // +1 to not touch the node boundaries
for (let i = 1; i <= amount; i++) {
const y = stepSize * i; // Multiply by index to set position
anchors.push([x, y, ox, oy]);
}
return anchors;
}
const y = type === 'input' ? 0.99 : 0.01;
const oy = type === 'input' ? 1 : -1;
const ox = 0;
const spacedAmount = amount + spacerIndexes.length;
const returnPositions: ArrayAnchorSpec[] = [];
for (let i = 0; i < spacedAmount; i++) {
const stepSize = 1 / (spacedAmount + 1);
let x = stepSize * i;
x += stepSize;
if (spacerIndexes.includes(i)) {
continue;
}
returnPositions.push([x, y, ox, oy]);
}
return returnPositions;
};
export const getScope = (type?: NodeConnectionType): NodeConnectionType | undefined => {
if (!type || type === NodeConnectionType.Main) {
return undefined;
}
return type;
};
export const getEndpointScope = (
endpointType: NodeConnectionType | string,
): NodeConnectionType | undefined => {
if (isValidNodeConnectionType(endpointType)) {
return getScope(endpointType);
}
return undefined;
};
export const getInputEndpointStyle = (
nodeTypeData: INodeTypeDescription,
color: string,
connectionType: NodeConnectionType = NodeConnectionType.Main,
): EndpointStyle => {
let width = 8;
let height = nodeTypeData && nodeTypeData.outputs.length > 2 ? 18 : 20;
if (connectionType !== NodeConnectionType.Main) {
const temp = width;
width = height;
height = temp;
}
return {
width,
height,
fill: `var(${color})`,
stroke: `var(${color})`,
lineWidth: 0,
};
};
export const getInputNameOverlay = (
labelText: string,
inputName: string,
required?: boolean,
): OverlaySpec => ({
type: 'Custom',
options: {
id: OVERLAY_INPUT_NAME_LABEL,
visible: true,
location: [-1, -1],
create: (_: Endpoint) => {
const label = document.createElement('div');
label.innerHTML = labelText;
if (required) {
label.innerHTML += ' <strong style="color: var(--color-primary)">*</strong>';
}
label.classList.add('node-input-endpoint-label');
label.classList.add(`node-connection-type-${inputName ?? 'main'}`);
if (inputName !== NodeConnectionType.Main) {
label.classList.add('node-input-endpoint-label--data');
}
return label;
},
},
});
export const getOutputEndpointStyle = (
nodeTypeData: INodeTypeDescription,
color: string,
): PaintStyle => ({
strokeWidth: nodeTypeData && nodeTypeData.outputs.length > 2 ? 7 : 9,
fill: `var(${color})`,
outlineStroke: 'none',
});
export const getOutputNameOverlay = (
labelText: string,
outputName: NodeConnectionType,
category?: string,
): OverlaySpec => ({
type: 'Custom',
options: {
id: OVERLAY_OUTPUT_NAME_LABEL,
visible: true,
create: (ep: Endpoint) => {
const label = document.createElement('div');
label.innerHTML = labelText;
label.classList.add('node-output-endpoint-label');
if (ep?.__meta?.endpointLabelLength) {
label.setAttribute('data-endpoint-label-length', `${ep?.__meta?.endpointLabelLength}`);
}
label.classList.add(`node-connection-type-${getScope(outputName) ?? 'main'}`);
if (outputName !== NodeConnectionType.Main) {
label.classList.add('node-output-endpoint-label--data');
}
if (category) {
label.classList.add(`node-connection-category-${category}`);
}
return label;
},
},
});
export const addOverlays = (connection: Connection, overlays: OverlaySpec[]) => {
overlays.forEach((overlay: OverlaySpec) => {
connection.addOverlay(overlay);
});
};
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 getWorkflowCorners = (nodes: INodeUi[]): IBounds => {
return nodes.reduce(
(accu: IBounds, node: INodeUi) => {
const hasCustomDimensions = [STICKY_NODE_TYPE, PLACEHOLDER_BUTTON].includes(node.type);
const xOffset =
hasCustomDimensions && isNumber(node.parameters.width) ? node.parameters.width : NODE_SIZE;
const yOffset =
hasCustomDimensions && isNumber(node.parameters.height)
? node.parameters.height
: NODE_SIZE;
const x = node.position[0];
const y = node.position[1];
if (x < accu.minX) {
accu.minX = x;
}
if (y < accu.minY) {
accu.minY = y;
}
if (x + xOffset > accu.maxX) {
accu.maxX = x + xOffset;
}
if (y + yOffset > accu.maxY) {
accu.maxY = y + yOffset;
}
return accu;
},
{
minX: nodes[0].position[0],
minY: nodes[0].position[1],
maxX: nodes[0].position[0],
maxY: nodes[0].position[1],
},
);
};
export const getOverlay = (item: Connection | Endpoint, overlayId: string) => {
try {
return item.getOverlay(overlayId); // handle when _jsPlumb element is deleted
} catch (e) {
return null;
}
};
export const showOverlay = (item: Connection | Endpoint, overlayId: string) => {
const overlay = getOverlay(item, overlayId);
if (overlay) {
overlay.setVisible(true);
}
};
export const hideOverlay = (item: Connection | Endpoint, overlayId: string) => {
const overlay = getOverlay(item, overlayId);
if (overlay) {
overlay.setVisible(false);
}
};
export const showOrHideMidpointArrow = (connection: Connection) => {
if (!connection?.endpoints || connection.endpoints.length !== 2) {
return;
}
const hasItemsLabel = !!getOverlay(connection, OVERLAY_RUN_ITEMS_ID);
const sourceEndpoint = connection.endpoints[0];
const targetEndpoint = connection.endpoints[1];
const sourcePosition = sourceEndpoint._anchor.computedPosition?.curX ?? 0;
const targetPosition = targetEndpoint._anchor.computedPosition?.curX ?? sourcePosition + 1;
const minimum = hasItemsLabel ? 150 : 0;
const isBackwards = sourcePosition >= targetPosition;
const isTooLong = Math.abs(sourcePosition - targetPosition) >= minimum;
const isActionsOverlayHovered = getOverlay(
connection,
OVERLAY_CONNECTION_ACTIONS_ID,
)?.component.isHover();
const isConnectionHovered = connection.isHover();
const arrow = getOverlay(connection, OVERLAY_MIDPOINT_ARROW_ID);
const isArrowVisible =
connection.parameters.type === NodeConnectionType.Main &&
isBackwards &&
isTooLong &&
!isActionsOverlayHovered &&
!isConnectionHovered &&
!connection.instance.isConnectionBeingDragged;
if (arrow) {
arrow.setVisible(isArrowVisible);
arrow.setLocation(hasItemsLabel ? 0.6 : 0.5);
if (isCanvasAugmentedType(arrow)) {
connection.instance.repaint(arrow.canvas);
}
}
};
export const getConnectorLengths = (connection: Connection): [number, number] => {
if (!connection.connector) {
return [0, 0];
}
const bounds = connection.connector.bounds;
const diffX = Math.abs(bounds.xmax - bounds.xmin);
const diffY = Math.abs(bounds.ymax - bounds.ymin);
return [diffX, diffY];
};
const isLoopingBackwards = (connection: Connection) => {
const sourceEndpoint = connection.endpoints[0];
const targetEndpoint = connection.endpoints[1];
const sourcePosition = sourceEndpoint._anchor.computedPosition?.curX ?? 0;
const targetPosition = targetEndpoint._anchor.computedPosition?.curX ?? 0;
return targetPosition - sourcePosition < -1 * LOOPBACK_MINIMUM;
};
export const showOrHideItemsLabel = (connection: Connection) => {
if (!connection?.connector) return;
const overlay = getOverlay(connection, OVERLAY_RUN_ITEMS_ID);
if (!overlay) return;
const actionsOverlay = getOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID);
const isActionsOverlayHovered = actionsOverlay?.component.isHover();
if (isActionsOverlayHovered) {
overlay.setVisible(false);
return;
}
const [diffX, diffY] = getConnectorLengths(connection);
const isHidden = diffX < MIN_X_TO_SHOW_OUTPUT_LABEL && diffY < MIN_Y_TO_SHOW_OUTPUT_LABEL;
overlay.setVisible(!isHidden);
const innerElement = isCanvasAugmentedType(overlay)
? overlay.canvas.querySelector('span')
: undefined;
if (innerElement) {
if (diffY === 0 || isLoopingBackwards(connection)) {
innerElement.classList.add('floating');
} else {
innerElement.classList.remove('floating');
}
}
};
export const getIcon = (name: string): string => {
if (name === 'trash') {
return '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="trash" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="svg-inline--fa fa-trash fa-w-14 Icon__medium_ctPPJ"><path data-v-66d5c7e2="" fill="currentColor" d="M432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16zM53.2 467a48 48 0 0 0 47.9 45h245.8a48 48 0 0 0 47.9-45L416 128H32z" class=""></path></svg>';
}
if (name === 'plus') {
return '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="plus" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="svg-inline--fa fa-plus fa-w-14 Icon__medium_ctPPJ"><path data-v-301ed208="" fill="currentColor" d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z" class=""></path></svg>';
}
return '';
};
const canUsePosition = (position1: XYPosition, position2: XYPosition) => {
if (Math.abs(position1[0] - position2[0]) <= 100) {
if (Math.abs(position1[1] - position2[1]) <= 50) {
return false;
}
}
return true;
};
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;
};
export const getNewNodePosition = (
nodes: INodeUi[],
newPosition: XYPosition,
movePosition?: XYPosition,
): XYPosition => {
const targetPosition: XYPosition = [...newPosition];
targetPosition[0] = closestNumberDivisibleBy(targetPosition[0], GRID_SIZE);
targetPosition[1] = closestNumberDivisibleBy(targetPosition[1], GRID_SIZE);
if (!movePosition) {
movePosition = [40, 40];
}
let conflictFound = false;
let i, node;
do {
conflictFound = false;
for (i = 0; i < nodes.length; i++) {
node = nodes[i];
if (!canUsePosition(node.position, targetPosition)) {
conflictFound = true;
break;
}
}
if (conflictFound) {
targetPosition[0] += movePosition[0];
targetPosition[1] += movePosition[1];
}
} while (conflictFound);
return targetPosition;
};
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;
return [x, y];
};
export const getRelativePosition = (
x: number,
y: number,
scale: number,
offset: XYPosition,
): XYPosition => {
return [(x - offset[0]) / scale, (y - offset[1]) / scale];
};
export const getMidCanvasPosition = (scale: number, offset: XYPosition): XYPosition => {
const { editorWidth, editorHeight } = getContentDimensions();
return getRelativePosition(editorWidth / 2, (editorHeight - HEADER_HEIGHT) / 2, scale, offset);
};
export const getBackgroundStyles = (
scale: number,
offsetPosition: XYPosition,
executionPreview: boolean,
): StyleValue => {
const squareSize = GRID_SIZE * scale;
const dotSize = 1 * scale;
const dotPosition = (GRID_SIZE / 2) * scale;
if (executionPreview) {
return {
'background-image':
'linear-gradient(135deg, var(--color-canvas-read-only-line) 25%, var(--color-canvas-background) 25%, var(--color-canvas-background) 50%, var(--color-canvas-read-only-line) 50%, var(--color-canvas-read-only-line) 75%, var(--color-canvas-background) 75%, var(--color-canvas-background) 100%)',
'background-size': `${squareSize}px ${squareSize}px`,
'background-position': `left ${offsetPosition[0]}px top ${offsetPosition[1]}px`,
};
}
const styles: StyleValue = {
'background-size': `${squareSize}px ${squareSize}px`,
'background-position': `left ${offsetPosition[0]}px top ${offsetPosition[1]}px`,
};
if (squareSize > 10.5) {
return {
...styles,
'background-image': `radial-gradient(circle at ${dotPosition}px ${dotPosition}px, var(--color-canvas-dot) ${dotSize}px, transparent 0)`,
};
}
return styles;
};
export const hideConnectionActions = (connection: Connection) => {
connection.instance.setSuspendDrawing(true);
hideOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID);
showOrHideMidpointArrow(connection);
showOrHideItemsLabel(connection);
connection.instance.setSuspendDrawing(false);
(connection.endpoints || []).forEach((endpoint) => {
connection.instance.repaint(endpoint.element);
});
};
export const showConnectionActions = (connection: Connection) => {
showOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID);
hideOverlay(connection, OVERLAY_RUN_ITEMS_ID);
if (!getOverlay(connection, OVERLAY_RUN_ITEMS_ID)) {
hideOverlay(connection, OVERLAY_MIDPOINT_ARROW_ID);
}
(connection.endpoints || []).forEach((endpoint) => {
connection.instance.repaint(endpoint.element);
});
};
export const getOutputSummary = (
data: ITaskData[],
nodeConnections: NodeInputConnections,
connectionType: NodeConnectionType,
) => {
const outputMap: {
[sourceOutputIndex: string]: {
[targetNodeName: string]: {
[targetInputIndex: string]: {
total: number;
iterations: number;
isArtificialRecoveredEventItem?: boolean;
};
};
};
} = {};
data.forEach((run: ITaskData) => {
if (!run?.data?.[connectionType]) {
return;
}
run.data[connectionType].forEach((output: INodeExecutionData[] | null, i: number) => {
const sourceOutputIndex = i;
// executionData that was recovered by recoverEvents in the CLI will have an isArtificialRecoveredEventItem property
// to indicate that it was not part of the original executionData
// we do not want to count these items in the summary
// if (output?.[0]?.json?.isArtificialRecoveredEventItem) {
// return outputMap;
// }
if (!outputMap[sourceOutputIndex]) {
outputMap[sourceOutputIndex] = {};
}
if (!outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY]) {
outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY] = {};
outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY][0] = {
total: 0,
iterations: 0,
};
}
const defaultOutput = outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY][0];
defaultOutput.total += output ? output.length : 0;
defaultOutput.iterations += output ? 1 : 0;
if (!nodeConnections[sourceOutputIndex]) {
return;
}
nodeConnections[sourceOutputIndex].map((connection: IConnection) => {
const targetNodeName = connection.node;
const targetInputIndex = connection.index;
if (!outputMap[sourceOutputIndex][targetNodeName]) {
outputMap[sourceOutputIndex][targetNodeName] = {};
}
if (!outputMap[sourceOutputIndex][targetNodeName][targetInputIndex]) {
outputMap[sourceOutputIndex][targetNodeName][targetInputIndex] = {
total: 0,
iterations: 0,
};
}
if (output?.[0]?.json?.isArtificialRecoveredEventItem) {
outputMap[sourceOutputIndex][targetNodeName][
targetInputIndex
].isArtificialRecoveredEventItem = true;
outputMap[sourceOutputIndex][targetNodeName][targetInputIndex].total = 0;
} else {
outputMap[sourceOutputIndex][targetNodeName][targetInputIndex].total += output
? output.length
: 0;
outputMap[sourceOutputIndex][targetNodeName][targetInputIndex].iterations += output
? 1
: 0;
}
});
});
});
return outputMap;
};
export const resetConnection = (connection: Connection) => {
connection.removeOverlay(OVERLAY_RUN_ITEMS_ID);
connection.removeClass('success');
showOrHideMidpointArrow(connection);
connection.setPaintStyle(getConnectorPaintStyleDefault(connection));
};
export const recoveredConnection = (connection: Connection) => {
connection.removeOverlay(OVERLAY_RUN_ITEMS_ID);
connection.addClass('success');
showOrHideMidpointArrow(connection);
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_PRIMARY);
};
export const getRunItemsLabel = (output: { total: number; iterations: number }): string => {
let label = `${output.total}`;
label = output.total > 1 ? `${label} items` : `${label} item`;
label = output.iterations > 1 ? `${label} total` : label;
return label;
};
export const addConnectionOutputSuccess = (
connection: Connection,
output: { total: number; iterations: number; classNames?: string[] },
) => {
const classNames: string[] = ['success'];
if (output.classNames) {
classNames.push(...output.classNames);
}
connection.addClass(classNames.join(' '));
if (getOverlay(connection, OVERLAY_RUN_ITEMS_ID)) {
connection.removeOverlay(OVERLAY_RUN_ITEMS_ID);
}
if (connection.parameters.type === NodeConnectionType.Main) {
const overlay = connection.addOverlay({
type: 'Custom',
options: {
id: OVERLAY_RUN_ITEMS_ID,
create() {
const container = document.createElement('div');
const span = document.createElement('span');
container.classList.add(...['connection-run-items-label', ...classNames]);
span.classList.add('floating');
span.innerHTML = getRunItemsLabel(output);
container.appendChild(span);
return container;
},
location: 0.5,
},
});
overlay.setVisible(true);
}
showOrHideItemsLabel(connection);
showOrHideMidpointArrow(connection);
(connection.endpoints || []).forEach((endpoint) => {
connection.instance.repaint(endpoint.element);
});
};
export const addClassesToOverlays = ({
connection,
overlayIds,
classNames,
includeConnector,
}: {
connection: Connection;
overlayIds: string[];
classNames: string[];
includeConnector?: boolean;
}) => {
overlayIds.forEach((overlayId) => {
const overlay = getOverlay(connection, overlayId);
if (overlay && isCanvasAugmentedType(overlay)) {
overlay.canvas?.classList.add(...classNames);
}
if (includeConnector && isCanvasAugmentedType(connection.connector)) {
connection.connector.canvas?.classList.add(...classNames);
}
});
};
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,
};
};
export const getZoomToFit = (
nodes: INodeUi[],
addFooterPadding = true,
): { offset: XYPosition; zoomLevel: number } => {
const { minX, minY, maxX, maxY } = getWorkflowCorners(nodes);
const { editorWidth, editorHeight } = getContentDimensions();
const footerHeight = addFooterPadding ? 200 : 100;
const uiStore = useUIStore();
const PADDING = NODE_SIZE * 4;
const diffX = maxX - minX + PADDING;
const scaleX = editorWidth / diffX;
const diffY = maxY - minY + PADDING;
const scaleY = editorHeight / diffY;
const zoomLevel = Math.min(scaleX, scaleY, 1);
let xOffset = minX * -1 * zoomLevel; // find top right corner
xOffset += (editorWidth - (maxX - minX) * zoomLevel) / 2; // add padding to center workflow
let yOffset = minY * -1 * zoomLevel; // find top right corner
yOffset +=
(editorHeight -
(maxY - minY + footerHeight - uiStore.headerHeight + uiStore.bannersHeight) * zoomLevel) /
2; // add padding to center workflow
return {
zoomLevel,
offset: [
closestNumberDivisibleBy(xOffset, GRID_SIZE),
closestNumberDivisibleBy(yOffset, GRID_SIZE),
],
};
};
export const showDropConnectionState = (connection: Connection, targetEndpoint?: Endpoint) => {
if (connection?.connector) {
const connector = connection.connector as N8nConnector;
if (targetEndpoint) {
connector.setTargetEndpoint(targetEndpoint);
}
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_PRIMARY);
hideOverlay(connection, OVERLAY_DROP_NODE_ID);
}
};
export const showPullConnectionState = (connection: Connection) => {
if (connection?.connector) {
const connector = connection.connector as N8nConnector;
connector.resetTargetEndpoint();
connection.setPaintStyle(getConnectorPaintStylePull(connection));
showOverlay(connection, OVERLAY_DROP_NODE_ID);
}
};
export const resetConnectionAfterPull = (connection: Connection) => {
if (connection?.connector) {
const connector = connection.connector as N8nConnector;
connector.resetTargetEndpoint();
connection.setPaintStyle(getConnectorPaintStyleDefault(connection));
}
};
export const resetInputLabelPosition = (targetEndpoint: Connection | Endpoint) => {
const inputNameOverlay = getOverlay(targetEndpoint, OVERLAY_INPUT_NAME_LABEL);
if (inputNameOverlay) {
targetEndpoint.instance.removeOverlayClass(inputNameOverlay, OVERLAY_INPUT_NAME_MOVED_CLASS);
}
};
export const hideOutputNameLabel = (sourceEndpoint: Connection | Endpoint) => {
hideOverlay(sourceEndpoint, OVERLAY_OUTPUT_NAME_LABEL);
};
export const showOutputNameLabel = (
sourceEndpoint: Connection | Endpoint,
connection: Connection,
) => {
const outputNameOverlay = getOverlay(sourceEndpoint, OVERLAY_OUTPUT_NAME_LABEL);
if (outputNameOverlay) {
outputNameOverlay.setVisible(true);
(connection.endpoints || []).forEach((endpoint) => {
connection.instance.repaint(endpoint.element);
});
}
};
export const moveBackInputLabelPosition = (targetEndpoint: Endpoint) => {
const inputNameOverlay = getOverlay(targetEndpoint, OVERLAY_INPUT_NAME_LABEL);
if (inputNameOverlay) {
targetEndpoint.instance.addOverlayClass(inputNameOverlay, OVERLAY_INPUT_NAME_MOVED_CLASS);
}
};
export const addConnectionTestData = (
source: HTMLElement,
target: HTMLElement,
el: HTMLElement | undefined,
) => {
// TODO: Only do this if running in test mode
const sourceNodeName = source.getAttribute('data-name')?.toString();
const targetNodeName = target.getAttribute('data-name')?.toString();
if (el && sourceNodeName && targetNodeName) {
el.setAttribute('data-source-node', sourceNodeName);
el.setAttribute('data-target-node', targetNodeName);
}
};
export const addConnectionActionsOverlay = (
connection: Connection,
onDelete: Function,
onAdd: Function,
) => {
const overlay = connection.addOverlay({
type: 'Custom',
options: {
id: OVERLAY_CONNECTION_ACTIONS_ID,
create: (component: Connection) => {
const div = document.createElement('div');
const deleteButton = document.createElement('button');
div.classList.add(OVERLAY_CONNECTION_ACTIONS_ID);
addConnectionTestData(component.source, component.target, div);
deleteButton.innerHTML = getIcon('trash');
deleteButton.addEventListener('click', () => onDelete());
// We have to manually trigger connection mouse events because the overlay
// is not part of the connection element
div.addEventListener('mouseout', () =>
connection.instance.fire(EVENT_CONNECTION_MOUSEOUT, component),
);
div.addEventListener('mouseover', () =>
connection.instance.fire(EVENT_CONNECTION_MOUSEOVER, component),
);
if (connection.parameters.type === NodeConnectionType.Main) {
const addButton = document.createElement('button');
addButton.classList.add('add');
addButton.innerHTML = getIcon('plus');
addButton.addEventListener('click', () => onAdd());
div.appendChild(addButton);
deleteButton.classList.add('delete');
} else {
deleteButton.classList.add('delete-single');
}
div.appendChild(deleteButton);
return div;
},
},
});
overlay.setVisible(false);
};
export const getOutputEndpointUUID = (
nodeId: string,
connectionType: NodeConnectionType,
outputIndex: number,
) => {
return `${nodeId}${OUTPUT_UUID_KEY}${getScope(connectionType) ?? ''}${outputIndex}`;
};
export const getInputEndpointUUID = (
nodeId: string,
connectionType: NodeConnectionType,
inputIndex: number,
) => {
return `${nodeId}${INPUT_UUID_KEY}${getScope(connectionType) ?? ''}${inputIndex}`;
};
export const getFixedNodesList = <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;
}
export const getJSPlumbEndpoints = (
node: INodeUi | null,
instance: BrowserJsPlumbInstance,
): Endpoint[] => {
if (!node) return [];
const nodeEl = instance.getManagedElement(node?.id);
return instance?.getEndpoints(nodeEl);
};
export const getPlusEndpoint = (
node: INodeUi | null,
outputIndex: number,
instance: BrowserJsPlumbInstance,
): Endpoint | undefined => {
const endpoints = getJSPlumbEndpoints(node, instance);
return endpoints.find(
(endpoint: Endpoint) =>
endpoint.endpoint.type === 'N8nPlus' && endpoint?.__meta?.index === outputIndex,
);
};
export const getJSPlumbConnection = (
sourceNode: INodeUi | null,
sourceOutputIndex: number,
targetNode: INodeUi | null,
targetInputIndex: number,
connectionType: NodeConnectionType,
sourceNodeType: INodeTypeDescription | null,
instance: BrowserJsPlumbInstance,
): Connection | undefined => {
if (!sourceNode || !targetNode) {
return;
}
const sourceId = sourceNode.id;
const targetId = targetNode.id;
const sourceEndpoint = getOutputEndpointUUID(sourceId, connectionType, sourceOutputIndex);
const targetEndpoint = getInputEndpointUUID(targetId, connectionType, targetInputIndex);
const sourceNodeOutput = sourceNodeType?.outputs?.[sourceOutputIndex] ?? NodeConnectionType.Main;
const sourceNodeOutputName =
typeof sourceNodeOutput === 'string'
? sourceNodeOutput
: 'name' in sourceNodeOutput
? `${sourceNodeOutput.name}`
: '';
const scope = getEndpointScope(sourceNodeOutputName);
const connections = instance?.getConnections({
scope,
source: sourceId,
target: targetId,
} as SelectOptions<Element>);
if (!Array.isArray(connections)) {
return;
}
return connections.find((connection: Connection) => {
const uuids = connection.getUuids();
return uuids[0] === sourceEndpoint && uuids[1] === targetEndpoint;
});
};
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[] = [];
// add limit reached hint
if (hasNodeRun && workflowNode.parameters.limit) {
if (nodeOutputData.length === workflowNode.parameters.limit) {
nodeHints.push({
message: `Limit of ${workflowNode.parameters.limit as number} 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 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,
undefined,
false,
);
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;
}