refactor(editor): Remove old canvas code (no-changelog) (#13343)

This commit is contained in:
Alex Grozav
2025-02-19 13:10:40 +02:00
committed by GitHub
parent 44121a92e5
commit 143cb22bc5
41 changed files with 84 additions and 11941 deletions

View File

@@ -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;

View File

@@ -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<FrontendBetaFeatures> = ['canvas_v2'];
}

View File

@@ -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';

View File

@@ -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,
};

View File

@@ -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:*",

View File

@@ -100,22 +100,22 @@ watch(defaultLocale, (newLocale) => {
<BannerStack v-if="!isDemoMode" />
</div>
<div id="header" :class="$style.header">
<router-view name="header"></router-view>
<RouterView name="header" />
</div>
<div v-if="usersStore.currentUser" id="sidebar" :class="$style.sidebar">
<router-view name="sidebar"></router-view>
<RouterView name="sidebar" />
</div>
<div id="content" :class="$style.content">
<div :class="$style.contentWrapper">
<router-view v-slot="{ Component }">
<keep-alive v-if="$route.meta.keepWorkflowAlive" include="NodeViewSwitcher" :max="1">
<RouterView v-slot="{ Component }">
<KeepAlive v-if="$route.meta.keepWorkflowAlive" include="NodeView" :max="1">
<component :is="Component" />
</keep-alive>
</KeepAlive>
<component :is="Component" v-else />
</router-view>
</RouterView>
</div>
<div v-if="hasContentFooter" :class="$style.contentFooter">
<router-view name="footer" />
<RouterView name="footer" />
</div>
</div>
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">

View File

@@ -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'

View File

@@ -135,7 +135,6 @@ export const defaultSettings: FrontendSettings = {
enabled: false,
credits: 0,
},
betaFeatures: [],
easyAIWorkflowOnboarded: false,
partialExecution: {
version: 1,

View File

@@ -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 {

View File

@@ -1,125 +0,0 @@
<script lang="ts" setup>
import { onBeforeMount, onBeforeUnmount } from 'vue';
import { storeToRefs } from 'pinia';
import { useCanvasStore } from '@/stores/canvas.store';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useI18n } from '@/composables/useI18n';
const canvasStore = useCanvasStore();
const { zoomToFit, zoomIn, zoomOut, resetZoom } = canvasStore;
const { nodeViewScale, isDemo } = storeToRefs(canvasStore);
const deviceSupport = useDeviceSupport();
const i18n = useI18n();
const keyDown = (e: KeyboardEvent) => {
const isCtrlKeyPressed = deviceSupport.isCtrlKeyPressed(e);
if ((e.key === '=' || e.key === '+') && !isCtrlKeyPressed) {
zoomIn();
} else if ((e.key === '_' || e.key === '-') && !isCtrlKeyPressed) {
zoomOut();
} else if (e.key === '0' && !isCtrlKeyPressed) {
resetZoom();
} else if (e.key === '1' && !isCtrlKeyPressed) {
zoomToFit();
}
};
onBeforeMount(() => {
document.addEventListener('keydown', keyDown);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', keyDown);
});
</script>
<template>
<div
:class="{
[$style.zoomMenu]: true,
[$style.regularZoomMenu]: !isDemo,
[$style.demoZoomMenu]: isDemo,
}"
>
<KeyboardShortcutTooltip
:label="i18n.baseText('nodeView.zoomToFit')"
:shortcut="{ keys: ['1'] }"
>
<n8n-icon-button
type="tertiary"
size="large"
icon="expand"
data-test-id="zoom-to-fit"
@click="zoomToFit"
/>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip :label="i18n.baseText('nodeView.zoomIn')" :shortcut="{ keys: ['+'] }">
<n8n-icon-button
type="tertiary"
size="large"
icon="search-plus"
data-test-id="zoom-in-button"
@click="zoomIn"
/>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip :label="i18n.baseText('nodeView.zoomOut')" :shortcut="{ keys: ['-'] }">
<n8n-icon-button
type="tertiary"
size="large"
icon="search-minus"
data-test-id="zoom-out-button"
@click="zoomOut"
/>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip
:label="i18n.baseText('nodeView.resetZoom')"
:shortcut="{ keys: ['0'] }"
>
<n8n-icon-button
v-if="nodeViewScale !== 1 && !isDemo"
type="tertiary"
size="large"
icon="undo"
data-test-id="reset-zoom-button"
@click="resetZoom"
/>
</KeyboardShortcutTooltip>
</div>
</template>
<style lang="scss" module>
.zoomMenu {
position: absolute;
bottom: var(--spacing-s);
left: var(--spacing-s);
line-height: 25px;
color: #444;
padding-right: 5px;
button {
border: var(--border-base);
}
> * {
+ * {
margin-left: var(--spacing-3xs);
}
&:hover {
transform: scale(1.1);
}
}
}
.regularZoomMenu {
@media (max-width: $breakpoint-2xs) {
bottom: 90px;
}
}
.demoZoomMenu {
left: 10px;
bottom: 10px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,500 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import type { StyleValue } from 'vue';
import { onClickOutside } from '@vueuse/core';
import type { Workflow } from 'n8n-workflow';
import { isNumber, isString } from '@/utils/typeGuards';
import type { INodeUi, XYPosition } from '@/Interface';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useContextMenu } from '@/composables/useContextMenu';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { useToast } from '@/composables/useToast';
import { assert } from '@/utils/assert';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import { useNodeBase } from '@/composables/useNodeBase';
import { useTelemetry } from '@/composables/useTelemetry';
import { useStyles } from '@/composables/useStyles';
import { useI18n } from '@/composables/useI18n';
const props = withDefaults(
defineProps<{
nodeViewScale?: number;
gridSize?: number;
name: string;
instance: BrowserJsPlumbInstance;
isReadOnly?: boolean;
isActive?: boolean;
hideActions?: boolean;
disableSelecting?: boolean;
showCustomTooltip?: boolean;
workflow: Workflow;
}>(),
{
nodeViewScale: 1,
gridSize: GRID_SIZE,
},
);
defineOptions({ name: 'Sticky' });
const emit = defineEmits<{
removeNode: [string];
nodeSelected: [string, boolean, boolean];
}>();
const deviceSupport = useDeviceSupport();
const telemetry = useTelemetry();
const toast = useToast();
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const { APP_Z_INDEXES } = useStyles();
const i18n = useI18n();
const isResizing = ref<boolean>(false);
const isTouchActive = ref<boolean>(false);
const forceActions = ref(false);
const isColorPopoverVisible = ref(false);
const stickOptions = ref<HTMLElement>();
const isEditing = ref(false);
const setForceActions = (value: boolean) => {
forceActions.value = value;
};
const setColorPopoverVisible = (value: boolean) => {
isColorPopoverVisible.value = value;
};
const contextMenu = useContextMenu((action) => {
if (action === 'change_color') {
setForceActions(true);
setColorPopoverVisible(true);
}
});
const nodeBase = useNodeBase({
name: props.name,
instance: props.instance,
workflowObject: props.workflow,
isReadOnly: props.isReadOnly,
emit: emit as (event: string, ...args: unknown[]) => void,
});
onClickOutside(stickOptions, () => setColorPopoverVisible(false));
defineExpose({
deviceSupport,
toast,
contextMenu,
forceActions,
...nodeBase,
setForceActions,
isColorPopoverVisible,
setColorPopoverVisible,
stickOptions,
});
const data = computed(() => workflowsStore.getNodeByName(props.name));
// TODO: remove either node or data
const node = computed(() => workflowsStore.getNodeByName(props.name));
const nodeId = computed(() => data.value?.id);
const nodeType = computed(() => {
return data.value && nodeTypesStore.getNodeType(data.value.type, data.value.typeVersion);
});
const defaultText = computed(() => {
if (!nodeType.value) {
return '';
}
const properties = nodeType.value.properties;
const content = properties.find((property) => property.name === 'content');
return content && isString(content.default) ? content.default : '';
});
const isSelected = computed(
() =>
uiStore.getSelectedNodes.find(({ name }: INodeUi) => name === data.value?.name) !== undefined,
);
const position = computed<XYPosition>(() => (node.value ? node.value.position : [0, 0]));
const height = computed(() =>
node.value && isNumber(node.value.parameters.height) ? node.value.parameters.height : 0,
);
const width = computed(() =>
node.value && isNumber(node.value.parameters.width) ? node.value.parameters.width : 0,
);
const stickySize = computed<StyleValue>(() => ({
height: height.value + 'px',
width: width.value + 'px',
}));
const stickyPosition = computed<StyleValue>(() => ({
left: position.value[0] + 'px',
top: position.value[1] + 'px',
zIndex: props.isActive
? APP_Z_INDEXES.ACTIVE_STICKY
: -1 * Math.floor((height.value * width.value) / 1000),
}));
const workflowRunning = computed(() => uiStore.isActionActive.workflowRunning);
const showActions = computed(
() =>
!(
props.hideActions ||
isEditing.value ||
props.isReadOnly ||
workflowRunning.value ||
isResizing.value
) || forceActions.value,
);
onMounted(() => {
// Initialize the node
if (data.value !== null) {
try {
nodeBase.addNode(data.value);
} catch (error) {
// This breaks when new nodes are loaded into store but workflow tab is not currently active
// Shouldn't affect anything
}
}
});
const onShowPopover = () => setForceActions(true);
const onHidePopover = () => setForceActions(false);
const deleteNode = async () => {
assert(data.value);
// Wait a tick else vue causes problems because the data is gone
await nextTick();
emit('removeNode', data.value.name);
};
const changeColor = (index: number) => {
workflowsStore.updateNodeProperties({
name: props.name,
properties: {
parameters: {
...node.value?.parameters,
color: index,
},
position: node.value?.position ?? [0, 0],
},
});
};
const onEdit = (edit: boolean) => {
isEditing.value = edit;
if (edit && !props.isActive && node.value) {
ndvStore.activeNodeName = node.value.name;
} else if (props.isActive && !edit) {
ndvStore.activeNodeName = null;
}
};
const onMarkdownClick = (link: HTMLAnchorElement) => {
if (link) {
telemetry.track('User clicked note link', { type: 'other' });
}
};
const setParameters = (params: {
content?: string;
height?: number;
width?: number;
color?: string;
}) => {
if (node.value) {
const nodeParameters = {
content: isString(params.content) ? params.content : node.value.parameters.content,
height: isNumber(params.height) ? params.height : node.value.parameters.height,
width: isNumber(params.width) ? params.width : node.value.parameters.width,
color: isString(params.color) ? params.color : node.value.parameters.color,
};
workflowsStore.setNodeParameters({
key: node.value.id,
name: node.value.name,
value: nodeParameters,
});
}
};
const onInputChange = (content: string) => {
if (!node.value) {
return;
}
node.value.parameters.content = content;
setParameters({ content });
};
const setPosition = (newPosition: XYPosition) => {
if (!node.value) return;
workflowsStore.updateNodeProperties({
name: node.value.name,
properties: { position: newPosition },
});
};
const onResizeStart = () => {
isResizing.value = true;
if (!isSelected.value && node.value) {
emit('nodeSelected', node.value.name, false, true);
}
};
const onResize = ({
height,
width,
dX,
dY,
}: {
width: number;
height: number;
dX: number;
dY: number;
}) => {
if (!node.value) {
return;
}
if (dX !== 0 || dY !== 0) {
setPosition([node.value.position[0] + (dX || 0), node.value.position[1] + (dY || 0)]);
}
setParameters({ height, width });
};
const onResizeEnd = () => {
isResizing.value = false;
};
const touchStart = () => {
if (deviceSupport.isTouchDevice && !deviceSupport.isMacOs && !isTouchActive.value) {
isTouchActive.value = true;
setTimeout(() => {
isTouchActive.value = false;
}, 2000);
}
};
const onContextMenu = (e: MouseEvent): void => {
if (node.value && !props.isActive) {
contextMenu.open(e, { source: 'node-right-click', nodeId: node.value.id });
} else {
e.stopPropagation();
}
};
</script>
<template>
<div
:id="nodeId"
:ref="data?.name"
class="sticky-wrapper"
:style="stickyPosition"
:data-name="data?.name"
data-test-id="sticky"
>
<div
:class="{
'sticky-default': true,
'touch-active': isTouchActive,
'is-touch-device': deviceSupport.isTouchDevice,
'is-read-only': isReadOnly,
}"
:style="stickySize"
>
<div v-show="isSelected" class="select-sticky-background" />
<div
v-touch:start="touchStart"
v-touch:end="nodeBase.touchEnd"
class="sticky-box"
@click.left="nodeBase.mouseLeftClick"
@contextmenu="onContextMenu"
>
<N8nResizeableSticky
v-if="node"
:id="node.id"
:model-value="node.parameters.content"
:height="node.parameters.height"
:width="node.parameters.width"
:scale="nodeViewScale"
:background-color="node.parameters.color"
:read-only="isReadOnly"
:default-text="defaultText"
:edit-mode="isActive && !isReadOnly"
:grid-size="gridSize"
@edit="onEdit"
@resizestart="onResizeStart"
@resize="onResize"
@resizeend="onResizeEnd"
@markdown-click="onMarkdownClick"
@update:model-value="onInputChange"
/>
</div>
<div
v-show="showActions"
ref="stickOptions"
:class="{ 'sticky-options': true, 'no-select-on-click': true, 'force-show': forceActions }"
>
<div
v-touch:tap="deleteNode"
class="option"
data-test-id="delete-sticky"
:title="i18n.baseText('node.delete')"
>
<font-awesome-icon icon="trash" />
</div>
<n8n-popover
effect="dark"
trigger="click"
placement="top"
:popper-style="{ width: '208px' }"
:visible="isColorPopoverVisible"
@show="onShowPopover"
@hide="onHidePopover"
>
<template #reference>
<div
class="option"
data-test-id="change-sticky-color"
:title="i18n.baseText('node.changeColor')"
@click="() => setColorPopoverVisible(!isColorPopoverVisible)"
>
<font-awesome-icon icon="palette" />
</div>
</template>
<div class="content">
<div
v-for="(_, index) in Array.from({ length: 7 })"
:key="index"
class="color"
data-test-id="color"
:class="`sticky-color-${index + 1}`"
:style="{
'border-width': '1px',
'border-style': 'solid',
'border-color': 'var(--color-foreground-xdark)',
'background-color': `var(--color-sticky-background-${index + 1})`,
'box-shadow':
(index === 0 && node?.parameters.color === '') ||
index + 1 === node?.parameters.color
? `0 0 0 1px var(--color-sticky-background-${index + 1})`
: 'none',
}"
@click="changeColor(index + 1)"
></div>
</div>
</n8n-popover>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.sticky-wrapper {
position: absolute;
.sticky-default {
.sticky-box {
width: 100%;
height: 100%;
}
&.touch-active,
&:hover {
.sticky-options {
display: flex;
cursor: pointer;
}
}
&.is-read-only {
pointer-events: none;
}
.sticky-options {
display: none;
justify-content: flex-start;
position: absolute;
top: -25px;
left: -8px;
height: 26px;
font-size: 0.9em;
text-align: left;
z-index: 10;
color: #aaa;
text-align: center;
.option {
width: 28px;
display: inline-block;
&.touch {
display: none;
}
&:hover {
color: $color-primary;
}
}
}
.force-show {
display: flex;
}
&.is-touch-device .sticky-options {
left: -25px;
width: 150px;
.option.touch {
display: initial;
}
}
}
}
.select-sticky-background {
display: block;
position: absolute;
background-color: var(--color-canvas-selected);
border-radius: var(--border-radius-xlarge);
overflow: hidden;
height: calc(100% + 16px);
width: calc(100% + 16px);
left: -8px;
top: -8px;
z-index: 0;
}
.content {
display: flex;
flex-direction: row;
width: fit-content;
gap: var(--spacing-2xs);
}
.color {
width: 20px;
height: 20px;
border-radius: 50%;
border-color: var(--color-primary-shade-1);
&:hover {
cursor: pointer;
}
}
</style>

