import { closestNumberDivisibleBy, getStyleTokenValue, isNumber } from '@/utils'; import { NODE_OUTPUT_DEFAULT_KEY, STICKY_NODE_TYPE, QUICKSTART_NOTE_NAME } from '@/constants'; import { EndpointStyle, IBounds, INodeUi, XYPosition } from '@/Interface'; import { AnchorArraySpec, Connection, Endpoint, Overlay, OverlaySpec, PaintStyle } from 'jsplumb'; import { IConnection, INode, ITaskData, INodeExecutionData, NodeInputConnections, INodeTypeDescription, } from 'n8n-workflow'; /* 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_LABEL_POSITION = [-3, 0.5]; export const OVERLAY_INPUT_NAME_LABEL_POSITION_MOVED = [-4.5, 0.5]; 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 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 WELCOME_STICKY_NODE = { name: QUICKSTART_NOTE_NAME, type: STICKY_NODE_TYPE, typeVersion: 1, position: [0, 0] as XYPosition, parameters: { height: 300, width: 380, }, }; export const CONNECTOR_FLOWCHART_TYPE = [ 'N8nCustom', { 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 && endpoint.__meta ? endpoint.__meta.index : 0; const totalEndpoints = endpoint && endpoint.__meta ? endpoint.__meta.totalEndpoints : 0; const outputOverlay = getOverlay(endpoint, OVERLAY_OUTPUT_NAME_LABEL); const labelOffset = outputOverlay && outputOverlay.label && outputOverlay.label.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: getStyleTokenValue('--color-foreground-dark'), strokeWidth: 2, outlineWidth: 12, outlineStroke: 'transparent', }; export const CONNECTOR_PAINT_STYLE_PULL: PaintStyle = { ...CONNECTOR_PAINT_STYLE_DEFAULT, stroke: getStyleTokenValue('--color-foreground-xdark'), }; export const CONNECTOR_PAINT_STYLE_PRIMARY = { ...CONNECTOR_PAINT_STYLE_DEFAULT, stroke: getStyleTokenValue('--color-primary'), }; export const CONNECTOR_PAINT_STYLE_SUCCESS = { ...CONNECTOR_PAINT_STYLE_DEFAULT, stroke: getStyleTokenValue('--color-success-light'), }; export const CONNECTOR_ARROW_OVERLAYS: OverlaySpec[] = [ [ 'Arrow', { id: OVERLAY_ENDPOINT_ARROW_ID, location: 1, width: 12, foldback: 1, length: 10, visible: true, }, ], [ 'Arrow', { id: OVERLAY_MIDPOINT_ARROW_ID, location: 0.5, width: 12, foldback: 1, length: 10, visible: false, }, ], ]; export const ANCHOR_POSITIONS: { [key: string]: { [key: number]: AnchorArraySpec[]; }; } = { input: { 1: [[0.01, 0.5, -1, 0]], 2: [ [0.01, 0.3, -1, 0], [0.01, 0.7, -1, 0], ], 3: [ [0.01, 0.25, -1, 0], [0.01, 0.5, -1, 0], [0.01, 0.75, -1, 0], ], 4: [ [0.01, 0.2, -1, 0], [0.01, 0.4, -1, 0], [0.01, 0.6, -1, 0], [0.01, 0.8, -1, 0], ], }, output: { 1: [[0.99, 0.5, 1, 0]], 2: [ [0.99, 0.3, 1, 0], [0.99, 0.7, 1, 0], ], 3: [ [0.99, 0.25, 1, 0], [0.99, 0.5, 1, 0], [0.99, 0.75, 1, 0], ], 4: [ [0.99, 0.2, 1, 0], [0.99, 0.4, 1, 0], [0.99, 0.6, 1, 0], [0.99, 0.8, 1, 0], ], }, }; export const getInputEndpointStyle = ( nodeTypeData: INodeTypeDescription, color: string, ): EndpointStyle => ({ width: 8, height: nodeTypeData && nodeTypeData.outputs.length > 2 ? 18 : 20, fill: getStyleTokenValue(color), stroke: getStyleTokenValue(color), lineWidth: 0, }); export const getInputNameOverlay = (label: string): OverlaySpec => [ 'Label', { id: OVERLAY_INPUT_NAME_LABEL, location: OVERLAY_INPUT_NAME_LABEL_POSITION, label, cssClass: 'node-input-endpoint-label', visible: true, }, ]; export const getOutputEndpointStyle = (nodeTypeData: INodeTypeDescription, color: string) => ({ radius: nodeTypeData && nodeTypeData.outputs.length > 2 ? 7 : 9, fill: getStyleTokenValue(color), outlineStroke: 'none', }); export const getOutputNameOverlay = (label: string): OverlaySpec => [ 'Label', { id: OVERLAY_OUTPUT_NAME_LABEL, location: [1.9, 0.5], label, cssClass: 'node-output-endpoint-label', visible: true, }, ]; export const addOverlays = (connection: Connection, overlays: OverlaySpec[]) => { overlays.forEach((overlay: OverlaySpec) => { connection.addOverlay(overlay); }); }; export const getLeftmostTopNode = (nodes: INodeUi[]): INodeUi => { return nodes.reduce((leftmostTop, node) => { if (node.position[0] > leftmostTop.position[0] || node.position[1] > leftmostTop.position[1]) { return leftmostTop; } return node; }); }; 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 || !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.lastReturnValue[0]; const targetPosition = targetEndpoint.anchor.lastReturnValue ? targetEndpoint.anchor.lastReturnValue[0] : sourcePosition + 1; // lastReturnValue is null when moving connections from node to another const minimum = hasItemsLabel ? 150 : 0; const isBackwards = sourcePosition >= targetPosition; const isTooLong = Math.abs(sourcePosition - targetPosition) >= minimum; const arrow = getOverlay(connection, OVERLAY_MIDPOINT_ARROW_ID); if (arrow) { arrow.setVisible(isBackwards && isTooLong); arrow.setLocation(hasItemsLabel ? 0.6 : 0.5); } }; export const getConnectorLengths = (connection: Connection): [number, number] => { if (!connection.connector) { return [0, 0]; } const bounds = connection.connector.bounds; const diffX = Math.abs(bounds.maxX - bounds.minX); const diffY = Math.abs(bounds.maxY - bounds.minY); return [diffX, diffY]; }; const isLoopingBackwards = (connection: Connection) => { const sourceEndpoint = connection.endpoints[0]; const targetEndpoint = connection.endpoints[1]; const sourcePosition = sourceEndpoint.anchor.lastReturnValue[0]; const targetPosition = targetEndpoint.anchor.lastReturnValue[0]; return targetPosition - sourcePosition < -1 * LOOPBACK_MINIMUM; }; export const showOrHideItemsLabel = (connection: Connection) => { if (!connection || !connection.connector) { return; } const overlay = getOverlay(connection, OVERLAY_RUN_ITEMS_ID); if (!overlay) { return; } const actionsOverlay = getOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID); if (actionsOverlay && actionsOverlay.visible) { overlay.setVisible(false); return; } const [diffX, diffY] = getConnectorLengths(connection); if (diffX < MIN_X_TO_SHOW_OUTPUT_LABEL && diffY < MIN_Y_TO_SHOW_OUTPUT_LABEL) { overlay.setVisible(false); } else { overlay.setVisible(true); } const innerElement = overlay.canvas && overlay.canvas.querySelector('span'); 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 ''; } if (name === 'plus') { return ''; } 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; }; 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 === true) { targetPosition[0] += movePosition[0]; targetPosition[1] += movePosition[1]; } } while (conflictFound === true); return targetPosition; }; export const getMousePosition = (e: MouseEvent | TouchEvent): XYPosition => { // @ts-ignore const x = e.pageX !== undefined ? e.pageX : e.touches && e.touches[0] && e.touches[0].pageX ? e.touches[0].pageX : 0; // @ts-ignore const y = e.pageY !== undefined ? e.pageY : e.touches && e.touches[0] && 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, ) => { const squareSize = GRID_SIZE * scale; const dotSize = 1 * scale; const dotPosition = (GRID_SIZE / 2) * scale; if (executionPreview) { return { 'background-image': 'linear-gradient(135deg, #f9f9fb 25%, #ffffff 25%, #ffffff 50%, #f9f9fb 50%, #f9f9fb 75%, #ffffff 75%, #ffffff 100%)', 'background-size': `${squareSize}px ${squareSize}px`, 'background-position': `left ${offsetPosition[0]}px top ${offsetPosition[1]}px`, }; } const styles: object = { 'background-size': `${squareSize}px ${squareSize}px`, 'background-position': `left ${offsetPosition[0]}px top ${offsetPosition[1]}px`, }; if (squareSize > 10.5) { const dotColor = getStyleTokenValue('--color-canvas-dot'); return { ...styles, 'background-image': `radial-gradient(circle at ${dotPosition}px ${dotPosition}px, ${dotColor} ${dotSize}px, transparent 0)`, }; } return styles; }; export const hideConnectionActions = (connection: Connection | null) => { if (connection && connection.connector) { hideOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID); showOrHideItemsLabel(connection); showOrHideMidpointArrow(connection); } }; export const showConnectionActions = (connection: Connection | null) => { if (connection && connection.connector) { 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); } } }; export const getOutputSummary = (data: ITaskData[], nodeConnections: NodeInputConnections) => { const outputMap: { [sourceOutputIndex: string]: { [targetNodeName: string]: { [targetInputIndex: string]: { total: number; iterations: number }; }; }; } = {}; data.forEach((run: ITaskData) => { if (!run.data || !run.data.main) { return; } run.data.main.forEach((output: INodeExecutionData[] | null, i: number) => { const sourceOutputIndex = i; 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, }; } 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.setPaintStyle(CONNECTOR_PAINT_STYLE_DEFAULT); showOrHideMidpointArrow(connection); if (connection.canvas) { connection.canvas.classList.remove('success'); } }; 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 }, ) => { connection.setPaintStyle(CONNECTOR_PAINT_STYLE_SUCCESS); if (connection.canvas) { connection.canvas.classList.add('success'); } if (getOverlay(connection, OVERLAY_RUN_ITEMS_ID)) { connection.removeOverlay(OVERLAY_RUN_ITEMS_ID); } connection.addOverlay([ 'Label', { id: OVERLAY_RUN_ITEMS_ID, label: `${getRunItemsLabel(output)}`, cssClass: 'connection-run-items-label', location: 0.5, }, ]); showOrHideItemsLabel(connection); showOrHideMidpointArrow(connection); }; 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 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) * 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 && connection.connector) { if (targetEndpoint) { connection.connector.setTargetEndpoint(targetEndpoint); } connection.setPaintStyle(CONNECTOR_PAINT_STYLE_PRIMARY); hideOverlay(connection, OVERLAY_DROP_NODE_ID); } }; export const showPullConnectionState = (connection: Connection) => { if (connection && connection.connector) { connection.connector.resetTargetEndpoint(); connection.setPaintStyle(CONNECTOR_PAINT_STYLE_PULL); showOverlay(connection, OVERLAY_DROP_NODE_ID); } }; export const resetConnectionAfterPull = (connection: Connection) => { if (connection && connection.connector) { connection.connector.resetTargetEndpoint(); connection.setPaintStyle(CONNECTOR_PAINT_STYLE_DEFAULT); } }; export const resetInputLabelPosition = (targetEndpoint: Endpoint) => { const inputNameOverlay = getOverlay(targetEndpoint, OVERLAY_INPUT_NAME_LABEL); if (inputNameOverlay) { inputNameOverlay.setLocation(OVERLAY_INPUT_NAME_LABEL_POSITION); } }; export const moveBackInputLabelPosition = (targetEndpoint: Endpoint) => { const inputNameOverlay = getOverlay(targetEndpoint, OVERLAY_INPUT_NAME_LABEL); if (inputNameOverlay) { inputNameOverlay.setLocation(OVERLAY_INPUT_NAME_LABEL_POSITION_MOVED); } }; export const addConnectionActionsOverlay = ( connection: Connection, onDelete: Function, onAdd: Function, ) => { if (getOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID)) { return; // avoid free floating actions when moving connection from one node to another } connection.addOverlay([ 'Label', { id: OVERLAY_CONNECTION_ACTIONS_ID, label: `
${getIcon('plus')}
${getIcon( 'trash', )}
`, cssClass: OVERLAY_CONNECTION_ACTIONS_ID, visible: false, events: { mousedown: (overlay: Overlay, event: MouseEvent) => { const element = event.target as HTMLElement; if ( element.classList.contains('delete') || (element.parentElement && element.parentElement.classList.contains('delete')) ) { onDelete(); } else if ( element.classList.contains('add') || (element.parentElement && element.parentElement.classList.contains('add')) ) { onAdd(); } }, }, }, ]); }; export const getOutputEndpointUUID = (nodeId: string, outputIndex: number) => { return `${nodeId}${OUTPUT_UUID_KEY}${outputIndex}`; }; export const getInputEndpointUUID = (nodeId: string, inputIndex: number) => { return `${nodeId}${INPUT_UUID_KEY}${inputIndex}`; }; export const getFixedNodesList = (workflowNodes: INode[]) => { const nodes = [...workflowNodes]; const leftmostTop = getLeftmostTopNode(nodes); const diffX = DEFAULT_START_POSITION_X - leftmostTop.position[0]; const diffY = DEFAULT_START_POSITION_Y - leftmostTop.position[1]; nodes.map((node) => { node.position[0] += diffX + NODE_SIZE * 2; node.position[1] += diffY; }); return nodes; };