diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 2a8bb3ea86..f3f67f6eb1 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -1,4 +1,3 @@ -import type { FrontendBetaFeatures } from '@n8n/config'; import type { ExpressionEvaluatorType, LogLevel, WorkflowSettings } from 'n8n-workflow'; export interface IVersionNotificationSettings { @@ -176,7 +175,6 @@ export interface FrontendSettings { security: { blockFileAccessToN8nFiles: boolean; }; - betaFeatures: FrontendBetaFeatures[]; easyAIWorkflowOnboarded: boolean; partialExecution: { version: 1 | 2; diff --git a/packages/@n8n/config/src/configs/frontend.config.ts b/packages/@n8n/config/src/configs/frontend.config.ts deleted file mode 100644 index 62fa004dd5..0000000000 --- a/packages/@n8n/config/src/configs/frontend.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Config, Env } from '../decorators'; -import { StringArray } from '../utils'; - -export type FrontendBetaFeatures = 'canvas_v2'; - -@Config -export class FrontendConfig { - /** Which UI experiments to enable. Separate multiple values with a comma `,` */ - @Env('N8N_UI_BETA_FEATURES') - betaFeatures: StringArray = ['canvas_v2']; -} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index db3861d855..7b7ef607aa 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -31,7 +31,6 @@ export { Config, Env, Nested } from './decorators'; export { TaskRunnersConfig } from './configs/runners.config'; export { SecurityConfig } from './configs/security.config'; export { ExecutionsConfig } from './configs/executions.config'; -export { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config'; export { S3Config } from './configs/external-storage.config'; export { LOG_SCOPES } from './configs/logging.config'; export type { LogScope } from './configs/logging.config'; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 609e30ec60..06da1382cf 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -1,5 +1,5 @@ import type { FrontendSettings, ITelemetrySettings } from '@n8n/api-types'; -import { GlobalConfig, FrontendConfig, SecurityConfig } from '@n8n/config'; +import { GlobalConfig, SecurityConfig } from '@n8n/config'; import { Container, Service } from '@n8n/di'; import { createWriteStream } from 'fs'; import { mkdir } from 'fs/promises'; @@ -44,7 +44,6 @@ export class FrontendService { private readonly instanceSettings: InstanceSettings, private readonly urlService: UrlService, private readonly securityConfig: SecurityConfig, - private readonly frontendConfig: FrontendConfig, ) { loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes()); void this.generateTypes(); @@ -232,7 +231,6 @@ export class FrontendService { security: { blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles, }, - betaFeatures: this.frontendConfig.betaFeatures, easyAIWorkflowOnboarded: false, partialExecution: this.globalConfig.partialExecutions, }; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 3b3996d0dd..e573d5f40e 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -29,11 +29,6 @@ "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.26.3", "@fontsource/open-sans": "^4.5.0", - "@jsplumb/browser-ui": "^5.13.2", - "@jsplumb/common": "^5.13.2", - "@jsplumb/connector-bezier": "^5.13.2", - "@jsplumb/core": "^5.13.2", - "@jsplumb/util": "^5.13.2", "@lezer/common": "^1.0.4", "@n8n/api-types": "workspace:*", "@n8n/chat": "workspace:*", diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index 04223f8d92..30ce2a686d 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -100,22 +100,22 @@ watch(defaultLocale, (newLocale) => {
- - + + - + - +
- +
diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 7f7e445fde..883a9b753f 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1,6 +1,5 @@ import type { Component } from 'vue'; import type { NotificationOptions as ElementNotificationOptions } from 'element-plus'; -import type { Connection } from '@jsplumb/core'; import type { BannerName, FrontendSettings, @@ -1457,16 +1456,6 @@ export type ToggleNodeCreatorOptions = { export type AppliedThemeOption = 'light' | 'dark'; export type ThemeOption = AppliedThemeOption | 'system'; -export type NewConnectionInfo = { - sourceId: string; - index: number; - eventSource: NodeCreatorOpenSource; - connection?: Connection; - nodeCreatorView?: NodeFilterType; - outputType?: NodeConnectionType; - endpointUuid?: string; -}; - export type EnterpriseEditionFeatureKey = | 'AdvancedExecutionFilters' | 'Sharing' diff --git a/packages/editor-ui/src/__tests__/defaults.ts b/packages/editor-ui/src/__tests__/defaults.ts index f1d16ed7e3..a058b33f88 100644 --- a/packages/editor-ui/src/__tests__/defaults.ts +++ b/packages/editor-ui/src/__tests__/defaults.ts @@ -135,7 +135,6 @@ export const defaultSettings: FrontendSettings = { enabled: false, credits: 0, }, - betaFeatures: [], easyAIWorkflowOnboarded: false, partialExecution: { version: 1, diff --git a/packages/editor-ui/src/__tests__/mocks.ts b/packages/editor-ui/src/__tests__/mocks.ts index f514d809d9..6db800e785 100644 --- a/packages/editor-ui/src/__tests__/mocks.ts +++ b/packages/editor-ui/src/__tests__/mocks.ts @@ -12,7 +12,7 @@ import type { INodeIssues, } from 'n8n-workflow'; import { NodeConnectionType, NodeHelpers, Workflow } from 'n8n-workflow'; -import { uuid } from '@jsplumb/util'; +import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; import { diff --git a/packages/editor-ui/src/components/CanvasControls.vue b/packages/editor-ui/src/components/CanvasControls.vue deleted file mode 100644 index e0bced9be0..0000000000 --- a/packages/editor-ui/src/components/CanvasControls.vue +++ /dev/null @@ -1,125 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue deleted file mode 100644 index 5c2336f4df..0000000000 --- a/packages/editor-ui/src/components/Node.vue +++ /dev/null @@ -1,1579 +0,0 @@ - - - - - - - - - diff --git a/packages/editor-ui/src/components/Sticky.vue b/packages/editor-ui/src/components/Sticky.vue deleted file mode 100644 index 53ea66747d..0000000000 --- a/packages/editor-ui/src/components/Sticky.vue +++ /dev/null @@ -1,500 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue index 5aced98fcf..a9851ed0fd 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue @@ -5,7 +5,7 @@ import WorkflowExecutionsSidebar from '@/components/executions/workflow/Workflow import { MAIN_HEADER_TABS, VIEWS } from '@/constants'; import type { ExecutionFilterType, IWorkflowDb } from '@/Interface'; import type { ExecutionSummary } from 'n8n-workflow'; -import { getNodeViewTab } from '@/utils/canvasUtils'; +import { getNodeViewTab } from '@/utils/nodeViewUtils'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; const props = withDefaults( diff --git a/packages/editor-ui/src/composables/useCanvasMouseSelect.ts b/packages/editor-ui/src/composables/useCanvasMouseSelect.ts deleted file mode 100644 index 449edfd5db..0000000000 --- a/packages/editor-ui/src/composables/useCanvasMouseSelect.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { INodeUi, XYPosition } from '@/Interface'; - -import { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; -import { useUIStore } from '@/stores/ui.store'; -import { useWorkflowsStore } from '@/stores/workflows.store'; -import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils'; -import { ref, computed } from 'vue'; -import { useCanvasStore } from '@/stores/canvas.store'; -import { useContextMenu } from './useContextMenu'; -import { useStyles } from './useStyles'; - -interface ExtendedHTMLSpanElement extends HTMLSpanElement { - x: number; - y: number; -} - -export default function useCanvasMouseSelect() { - const selectActive = ref(false); - const selectBox = ref(document.createElement('span') as ExtendedHTMLSpanElement); - - const { isTouchDevice, isCtrlKeyPressed } = useDeviceSupport(); - const uiStore = useUIStore(); - const canvasStore = useCanvasStore(); - const workflowsStore = useWorkflowsStore(); - const { isOpen: isContextMenuOpen } = useContextMenu(); - const { APP_Z_INDEXES } = useStyles(); - - function _setSelectBoxStyle(styles: Record) { - Object.assign(selectBox.value.style, styles); - } - - function _showSelectBox(event: MouseEvent) { - const [x, y] = getMousePositionWithinNodeView(event); - selectBox.value = Object.assign(selectBox.value, { x, y }); - - _setSelectBoxStyle({ - left: selectBox.value.x + 'px', - top: selectBox.value.y + 'px', - visibility: 'visible', - }); - selectActive.value = true; - } - - function _updateSelectBox(event: MouseEvent) { - const selectionBox = _getSelectionBox(event); - - _setSelectBoxStyle({ - left: selectionBox.x + 'px', - top: selectionBox.y + 'px', - width: selectionBox.width + 'px', - height: selectionBox.height + 'px', - }); - } - - function _hideSelectBox() { - selectBox.value.x = 0; - selectBox.value.y = 0; - - _setSelectBoxStyle({ - visibility: 'hidden', - left: '0px', - top: '0px', - width: '0px', - height: '0px', - }); - selectActive.value = false; - } - - function _getSelectionBox(event: MouseEvent | TouchEvent) { - const [x, y] = getMousePositionWithinNodeView(event); - return { - x: Math.min(x, selectBox.value.x), - y: Math.min(y, selectBox.value.y), - width: Math.abs(x - selectBox.value.x), - height: Math.abs(y - selectBox.value.y), - }; - } - - function _getNodesInSelection(event: MouseEvent | TouchEvent): INodeUi[] { - const returnNodes: INodeUi[] = []; - const selectionBox = _getSelectionBox(event); - - // Go through all nodes and check if they are selected - workflowsStore.allNodes.forEach((node: INodeUi) => { - // TODO: Currently always uses the top left corner for checking. Should probably use the center instead - if ( - node.position[0] < selectionBox.x || - node.position[0] > selectionBox.x + selectionBox.width - ) { - return; - } - if ( - node.position[1] < selectionBox.y || - node.position[1] > selectionBox.y + selectionBox.height - ) { - return; - } - returnNodes.push(node); - }); - - return returnNodes; - } - - function _createSelectBox() { - selectBox.value.id = 'select-box'; - _setSelectBoxStyle({ - margin: '0px auto', - border: '2px dotted #FF0000', - // Positioned absolutely within #node-view. This is consistent with how nodes are positioned. - position: 'absolute', - zIndex: `${APP_Z_INDEXES.SELECT_BOX}`, - visibility: 'hidden', - }); - - selectBox.value.addEventListener('mouseup', mouseUpMouseSelect); - - const nodeViewEl = document.querySelector('#node-view') as HTMLDivElement; - nodeViewEl.appendChild(selectBox.value); - } - - function _mouseMoveSelect(e: MouseEvent) { - if (e.buttons === 0) { - // Mouse button is not pressed anymore so stop selection mode - // Happens normally when mouse leave the view pressed and then - // comes back unpressed. - mouseUpMouseSelect(e); - return; - } - - _updateSelectBox(e); - } - - function mouseUpMouseSelect(e: MouseEvent | TouchEvent) { - // Ignore right-click - if (('button' in e && e.button === 2) || isContextMenuOpen.value) return; - - if (!selectActive.value) { - if (isTouchDevice && e.target instanceof HTMLElement) { - if (e.target && e.target.id.includes('node-view')) { - // Deselect all nodes - deselectAllNodes(); - } - } - // If it is not active return directly. - // Else normal node dragging will not work. - return; - } - document.removeEventListener('mousemove', _mouseMoveSelect); - - // Deselect all nodes - deselectAllNodes(); - - // Select the nodes which are in the selection box - const selectedNodes = _getNodesInSelection(e); - selectedNodes.forEach((node) => { - nodeSelected(node); - }); - - if (selectedNodes.length === 1) { - uiStore.lastSelectedNode = selectedNodes[0].name; - } - - _hideSelectBox(); - } - function mouseDownMouseSelect(e: MouseEvent, moveButtonPressed: boolean) { - if (isCtrlKeyPressed(e) || moveButtonPressed || e.button === 2) { - // We only care about it when the ctrl key is not pressed at the same time. - // So we exit when it is pressed. - return; - } - - if (uiStore.isActionActive['dragActive']) { - // If a node does currently get dragged we do not activate the selection - return; - } - _showSelectBox(e); - - // Leave like this. - // Do not add an anonymous function because then remove would not work anymore - document.addEventListener('mousemove', _mouseMoveSelect); - } - - function getMousePositionWithinNodeView(event: MouseEvent | TouchEvent): XYPosition { - const mousePosition = getMousePosition(event); - - const [relativeX, relativeY] = canvasStore.canvasPositionFromPagePosition(mousePosition); - const nodeViewScale = canvasStore.nodeViewScale; - const nodeViewOffsetPosition = uiStore.nodeViewOffsetPosition; - - return getRelativePosition(relativeX, relativeY, nodeViewScale, nodeViewOffsetPosition); - } - - function nodeDeselected(node: INodeUi) { - uiStore.removeNodeFromSelection(node); - instance.value.removeFromDragSelection(instance.value.getManagedElement(node?.id)); - } - - function nodeSelected(node: INodeUi) { - uiStore.addSelectedNode(node); - instance.value.addToDragSelection(instance.value.getManagedElement(node?.id)); - } - - function deselectAllNodes() { - instance.value.clearDragSelection(); - uiStore.resetSelectedNodes(); - uiStore.lastSelectedNode = null; - uiStore.lastSelectedNodeOutputIndex = null; - - canvasStore.newNodeInsertPosition = null; - canvasStore.setLastSelectedConnection(undefined); - } - - const instance = computed(() => canvasStore.jsPlumbInstance); - - function initializeCanvasMouseSelect() { - _createSelectBox(); - } - - return { - selectActive, - getMousePositionWithinNodeView, - mouseUpMouseSelect, - mouseDownMouseSelect, - nodeDeselected, - nodeSelected, - deselectAllNodes, - initializeCanvasMouseSelect, - }; -} diff --git a/packages/editor-ui/src/composables/useCanvasPanning.test.ts b/packages/editor-ui/src/composables/useCanvasPanning.test.ts deleted file mode 100644 index b820e05c17..0000000000 --- a/packages/editor-ui/src/composables/useCanvasPanning.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { Ref } from 'vue'; -import { ref } from 'vue'; -import { useCanvasPanning } from '@/composables/useCanvasPanning'; -import { getMousePosition } from '@/utils/nodeViewUtils'; -import { useUIStore } from '@/stores/ui.store'; - -vi.mock('@/stores/ui.store', () => ({ - useUIStore: vi.fn(() => ({ - nodeViewOffsetPosition: [0, 0], - nodeViewMoveInProgress: false, - isActionActive: vi.fn().mockReturnValue(() => true), - })), -})); - -vi.mock('@/utils/nodeViewUtils', () => ({ - getMousePosition: vi.fn(() => [0, 0]), -})); - -describe('useCanvasPanning()', () => { - let element: HTMLElement; - let elementRef: Ref; - - beforeEach(() => { - element = document.createElement('div'); - element.id = 'node-view'; - elementRef = ref(element); - document.body.appendChild(element); - }); - - afterEach(() => { - document.body.removeChild(element); - }); - - describe('onMouseDown()', () => { - it('should attach mousemove event listener on mousedown', () => { - const addEventListenerSpy = vi.spyOn(element, 'addEventListener'); - const { onMouseDown, onMouseMove } = useCanvasPanning(elementRef); - const mouseEvent = new MouseEvent('mousedown', { button: 1 }); - - onMouseDown(mouseEvent, true); - - expect(addEventListenerSpy).toHaveBeenCalledWith('mousemove', onMouseMove); - }); - }); - - describe('onMouseMove()', () => { - it('should update node view position on mousemove', () => { - vi.mocked(getMousePosition).mockReturnValueOnce([0, 0]).mockReturnValueOnce([100, 100]); - const { onMouseDown, onMouseMove, moveLastPosition } = useCanvasPanning(elementRef); - - expect(moveLastPosition.value).toEqual([0, 0]); - - onMouseDown(new MouseEvent('mousedown', { button: 1 }), true); - onMouseMove(new MouseEvent('mousemove', { buttons: 4 })); - - expect(moveLastPosition.value).toEqual([100, 100]); - }); - }); - - describe('onMouseUp()', () => { - it('should remove mousemove event listener on mouseup', () => { - vi.mocked(useUIStore).mockReturnValueOnce({ - nodeViewOffsetPosition: [0, 0], - nodeViewMoveInProgress: true, - isActionActive: vi.fn().mockReturnValue(() => true), - } as unknown as ReturnType); - - const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener'); - const { onMouseDown, onMouseMove, onMouseUp } = useCanvasPanning(elementRef); - - onMouseDown(new MouseEvent('mousedown', { button: 1 }), true); - onMouseUp(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith('mousemove', onMouseMove); - }); - }); - - describe('panCanvas()', () => { - it('should update node view offset position correctly', () => { - vi.mocked(getMousePosition).mockReturnValueOnce([100, 100]); - - const { panCanvas } = useCanvasPanning(elementRef); - const [x, y] = panCanvas(new MouseEvent('mousemove')); - - expect(x).toEqual(100); - expect(y).toEqual(100); - }); - - it('should not update offset position if mouse is not moved', () => { - const { panCanvas } = useCanvasPanning(elementRef); - const [x, y] = panCanvas(new MouseEvent('mousemove')); - - expect(x).toEqual(0); - expect(y).toEqual(0); - }); - }); -}); diff --git a/packages/editor-ui/src/composables/useCanvasPanning.ts b/packages/editor-ui/src/composables/useCanvasPanning.ts deleted file mode 100644 index 88aee9ba09..0000000000 --- a/packages/editor-ui/src/composables/useCanvasPanning.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { Ref } from 'vue'; -import { ref, unref } from 'vue'; - -import { getMousePosition } from '@/utils/nodeViewUtils'; -import { useUIStore } from '@/stores/ui.store'; -import { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; -import { MOUSE_EVENT_BUTTON, MOUSE_EVENT_BUTTONS } from '@/constants'; - -/** - * Composable for handling canvas panning interactions - it facilitates the movement of the - * canvas element in response to mouse events - */ -export function useCanvasPanning( - elementRef: Ref, - options: { - // @TODO To be refactored (unref) when migrating NodeView to composition API - onMouseMoveEnd?: Ref void)>; - } = {}, -) { - const uiStore = useUIStore(); - const moveLastPosition = ref([0, 0]); - const deviceSupport = useDeviceSupport(); - - /** - * Updates the canvas offset position based on the mouse movement - */ - function panCanvas(e: MouseEvent | TouchEvent) { - const offsetPosition = uiStore.nodeViewOffsetPosition; - - const [x, y] = getMousePosition(e); - - const nodeViewOffsetPositionX = offsetPosition[0] + (x - moveLastPosition.value[0]); - const nodeViewOffsetPositionY = offsetPosition[1] + (y - moveLastPosition.value[1]); - uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY]; - - // Update the last position - moveLastPosition.value = [x, y]; - - return [nodeViewOffsetPositionX, nodeViewOffsetPositionY]; - } - - /** - * Initiates the panning process when specific conditions are met (middle mouse or ctrl key pressed) - */ - function onMouseDown(e: MouseEvent, moveButtonPressed: boolean) { - if (!(deviceSupport.isCtrlKeyPressed(e) || moveButtonPressed)) { - // We only care about it when the ctrl key is pressed at the same time. - // So we exit when it is not pressed. - return; - } - - if (uiStore.isActionActive['dragActive']) { - // If a node does currently get dragged we do not activate the selection - return; - } - - // Prevent moving canvas on anything but middle button - if (e.button !== MOUSE_EVENT_BUTTON.MIDDLE) { - uiStore.nodeViewMoveInProgress = true; - } - - const [x, y] = getMousePosition(e); - - moveLastPosition.value = [x, y]; - - const element = unref(elementRef); - element?.addEventListener('mousemove', onMouseMove); - } - - /** - * Ends the panning process and removes the mousemove event listener - */ - function onMouseUp() { - if (!uiStore.nodeViewMoveInProgress) { - // If it is not active return directly. - // Else normal node dragging will not work. - return; - } - - const element = unref(elementRef); - element?.removeEventListener('mousemove', onMouseMove); - - uiStore.nodeViewMoveInProgress = false; - - // Nothing else to do. Simply leave the node view at the current offset - } - - /** - * Handles the actual movement of the canvas during a mouse drag, - * updating the position based on the current mouse position - */ - function onMouseMove(e: MouseEvent | TouchEvent) { - const element = unref(elementRef); - if (e.target && !(element === e.target || element?.contains(e.target as Node))) { - return; - } - - if (uiStore.isActionActive['dragActive']) { - return; - } - - // Signal that moving canvas is active if middle button is pressed and mouse is moved - if (e instanceof MouseEvent && e.buttons === MOUSE_EVENT_BUTTONS.MIDDLE) { - uiStore.nodeViewMoveInProgress = true; - } - - if (e instanceof MouseEvent && e.buttons === MOUSE_EVENT_BUTTONS.NONE) { - // Mouse button is not pressed anymore so stop selection mode - // Happens normally when mouse leave the view pressed and then - // comes back unpressed. - const onMouseMoveEnd = unref(options.onMouseMoveEnd); - onMouseMoveEnd?.(e); - return; - } - - panCanvas(e); - } - - return { - moveLastPosition, - onMouseDown, - onMouseUp, - onMouseMove, - panCanvas, - }; -} diff --git a/packages/editor-ui/src/composables/useHistoryHelper.ts b/packages/editor-ui/src/composables/useHistoryHelper.ts index 5ea09bd9bb..9a952e1e24 100644 --- a/packages/editor-ui/src/composables/useHistoryHelper.ts +++ b/packages/editor-ui/src/composables/useHistoryHelper.ts @@ -7,7 +7,7 @@ import { useUIStore } from '@/stores/ui.store'; import { onMounted, onUnmounted, nextTick } from 'vue'; import { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; -import { getNodeViewTab } from '@/utils/canvasUtils'; +import { getNodeViewTab } from '@/utils/nodeViewUtils'; import type { RouteLocationNormalizedLoaded } from 'vue-router'; import { useTelemetry } from './useTelemetry'; import { useDebounce } from '@/composables/useDebounce'; diff --git a/packages/editor-ui/src/composables/useNodeBase.test.ts b/packages/editor-ui/src/composables/useNodeBase.test.ts deleted file mode 100644 index 6e8bfd94fd..0000000000 --- a/packages/editor-ui/src/composables/useNodeBase.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { createPinia, setActivePinia } from 'pinia'; -import { mock, mockClear } from 'vitest-mock-extended'; -import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui'; -import { - NodeConnectionType, - type INode, - type INodeTypeDescription, - type Workflow, -} from 'n8n-workflow'; - -import { useNodeBase } from '@/composables/useNodeBase'; -import { useWorkflowsStore } from '@/stores/workflows.store'; -import { useUIStore } from '@/stores/ui.store'; - -describe('useNodeBase', () => { - let pinia: ReturnType; - let workflowsStore: ReturnType; - let uiStore: ReturnType; - let emit: (event: string, ...args: unknown[]) => void; - let nodeBase: ReturnType; - - const jsPlumbInstance = mock(); - const nodeTypeDescription = mock({ - inputs: [NodeConnectionType.Main], - outputs: [NodeConnectionType.Main], - }); - const workflowObject = mock(); - const node = mock(); - - beforeEach(() => { - mockClear(jsPlumbInstance); - - pinia = createPinia(); - setActivePinia(pinia); - - workflowsStore = useWorkflowsStore(); - uiStore = useUIStore(); - emit = vi.fn(); - - nodeBase = useNodeBase({ - instance: jsPlumbInstance, - name: node.name, - workflowObject, - isReadOnly: false, - emit, - }); - }); - - it('should initialize correctly', () => { - const { inputs, outputs } = nodeBase; - - expect(inputs.value).toEqual([]); - expect(outputs.value).toEqual([]); - }); - - describe('addInputEndpoints', () => { - it('should add input endpoints correctly', () => { - jsPlumbInstance.addEndpoint.mockReturnValue(mock()); - vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node); - - nodeBase.addInputEndpoints(node, nodeTypeDescription); - - expect(workflowsStore.getNodeByName).toHaveBeenCalledWith(node.name); - expect(jsPlumbInstance.addEndpoint).toHaveBeenCalledWith(undefined, { - anchor: [0.01, 0.5, -1, 0], - maxConnections: -1, - endpoint: 'Rectangle', - paintStyle: { - width: 8, - height: 20, - fill: 'var(--node-type-main-color)', - stroke: 'var(--node-type-main-color)', - lineWidth: 0, - }, - hoverPaintStyle: { - width: 8, - height: 20, - fill: 'var(--color-primary)', - stroke: 'var(--color-primary)', - lineWidth: 0, - }, - source: false, - target: false, - parameters: { - connection: 'target', - nodeId: node.id, - type: 'main', - index: 0, - }, - enabled: true, - cssClass: 'rect-input-endpoint', - dragAllowedWhenFull: true, - hoverClass: 'rect-input-endpoint-hover', - uuid: `${node.id}-input0`, - }); - }); - }); - - describe('addOutputEndpoints', () => { - it('should add output endpoints correctly', () => { - const getNodeByNameSpy = vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node); - - nodeBase.addOutputEndpoints(node, nodeTypeDescription); - - expect(getNodeByNameSpy).toHaveBeenCalledWith(node.name); - expect(jsPlumbInstance.addEndpoint).toHaveBeenCalledWith(undefined, { - anchor: [0.99, 0.5, 1, 0], - connectionsDirected: true, - cssClass: 'dot-output-endpoint', - dragAllowedWhenFull: false, - enabled: true, - endpoint: { - options: { - radius: 9, - }, - type: 'Dot', - }, - hoverClass: 'dot-output-endpoint-hover', - hoverPaintStyle: { - fill: 'var(--color-primary)', - outlineStroke: 'none', - strokeWidth: 9, - }, - maxConnections: -1, - paintStyle: { - fill: 'var(--node-type-main-color)', - outlineStroke: 'none', - strokeWidth: 9, - }, - parameters: { - connection: 'source', - index: 0, - nodeId: node.id, - type: 'main', - }, - scope: undefined, - source: true, - target: false, - uuid: `${node.id}-output0`, - }); - }); - }); - - describe('mouseLeftClick', () => { - it('should handle mouse left click correctly', () => { - const { mouseLeftClick } = nodeBase; - - const event = new MouseEvent('click', { - bubbles: true, - cancelable: true, - }); - - uiStore.addActiveAction('notDragActive'); - - mouseLeftClick(event); - - expect(emit).toHaveBeenCalledWith('deselectAllNodes'); - expect(emit).toHaveBeenCalledWith('nodeSelected', node.name); - }); - }); - - describe('getSpacerIndexes', () => { - it('should return spacer indexes when left and right group have items and spacer between groups is true', () => { - const { getSpacerIndexes } = nodeBase; - const result = getSpacerIndexes(3, 3, true); - expect(result).toEqual([3]); - }); - - it('should return spacer indexes to meet the min items count if there are less items in both groups', () => { - const { getSpacerIndexes } = nodeBase; - const result = getSpacerIndexes(1, 1, false, 5); - expect(result).toEqual([1, 2, 3]); - }); - - it('should return spacer indexes for left group when only left group has items and less than min items count', () => { - const { getSpacerIndexes } = nodeBase; - const result = getSpacerIndexes(2, 0, false, 4); - expect(result).toEqual([2, 3]); - }); - - it('should return spacer indexes for right group when only right group has items and less than min items count', () => { - const { getSpacerIndexes } = nodeBase; - const result = getSpacerIndexes(0, 3, false, 5); - expect(result).toEqual([0, 1]); - }); - - it('should return empty array when both groups have items more than min items count and spacer between groups is false', () => { - const { getSpacerIndexes } = nodeBase; - const result = getSpacerIndexes(3, 3, false, 5); - expect(result).toEqual([]); - }); - - it('should return empty array when left and right group have items and spacer between groups is false', () => { - const { getSpacerIndexes } = nodeBase; - const result = getSpacerIndexes(2, 2, false, 4); - expect(result).toEqual([]); - }); - }); -}); diff --git a/packages/editor-ui/src/composables/useNodeBase.ts b/packages/editor-ui/src/composables/useNodeBase.ts deleted file mode 100644 index e051422b45..0000000000 --- a/packages/editor-ui/src/composables/useNodeBase.ts +++ /dev/null @@ -1,668 +0,0 @@ -import { computed, getCurrentInstance, ref } from 'vue'; - -import type { INodeUi } from '@/Interface'; -import { - NO_OP_NODE_TYPE, - NODE_CONNECTION_TYPE_ALLOW_MULTIPLE, - NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS, - NODE_MIN_INPUT_ITEMS_COUNT, -} from '@/constants'; - -import { NodeHelpers, NodeConnectionType } from 'n8n-workflow'; -import type { - INodeInputConfiguration, - INodeTypeDescription, - INodeOutputConfiguration, - Workflow, -} from 'n8n-workflow'; -import { useUIStore } from '@/stores/ui.store'; -import { useWorkflowsStore } from '@/stores/workflows.store'; -import { useNodeTypesStore } from '@/stores/nodeTypes.store'; -import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui'; -import type { Endpoint, EndpointOptions } from '@jsplumb/core'; -import * as NodeViewUtils from '@/utils/nodeViewUtils'; -import type { EndpointSpec } from '@jsplumb/common'; -import { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; -import type { N8nEndpointLabelLength } from '@/plugins/jsplumb/N8nPlusEndpointType'; -import { isValidNodeConnectionType } from '@/utils/typeGuards'; -import { useI18n } from '@/composables/useI18n'; - -export function useNodeBase({ - name, - instance, - workflowObject, - isReadOnly, - emit, -}: { - name: string; - instance: BrowserJsPlumbInstance; - workflowObject: Workflow; - isReadOnly: boolean; - emit: (event: string, ...args: unknown[]) => void; -}) { - const uiStore = useUIStore(); - const deviceSupport = useDeviceSupport(); - const workflowsStore = useWorkflowsStore(); - const nodeTypesStore = useNodeTypesStore(); - - const i18n = useI18n(); - - // @TODO Remove this when Node.vue and Sticky.vue are migrated to composition API and pass refs instead - const refs = computed(() => getCurrentInstance()?.refs ?? {}); - - const data = computed(() => { - return workflowsStore.getNodeByName(name); - }); - - const nodeId = computed(() => data.value?.id ?? ''); - - const inputs = ref>([]); - const outputs = ref>([]); - - const createAddInputEndpointSpec = ( - connectionName: NodeConnectionType, - color: string, - ): EndpointSpec => { - const multiple = NODE_CONNECTION_TYPE_ALLOW_MULTIPLE.includes(connectionName); - - return { - type: 'N8nAddInput', - options: { - width: 24, - height: 72, - color, - multiple, - }, - }; - }; - - const createDiamondOutputEndpointSpec = (): EndpointSpec => ({ - type: 'Rectangle', - options: { - height: 10, - width: 10, - cssClass: 'diamond-output-endpoint', - }, - }); - - const getEndpointLabelLength = (length: number): N8nEndpointLabelLength => { - if (length <= 2) return 'small'; - else if (length <= 6) return 'medium'; - return 'large'; - }; - - function addEndpointTestingData(endpoint: Endpoint, type: string, inputIndex: number) { - if (window?.Cypress && 'canvas' in endpoint.endpoint && instance) { - const canvas = endpoint.endpoint.canvas; - instance.setAttribute(canvas, 'data-endpoint-name', data.value?.name ?? ''); - instance.setAttribute(canvas, 'data-input-index', inputIndex.toString()); - instance.setAttribute(canvas, 'data-endpoint-type', type); - } - } - - function addInputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) { - // Add Inputs - const rootTypeIndexData: { - [key: string]: number; - } = {}; - const typeIndexData: { - [key: string]: number; - } = {}; - - inputs.value = NodeHelpers.getNodeInputs(workflowObject, data.value!, nodeTypeData) || []; - - const sortedInputs = [...inputs.value]; - sortedInputs.sort((a, b) => { - if (typeof a === 'string') { - return 1; - } else if (typeof b === 'string') { - return -1; - } - - if (a.required && !b.required) { - return -1; - } else if (!a.required && b.required) { - return 1; - } - - return 0; - }); - - sortedInputs.forEach((value, i) => { - let inputConfiguration: INodeInputConfiguration; - if (typeof value === 'string') { - inputConfiguration = { - type: value, - }; - } else { - inputConfiguration = value; - } - - const inputName: NodeConnectionType = inputConfiguration.type; - - const rootCategoryInputName = - inputName === NodeConnectionType.Main ? NodeConnectionType.Main : 'other'; - - // Increment the index for inputs with current name - if (rootTypeIndexData.hasOwnProperty(rootCategoryInputName)) { - rootTypeIndexData[rootCategoryInputName]++; - } else { - rootTypeIndexData[rootCategoryInputName] = 0; - } - - if (typeIndexData.hasOwnProperty(inputName)) { - typeIndexData[inputName]++; - } else { - typeIndexData[inputName] = 0; - } - - const rootTypeIndex = rootTypeIndexData[rootCategoryInputName]; - const typeIndex = typeIndexData[inputName]; - - const inputsOfSameRootType = inputs.value.filter((inputData) => { - const thisInputName: string = typeof inputData === 'string' ? inputData : inputData.type; - return inputName === NodeConnectionType.Main - ? thisInputName === NodeConnectionType.Main - : thisInputName !== NodeConnectionType.Main; - }); - - const nonMainInputs = inputsOfSameRootType.filter((inputData) => { - return inputData !== NodeConnectionType.Main; - }); - const requiredNonMainInputs = nonMainInputs.filter((inputData) => { - return typeof inputData !== 'string' && inputData.required; - }); - const optionalNonMainInputs = nonMainInputs.filter((inputData) => { - return typeof inputData !== 'string' && !inputData.required; - }); - const spacerIndexes = getSpacerIndexes( - requiredNonMainInputs.length, - optionalNonMainInputs.length, - ); - - // Get the position of the anchor depending on how many it has - const anchorPosition = NodeViewUtils.getAnchorPosition( - inputName, - 'input', - inputsOfSameRootType.length, - spacerIndexes, - )[rootTypeIndex]; - - if (!isValidNodeConnectionType(inputName)) { - return; - } - - const scope = NodeViewUtils.getEndpointScope(inputName); - - const newEndpointData: EndpointOptions = { - uuid: NodeViewUtils.getInputEndpointUUID(nodeId.value, inputName, typeIndex), - anchor: anchorPosition, - // We potentially want to change that in the future to allow people to dynamically - // activate and deactivate connected nodes - maxConnections: inputConfiguration.maxConnections ?? -1, - endpoint: 'Rectangle', - paintStyle: NodeViewUtils.getInputEndpointStyle( - nodeTypeData, - '--color-foreground-xdark', - inputName, - ), - hoverPaintStyle: NodeViewUtils.getInputEndpointStyle( - nodeTypeData, - '--color-primary', - inputName, - ), - scope: NodeViewUtils.getScope(scope), - source: inputName !== NodeConnectionType.Main, - target: !isReadOnly && inputs.value.length > 1, // only enabled for nodes with multiple inputs.. otherwise attachment handled by connectionDrag event in NodeView, - parameters: { - connection: 'target', - nodeId: nodeId.value, - type: inputName, - index: typeIndex, - }, - enabled: !isReadOnly, // enabled in default case to allow dragging - cssClass: 'rect-input-endpoint', - dragAllowedWhenFull: true, - hoverClass: 'rect-input-endpoint-hover', - ...getInputConnectionStyle(inputName, nodeTypeData), - }; - - const endpoint = instance?.addEndpoint( - refs.value[data.value?.name ?? ''] as Element, - newEndpointData, - ); - addEndpointTestingData(endpoint, 'input', typeIndex); - if (inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i]) { - // Apply input names if they got set - endpoint.addOverlay( - NodeViewUtils.getInputNameOverlay( - inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i] ?? '', - inputName, - inputConfiguration.required, - ), - ); - } - if (!Array.isArray(endpoint)) { - endpoint.__meta = { - nodeName: node.name, - nodeId: nodeId.value, - index: typeIndex, - totalEndpoints: inputsOfSameRootType.length, - nodeType: node.type, - }; - } - - // TODO: Activate again if it makes sense. Currently makes problems when removing - // connection on which the input has a name. It does not get hidden because - // the endpoint to which it connects when letting it go over the node is - // different to the regular one (have different ids). So that seems to make - // problems when hiding the input-name. - - // if (index === 0 && inputName === NodeConnectionType.Main) { - // // Make the first main-input the default one to connect to when connection gets dropped on node - // instance.makeTarget(nodeId.value, newEndpointData); - // } - }); - if (sortedInputs.length === 0) { - instance?.manage(refs.value[data.value?.name ?? ''] as Element); - } - } - - function getSpacerIndexes( - leftGroupItemsCount: number, - rightGroupItemsCount: number, - insertSpacerBetweenGroups = NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS, - minItemsCount = NODE_MIN_INPUT_ITEMS_COUNT, - ): number[] { - const spacerIndexes = []; - - if (leftGroupItemsCount > 0 && rightGroupItemsCount > 0) { - if (insertSpacerBetweenGroups) { - spacerIndexes.push(leftGroupItemsCount); - } else if (leftGroupItemsCount + rightGroupItemsCount < minItemsCount) { - for ( - let spacerIndex = leftGroupItemsCount; - spacerIndex < minItemsCount - rightGroupItemsCount; - spacerIndex++ - ) { - spacerIndexes.push(spacerIndex); - } - } - } else { - if ( - leftGroupItemsCount > 0 && - leftGroupItemsCount < minItemsCount && - rightGroupItemsCount === 0 - ) { - for ( - let spacerIndex = 0; - spacerIndex < minItemsCount - leftGroupItemsCount; - spacerIndex++ - ) { - spacerIndexes.push(spacerIndex + leftGroupItemsCount); - } - } else if ( - leftGroupItemsCount === 0 && - rightGroupItemsCount > 0 && - rightGroupItemsCount < minItemsCount - ) { - for ( - let spacerIndex = 0; - spacerIndex < minItemsCount - rightGroupItemsCount; - spacerIndex++ - ) { - spacerIndexes.push(spacerIndex); - } - } - } - - return spacerIndexes; - } - - function addOutputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) { - const rootTypeIndexData: { - [key: string]: number; - } = {}; - const typeIndexData: { - [key: string]: number; - } = {}; - - if (!data.value) { - return; - } - - outputs.value = NodeHelpers.getNodeOutputs(workflowObject, data.value, nodeTypeData) || []; - - // TODO: There are still a lot of references of "main" in NodesView and - // other locations. So assume there will be more problems - let maxLabelLength = 0; - const outputConfigurations: INodeOutputConfiguration[] = []; - outputs.value.forEach((value, i) => { - let outputConfiguration: INodeOutputConfiguration; - if (typeof value === 'string') { - outputConfiguration = { - type: value, - }; - } else { - outputConfiguration = value; - } - if (nodeTypeData.outputNames?.[i]) { - outputConfiguration.displayName = nodeTypeData.outputNames[i]; - } - - if (outputConfiguration.displayName) { - maxLabelLength = - outputConfiguration.displayName.length > maxLabelLength - ? outputConfiguration.displayName.length - : maxLabelLength; - } - - outputConfigurations.push(outputConfiguration); - }); - - const endpointLabelLength = getEndpointLabelLength(maxLabelLength); - - outputs.value.forEach((_value, i) => { - const outputConfiguration = outputConfigurations[i]; - - const outputName: NodeConnectionType = outputConfiguration.type; - - const rootCategoryOutputName = - outputName === NodeConnectionType.Main ? NodeConnectionType.Main : 'other'; - - // Increment the index for outputs with current name - if (rootTypeIndexData.hasOwnProperty(rootCategoryOutputName)) { - rootTypeIndexData[rootCategoryOutputName]++; - } else { - rootTypeIndexData[rootCategoryOutputName] = 0; - } - - if (typeIndexData.hasOwnProperty(outputName)) { - typeIndexData[outputName]++; - } else { - typeIndexData[outputName] = 0; - } - - const rootTypeIndex = rootTypeIndexData[rootCategoryOutputName]; - const typeIndex = typeIndexData[outputName]; - - const outputsOfSameRootType = outputs.value.filter((outputData) => { - const thisOutputName: string = - typeof outputData === 'string' ? outputData : outputData.type; - return outputName === NodeConnectionType.Main - ? thisOutputName === NodeConnectionType.Main - : thisOutputName !== NodeConnectionType.Main; - }); - - // Get the position of the anchor depending on how many it has - const anchorPosition = NodeViewUtils.getAnchorPosition( - outputName, - 'output', - outputsOfSameRootType.length, - )[rootTypeIndex]; - - if (!isValidNodeConnectionType(outputName)) { - return; - } - - const scope = NodeViewUtils.getEndpointScope(outputName); - - const newEndpointData: EndpointOptions = { - uuid: NodeViewUtils.getOutputEndpointUUID(nodeId.value, outputName, typeIndex), - anchor: anchorPosition, - maxConnections: -1, - endpoint: { - type: 'Dot', - options: { - radius: nodeTypeData && outputsOfSameRootType.length > 2 ? 7 : 9, - }, - }, - hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle(nodeTypeData, '--color-primary'), - scope, - source: true, - target: outputName !== NodeConnectionType.Main, - enabled: !isReadOnly, - parameters: { - connection: 'source', - nodeId: nodeId.value, - type: outputName, - index: typeIndex, - }, - hoverClass: 'dot-output-endpoint-hover', - connectionsDirected: true, - dragAllowedWhenFull: false, - ...getOutputConnectionStyle(outputName, outputConfiguration, nodeTypeData), - }; - - const endpoint = instance?.addEndpoint( - refs.value[data.value?.name ?? ''] as Element, - newEndpointData, - ); - - if (!endpoint) { - return; - } - - addEndpointTestingData(endpoint, 'output', typeIndex); - if (outputConfiguration.displayName && isValidNodeConnectionType(outputName)) { - // Apply output names if they got set - const overlaySpec = NodeViewUtils.getOutputNameOverlay( - outputConfiguration.displayName, - outputName, - outputConfiguration?.category, - ); - endpoint.addOverlay(overlaySpec); - } - - if (!Array.isArray(endpoint)) { - endpoint.__meta = { - nodeName: node.name, - nodeId: nodeId.value, - index: typeIndex, - totalEndpoints: outputsOfSameRootType.length, - endpointLabelLength, - }; - } - - if (!isReadOnly && outputName === NodeConnectionType.Main) { - const plusEndpointData: EndpointOptions = { - uuid: NodeViewUtils.getOutputEndpointUUID(nodeId.value, outputName, typeIndex), - anchor: anchorPosition, - maxConnections: -1, - endpoint: { - type: 'N8nPlus', - options: { - dimensions: 24, - connectedEndpoint: endpoint, - showOutputLabel: outputs.value.length === 1, - size: outputs.value.length >= 3 ? 'small' : 'medium', - endpointLabelLength, - hoverMessage: i18n.baseText('nodeBase.clickToAddNodeOrDragToConnect'), - }, - }, - source: true, - target: false, - enabled: !isReadOnly, - paintStyle: { - outlineStroke: 'none', - }, - hoverPaintStyle: { - outlineStroke: 'none', - }, - parameters: { - connection: 'source', - nodeId: nodeId.value, - type: outputName, - index: typeIndex, - category: outputConfiguration?.category, - }, - cssClass: 'plus-draggable-endpoint', - dragAllowedWhenFull: false, - }; - - if (outputConfiguration?.category) { - plusEndpointData.cssClass = `${plusEndpointData.cssClass} ${outputConfiguration?.category}`; - } - - if (!instance || !data.value) { - return; - } - - const plusEndpoint = instance.addEndpoint( - refs.value[data.value.name] as Element, - plusEndpointData, - ); - addEndpointTestingData(plusEndpoint, 'plus', typeIndex); - - if (!Array.isArray(plusEndpoint)) { - plusEndpoint.__meta = { - nodeName: node.name, - nodeId: nodeId.value, - index: typeIndex, - nodeType: node.type, - totalEndpoints: outputsOfSameRootType.length, - }; - } - } - }); - } - - function addNode(node: INodeUi) { - const nodeTypeData = (nodeTypesStore.getNodeType(node.type, node.typeVersion) ?? - nodeTypesStore.getNodeType(NO_OP_NODE_TYPE)) as INodeTypeDescription; - - addInputEndpoints(node, nodeTypeData); - addOutputEndpoints(node, nodeTypeData); - } - - function getEndpointColor(connectionType: NodeConnectionType) { - return `--node-type-${connectionType}-color`; - } - - function getInputConnectionStyle( - connectionType: NodeConnectionType, - nodeTypeData: INodeTypeDescription, - ): EndpointOptions { - if (connectionType === NodeConnectionType.Main) { - return { - paintStyle: NodeViewUtils.getInputEndpointStyle( - nodeTypeData, - getEndpointColor(NodeConnectionType.Main), - connectionType, - ), - }; - } - - if (!isValidNodeConnectionType(connectionType)) { - return {}; - } - - const createSupplementalConnectionType = ( - connectionName: NodeConnectionType, - ): EndpointOptions => ({ - endpoint: createAddInputEndpointSpec( - connectionName as NodeConnectionType, - getEndpointColor(connectionName), - ), - }); - - return createSupplementalConnectionType(connectionType); - } - - function getOutputConnectionStyle( - connectionType: NodeConnectionType, - outputConfiguration: INodeOutputConfiguration, - nodeTypeData: INodeTypeDescription, - ): EndpointOptions { - const createSupplementalConnectionType = ( - connectionName: NodeConnectionType, - ): EndpointOptions => ({ - endpoint: createDiamondOutputEndpointSpec(), - paintStyle: NodeViewUtils.getOutputEndpointStyle( - nodeTypeData, - getEndpointColor(connectionName), - ), - hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle( - nodeTypeData, - getEndpointColor(connectionName), - ), - }); - - const type = 'output'; - - if (connectionType === NodeConnectionType.Main) { - if (outputConfiguration.category === 'error') { - return { - paintStyle: { - ...NodeViewUtils.getOutputEndpointStyle( - nodeTypeData, - getEndpointColor(NodeConnectionType.Main), - ), - fill: 'var(--color-danger)', - }, - cssClass: `dot-${type}-endpoint`, - }; - } - return { - paintStyle: NodeViewUtils.getOutputEndpointStyle( - nodeTypeData, - getEndpointColor(NodeConnectionType.Main), - ), - cssClass: `dot-${type}-endpoint`, - }; - } - - if (!isValidNodeConnectionType(connectionType)) { - return {}; - } - - return createSupplementalConnectionType(connectionType); - } - - function touchEnd(_e: MouseEvent) { - if (deviceSupport.isTouchDevice && uiStore.isActionActive['dragActive']) { - uiStore.removeActiveAction('dragActive'); - } - } - - function mouseLeftClick(e: MouseEvent) { - // @ts-expect-error path is not defined in MouseEvent on all browsers - const path = e.path || e.composedPath?.(); - for (let index = 0; index < path.length; index++) { - if ( - path[index].className && - typeof path[index].className === 'string' && - path[index].className.includes('no-select-on-click') - ) { - return; - } - } - - if (!deviceSupport.isTouchDevice) { - if (uiStore.isActionActive['dragActive']) { - uiStore.removeActiveAction('dragActive'); - } else { - if (!deviceSupport.isCtrlKeyPressed(e)) { - emit('deselectAllNodes'); - } - - if (uiStore.isNodeSelected[data.value?.name ?? '']) { - emit('deselectNode', name); - } else { - emit('nodeSelected', name); - } - } - } - } - - return { - getSpacerIndexes, - addInputEndpoints, - addOutputEndpoints, - addNode, - mouseLeftClick, - touchEnd, - inputs, - outputs, - }; -} diff --git a/packages/editor-ui/src/composables/useNodeHelpers.ts b/packages/editor-ui/src/composables/useNodeHelpers.ts index 461f550212..15491aced6 100644 --- a/packages/editor-ui/src/composables/useNodeHelpers.ts +++ b/packages/editor-ui/src/composables/useNodeHelpers.ts @@ -1,14 +1,9 @@ -import { ref, nextTick } from 'vue'; -import { useRoute } from 'vue-router'; -import type { Connection, ConnectionDetachedParams } from '@jsplumb/core'; +import { ref } from 'vue'; import { useHistoryStore } from '@/stores/history.store'; import { CUSTOM_API_CALL_KEY, - FORM_TRIGGER_NODE_TYPE, - NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_FILLED_AT_EXECUTION_TIME, SPLIT_IN_BATCHES_NODE_TYPE, - WEBHOOK_NODE_TYPE, } from '@/constants'; import { NodeHelpers, ExpressionEvaluatorProxy, NodeConnectionType } from 'n8n-workflow'; @@ -30,11 +25,7 @@ import type { INodePropertyOptions, INodeCredentialsDetails, INodeParameters, - ITaskData, - IConnections, INodeTypeNameVersion, - IConnection, - IPinData, NodeParameterValue, } from 'n8n-workflow'; @@ -53,16 +44,10 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useCredentialsStore } from '@/stores/credentials.store'; import { get } from 'lodash-es'; import { useI18n } from './useI18n'; -import { AddNodeCommand, EnableNodeToggleCommand, RemoveConnectionCommand } from '@/models/history'; +import { EnableNodeToggleCommand } from '@/models/history'; import { useTelemetry } from './useTelemetry'; import { hasPermission } from '@/utils/rbac/permissions'; -import type { N8nPlusEndpoint } from '@/plugins/jsplumb/N8nPlusEndpointType'; -import * as NodeViewUtils from '@/utils/nodeViewUtils'; import { useCanvasStore } from '@/stores/canvas.store'; -import { getEndpointScope } from '@/utils/nodeViewUtils'; -import { useSourceControlStore } from '@/stores/sourceControl.store'; -import { getConnectionInfo } from '@/utils/canvasUtils'; -import type { UnpinNodeDataEvent } from '@/event-bus/data-pinning'; declare namespace HttpRequestNode { namespace V2 { @@ -81,8 +66,6 @@ export function useNodeHelpers() { const workflowsStore = useWorkflowsStore(); const i18n = useI18n(); const canvasStore = useCanvasStore(); - const sourceControlStore = useSourceControlStore(); - const route = useRoute(); const isInsertingNodes = ref(false); const credentialsUpdated = ref(false); @@ -125,23 +108,6 @@ export function useNodeHelpers() { return NodeHelpers.displayParameterPath(nodeValues, parameter, path, node, displayKey); } - function refreshNodeIssues(): void { - const nodes = workflowsStore.allNodes; - const workflow = workflowsStore.getCurrentWorkflow(); - let nodeType: INodeTypeDescription | null; - let foundNodeIssues: INodeIssues | null; - - nodes.forEach((node) => { - if (node.disabled === true) return; - - nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion); - foundNodeIssues = getNodeIssues(nodeType, node, workflow); - if (foundNodeIssues !== null) { - node.issues = foundNodeIssues; - } - }); - } - function getNodeIssues( nodeType: INodeTypeDescription | null, node: INodeUi, @@ -766,73 +732,6 @@ export function useNodeHelpers() { return undefined; } - function setSuccessOutput(data: ITaskData[], sourceNode: INodeUi | null) { - if (!sourceNode) { - throw new Error('Source node is null or not defined'); - } - - const allNodeConnections = workflowsStore.outgoingConnectionsByNodeName(sourceNode.name); - - const connectionType = Object.keys(allNodeConnections)[0] as NodeConnectionType; - const nodeConnections = allNodeConnections[connectionType]; - const outputMap = NodeViewUtils.getOutputSummary( - data, - nodeConnections || [], - connectionType ?? NodeConnectionType.Main, - ); - const sourceNodeType = nodeTypesStore.getNodeType(sourceNode.type, sourceNode.typeVersion); - - Object.keys(outputMap).forEach((sourceOutputIndex: string) => { - Object.keys(outputMap[sourceOutputIndex]).forEach((targetNodeName: string) => { - Object.keys(outputMap[sourceOutputIndex][targetNodeName]).forEach( - (targetInputIndex: string) => { - if (targetNodeName) { - const targetNode = workflowsStore.getNodeByName(targetNodeName); - const connection = NodeViewUtils.getJSPlumbConnection( - sourceNode, - parseInt(sourceOutputIndex, 10), - targetNode, - parseInt(targetInputIndex, 10), - connectionType, - sourceNodeType, - canvasStore.jsPlumbInstance, - ); - - if (connection) { - const output = outputMap[sourceOutputIndex][targetNodeName][targetInputIndex]; - - if (output.isArtificialRecoveredEventItem) { - NodeViewUtils.recoveredConnection(connection); - } else if (!output?.total && !output.isArtificialRecoveredEventItem) { - NodeViewUtils.resetConnection(connection); - } else { - NodeViewUtils.addConnectionOutputSuccess(connection, output); - } - } - } - - const endpoint = NodeViewUtils.getPlusEndpoint( - sourceNode, - parseInt(sourceOutputIndex, 10), - canvasStore.jsPlumbInstance, - ); - if (endpoint?.endpoint) { - const output = outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY][0]; - - if (output && output.total > 0) { - (endpoint.endpoint as N8nPlusEndpoint).setSuccessOutput( - NodeViewUtils.getRunItemsLabel(output), - ); - } else { - (endpoint.endpoint as N8nPlusEndpoint).clearSuccessOutput(); - } - } - }, - ); - }); - }); - } - function matchCredentials(node: INodeUi) { if (!node.credentials) { return; @@ -888,31 +787,6 @@ export function useNodeHelpers() { ); } - function deleteJSPlumbConnection(connection: Connection, trackHistory = false) { - // Make sure to remove the overlay else after the second move - // it visibly stays behind free floating without a connection. - connection.removeOverlays(); - - pullConnActiveNodeName.value = null; // prevent new connections when connectionDetached is triggered - canvasStore.jsPlumbInstance?.deleteConnection(connection); // on delete, triggers connectionDetached event which applies mutation to store - if (trackHistory && connection.__meta) { - const connectionData: [IConnection, IConnection] = [ - { - index: connection.__meta?.sourceOutputIndex, - node: connection.__meta.sourceNodeName, - type: NodeConnectionType.Main, - }, - { - index: connection.__meta?.targetOutputIndex, - node: connection.__meta.targetNodeName, - type: NodeConnectionType.Main, - }, - ]; - const removeCommand = new RemoveConnectionCommand(connectionData); - historyStore.pushCommandToUndo(removeCommand); - } - } - async function loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise { const allNodes: INodeTypeDescription[] = nodeTypesStore.allNodeTypes; @@ -938,325 +812,6 @@ export function useNodeHelpers() { } } - function addConnectionsTestData() { - canvasStore.jsPlumbInstance?.connections.forEach((connection) => { - NodeViewUtils.addConnectionTestData( - connection.source, - connection.target, - connection?.connector?.hasOwnProperty('canvas') ? connection?.connector.canvas : undefined, - ); - }); - } - - async function processConnectionBatch(batchedConnectionData: Array<[IConnection, IConnection]>) { - const batchSize = 100; - - for (let i = 0; i < batchedConnectionData.length; i += batchSize) { - const batch = batchedConnectionData.slice(i, i + batchSize); - - batch.forEach((connectionData) => { - addConnection(connectionData); - }); - } - } - - function addPinDataConnections(pinData?: IPinData) { - if (!pinData) { - return; - } - - Object.keys(pinData).forEach((nodeName) => { - const node = workflowsStore.getNodeByName(nodeName); - if (!node) { - return; - } - - const nodeElement = document.getElementById(node.id); - if (!nodeElement) { - return; - } - - const hasRun = workflowsStore.getWorkflowResultDataByNodeName(nodeName) !== null; - // In case we are showing a production execution preview we want - // to show pinned data connections as they wouldn't have been pinned - const classNames = isProductionExecutionPreview.value ? [] : ['pinned']; - - if (hasRun) { - classNames.push('has-run'); - } - - const connections = canvasStore.jsPlumbInstance?.getConnections({ - source: nodeElement, - }); - - const connectionsArray = Array.isArray(connections) - ? connections - : Object.values(connections); - - connectionsArray.forEach((connection) => { - NodeViewUtils.addConnectionOutputSuccess(connection, { - total: pinData[nodeName].length, - iterations: 0, - classNames, - }); - }); - }); - } - - function removePinDataConnections(event: UnpinNodeDataEvent) { - for (const nodeName of event.nodeNames) { - const node = workflowsStore.getNodeByName(nodeName); - if (!node) { - return; - } - - const nodeElement = document.getElementById(node.id); - if (!nodeElement) { - return; - } - - const connections = canvasStore.jsPlumbInstance?.getConnections({ - source: nodeElement, - }); - - const connectionsArray = Array.isArray(connections) - ? connections - : Object.values(connections); - - canvasStore.jsPlumbInstance.setSuspendDrawing(true); - connectionsArray.forEach(NodeViewUtils.resetConnection); - canvasStore.jsPlumbInstance.setSuspendDrawing(false, true); - } - } - - function getOutputEndpointUUID( - nodeName: string, - connectionType: NodeConnectionType, - index: number, - ): string | null { - const node = workflowsStore.getNodeByName(nodeName); - if (!node) { - return null; - } - - return NodeViewUtils.getOutputEndpointUUID(node.id, connectionType, index); - } - function getInputEndpointUUID( - nodeName: string, - connectionType: NodeConnectionType, - index: number, - ) { - const node = workflowsStore.getNodeByName(nodeName); - if (!node) { - return null; - } - - return NodeViewUtils.getInputEndpointUUID(node.id, connectionType, index); - } - - function addConnection(connection: [IConnection, IConnection]) { - const outputUuid = getOutputEndpointUUID( - connection[0].node, - connection[0].type, - connection[0].index, - ); - const inputUuid = getInputEndpointUUID( - connection[1].node, - connection[1].type, - connection[1].index, - ); - if (!outputUuid || !inputUuid) { - return; - } - - const uuids: [string, string] = [outputUuid, inputUuid]; - // Create connections in DOM - canvasStore.jsPlumbInstance?.connect({ - uuids, - detachable: !route?.meta?.readOnlyCanvas && !sourceControlStore.preferences.branchReadOnly, - }); - - setTimeout(() => { - addPinDataConnections(workflowsStore.pinnedWorkflowData); - }); - } - - function removeConnection( - connection: [IConnection, IConnection], - removeVisualConnection = false, - ) { - if (removeVisualConnection) { - const sourceNode = workflowsStore.getNodeByName(connection[0].node); - const targetNode = workflowsStore.getNodeByName(connection[1].node); - - if (!sourceNode || !targetNode) { - return; - } - - const sourceElement = document.getElementById(sourceNode.id); - const targetElement = document.getElementById(targetNode.id); - - if (sourceElement && targetElement) { - const connections = canvasStore.jsPlumbInstance?.getConnections({ - source: sourceElement, - target: targetElement, - }); - - if (Array.isArray(connections)) { - connections.forEach((connectionInstance: Connection) => { - if (connectionInstance.__meta) { - // Only delete connections from specific indexes (if it can be determined by meta) - if ( - connectionInstance.__meta.sourceOutputIndex === connection[0].index && - connectionInstance.__meta.targetOutputIndex === connection[1].index - ) { - deleteJSPlumbConnection(connectionInstance); - } - } else { - deleteJSPlumbConnection(connectionInstance); - } - }); - } - } - } - - workflowsStore.removeConnection({ connection }); - } - - function removeConnectionByConnectionInfo( - info: ConnectionDetachedParams, - removeVisualConnection = false, - trackHistory = false, - ) { - const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info); - - if (connectionInfo) { - if (removeVisualConnection) { - deleteJSPlumbConnection(info.connection, trackHistory); - } else if (trackHistory) { - historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo)); - } - workflowsStore.removeConnection({ connection: connectionInfo }); - } - } - - async function addConnections(connections: IConnections) { - const batchedConnectionData: Array<[IConnection, IConnection]> = []; - - for (const sourceNode in connections) { - for (const type in connections[sourceNode]) { - connections[sourceNode][type].forEach((outwardConnections, sourceIndex) => { - if (outwardConnections) { - outwardConnections.forEach((targetData) => { - batchedConnectionData.push([ - { - node: sourceNode, - type: getEndpointScope(type) ?? NodeConnectionType.Main, - index: sourceIndex, - }, - { node: targetData.node, type: targetData.type, index: targetData.index }, - ]); - }); - } - }); - } - } - - // Process the connections in batches - await processConnectionBatch(batchedConnectionData); - setTimeout(addConnectionsTestData, 0); - } - - async function addNodes(nodes: INodeUi[], connections?: IConnections, trackHistory = false) { - if (!nodes?.length) { - return; - } - isInsertingNodes.value = true; - // Before proceeding we must check if all nodes contain the `properties` attribute. - // Nodes are loaded without this information so we must make sure that all nodes - // being added have this information. - await loadNodesProperties( - nodes.map((node) => ({ name: node.type, version: node.typeVersion })), - ); - - // Add the node to the node-list - let nodeType: INodeTypeDescription | null; - nodes.forEach((node) => { - const newNode: INodeUi = { - ...node, - }; - - if (!newNode.id) { - assignNodeId(newNode); - } - - nodeType = nodeTypesStore.getNodeType(newNode.type, newNode.typeVersion); - - // Make sure that some properties always exist - if (!newNode.hasOwnProperty('disabled')) { - newNode.disabled = false; - } - - if (!newNode.hasOwnProperty('parameters')) { - newNode.parameters = {}; - } - - // Load the default parameter values because only values which differ - // from the defaults get saved - if (nodeType !== null) { - let nodeParameters = null; - try { - nodeParameters = NodeHelpers.getNodeParameters( - nodeType.properties, - newNode.parameters, - true, - false, - node, - ); - } catch (e) { - console.error( - i18n.baseText('nodeView.thereWasAProblemLoadingTheNodeParametersOfNode') + - `: "${newNode.name}"`, - ); - console.error(e); - } - newNode.parameters = nodeParameters ?? {}; - - // if it's a webhook and the path is empty set the UUID as the default path - if ( - [WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE].includes(newNode.type) && - newNode.parameters.path === '' - ) { - newNode.parameters.path = newNode.webhookId as string; - } - } - - // check and match credentials, apply new format if old is used - matchCredentials(newNode); - workflowsStore.addNode(newNode); - if (trackHistory) { - historyStore.pushCommandToUndo(new AddNodeCommand(newNode)); - } - }); - - // Wait for the nodes to be rendered - await nextTick(); - - canvasStore.jsPlumbInstance?.setSuspendDrawing(true); - - if (connections) { - await addConnections(connections); - } - // Add the node issues at the end as the node-connections are required - refreshNodeIssues(); - updateNodesInputIssues(); - /////////////////////////////this.resetEndpointsErrors(); - isInsertingNodes.value = false; - - // Now it can draw again - canvasStore.jsPlumbInstance?.setSuspendDrawing(false, true); - } - function assignNodeId(node: INodeUi) { const id = window.crypto.randomUUID(); node.id = id; @@ -1332,21 +887,12 @@ export function useNodeHelpers() { getNodeSubtitle, updateNodesCredentialsIssues, getNodeInputData, - setSuccessOutput, matchCredentials, isInsertingNodes, credentialsUpdated, isProductionExecutionPreview, pullConnActiveNodeName, - deleteJSPlumbConnection, loadNodesProperties, - addNodes, - addConnections, - addConnection, - removeConnection, - removeConnectionByConnectionInfo, - addPinDataConnections, - removePinDataConnections, getNodeTaskData, assignNodeId, assignWebhookId, diff --git a/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.test.ts b/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.test.ts deleted file mode 100644 index 2b8ce7710d..0000000000 --- a/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { useNodeViewVersionSwitcher } from './useNodeViewVersionSwitcher'; -import { useWorkflowsStore } from '@/stores/workflows.store'; -import { createTestingPinia } from '@pinia/testing'; -import { STORES } from '@/constants'; -import { setActivePinia } from 'pinia'; -import { mockedStore } from '@/__tests__/utils'; -import { useNDVStore } from '@/stores/ndv.store'; - -vi.mock('@/composables/useTelemetry', () => ({ - useTelemetry: () => ({ - track: vi.fn(), - }), -})); - -describe('useNodeViewVersionSwitcher', () => { - const initialState = { - [STORES.WORKFLOWS]: {}, - [STORES.NDV]: {}, - [STORES.SETTINGS]: { - settings: { - betaFeatures: ['canvas_v2'], - }, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); - const pinia = createTestingPinia({ initialState }); - setActivePinia(pinia); - }); - - describe('isNewUser', () => { - test('should return true when there are no active workflows', () => { - const { isNewUser } = useNodeViewVersionSwitcher(); - expect(isNewUser.value).toBe(true); - }); - - test('should return false when there are active workflows', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - workflowsStore.activeWorkflows = ['1']; - - const { isNewUser } = useNodeViewVersionSwitcher(); - expect(isNewUser.value).toBe(false); - }); - }); - - describe('nodeViewVersion', () => { - test('should initialize with default version "2"', () => { - const { nodeViewVersion } = useNodeViewVersionSwitcher(); - expect(nodeViewVersion.value).toBe('2'); - }); - }); - - describe('isNodeViewDiscoveryTooltipVisible', () => { - test('should be visible under correct conditions', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - workflowsStore.activeWorkflows = ['1']; - - const ndvStore = mockedStore(useNDVStore); - ndvStore.activeNodeName = null; - - const { isNodeViewDiscoveryTooltipVisible } = useNodeViewVersionSwitcher(); - expect(isNodeViewDiscoveryTooltipVisible.value).toBe(true); - }); - - test('should not be visible for new users', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - workflowsStore.activeWorkflows = []; - - const { isNodeViewDiscoveryTooltipVisible } = useNodeViewVersionSwitcher(); - expect(isNodeViewDiscoveryTooltipVisible.value).toBe(false); - }); - - test('should not be visible when node is selected', () => { - const ndvStore = mockedStore(useNDVStore); - ndvStore.activeNodeName = 'test-node'; - - const { isNodeViewDiscoveryTooltipVisible } = useNodeViewVersionSwitcher(); - expect(isNodeViewDiscoveryTooltipVisible.value).toBe(false); - }); - }); - - describe('switchNodeViewVersion', () => { - test('should switch from version 2 to 1 and back', () => { - const { nodeViewVersion, switchNodeViewVersion } = useNodeViewVersionSwitcher(); - - switchNodeViewVersion(); - - expect(nodeViewVersion.value).toBe('1'); - - switchNodeViewVersion(); - - expect(nodeViewVersion.value).toBe('2'); - }); - }); - - describe('migrateToNewNodeViewVersion', () => { - test('should not migrate if already migrated', () => { - const { nodeViewVersion, nodeViewVersionMigrated, migrateToNewNodeViewVersion } = - useNodeViewVersionSwitcher(); - nodeViewVersionMigrated.value = true; - - migrateToNewNodeViewVersion(); - - expect(nodeViewVersion.value).toBe('2'); - }); - - test('should not migrate if already on version 2', () => { - const { nodeViewVersion, migrateToNewNodeViewVersion } = useNodeViewVersionSwitcher(); - nodeViewVersion.value = '2'; - - migrateToNewNodeViewVersion(); - - expect(nodeViewVersion.value).not.toBe('1'); - }); - - test('should migrate to version 2 if not migrated and on version 1', () => { - const { nodeViewVersion, nodeViewVersionMigrated, migrateToNewNodeViewVersion } = - useNodeViewVersionSwitcher(); - nodeViewVersion.value = '1'; - nodeViewVersionMigrated.value = false; - - migrateToNewNodeViewVersion(); - - expect(nodeViewVersion.value).toBe('2'); - expect(nodeViewVersionMigrated.value).toBe(true); - }); - }); - - describe('setNodeViewSwitcherDropdownOpened', () => { - test('should set discovered when dropdown is closed', () => { - const { setNodeViewSwitcherDropdownOpened, nodeViewSwitcherDiscovered } = - useNodeViewVersionSwitcher(); - - setNodeViewSwitcherDropdownOpened(false); - - expect(nodeViewSwitcherDiscovered.value).toBe(true); - nodeViewSwitcherDiscovered.value = false; - }); - - test('should not set discovered when dropdown is opened', () => { - const { setNodeViewSwitcherDropdownOpened, nodeViewSwitcherDiscovered } = - useNodeViewVersionSwitcher(); - - setNodeViewSwitcherDropdownOpened(true); - - expect(nodeViewSwitcherDiscovered.value).toBe(false); - }); - }); - - describe('setNodeViewSwitcherDiscovered', () => { - test('should set nodeViewSwitcherDiscovered to true', () => { - const { setNodeViewSwitcherDiscovered, nodeViewSwitcherDiscovered } = - useNodeViewVersionSwitcher(); - - setNodeViewSwitcherDiscovered(); - - expect(nodeViewSwitcherDiscovered.value).toBe(true); - }); - }); -}); diff --git a/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.ts b/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.ts deleted file mode 100644 index 16e1f06977..0000000000 --- a/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { computed } from 'vue'; -import { useLocalStorage } from '@vueuse/core'; -import { useTelemetry } from '@/composables/useTelemetry'; -import { useWorkflowsStore } from '@/stores/workflows.store'; -import { useNDVStore } from '@/stores/ndv.store'; -import { useSettingsStore } from '@/stores/settings.store'; - -export function useNodeViewVersionSwitcher() { - const ndvStore = useNDVStore(); - const workflowsStore = useWorkflowsStore(); - const telemetry = useTelemetry(); - const settingsStore = useSettingsStore(); - - const isNewUser = computed(() => workflowsStore.activeWorkflows.length === 0); - - const defaultVersion = settingsStore.isCanvasV2Enabled ? '2' : '1'; - const nodeViewVersion = useLocalStorage('NodeView.version', defaultVersion); - const nodeViewVersionMigrated = useLocalStorage('NodeView.migrated.release', false); - - function setNodeViewSwitcherDropdownOpened(visible: boolean) { - if (!visible) { - setNodeViewSwitcherDiscovered(); - } - } - - const nodeViewSwitcherDiscovered = useLocalStorage('NodeView.switcher.discovered.beta', false); - function setNodeViewSwitcherDiscovered() { - nodeViewSwitcherDiscovered.value = true; - } - - const isNodeViewDiscoveryTooltipVisible = computed( - () => - !isNewUser.value && - !ndvStore.activeNodeName && - nodeViewVersion.value === '2' && - !nodeViewSwitcherDiscovered.value, - ); - - function switchNodeViewVersion() { - const toVersion = nodeViewVersion.value === '2' ? '1' : '2'; - - if (!nodeViewVersionMigrated.value) { - nodeViewVersionMigrated.value = true; - } - - telemetry.track('User switched canvas version', { - to_version: toVersion, - }); - - nodeViewVersion.value = toVersion; - } - - function migrateToNewNodeViewVersion() { - if (nodeViewVersionMigrated.value || nodeViewVersion.value === '2') { - return; - } - - switchNodeViewVersion(); - } - - return { - isNewUser, - nodeViewVersion, - nodeViewVersionMigrated, - nodeViewSwitcherDiscovered, - isNodeViewDiscoveryTooltipVisible, - setNodeViewSwitcherDropdownOpened, - setNodeViewSwitcherDiscovered, - switchNodeViewVersion, - migrateToNewNodeViewVersion, - }; -} diff --git a/packages/editor-ui/src/main.ts b/packages/editor-ui/src/main.ts index 6370591435..3420468976 100644 --- a/packages/editor-ui/src/main.ts +++ b/packages/editor-ui/src/main.ts @@ -7,7 +7,6 @@ import '@vue-flow/minimap/dist/style.css'; import '@vue-flow/node-resizer/dist/style.css'; import 'vue-json-pretty/lib/styles.css'; -import '@jsplumb/browser-ui/css/jsplumbtoolkit.css'; import 'n8n-design-system/css/index.scss'; // import 'n8n-design-system/css/tailwind/index.css'; @@ -27,7 +26,6 @@ import { GlobalDirectivesPlugin } from './plugins/directives'; import { FontAwesomePlugin } from './plugins/icons'; import { createPinia, PiniaVuePlugin } from 'pinia'; -import { JsPlumbPlugin } from '@/plugins/jsplumb'; import { ChartJSPlugin } from '@/plugins/chartjs'; import { SentryPlugin } from '@/plugins/sentry'; @@ -41,7 +39,6 @@ app.use(PiniaVuePlugin); app.use(FontAwesomePlugin); app.use(GlobalComponentsPlugin); app.use(GlobalDirectivesPlugin); -app.use(JsPlumbPlugin); app.use(pinia); app.use(router); app.use(i18nInstance); diff --git a/packages/editor-ui/src/plugins/connectors/N8nCustomConnector.ts b/packages/editor-ui/src/plugins/connectors/N8nCustomConnector.ts deleted file mode 100644 index 6b41ab77a9..0000000000 --- a/packages/editor-ui/src/plugins/connectors/N8nCustomConnector.ts +++ /dev/null @@ -1,605 +0,0 @@ -import type { PointXY } from '@jsplumb/util'; -import { quadrant } from '@jsplumb/util'; - -import type { - Connection, - ConnectorComputeParams, - PaintGeometry, - Endpoint, - Orientation, -} from '@jsplumb/core'; -import { ArcSegment, AbstractConnector, StraightSegment } from '@jsplumb/core'; -import type { AnchorPlacement, ConnectorOptions, Geometry, PaintAxis } from '@jsplumb/common'; -import { BezierSegment } from '@jsplumb/connector-bezier'; -import { isArray } from 'lodash-es'; -import { deepCopy } from 'n8n-workflow'; - -export type N8nConnectorOptions = ConnectorOptions; -interface N8nConnectorPaintGeometry extends PaintGeometry { - sourceEndpoint: Endpoint; - targetEndpoint: Endpoint; - sourcePos: AnchorPlacement; - targetPos: AnchorPlacement; - targetGap: number; - lw: number; -} - -type FlowchartSegment = [number, number, number, number, string]; -type StubPositions = [number, number, number, number]; - -const lineCalculators = { - opposite( - paintInfo: PaintGeometry, - { - axis, - startStub, - endStub, - idx, - midx, - midy, - }: { - axis: 'x' | 'y'; - startStub: number; - endStub: number; - idx: number; - midx: number; - midy: number; - }, - ) { - const pi = paintInfo, - comparator = pi[('is' + axis.toUpperCase() + 'GreaterThanStubTimes2') as keyof PaintGeometry]; - - if ( - !comparator || - (pi.so[idx] === 1 && startStub > endStub) || - (pi.so[idx] === -1 && startStub < endStub) - ) { - return { - x: [ - [startStub, midy], - [endStub, midy], - ], - y: [ - [midx, startStub], - [midx, endStub], - ], - }[axis]; - } else if ( - (pi.so[idx] === 1 && startStub < endStub) || - (pi.so[idx] === -1 && startStub > endStub) - ) { - return { - x: [ - [midx, pi.sy], - [midx, pi.ty], - ], - y: [ - [pi.sx, midy], - [pi.tx, midy], - ], - }[axis]; - } - - return undefined; - }, -}; - -const stubCalculators = { - opposite( - paintInfo: PaintGeometry, - { axis, alwaysRespectStubs }: { axis: 'x' | 'y'; alwaysRespectStubs: boolean }, - ): StubPositions { - const pi = paintInfo, - idx = axis === 'x' ? 0 : 1, - areInProximity = { - x() { - return ( - (pi.so[idx] === 1 && - ((pi.startStubX > pi.endStubX && pi.tx > pi.startStubX) || - (pi.sx > pi.endStubX && pi.tx > pi.sx))) || - (pi.so[idx] === -1 && - ((pi.startStubX < pi.endStubX && pi.tx < pi.startStubX) || - (pi.sx < pi.endStubX && pi.tx < pi.sx))) - ); - }, - y() { - return ( - (pi.so[idx] === 1 && - ((pi.startStubY > pi.endStubY && pi.ty > pi.startStubY) || - (pi.sy > pi.endStubY && pi.ty > pi.sy))) || - (pi.so[idx] === -1 && - ((pi.startStubY < pi.endStubY && pi.ty < pi.startStubY) || - (pi.sy < pi.endStubY && pi.ty < pi.sy))) - ); - }, - }; - - if (!alwaysRespectStubs && areInProximity[axis]()) { - return { - x: [ - (paintInfo.sx + paintInfo.tx) / 2, - paintInfo.startStubY, - (paintInfo.sx + paintInfo.tx) / 2, - paintInfo.endStubY, - ] as StubPositions, - y: [ - paintInfo.startStubX, - (paintInfo.sy + paintInfo.ty) / 2, - paintInfo.endStubX, - (paintInfo.sy + paintInfo.ty) / 2, - ] as StubPositions, - }[axis]; - } else { - return [ - paintInfo.startStubX, - paintInfo.startStubY, - paintInfo.endStubX, - paintInfo.endStubY, - ] as StubPositions; - } - }, -}; - -export class N8nConnector extends AbstractConnector { - static type = 'N8nConnector'; - - type = N8nConnector.type; - - majorAnchor: number; - - minorAnchor: number; - - midpoint: number; - - alwaysRespectStubs: boolean; - - loopbackVerticalLength: number; - - lastx: number | null; - - lasty: number | null; - - cornerRadius: number; - - loopbackMinimum: number; - - curvinessCoefficient: number; - - zBezierOffset: number; - - targetGap: number; - - overrideTargetEndpoint: Endpoint; - - getEndpointOffset?: (e: Endpoint) => number | null; - - private internalSegments: FlowchartSegment[] = []; - - constructor( - public connection: Connection, - params: N8nConnectorOptions, - ) { - super(connection, params); - params = params || {}; - this.minorAnchor = 0; // seems to be angle at which connector leaves endpoint - this.majorAnchor = 0; // translates to curviness of bezier curve - this.stub = params.stub || 0; - this.midpoint = 0.5; - this.alwaysRespectStubs = params.alwaysRespectStubs === true; - this.loopbackVerticalLength = params.loopbackVerticalLength || 0; - this.lastx = null; - this.lasty = null; - this.cornerRadius = params.cornerRadius !== null ? params.cornerRadius : 0; - this.loopbackMinimum = params.loopbackMinimum || 100; - this.curvinessCoefficient = 0.4; - this.zBezierOffset = 40; - this.targetGap = params.targetGap || 0; - this.stub = params.stub || 0; - this.overrideTargetEndpoint = params.overrideTargetEndpoint || null; - this.getEndpointOffset = params.getEndpointOffset || null; - } - - getDefaultStubs(): [number, number] { - return [30, 30]; - } - - sgn(n: number) { - return n < 0 ? -1 : n === 0 ? 0 : 1; - } - - getFlowchartSegmentDirections(segment: FlowchartSegment): [number, number] { - return [this.sgn(segment[2] - segment[0]), this.sgn(segment[3] - segment[1])]; - } - - getSegmentLength(s: FlowchartSegment) { - return Math.sqrt(Math.pow(s[0] - s[2], 2) + Math.pow(s[1] - s[3], 2)); - } - - protected _findControlPoint( - point: PointXY, - sourceAnchorPosition: AnchorPlacement, - targetAnchorPosition: AnchorPlacement, - soo: [number, number], - too: [number, number], - ): PointXY { - // determine if the two anchors are perpendicular to each other in their orientation. we swap the control - // points around if so (code could be tightened up) - const perpendicular = soo[0] !== too[0] || soo[1] === too[1], - p: PointXY = { - x: 0, - y: 0, - }; - - if (!perpendicular) { - if (soo[0] === 0) { - p.x = - sourceAnchorPosition.curX < targetAnchorPosition.curX - ? point.x + this.minorAnchor - : point.x - this.minorAnchor; - } else { - p.x = point.x - this.majorAnchor * soo[0]; - } - if (soo[1] === 0) { - p.y = - sourceAnchorPosition.curY < targetAnchorPosition.curY - ? point.y + this.minorAnchor - : point.y - this.minorAnchor; - } else { - p.y = point.y + this.majorAnchor * too[1]; - } - } else { - if (too[0] === 0) { - p.x = - targetAnchorPosition.curX < sourceAnchorPosition.curX - ? point.x + this.minorAnchor - : point.x - this.minorAnchor; - } else { - p.x = point.x + this.majorAnchor * too[0]; - } - - if (too[1] === 0) { - p.y = - targetAnchorPosition.curY < sourceAnchorPosition.curY - ? point.y + this.minorAnchor - : point.y - this.minorAnchor; - } else { - p.y = point.y + this.majorAnchor * soo[1]; - } - } - - return p; - } - - writeFlowchartSegments(paintInfo: N8nConnectorPaintGeometry) { - let current: FlowchartSegment | null = null; - let next: FlowchartSegment | null = null; - let currentDirection: [number, number]; - let nextDirection: [number, number]; - - for (let i = 0; i < this.internalSegments.length - 1; i++) { - current = current || (deepCopy(this.internalSegments[i]) as FlowchartSegment); - next = deepCopy(this.internalSegments[i + 1]) as FlowchartSegment; - - currentDirection = this.getFlowchartSegmentDirections(current); - nextDirection = this.getFlowchartSegmentDirections(next); - - if (this.cornerRadius > 0 && current[4] !== next[4]) { - const minSegLength = Math.min(this.getSegmentLength(current), this.getSegmentLength(next)); - const radiusToUse = Math.min(this.cornerRadius, minSegLength / 2); - - current[2] -= currentDirection[0] * radiusToUse; - current[3] -= currentDirection[1] * radiusToUse; - next[0] += nextDirection[0] * radiusToUse; - next[1] += nextDirection[1] * radiusToUse; - - const ac = - (currentDirection[1] === nextDirection[0] && nextDirection[0] === 1) || - (currentDirection[1] === nextDirection[0] && - nextDirection[0] === 0 && - currentDirection[0] !== nextDirection[1]) || - (currentDirection[1] === nextDirection[0] && nextDirection[0] === -1), - sgny = next[1] > current[3] ? 1 : -1, - sgnx = next[0] > current[2] ? 1 : -1, - sgnEqual = sgny === sgnx, - cx = (sgnEqual && ac) || (!sgnEqual && !ac) ? next[0] : current[2], - cy = (sgnEqual && ac) || (!sgnEqual && !ac) ? current[3] : next[1]; - - this._addSegment(StraightSegment, { - x1: current[0], - y1: current[1], - x2: current[2], - y2: current[3], - }); - - this._addSegment(ArcSegment, { - r: radiusToUse, - x1: current[2], - y1: current[3], - x2: next[0], - y2: next[1], - cx, - cy, - ac, - }); - } else { - // dx + dy are used to adjust for line width. - const dx = - current[2] === current[0] - ? 0 - : current[2] > current[0] - ? paintInfo.lw / 2 - : -(paintInfo.lw / 2), - dy = - current[3] === current[1] - ? 0 - : current[3] > current[1] - ? paintInfo.lw / 2 - : -(paintInfo.lw / 2); - - this._addSegment(StraightSegment, { - x1: current[0] - dx, - y1: current[1] - dy, - x2: current[2] + dx, - y2: current[3] + dy, - }); - } - current = next; - } - if (next !== null) { - // last segment - this._addSegment(StraightSegment, { - x1: next[0], - y1: next[1], - x2: next[2], - y2: next[3], - }); - } - } - - calculateStubSegment(paintInfo: PaintGeometry): StubPositions { - return stubCalculators.opposite(paintInfo, { - axis: paintInfo.sourceAxis, - alwaysRespectStubs: this.alwaysRespectStubs, - }); - } - - calculateLineSegment(paintInfo: PaintGeometry, stubs: StubPositions) { - const axis = paintInfo.sourceAxis; - const idx = paintInfo.sourceAxis === 'x' ? 0 : 1; - const startStub = stubs[idx]; - const endStub = stubs[idx + 2]; - - const diffX = paintInfo.endStubX - paintInfo.startStubX; - const diffY = paintInfo.endStubY - paintInfo.startStubY; - const direction = -1; // vertical direction of loop, always below source - - const midx = paintInfo.startStubX + (paintInfo.endStubX - paintInfo.startStubX) * this.midpoint; - let midy: number; - - if (diffY >= 0 || diffX < -1 * this.loopbackMinimum) { - // loop backward behavior - midy = paintInfo.startStubY - (diffX < 0 ? direction * this.loopbackVerticalLength : 0); - } else { - // original flowchart behavior - midy = paintInfo.startStubY + (paintInfo.endStubY - paintInfo.startStubY) * this.midpoint; - } - return lineCalculators.opposite(paintInfo, { axis, startStub, endStub, idx, midx, midy }); - } - - _getPaintInfo(params: ConnectorComputeParams): N8nConnectorPaintGeometry { - let targetPos = params.targetPos; - let targetEndpoint: Endpoint = params.targetEndpoint; - if (this.overrideTargetEndpoint) { - targetPos = this.overrideTargetEndpoint._anchor.computedPosition as AnchorPlacement; - targetEndpoint = this.overrideTargetEndpoint; - } - - this.stub = this.stub || 0; - const sourceGap = 0; - const sourceStub = isArray(this.stub) ? this.stub[0] : this.stub; - const targetStub = isArray(this.stub) ? this.stub[1] : this.stub; - const segment = quadrant(params.sourcePos, targetPos); - const swapX = targetPos.curX < params.sourcePos.curX; - const swapY = targetPos.curY < params.sourcePos.curY; - const lw = params.strokeWidth || 1; - const x = swapX ? targetPos.curX : params.sourcePos.curX; - const y = swapY ? targetPos.curY : params.sourcePos.curY; - const w = Math.abs(targetPos.curX - params.sourcePos.curX); - const h = Math.abs(targetPos.curY - params.sourcePos.curY); - let so: Orientation = [params.sourcePos.ox, params.sourcePos.oy]; - let to: Orientation = [targetPos.ox, targetPos.oy]; - - // if either anchor does not have an orientation set, we derive one from their relative - // positions. we fix the axis to be the one in which the two elements are further apart, and - // point each anchor at the other element. this is also used when dragging a new connection. - if ((so[0] === 0 && so[1] === 0) || (to[0] === 0 && to[1] === 0)) { - const index = w > h ? 'curX' : 'curY'; - const indexNum = w > h ? 0 : 1; - const oIndex = [1, 0][indexNum]; - so = [0, 0]; - to = [0, 0]; - so[indexNum] = params.sourcePos[index] > targetPos[index] ? -1 : 1; - to[indexNum] = params.sourcePos[index] > targetPos[index] ? 1 : -1; - so[oIndex] = 0; - to[oIndex] = 0; - } - - const sx = swapX ? w + sourceGap * so[0] : sourceGap * so[0], - sy = swapY ? h + sourceGap * so[1] : sourceGap * so[1], - tx = swapX ? this.targetGap * to[0] : w + this.targetGap * to[0], - ty = swapY ? this.targetGap * to[1] : h + this.targetGap * to[1], - oProduct = so[0] * to[0] + so[1] * to[1]; - - const sourceStubWithOffset = - sourceStub + - (this.getEndpointOffset && params.sourceEndpoint - ? (this.getEndpointOffset(params.sourceEndpoint) ?? 0) - : 0); - - const targetStubWithOffset = - targetStub + - (this.getEndpointOffset && targetEndpoint - ? (this.getEndpointOffset(targetEndpoint) ?? 0) - : 0); - - // same as paintinfo generated by jsplumb AbstractConnector type - const result = { - sx, - sy, - tx, - ty, - lw, - xSpan: Math.abs(tx - sx), - ySpan: Math.abs(ty - sy), - mx: (sx + tx) / 2, - my: (sy + ty) / 2, - so, - to, - x, - y, - w, - h, - segment, - startStubX: sx + so[0] * sourceStubWithOffset, - startStubY: sy + so[1] * sourceStubWithOffset, - endStubX: tx + to[0] * targetStubWithOffset, - endStubY: ty + to[1] * targetStubWithOffset, - isXGreaterThanStubTimes2: Math.abs(sx - tx) > sourceStubWithOffset + targetStubWithOffset, - isYGreaterThanStubTimes2: Math.abs(sy - ty) > sourceStubWithOffset + targetStubWithOffset, - opposite: oProduct === -1, - perpendicular: oProduct === 0, - orthogonal: oProduct === 1, - sourceAxis: so[0] === 0 ? 'y' : ('x' as PaintAxis), - points: [x, y, w, h, sx, sy, tx, ty] as [ - number, - number, - number, - number, - number, - number, - number, - number, - ], - stubs: [sourceStubWithOffset, targetStubWithOffset] as [number, number], - anchorOrientation: 'opposite', // always opposite since our endpoints are always opposite (source orientation is left (1) and target orientation is right (-1)) - - /** custom keys added */ - sourceEndpoint: params.sourceEndpoint, - targetEndpoint, - sourcePos: params.sourcePos, - targetPos, - targetGap: this.targetGap, - }; - - return result; - } - - _compute(originalPaintInfo: PaintGeometry, connParams: ConnectorComputeParams) { - const paintInfo = this._getPaintInfo(connParams); - - // override so that bounding box is calculated correctly when target override is set - Object.assign(originalPaintInfo, paintInfo); - - try { - if (paintInfo.tx < 0) { - this._computeFlowchart(paintInfo); - } else { - this._computeBezier(paintInfo); - } - } catch (error) {} - } - - /** - * Set target endpoint - * (to override default behavior tracking mouse when dragging mouse) - * @param {Endpoint} endpoint - */ - setTargetEndpoint(endpoint: Endpoint) { - this.overrideTargetEndpoint = endpoint; - } - - resetTargetEndpoint() { - this.overrideTargetEndpoint = null as unknown as Endpoint; - } - - _computeBezier(paintInfo: N8nConnectorPaintGeometry) { - const sp = paintInfo.sourcePos; - const tp = paintInfo.targetPos; - const _w = Math.abs(sp.curX - tp.curX) - this.targetGap; - const _h = Math.abs(sp.curY - tp.curY); - const _sx = sp.curX < tp.curX ? _w : 0; - const _sy = sp.curY < tp.curY ? _h : 0; - const _tx = sp.curX < tp.curX ? 0 : _w; - const _ty = sp.curY < tp.curY ? 0 : _h; - - if (paintInfo.ySpan <= 20 || (paintInfo.ySpan <= 100 && paintInfo.xSpan <= 100)) { - this.majorAnchor = 0.1; - } else { - this.majorAnchor = paintInfo.xSpan * this.curvinessCoefficient + this.zBezierOffset; - } - - const _CP = this._findControlPoint({ x: _sx, y: _sy }, sp, tp, paintInfo.so, paintInfo.to); - const _CP2 = this._findControlPoint({ x: _tx, y: _ty }, tp, sp, paintInfo.to, paintInfo.so); - - const bezRes = { - x1: _sx, - y1: _sy, - x2: _tx, - y2: _ty, - cp1x: _CP.x, - cp1y: _CP.y, - cp2x: _CP2.x, - cp2y: _CP2.y, - }; - this._addSegment(BezierSegment, bezRes); - } - - addFlowchartSegment(x: number, y: number, paintInfo: PaintGeometry) { - if (this.lastx === x && this.lasty === y) { - return; - } - const lx = this.lastx ?? paintInfo.sx; - const ly = this.lasty ?? paintInfo.sy; - const o = lx === x ? 'v' : 'h'; - - this.lastx = x; - this.lasty = y; - this.internalSegments.push([lx, ly, x, y, o]); - } - - _computeFlowchart(paintInfo: N8nConnectorPaintGeometry) { - this.segments = []; - this.lastx = null; - this.lasty = null; - - this.internalSegments = []; - - // calculate Stubs. - const stubs = this.calculateStubSegment(paintInfo); - - // add the start stub segment. use stubs for loopback as it will look better, with the loop spaced - // away from the element. - this.addFlowchartSegment(stubs[0], stubs[1], paintInfo); - - // compute the rest of the line - const p = this.calculateLineSegment(paintInfo, stubs); - if (p) { - for (let i = 0; i < p.length; i++) { - this.addFlowchartSegment(p[i][0], p[i][1], paintInfo); - } - } - - // line to end stub - this.addFlowchartSegment(stubs[2], stubs[3], paintInfo); - - // end stub to end (common) - this.addFlowchartSegment(paintInfo.tx, paintInfo.ty, paintInfo); - - // write out the segments. - this.writeFlowchartSegments(paintInfo); - } - - transformGeometry(g: Geometry): Geometry { - return g; - } -} diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts b/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts deleted file mode 100644 index d3dfea903a..0000000000 --- a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { registerEndpointRenderer, svg } from '@jsplumb/browser-ui'; -import { N8nAddInputEndpoint } from './N8nAddInputEndpointType'; - -export const register = () => { - registerEndpointRenderer(N8nAddInputEndpoint.type, { - makeNode: (endpointInstance: N8nAddInputEndpoint) => { - const xOffset = 1; - const lineYOffset = -2; - const width = endpointInstance.params.width; - const height = endpointInstance.params.height; - const unconnectedDiamondSize = width / 2; - const unconnectedDiamondWidth = unconnectedDiamondSize * Math.sqrt(2); - const unconnectedPlusStroke = 2; - const unconnectedPlusSize = width - 2 * unconnectedPlusStroke; - - const sizeDifference = (unconnectedPlusSize - unconnectedDiamondWidth) / 2; - - const container = svg.node('g', { - style: `--svg-color: var(--endpoint-svg-color, var(${endpointInstance.params.color}))`, - width, - height, - }); - - const unconnectedGroup = svg.node('g', { class: 'add-input-endpoint-unconnected' }); - const unconnectedLine = svg.node('rect', { - x: xOffset / 2 + unconnectedDiamondWidth / 2 + sizeDifference, - y: unconnectedDiamondWidth + lineYOffset, - width: 2, - height: height - unconnectedDiamondWidth - unconnectedPlusSize, - 'stroke-width': 0, - class: 'add-input-endpoint-line', - }); - const unconnectedPlusGroup = svg.node('g', { - transform: `translate(${xOffset / 2}, ${height - unconnectedPlusSize + lineYOffset})`, - }); - const plusRectangle = svg.node('rect', { - x: 1, - y: 1, - rx: 3, - 'stroke-width': unconnectedPlusStroke, - fillOpacity: 0, - height: unconnectedPlusSize, - width: unconnectedPlusSize, - class: 'add-input-endpoint-plus-rectangle', - }); - const plusIcon = svg.node('path', { - transform: `scale(${width / 24})`, - d: 'm15.40655,9.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z', - class: 'add-input-endpoint-plus-icon', - }); - - unconnectedPlusGroup.appendChild(plusRectangle); - unconnectedPlusGroup.appendChild(plusIcon); - unconnectedGroup.appendChild(unconnectedLine); - unconnectedGroup.appendChild(unconnectedPlusGroup); - - const defaultGroup = svg.node('g', { class: 'add-input-endpoint-default' }); - const defaultDiamond = svg.node('rect', { - x: xOffset + sizeDifference + unconnectedPlusStroke, - y: 0, - 'stroke-width': 0, - width: unconnectedDiamondSize, - height: unconnectedDiamondSize, - transform: `translate(${unconnectedDiamondWidth / 2}, 0) rotate(45)`, - class: 'add-input-endpoint-diamond', - }); - - defaultGroup.appendChild(defaultDiamond); - - container.appendChild(unconnectedGroup); - container.appendChild(defaultGroup); - - endpointInstance.setVisible(false); - - return container; - }, - updateNode: () => {}, - }); -}; diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts b/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts deleted file mode 100644 index 29ee360a70..0000000000 --- a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { EndpointHandler, Endpoint } from '@jsplumb/core'; -import { EndpointRepresentation } from '@jsplumb/core'; -import type { AnchorPlacement, EndpointRepresentationParams } from '@jsplumb/common'; -import { EVENT_ENDPOINT_CLICK } from '@jsplumb/browser-ui'; - -export type ComputedN8nAddInputEndpoint = [number, number, number, number, number]; -interface N8nAddInputEndpointParams extends EndpointRepresentationParams { - endpoint: Endpoint; - width: number; - height: number; - color: string; - multiple: boolean; -} -export const N8nAddInputEndpointType = 'N8nAddInput'; -export const EVENT_ADD_INPUT_ENDPOINT_CLICK = 'eventAddInputEndpointClick'; -export class N8nAddInputEndpoint extends EndpointRepresentation { - params: N8nAddInputEndpointParams; - - constructor(endpoint: Endpoint, params: N8nAddInputEndpointParams) { - super(endpoint, params); - - this.params = params; - this.params.width = params.width || 18; - this.params.height = params.height || 48; - this.params.color = params.color || '--color-foreground-xdark'; - this.params.multiple = params.multiple || false; - - this.unbindEvents(); - this.bindEvents(); - } - - static type = N8nAddInputEndpointType; - - type = N8nAddInputEndpoint.type; - - bindEvents() { - this.instance.bind(EVENT_ENDPOINT_CLICK, this.fireClickEvent); - } - - unbindEvents() { - this.instance.unbind(EVENT_ENDPOINT_CLICK, this.fireClickEvent); - } - - setError() { - this.endpoint.addClass('add-input-endpoint-error'); - } - - resetError() { - this.endpoint.removeClass('add-input-endpoint-error'); - } - - fireClickEvent = (endpoint: Endpoint) => { - if (endpoint === this.endpoint) { - this.instance.fire(EVENT_ADD_INPUT_ENDPOINT_CLICK, this.endpoint); - } - }; -} - -export const N8nAddInputEndpointHandler: EndpointHandler< - N8nAddInputEndpoint, - ComputedN8nAddInputEndpoint -> = { - type: N8nAddInputEndpoint.type, - cls: N8nAddInputEndpoint, - compute: ( - ep: EndpointRepresentation, - anchorPoint: AnchorPlacement, - ): ComputedN8nAddInputEndpoint => { - if (!(ep instanceof N8nAddInputEndpoint)) { - throw Error('Unexpected Endpoint type'); - } - const x = anchorPoint.curX - ep.params.width / 2; - const y = anchorPoint.curY - ep.params.width / 2; - const w = ep.params.width; - const h = ep.params.height; - - ep.x = x; - ep.y = y; - ep.w = w; - ep.h = h; - - ep.addClass('add-input-endpoint'); - if (ep.params.multiple) { - ep.addClass('add-input-endpoint-multiple'); - } - return [x, y, w, h, ep.params.width]; - }, - - getParams: (ep: N8nAddInputEndpoint): N8nAddInputEndpointParams => { - return ep.params; - }, -}; diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointRenderer.ts b/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointRenderer.ts deleted file mode 100644 index 07b528ee72..0000000000 --- a/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointRenderer.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { registerEndpointRenderer, svg } from '@jsplumb/browser-ui'; -import { N8nPlusEndpoint } from './N8nPlusEndpointType'; - -export const register = () => { - registerEndpointRenderer(N8nPlusEndpoint.type, { - makeNode: (ep: N8nPlusEndpoint) => { - const group = svg.node('g'); - const containerBorder = svg.node('rect', { - rx: 3, - 'stroke-width': 2, - fillOpacity: 0, - height: ep.params.dimensions - 2, - width: ep.params.dimensions - 2, - y: 1, - x: 1, - }); - const plusPath = svg.node('path', { - d: 'm16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z', - }); - if (ep.params.size !== 'medium') { - ep.addClass(ep.params.size); - } - group.appendChild(containerBorder); - group.appendChild(plusPath); - - ep.setupOverlays(); - ep.setVisible(false); - return group; - }, - - updateNode: (ep: N8nPlusEndpoint) => { - const ifNoConnections = ep.getConnections().length === 0; - - ep.setIsVisible(ifNoConnections); - }, - }); -}; diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts b/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts deleted file mode 100644 index fc19318549..0000000000 --- a/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { EndpointHandler, Endpoint, Overlay } from '@jsplumb/core'; -import { EndpointRepresentation } from '@jsplumb/core'; -import type { AnchorPlacement, EndpointRepresentationParams } from '@jsplumb/common'; -import { - createElement, - EVENT_ENDPOINT_MOUSEOVER, - EVENT_ENDPOINT_MOUSEOUT, - EVENT_ENDPOINT_CLICK, - EVENT_CONNECTION_ABORT, -} from '@jsplumb/browser-ui'; - -export type ComputedN8nPlusEndpoint = [number, number, number, number, number]; -export type N8nEndpointLabelLength = 'small' | 'medium' | 'large'; -interface N8nPlusEndpointParams extends EndpointRepresentationParams { - dimensions: number; - connectedEndpoint: Endpoint; - hoverMessage: string; - endpointLabelLength: N8nEndpointLabelLength; - size: 'small' | 'medium'; - showOutputLabel: boolean; -} -export const PlusStalkOverlay = 'plus-stalk'; -export const HoverMessageOverlay = 'hover-message'; -export const N8nPlusEndpointType = 'N8nPlus'; -export const EVENT_PLUS_ENDPOINT_CLICK = 'eventPlusEndpointClick'; -export class N8nPlusEndpoint extends EndpointRepresentation { - params: N8nPlusEndpointParams; - - label: string; - - stalkOverlay: Overlay | null; - - messageOverlay: Overlay | null; - - constructor(endpoint: Endpoint, params: N8nPlusEndpointParams) { - super(endpoint, params); - - this.params = params; - this.label = ''; - this.stalkOverlay = null; - this.messageOverlay = null; - - this.unbindEvents(); - this.bindEvents(); - } - - static type = N8nPlusEndpointType; - - type = N8nPlusEndpoint.type; - - setupOverlays() { - this.clearOverlays(); - this.stalkOverlay = this.endpoint.addOverlay({ - type: 'Custom', - options: { - id: PlusStalkOverlay, - attributes: { - 'data-endpoint-label-length': this.params.endpointLabelLength, - }, - create: () => { - const stalk = createElement('div', {}, `${PlusStalkOverlay} ${this.params.size}`); - return stalk; - }, - }, - }); - this.messageOverlay = this.endpoint.addOverlay({ - type: 'Custom', - options: { - id: HoverMessageOverlay, - location: 0.5, - attributes: { - 'data-endpoint-label-length': this.params.endpointLabelLength, - }, - create: () => { - const hoverMessage = createElement('p', {}, `${HoverMessageOverlay} ${this.params.size}`); - hoverMessage.innerHTML = this.params.hoverMessage; - return hoverMessage; - }, - }, - }); - } - - bindEvents() { - this.instance.bind(EVENT_ENDPOINT_MOUSEOVER, this.setHoverMessageVisible); - this.instance.bind(EVENT_ENDPOINT_MOUSEOUT, this.unsetHoverMessageVisible); - this.instance.bind(EVENT_ENDPOINT_CLICK, this.fireClickEvent); - this.instance.bind(EVENT_CONNECTION_ABORT, this.setStalkLabels); - } - - unbindEvents() { - this.instance.unbind(EVENT_ENDPOINT_MOUSEOVER, this.setHoverMessageVisible); - this.instance.unbind(EVENT_ENDPOINT_MOUSEOUT, this.unsetHoverMessageVisible); - this.instance.unbind(EVENT_ENDPOINT_CLICK, this.fireClickEvent); - this.instance.unbind(EVENT_CONNECTION_ABORT, this.setStalkLabels); - } - - setStalkLabels = () => { - if (!this.endpoint) return; - - const stalkOverlay = this.endpoint.getOverlay(PlusStalkOverlay); - const messageOverlay = this.endpoint.getOverlay(HoverMessageOverlay); - - if (stalkOverlay && messageOverlay) { - // Increase the size of the stalk overlay if the label is too long - const fnKey = this.label.length > 10 ? 'add' : 'remove'; - this.instance[`${fnKey}OverlayClass`](stalkOverlay, 'long-stalk'); - this.instance[`${fnKey}OverlayClass`](messageOverlay, 'long-stalk'); - this[`${fnKey}Class`]('long-stalk'); - - if (this.label) { - stalkOverlay.canvas.setAttribute('data-label', this.label); - } - } - }; - - fireClickEvent = (endpoint: Endpoint) => { - if (endpoint === this.endpoint) { - this.instance.fire(EVENT_PLUS_ENDPOINT_CLICK, this.endpoint); - } - }; - - setHoverMessageVisible = (endpoint: Endpoint) => { - if (endpoint === this.endpoint && this.messageOverlay) { - this.instance.addOverlayClass(this.messageOverlay, 'visible'); - } - }; - - unsetHoverMessageVisible = (endpoint: Endpoint) => { - if (endpoint === this.endpoint && this.messageOverlay) { - this.instance.removeOverlayClass(this.messageOverlay, 'visible'); - } - }; - - clearOverlays() { - Object.keys(this.endpoint.getOverlays()).forEach((key) => { - this.endpoint.removeOverlay(key); - }); - this.stalkOverlay = null; - this.messageOverlay = null; - } - - getConnections() { - const connections = [ - ...this.endpoint.connections, - ...this.params.connectedEndpoint.connections, - ]; - - return connections; - } - - setIsVisible(visible: boolean) { - Object.keys(this.endpoint.getOverlays()).forEach((overlay) => { - this.endpoint.getOverlays()[overlay].setVisible(visible); - }); - this.setVisible(visible); - // Re-trigger the success state if label is set - if (visible && this.label) { - this.setSuccessOutput(this.label); - } - } - - setSuccessOutput(label: string) { - this.endpoint.addClass('ep-success'); - if (this.params.showOutputLabel) { - this.label = label; - this.setStalkLabels(); - return; - } - - this.endpoint.addClass('ep-success--without-label'); - } - - clearSuccessOutput() { - this.endpoint.removeOverlay('successOutputOverlay'); - this.endpoint.removeClass('ep-success'); - this.endpoint.removeClass('ep-success--without-label'); - this.label = ''; - this.setStalkLabels(); - } -} - -export const N8nPlusEndpointHandler: EndpointHandler = { - type: N8nPlusEndpoint.type, - cls: N8nPlusEndpoint, - compute: ( - ep: EndpointRepresentation, - anchorPoint: AnchorPlacement, - ): ComputedN8nPlusEndpoint => { - if (!(ep instanceof N8nPlusEndpoint)) { - throw Error('Unexpected Endpoint type'); - } - const x = anchorPoint.curX - ep.params.dimensions / 2; - const y = anchorPoint.curY - ep.params.dimensions / 2; - const w = ep.params.dimensions; - const h = ep.params.dimensions; - - ep.x = x; - ep.y = y; - ep.w = w; - ep.h = h; - - ep.canvas?.setAttribute('data-endpoint-label-length', ep.params.endpointLabelLength); - - ep.addClass('plus-endpoint'); - return [x, y, w, h, ep.params.dimensions]; - }, - - getParams: (ep: N8nPlusEndpoint): N8nPlusEndpointParams => { - return ep.params; - }, -}; diff --git a/packages/editor-ui/src/plugins/jsplumb/index.ts b/packages/editor-ui/src/plugins/jsplumb/index.ts deleted file mode 100644 index f32a4df9fd..0000000000 --- a/packages/editor-ui/src/plugins/jsplumb/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Plugin } from 'vue'; -import { N8nPlusEndpointHandler } from '@/plugins/jsplumb/N8nPlusEndpointType'; -import * as N8nPlusEndpointRenderer from '@/plugins/jsplumb/N8nPlusEndpointRenderer'; -import { N8nConnector } from '@/plugins/connectors/N8nCustomConnector'; -import * as N8nAddInputEndpointRenderer from '@/plugins/jsplumb/N8nAddInputEndpointRenderer'; -import { N8nAddInputEndpointHandler } from '@/plugins/jsplumb/N8nAddInputEndpointType'; -import type { AbstractConnector } from '@jsplumb/core'; -import { Connectors, EndpointFactory } from '@jsplumb/core'; -import type { Constructable } from '@jsplumb/util'; - -export const JsPlumbPlugin: Plugin = { - install: () => { - Connectors.register(N8nConnector.type, N8nConnector as Constructable); - - N8nPlusEndpointRenderer.register(); - EndpointFactory.registerHandler(N8nPlusEndpointHandler); - - N8nAddInputEndpointRenderer.register(); - EndpointFactory.registerHandler(N8nAddInputEndpointHandler); - }, -}; diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 4a6faf9f51..4e69e40ac3 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -27,7 +27,7 @@ const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordV const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue'); const MainSidebar = async () => await import('@/components/MainSidebar.vue'); const CanvasChat = async () => await import('@/components/CanvasChat/CanvasChat.vue'); -const NodeView = async () => await import('@/views/NodeViewSwitcher.vue'); +const NodeView = async () => await import('@/views/NodeView.v2.vue'); const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue'); const WorkflowExecutionsLandingPage = async () => await import('@/components/executions/workflow/WorkflowExecutionsLandingPage.vue'); diff --git a/packages/editor-ui/src/shims-jsplumb.d.ts b/packages/editor-ui/src/shims-jsplumb.d.ts deleted file mode 100644 index 1bd6f5aedd..0000000000 --- a/packages/editor-ui/src/shims-jsplumb.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { - Connection, - Endpoint, - EndpointRepresentation, - AbstractConnector, - Overlay, -} from '@jsplumb/core'; -import type { NodeConnectionType } from 'n8n-workflow'; -import type { N8nEndpointLabelLength } from '@/plugins/jsplumb/N8nPlusEndpointType'; - -declare module '@jsplumb/core' { - interface EndpointRepresentation { - canvas: HTMLElement; - scope: NodeConnectionType; - } - interface AbstractConnector { - canvas: HTMLElement; - overrideTargetEndpoint: Endpoint; - } - interface Overlay { - canvas: HTMLElement; - } - interface Connection { - __meta: { - sourceOutputIndex: number; - targetNodeName: string; - targetOutputIndex: number; - sourceNodeName: string; - }; - } - interface Endpoint { - scope: NodeConnectionType; - __meta: { - nodeName: string; - nodeId: string; - index: number; - nodeType?: string; - totalEndpoints: number; - endpointLabelLength?: N8nEndpointLabelLength; - }; - } -} diff --git a/packages/editor-ui/src/stores/canvas.store.ts b/packages/editor-ui/src/stores/canvas.store.ts index b96699f297..c38d026ffd 100644 --- a/packages/editor-ui/src/stores/canvas.store.ts +++ b/packages/editor-ui/src/stores/canvas.store.ts @@ -1,359 +1,33 @@ -import { computed, ref, watch } from 'vue'; +import { computed, ref } from 'vue'; import { defineStore } from 'pinia'; -import { v4 as uuid } from 'uuid'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import { useNodeTypesStore } from '@/stores/nodeTypes.store'; -import { useUIStore } from '@/stores/ui.store'; -import { useHistoryStore } from '@/stores/history.store'; -import { useSourceControlStore } from '@/stores/sourceControl.store'; import type { INodeUi, XYPosition } from '@/Interface'; -import { - applyScale, - getScaleFromWheelEventDelta, - normalizeWheelEventDelta, - scaleBigger, - scaleReset, - scaleSmaller, -} from '@/utils/canvasUtils'; -import { MANUAL_TRIGGER_NODE_TYPE, START_NODE_TYPE } from '@/constants'; -import type { - BeforeStartEventParams, - BrowserJsPlumbInstance, - ConstrainFunction, - DragStopEventParams, -} from '@jsplumb/browser-ui'; -import { newInstance } from '@jsplumb/browser-ui'; -import type { Connection } from '@jsplumb/core'; -import { MoveNodeCommand } from '@/models/history'; -import { - DEFAULT_PLACEHOLDER_TRIGGER_BUTTON, - getMidCanvasPosition, - getNewNodePosition, - getZoomToFit, - PLACEHOLDER_TRIGGER_NODE_SIZE, - CONNECTOR_FLOWCHART_TYPE, - GRID_SIZE, - CONNECTOR_PAINT_STYLE_DEFAULT, - CONNECTOR_PAINT_STYLE_PRIMARY, - CONNECTOR_ARROW_OVERLAYS, - getMousePosition, - SIDEBAR_WIDTH, - SIDEBAR_WIDTH_EXPANDED, -} from '@/utils/nodeViewUtils'; -import type { PointXY } from '@jsplumb/util'; import { useLoadingService } from '@/composables/useLoadingService'; export const useCanvasStore = defineStore('canvas', () => { const workflowStore = useWorkflowsStore(); - const nodeTypesStore = useNodeTypesStore(); - const uiStore = useUIStore(); - const historyStore = useHistoryStore(); - const sourceControlStore = useSourceControlStore(); const loadingService = useLoadingService(); - const jsPlumbInstanceRef = ref(); - const isDragging = ref(false); - const lastSelectedConnection = ref(); const newNodeInsertPosition = ref(null); const panelHeight = ref(0); const nodes = computed(() => workflowStore.allNodes); - const triggerNodes = computed(() => - nodes.value.filter( - (node) => node.type === START_NODE_TYPE || nodeTypesStore.isTriggerNode(node.type), - ), - ); const aiNodes = computed(() => nodes.value.filter((node) => node.type.includes('langchain')), ); - const isDemo = ref(false); - const nodeViewScale = ref(1); - const canvasAddButtonPosition = ref([1, 1]); - const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly); - const lastSelectedConnectionComputed = computed( - () => lastSelectedConnection.value, - ); - - const setReadOnly = (readOnly: boolean) => { - if (jsPlumbInstanceRef.value) { - jsPlumbInstanceRef.value.elementsDraggable = !readOnly; - jsPlumbInstanceRef.value.setDragConstrainFunction(((pos: PointXY) => - readOnly ? null : pos) as ConstrainFunction); - } - }; - - const setLastSelectedConnection = (connection: Connection | undefined) => { - lastSelectedConnection.value = connection; - }; - - const setRecenteredCanvasAddButtonPosition = (offset?: XYPosition) => { - const position = getMidCanvasPosition(nodeViewScale.value, offset ?? [0, 0]); - - position[0] -= PLACEHOLDER_TRIGGER_NODE_SIZE / 2; - position[1] -= PLACEHOLDER_TRIGGER_NODE_SIZE / 2; - - canvasAddButtonPosition.value = getNewNodePosition(nodes.value, position); - }; - - const getPlaceholderTriggerNodeUI = (): INodeUi => { - setRecenteredCanvasAddButtonPosition(); - - return { - id: uuid(), - ...DEFAULT_PLACEHOLDER_TRIGGER_BUTTON, - position: canvasAddButtonPosition.value, - }; - }; - - const getAutoAddManualTriggerNode = (): INodeUi | null => { - const manualTriggerNode = nodeTypesStore.getNodeType(MANUAL_TRIGGER_NODE_TYPE); - - if (!manualTriggerNode) { - return null; - } - - return { - id: uuid(), - name: manualTriggerNode.defaults.name?.toString() ?? manualTriggerNode.displayName, - type: MANUAL_TRIGGER_NODE_TYPE, - parameters: {}, - position: canvasAddButtonPosition.value, - typeVersion: 1, - }; - }; - - const getNodesWithPlaceholderNode = (): INodeUi[] => - triggerNodes.value.length > 0 ? nodes.value : [getPlaceholderTriggerNodeUI(), ...nodes.value]; - - const canvasPositionFromPagePosition = (position: XYPosition): XYPosition => { - const sidebarWidth = isDemo.value - ? 0 - : uiStore.sidebarMenuCollapsed - ? SIDEBAR_WIDTH - : SIDEBAR_WIDTH_EXPANDED; - - const relativeX = position[0] - sidebarWidth; - const relativeY = isDemo.value - ? position[1] - : position[1] - uiStore.bannersHeight - uiStore.headerHeight; - - return [relativeX, relativeY]; - }; - - const setZoomLevel = (zoomLevel: number, offset: XYPosition) => { - nodeViewScale.value = zoomLevel; - jsPlumbInstanceRef.value?.setZoom(zoomLevel); - uiStore.nodeViewOffsetPosition = offset; - }; - - const resetZoom = () => { - const { scale, offset } = scaleReset({ - scale: nodeViewScale.value, - offset: uiStore.nodeViewOffsetPosition, - origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]), - }); - setZoomLevel(scale, offset); - }; - - const zoomIn = () => { - const { scale, offset } = scaleBigger({ - scale: nodeViewScale.value, - offset: uiStore.nodeViewOffsetPosition, - origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]), - }); - setZoomLevel(scale, offset); - }; - - const zoomOut = () => { - const { scale, offset } = scaleSmaller({ - scale: nodeViewScale.value, - offset: uiStore.nodeViewOffsetPosition, - origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]), - }); - setZoomLevel(scale, offset); - }; - - const zoomToFit = () => { - const nodes = getNodesWithPlaceholderNode(); - if (!nodes.length) { - // some unknown workflow executions - return; - } - const { zoomLevel, offset } = getZoomToFit(nodes, !isDemo.value); - setZoomLevel(zoomLevel, offset); - }; - - const wheelMoveWorkflow = (deltaX: number, deltaY: number, shiftKeyPressed = false) => { - const offsetPosition = uiStore.nodeViewOffsetPosition; - const nodeViewOffsetPositionX = offsetPosition[0] - (shiftKeyPressed ? deltaY : deltaX); - const nodeViewOffsetPositionY = offsetPosition[1] - (shiftKeyPressed ? deltaX : deltaY); - uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY]; - }; - - const wheelScroll = (e: WheelEvent) => { - // Prevent browser back/forward gesture, default pinch to zoom etc. - e.preventDefault(); - - const { deltaX, deltaY } = normalizeWheelEventDelta(e); - - if (e.ctrlKey || e.metaKey) { - const scaleFactor = getScaleFromWheelEventDelta(deltaY); - const { scale, offset } = applyScale(scaleFactor)({ - scale: nodeViewScale.value, - offset: uiStore.nodeViewOffsetPosition, - origin: canvasPositionFromPagePosition(getMousePosition(e)), - }); - setZoomLevel(scale, offset); - return; - } - wheelMoveWorkflow(deltaX, deltaY, e.shiftKey); - }; - - function initInstance(container: Element) { - // Make sure to clean-up previous instance if it exists - if (jsPlumbInstanceRef.value) { - jsPlumbInstanceRef.value.destroy(); - jsPlumbInstanceRef.value.reset(); - jsPlumbInstanceRef.value = undefined; - } - - jsPlumbInstanceRef.value = newInstance({ - container, - connector: CONNECTOR_FLOWCHART_TYPE, - resizeObserver: false, - endpoint: { - type: 'Dot', - options: { radius: 5 }, - }, - paintStyle: CONNECTOR_PAINT_STYLE_DEFAULT, - hoverPaintStyle: CONNECTOR_PAINT_STYLE_PRIMARY, - connectionOverlays: CONNECTOR_ARROW_OVERLAYS, - elementsDraggable: !readOnlyEnv.value, - dragOptions: { - cursor: 'pointer', - grid: { w: GRID_SIZE, h: GRID_SIZE }, - start: (params: BeforeStartEventParams) => { - const draggedNode = params.drag.getDragElement(); - const nodeName = draggedNode.getAttribute('data-name'); - if (!nodeName) return; - isDragging.value = true; - - const isSelected = uiStore.isNodeSelected[nodeName]; - - if (params.e && !isSelected) { - // Only the node which gets dragged directly gets an event, for all others it is - // undefined. So check if the currently dragged node is selected and if not clear - // the drag-selection. - jsPlumbInstanceRef.value?.clearDragSelection(); - uiStore.resetSelectedNodes(); - } - - uiStore.addActiveAction('dragActive'); - return true; - }, - stop: (params: DragStopEventParams) => { - const draggedNode = params.drag.getDragElement(); - const nodeName = draggedNode.getAttribute('data-name'); - if (!nodeName) return; - const nodeData = workflowStore.getNodeByName(nodeName); - isDragging.value = false; - if (uiStore.isActionActive.dragActive && nodeData) { - const moveNodes = uiStore.getSelectedNodes.slice(); - const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name); - if (!selectedNodeNames.includes(nodeData.name)) { - // If the current node is not in selected add it to the nodes which - // got moved manually - moveNodes.push(nodeData); - } - - if (moveNodes.length > 1) { - historyStore.startRecordingUndo(); - } - // This does for some reason just get called once for the node that got clicked - // even though "start" and "drag" gets called for all. So lets do for now - // some dirty DOM query to get the new positions till I have more time to - // create a proper solution - let newNodePosition: XYPosition; - moveNodes.forEach((node: INodeUi) => { - const element = document.getElementById(node.id); - if (element === null) { - return; - } - - newNodePosition = [ - parseInt(element.style.left.slice(0, -2), 10), - parseInt(element.style.top.slice(0, -2), 10), - ]; - - const updateInformation = { - name: node.name, - properties: { - position: newNodePosition, - }, - }; - const oldPosition = node.position; - if (oldPosition[0] !== newNodePosition[0] || oldPosition[1] !== newNodePosition[1]) { - historyStore.pushCommandToUndo( - new MoveNodeCommand(node.name, oldPosition, newNodePosition), - ); - workflowStore.updateNodeProperties(updateInformation); - } - }); - if (moveNodes.length > 1) { - historyStore.stopRecordingUndo(); - } - if (uiStore.isActionActive.dragActive) { - uiStore.removeActiveAction('dragActive'); - } - } - }, - filter: '.node-description, .node-description .node-name, .node-description .node-subtitle', - }, - }); - jsPlumbInstanceRef.value?.setDragConstrainFunction(((pos: PointXY) => { - const isReadOnly = uiStore.isReadOnlyView; - if (isReadOnly) { - // Do not allow to move nodes in readOnly mode - return null; - } - return pos; - }) as ConstrainFunction); - } - - const jsPlumbInstance = computed(() => jsPlumbInstanceRef.value as BrowserJsPlumbInstance); - - watch(readOnlyEnv, setReadOnly); function setPanelHeight(height: number) { panelHeight.value = height; } return { - isDemo, - nodeViewScale, - canvasAddButtonPosition, newNodeInsertPosition, - jsPlumbInstance, isLoading: loadingService.isLoading, aiNodes, - lastSelectedConnection: lastSelectedConnectionComputed, panelHeight: computed(() => panelHeight.value), setPanelHeight, - setReadOnly, - setLastSelectedConnection, startLoading: loadingService.startLoading, setLoadingText: loadingService.setLoadingText, stopLoading: loadingService.stopLoading, - setRecenteredCanvasAddButtonPosition, - getNodesWithPlaceholderNode, - canvasPositionFromPagePosition, - setZoomLevel, - resetZoom, - zoomIn, - zoomOut, - zoomToFit, - wheelScroll, - initInstance, - getAutoAddManualTriggerNode, }; }); diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 55165358b9..9d03ec8d38 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -185,10 +185,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const isDevRelease = computed(() => settings.value.releaseChannel === 'dev'); - const isCanvasV2Enabled = computed(() => - (settings.value.betaFeatures ?? []).includes('canvas_v2'), - ); - const setSettings = (newSettings: FrontendSettings) => { settings.value = newSettings; userManagement.value = newSettings.userManagement; @@ -436,7 +432,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { saveDataProgressExecution, isCommunityPlan, isAskAiEnabled, - isCanvasV2Enabled, isAiCreditsEnabled, aiCreditsQuota, reset, diff --git a/packages/editor-ui/src/utils/canvasUtils.ts b/packages/editor-ui/src/utils/canvasUtils.ts deleted file mode 100644 index 332d7c7569..0000000000 --- a/packages/editor-ui/src/utils/canvasUtils.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { MAIN_HEADER_TABS, VIEWS } from '@/constants'; -import type { IZoomConfig } from '@/Interface'; -import { useWorkflowsStore } from '@/stores/workflows.store'; -import type { ConnectionDetachedParams } from '@jsplumb/core'; -import type { IConnection } from 'n8n-workflow'; -import type { RouteLocation } from 'vue-router'; - -/* - Constants and utility functions mainly used by canvas store - and components used to display workflow in node view. - These are general-purpose functions that are exported - with this module and should be used by importing from - '@/utils/canvasUtils'. -*/ - -const SCALE_CHANGE_FACTOR = 1.25; -const MIN_SCALE = 0.2; -const MAX_SCALE = 5; -const SCROLL_ZOOM_SPEED = 0.01; -const MAX_WHEEL_DELTA = 32; - -const clamp = (min: number, max: number) => (num: number) => { - return Math.max(min, Math.min(max, num)); -}; - -const clampScale = clamp(MIN_SCALE, MAX_SCALE); - -export const applyScale = - (scale: number) => - ({ scale: initialScale, offset: [xOffset, yOffset], origin }: IZoomConfig): IZoomConfig => { - const newScale = clampScale(initialScale * scale); - const scaleChange = newScale / initialScale; - - const xOrigin = origin?.[0] ?? window.innerWidth / 2; - const yOrigin = origin?.[1] ?? window.innerHeight / 2; - - // Calculate the new offsets based on the zoom origin - xOffset = xOrigin - scaleChange * (xOrigin - xOffset); - yOffset = yOrigin - scaleChange * (yOrigin - yOffset); - - return { - scale: newScale, - offset: [xOffset, yOffset], - }; - }; - -export const scaleBigger = applyScale(SCALE_CHANGE_FACTOR); - -export const scaleSmaller = applyScale(1 / SCALE_CHANGE_FACTOR); - -export const scaleReset = (config: IZoomConfig): IZoomConfig => { - return applyScale(1 / config.scale)(config); -}; - -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 const getConnectionInfo = ( - connection: ConnectionDetachedParams, -): [IConnection, IConnection] | null => { - const sourceInfo = connection.sourceEndpoint.parameters; - const targetInfo = connection.targetEndpoint.parameters; - const sourceNode = useWorkflowsStore().getNodeById(sourceInfo.nodeId); - const targetNode = useWorkflowsStore().getNodeById(targetInfo.nodeId); - - if (sourceNode && targetNode) { - return [ - { - node: sourceNode.name, - type: sourceInfo.type, - index: sourceInfo.index, - }, - { - node: targetNode.name, - type: targetInfo.type, - index: targetInfo.index, - }, - ]; - } - return null; -}; - -const clampWheelDelta = clamp(-MAX_WHEEL_DELTA, MAX_WHEEL_DELTA); - -export const normalizeWheelEventDelta = (event: WheelEvent): { deltaX: number; deltaY: number } => { - const factorByMode: Record = { - [WheelEvent.DOM_DELTA_PIXEL]: 1, - [WheelEvent.DOM_DELTA_LINE]: 8, - [WheelEvent.DOM_DELTA_PAGE]: 24, - }; - - const factor = factorByMode[event.deltaMode] ?? 1; - - return { - deltaX: clampWheelDelta(event.deltaX * factor), - deltaY: clampWheelDelta(event.deltaY * factor), - }; -}; - -export const getScaleFromWheelEventDelta = (delta: number): number => { - return Math.pow(2, -delta * SCROLL_ZOOM_SPEED); -}; diff --git a/packages/editor-ui/src/utils/nodeViewUtils.ts b/packages/editor-ui/src/utils/nodeViewUtils.ts index a382180598..80ec02b15d 100644 --- a/packages/editor-ui/src/utils/nodeViewUtils.ts +++ b/packages/editor-ui/src/utils/nodeViewUtils.ts @@ -1,366 +1,38 @@ -import { isNumber, isValidNodeConnectionType } from '@/utils/typeGuards'; import { LIST_LIKE_NODE_OPERATIONS, - NODE_OUTPUT_DEFAULT_KEY, + MAIN_HEADER_TABS, NODE_POSITION_CONFLICT_ALLOWLIST, SET_NODE_TYPE, SPLIT_IN_BATCHES_NODE_TYPE, - STICKY_NODE_TYPE, + VIEWS, } 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 { INodeUi, XYPosition } from '@/Interface'; 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'; +import { NodeHelpers } from 'n8n-workflow'; +import type { RouteLocation } from 'vue-router'; /* - 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). -*/ + * Canvas constants and functions + */ -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(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 += ' *'; - } - 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 = (nodes: T[]): T => { return nodes.reduce((leftmostTop, node) => { @@ -372,168 +44,6 @@ export const getLeftmostTopNode = (nodes: T[ }, 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 ''; - } - - 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) { @@ -627,237 +137,6 @@ export const getMidCanvasPosition = (scale: number, offset: XYPosition): XYPosit 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; @@ -874,180 +153,6 @@ const getContentDimensions = (): { editorWidth: number; editorHeight: number } = }; }; -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 = (workflowNodes: T[]): T[] => { const nodes = [...workflowNodes]; @@ -1124,72 +229,6 @@ export function isElementIntersection( 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); - - 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, @@ -1326,3 +365,19 @@ export function generateOffsets(nodeCount: number, nodeSize: number, gridSize: n 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; +}; diff --git a/packages/editor-ui/src/utils/typeGuards.ts b/packages/editor-ui/src/utils/typeGuards.ts index c7013ac3ea..fed8d837ea 100644 --- a/packages/editor-ui/src/utils/typeGuards.ts +++ b/packages/editor-ui/src/utils/typeGuards.ts @@ -6,8 +6,6 @@ import type { } from 'n8n-workflow'; import { nodeConnectionTypes } from 'n8n-workflow'; import type { IExecutionResponse, ICredentialsResponse, NewCredentialsModal } from '@/Interface'; -import type { jsPlumbDOMElement } from '@jsplumb/browser-ui'; -import type { Connection } from '@jsplumb/core'; import type { Connection as VueFlowConnection } from '@vue-flow/core'; import type { RouteLocationRaw } from 'vue-router'; import type { CanvasConnectionMode } from '@/types'; @@ -53,14 +51,6 @@ export const isResourceMapperValue = (value: unknown): value is string | number return ['string', 'number', 'boolean'].includes(typeof value); }; -export const isJSPlumbEndpointElement = (element: Node): element is jsPlumbDOMElement => { - return 'jtk' in element && 'endpoint' in (element.jtk as object); -}; - -export const isJSPlumbConnection = (connection: unknown): connection is Connection => { - return connection !== null && typeof connection === 'object' && 'connector' in connection; -}; - export function isDateObject(date: unknown): date is Date { return ( !!date && Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date as number) diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index ac6cbd02d3..1a682958f1 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -13,7 +13,7 @@ import { h, onBeforeUnmount, } from 'vue'; -import { useRoute, useRouter } from 'vue-router'; +import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'; import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useUIStore } from '@/stores/ui.store'; @@ -95,12 +95,11 @@ import { sourceControlEventBus } from '@/event-bus/source-control'; import { useTagsStore } from '@/stores/tags.store'; import { usePushConnectionStore } from '@/stores/pushConnection.store'; import { useNDVStore } from '@/stores/ndv.store'; -import { getNodeViewTab } from '@/utils/canvasUtils'; +import { getFixedNodesList, getNodeViewTab } from '@/utils/nodeViewUtils'; import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue'; import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue'; import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue'; import { nodeViewEventBus } from '@/event-bus'; -import * as NodeViewUtils from '@/utils/nodeViewUtils'; import { tryToParseNumber } from '@/utils/typesUtils'; import { useTemplatesStore } from '@/stores/templates.store'; import { createEventBus, N8nCallout } from 'n8n-design-system'; @@ -113,6 +112,10 @@ import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2'; import { isValidNodeConnectionType } from '@/utils/typeGuards'; import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils'; +defineOptions({ + name: 'NodeView', +}); + const LazyNodeCreation = defineAsyncComponent( async () => await import('@/components/Node/NodeCreation.vue'), ); @@ -902,7 +905,7 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: IWork initializeWorkspace({ ...workflowData, - nodes: NodeViewUtils.getFixedNodesList(workflowData.nodes), + nodes: getFixedNodesList(workflowData.nodes), } as IWorkflowDb); fitView(); @@ -1595,6 +1598,42 @@ watch( }, ); +onBeforeRouteLeave(async (to, from, next) => { + const toNodeViewTab = getNodeViewTab(to); + + if ( + toNodeViewTab === MAIN_HEADER_TABS.EXECUTIONS || + from.name === VIEWS.TEMPLATE_IMPORT || + (toNodeViewTab === MAIN_HEADER_TABS.WORKFLOW && from.name === VIEWS.EXECUTION_DEBUG) || + isReadOnlyEnvironment.value + ) { + next(); + return; + } + + await workflowHelpers.promptSaveUnsavedWorkflowChanges(next, { + async confirm() { + if (from.name === VIEWS.NEW_WORKFLOW) { + // Replace the current route with the new workflow route + // before navigating to the new route when saving new workflow. + await router.replace({ + name: VIEWS.WORKFLOW, + params: { name: workflowId.value }, + }); + + await router.push(to); + + return false; + } + + // Make sure workflow id is empty when leaving the editor + workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); + + return true; + }, + }); +}); + /** * Lifecycle */ diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue deleted file mode 100644 index 517eb52239..0000000000 --- a/packages/editor-ui/src/views/NodeView.vue +++ /dev/null @@ -1,5034 +0,0 @@ - - - - - - - - - - - diff --git a/packages/editor-ui/src/views/NodeViewSwitcher.vue b/packages/editor-ui/src/views/NodeViewSwitcher.vue deleted file mode 100644 index d68d29b0b6..0000000000 --- a/packages/editor-ui/src/views/NodeViewSwitcher.vue +++ /dev/null @@ -1,80 +0,0 @@ - - - diff --git a/packages/editor-ui/src/views/WorkflowExecutionsView.vue b/packages/editor-ui/src/views/WorkflowExecutionsView.vue index 960379998d..f2ca2cc363 100644 --- a/packages/editor-ui/src/views/WorkflowExecutionsView.vue +++ b/packages/editor-ui/src/views/WorkflowExecutionsView.vue @@ -13,8 +13,7 @@ import { useRoute, useRouter } from 'vue-router'; import type { ExecutionSummary } from 'n8n-workflow'; import { useDebounce } from '@/composables/useDebounce'; import { useTelemetry } from '@/composables/useTelemetry'; -import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; -import { useNodeHelpers } from '@/composables/useNodeHelpers'; +import { useCanvasOperations } from '@/composables/useCanvasOperations'; const executionsStore = useExecutionsStore(); const workflowsStore = useWorkflowsStore(); @@ -26,8 +25,7 @@ const router = useRouter(); const toast = useToast(); const { callDebounced } = useDebounce(); -const workflowHelpers = useWorkflowHelpers({ router }); -const nodeHelpers = useNodeHelpers(); +const { initializeWorkspace } = useCanvasOperations({ router }); const loading = ref(false); const loadingMore = ref(false); @@ -139,8 +137,7 @@ async function fetchWorkflow() { try { await workflowsStore.fetchActiveWorkflows(); const data = await workflowsStore.fetchWorkflow(workflowId.value); - workflowHelpers.initState(data); - await nodeHelpers.addNodes(data.nodes, data.connections); + initializeWorkspace(data); } catch (error) { toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title')); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a7364ece5..a3fc95eaff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1429,21 +1429,6 @@ importers: '@fortawesome/vue-fontawesome': specifier: '*' version: 3.0.3(@fortawesome/fontawesome-svg-core@1.2.36)(vue@3.5.13(typescript@5.7.2)) - '@jsplumb/browser-ui': - specifier: ^5.13.2 - version: 5.13.2 - '@jsplumb/common': - specifier: ^5.13.2 - version: 5.13.2 - '@jsplumb/connector-bezier': - specifier: ^5.13.2 - version: 5.13.2 - '@jsplumb/core': - specifier: ^5.13.2 - version: 5.13.2 - '@jsplumb/util': - specifier: ^5.13.2 - version: 5.13.2 '@lezer/common': specifier: ^1.0.4 version: 1.1.0 @@ -3912,21 +3897,6 @@ packages: '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} - '@jsplumb/browser-ui@5.13.2': - resolution: {integrity: sha512-BZ76kPtxESMIdhcCtWXPdICMudJyBVzDxaKY4jlne93Zq1T2ErfpNQ3E6f3JZfvoyvlNbKgh0udYkZ7Yg7BmIQ==} - - '@jsplumb/common@5.13.2': - resolution: {integrity: sha512-ZX/EvvYi4HBkRVtsuSSAa/AuAz4p2wr3RrRz6l+r8yeElzX3lrrBx/fkERY2qwZPkKcOoLCr5ezZ7sslVMnl0Q==} - - '@jsplumb/connector-bezier@5.13.2': - resolution: {integrity: sha512-AALmOvkiP3ouGag6TGkBcd7SbCewPNwsKu9gku9AZqIq+fFu321zJ2IpfoyCFgkoFFSQjJ9jo1sWBbD3gnEXrg==} - - '@jsplumb/core@5.13.2': - resolution: {integrity: sha512-IODXQzhpq9QEzGKhPir6+ea8m4KeU3gzJsYjIu8oqSQ4jDhvEYF7TvSfeaNgy9sUAMt3OoKCqxCS4ga9J7LS5A==} - - '@jsplumb/util@5.13.2': - resolution: {integrity: sha512-POrqlZMOo821oa49Xbxb+pNmnxu0z2oS7FOeklRxKuYXR+7nsP0j9PpXjo8E8Ily4TaP+pdUnatb53vAaONO3g==} - '@kafkajs/confluent-schema-registry@1.0.6': resolution: {integrity: sha512-NrZL1peOIlmlLKvheQcJAx9PHdnc4kaW+9+Yt4jXUfbbYR9EFNCZt6yApI4SwlFilaiZieReM6XslWy1LZAvoQ==} @@ -16526,24 +16496,6 @@ snapshots: '@jsdevtools/ono@7.1.3': {} - '@jsplumb/browser-ui@5.13.2': - dependencies: - '@jsplumb/core': 5.13.2 - - '@jsplumb/common@5.13.2': - dependencies: - '@jsplumb/util': 5.13.2 - - '@jsplumb/connector-bezier@5.13.2': - dependencies: - '@jsplumb/core': 5.13.2 - - '@jsplumb/core@5.13.2': - dependencies: - '@jsplumb/common': 5.13.2 - - '@jsplumb/util@5.13.2': {} - '@kafkajs/confluent-schema-registry@1.0.6': dependencies: avsc: 5.7.6