View File

@@ -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(

View File

@@ -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<string, string>) {
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,
};
}

View File

@@ -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<null | HTMLElement>;
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<typeof useUIStore>);
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);
});
});
});

View File

@@ -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<null | HTMLElement>,
options: {
// @TODO To be refactored (unref) when migrating NodeView to composition API
onMouseMoveEnd?: Ref<null | ((e: MouseEvent) => 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,
};
}

View File

@@ -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';

View File

@@ -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<typeof createPinia>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let uiStore: ReturnType<typeof useUIStore>;
let emit: (event: string, ...args: unknown[]) => void;
let nodeBase: ReturnType<typeof useNodeBase>;
const jsPlumbInstance = mock<BrowserJsPlumbInstance>();
const nodeTypeDescription = mock<INodeTypeDescription>({
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
});
const workflowObject = mock<Workflow>();
const node = mock<INode>();
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([]);
});
});
});

View File

@@ -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<INodeUi | null>(() => {
return workflowsStore.getNodeByName(name);
});
const nodeId = computed<string>(() => data.value?.id ?? '');
const inputs = ref<Array<NodeConnectionType | INodeInputConfiguration>>([]);
const outputs = ref<Array<NodeConnectionType | INodeOutputConfiguration>>([]);
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,
};
}

View File

