import { getStyleTokenValue } from '@/utils/htmlUtils'; import { isNumber, closestNumberDivisibleBy } from '@/utils'; import { NODE_OUTPUT_DEFAULT_KEY, STICKY_NODE_TYPE } from '@/constants'; import type { EndpointStyle, IBounds, INodeUi, XYPosition } from '@/Interface'; import type { ArrayAnchorSpec, ConnectorSpec, OverlaySpec, PaintStyle } from '@jsplumb/common'; import type { Endpoint, Connection } from '@jsplumb/core'; import { N8nConnector } from '@/plugins/connectors/N8nCustomConnector'; import type { ConnectionTypes, IConnection, INode, ITaskData, INodeExecutionData, NodeInputConnections, INodeTypeDescription, } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow'; import { EVENT_CONNECTION_MOUSEOUT, EVENT_CONNECTION_MOUSEOVER } from '@jsplumb/browser-ui'; import { useUIStore } from '@/stores'; /* 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 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 labelOffset = 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', true), strokeWidth: 2, outlineWidth: 12, outlineStroke: 'transparent', }; export const CONNECTOR_PAINT_STYLE_PULL: PaintStyle = { ...CONNECTOR_PAINT_STYLE_DEFAULT, stroke: getStyleTokenValue('--color-foreground-xdark', true), }; export const CONNECTOR_PAINT_STYLE_PRIMARY = { ...CONNECTOR_PAINT_STYLE_DEFAULT, stroke: getStyleTokenValue('--color-primary', true), }; export const CONNECTOR_PAINT_STYLE_DATA: PaintStyle = { ...CONNECTOR_PAINT_STYLE_DEFAULT, ...{ dashstyle: '5 3', }, stroke: getStyleTokenValue('--color-foreground-dark', true), }; export const getConnectorColor = (type: ConnectionTypes, category?: string): string => { if (category === 'error') { return '--node-error-output-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 ConnectionTypes, connection.parameters.category, ); const additionalStyles: PaintStyle = {}; if (connection.parameters.type !== NodeConnectionType.Main) { additionalStyles.dashstyle = '5 3'; } return { ...CONNECTOR_PAINT_STYLE_PULL, ...(connectorColor ? { stroke: getStyleTokenValue(connectorColor, true) } : {}), ...additionalStyles, }; }; export const getConnectorPaintStyleDefault = (connection: Connection): PaintStyle => { const connectorColor = getConnectorColor( connection.parameters.type as ConnectionTypes, connection.parameters.category, ); return { ...CONNECTOR_PAINT_STYLE_DEFAULT, ...(connectorColor ? { stroke: getStyleTokenValue(connectorColor, true) } : {}), }; }; export const getConnectorPaintStyleData = ( connection: Connection, category?: string, ): PaintStyle => { const connectorColor = getConnectorColor(connection.parameters.type as ConnectionTypes, category); return { ...CONNECTOR_PAINT_STYLE_DATA, ...(connectorColor ? { stroke: getStyleTokenValue(connectorColor, true) } : {}), }; }; 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: ConnectionTypes, 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?: string) => { if (!type || type === NodeConnectionType.Main) { return undefined; } return type; }; export const getEndpointScope = (endpointType: ConnectionTypes): string | undefined => { if (Object.values(NodeConnectionType).includes(endpointType)) { return getScope(endpointType); } return undefined; }; export const getInputEndpointStyle = ( nodeTypeData: INodeTypeDescription, color: string, connectionType: ConnectionTypes = 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: getStyleTokenValue(color), stroke: getStyleTokenValue(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: (component: Endpoint) => { const label = document.createElement('div'); label.innerHTML = labelText; if (required) { label.innerHTML += ' *'; } label.classList.add('node-input-endpoint-label'); if (inputName !== NodeConnectionType.Main) { label.classList.add('node-input-endpoint-label--data'); label.classList.add(`node-connection-type-${inputName}`); } return label; }, }, }); export const getOutputEndpointStyle = ( nodeTypeData: INodeTypeDescription, color: string, ): PaintStyle => ({ strokeWidth: nodeTypeData && nodeTypeData.outputs.length > 2 ? 7 : 9, fill: getStyleTokenValue(color, true), outlineStroke: 'none', }); export const getOutputNameOverlay = ( labelText: string, outputName: ConnectionTypes, 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); } if (outputName !== NodeConnectionType.Main) { label.classList.add('node-output-endpoint-label--data'); label.classList.add(`node-connection-type-${getScope(outputName)}`); } 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 = (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; }, 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); 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 = 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) { 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, ) => { 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) => { 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: ConnectionTypes, ) => { 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 }, ) => { connection.addClass('success'); 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'); 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); }); }; 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: ConnectionTypes, outputIndex: number, ) => { return `${nodeId}${OUTPUT_UUID_KEY}${getScope(connectionType) || ''}${outputIndex}`; }; export const getInputEndpointUUID = ( nodeId: string, connectionType: ConnectionTypes, inputIndex: number, ) => { return `${nodeId}${INPUT_UUID_KEY}${getScope(connectionType) || ''}${inputIndex}`; }; export const getFixedNodesList = (workflowNodes: INode[]) => { 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; };