@@ -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<void> {
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,

View File

@@ -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);
});
});
});

View File

@@ -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,
};
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -1,79 +0,0 @@
import { registerEndpointRenderer, svg } from '@jsplumb/browser-ui';
import { N8nAddInputEndpoint } from './N8nAddInputEndpointType';
export const register = () => {
registerEndpointRenderer<N8nAddInputEndpoint>(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: () => {},
});
};

View File

@@ -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<ComputedN8nAddInputEndpoint> {
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<ComputedN8nAddInputEndpoint>,
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;
},
};

View File

@@ -1,37 +0,0 @@
import { registerEndpointRenderer, svg } from '@jsplumb/browser-ui';
import { N8nPlusEndpoint } from './N8nPlusEndpointType';
export const register = () => {
registerEndpointRenderer<N8nPlusEndpoint>(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);
},
});
};

View File

@@ -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<ComputedN8nPlusEndpoint> {
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<N8nPlusEndpoint, ComputedN8nPlusEndpoint> = {
type: N8nPlusEndpoint.type,
cls: N8nPlusEndpoint,
compute: (
ep: EndpointRepresentation<ComputedN8nPlusEndpoint>,
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;
},
};

View File

@@ -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<AbstractConnector>);
N8nPlusEndpointRenderer.register();
EndpointFactory.registerHandler(N8nPlusEndpointHandler);
N8nAddInputEndpointRenderer.register();
EndpointFactory.registerHandler(N8nAddInputEndpointHandler);
},
};

View File

@@ -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');

View File

@@ -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;
};
}
}

View File

@@ -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<BrowserJsPlumbInstance>();
const isDragging = ref<boolean>(false);
const lastSelectedConnection = ref<Connection>();
const newNodeInsertPosition = ref<XYPosition | null>(null);
const panelHeight = ref(0);
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
const triggerNodes = computed<INodeUi[]>(() =>
nodes.value.filter(
(node) => node.type === START_NODE_TYPE || nodeTypesStore.isTriggerNode(node.type),
),
);
const aiNodes = computed<INodeUi[]>(() =>
nodes.value.filter((node) => node.type.includes('langchain')),
);
const isDemo = ref<boolean>(false);
const nodeViewScale = ref<number>(1);
const canvasAddButtonPosition = ref<XYPosition>([1, 1]);
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const lastSelectedConnectionComputed = computed<Connection | undefined>(
() => 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,
};
});

View File

@@ -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,

View File

@@ -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<number, number> = {
[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);
};

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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<INodeUi>(workflowData.nodes),
nodes: getFixedNodesList<INodeUi>(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
*/

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +0,0 @@
<script lang="ts" setup>
import { computed, onMounted, watch } from 'vue';
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
import NodeViewV1 from '@/views/NodeView.vue';
import NodeViewV2 from '@/views/NodeView.v2.vue';
import { getNodeViewTab } from '@/utils/canvasUtils';
import { MAIN_HEADER_TABS, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeViewVersionSwitcher } from '@/composables/useNodeViewVersionSwitcher';
const workflowsStore = useWorkflowsStore();
const sourceControlStore = useSourceControlStore();
const router = useRouter();
const route = useRoute();
const workflowHelpers = useWorkflowHelpers({ router });
const { nodeViewVersion, migrateToNewNodeViewVersion } = useNodeViewVersionSwitcher();
const workflowId = computed<string>(() => route.params.name as string);
const isReadOnlyEnvironment = computed(() => {
return sourceControlStore.preferences.branchReadOnly;
});
onMounted(() => {
migrateToNewNodeViewVersion();
});
watch(nodeViewVersion, () => {
router.go(0);
});
/**
* Routing
*/
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;
},
});
});
</script>
<template>
<NodeViewV2 v-if="nodeViewVersion === '2'" />
<NodeViewV1 v-else />
</template>

View File

@@ -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'));
}

48
pnpm-lock.yaml generated
View File

@@ -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