mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
refactor(editor): Remove old canvas code (no-changelog) (#13343)
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import type { FrontendBetaFeatures } from '@n8n/config';
|
|
||||||
import type { ExpressionEvaluatorType, LogLevel, WorkflowSettings } from 'n8n-workflow';
|
import type { ExpressionEvaluatorType, LogLevel, WorkflowSettings } from 'n8n-workflow';
|
||||||
|
|
||||||
export interface IVersionNotificationSettings {
|
export interface IVersionNotificationSettings {
|
||||||
@@ -176,7 +175,6 @@ export interface FrontendSettings {
|
|||||||
security: {
|
security: {
|
||||||
blockFileAccessToN8nFiles: boolean;
|
blockFileAccessToN8nFiles: boolean;
|
||||||
};
|
};
|
||||||
betaFeatures: FrontendBetaFeatures[];
|
|
||||||
easyAIWorkflowOnboarded: boolean;
|
easyAIWorkflowOnboarded: boolean;
|
||||||
partialExecution: {
|
partialExecution: {
|
||||||
version: 1 | 2;
|
version: 1 | 2;
|
||||||
|
|||||||
@@ -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'];
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,6 @@ export { Config, Env, Nested } from './decorators';
|
|||||||
export { TaskRunnersConfig } from './configs/runners.config';
|
export { TaskRunnersConfig } from './configs/runners.config';
|
||||||
export { SecurityConfig } from './configs/security.config';
|
export { SecurityConfig } from './configs/security.config';
|
||||||
export { ExecutionsConfig } from './configs/executions.config';
|
export { ExecutionsConfig } from './configs/executions.config';
|
||||||
export { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config';
|
|
||||||
export { S3Config } from './configs/external-storage.config';
|
export { S3Config } from './configs/external-storage.config';
|
||||||
export { LOG_SCOPES } from './configs/logging.config';
|
export { LOG_SCOPES } from './configs/logging.config';
|
||||||
export type { LogScope } from './configs/logging.config';
|
export type { LogScope } from './configs/logging.config';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FrontendSettings, ITelemetrySettings } from '@n8n/api-types';
|
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 { Container, Service } from '@n8n/di';
|
||||||
import { createWriteStream } from 'fs';
|
import { createWriteStream } from 'fs';
|
||||||
import { mkdir } from 'fs/promises';
|
import { mkdir } from 'fs/promises';
|
||||||
@@ -44,7 +44,6 @@ export class FrontendService {
|
|||||||
private readonly instanceSettings: InstanceSettings,
|
private readonly instanceSettings: InstanceSettings,
|
||||||
private readonly urlService: UrlService,
|
private readonly urlService: UrlService,
|
||||||
private readonly securityConfig: SecurityConfig,
|
private readonly securityConfig: SecurityConfig,
|
||||||
private readonly frontendConfig: FrontendConfig,
|
|
||||||
) {
|
) {
|
||||||
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
|
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
|
||||||
void this.generateTypes();
|
void this.generateTypes();
|
||||||
@@ -232,7 +231,6 @@ export class FrontendService {
|
|||||||
security: {
|
security: {
|
||||||
blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles,
|
blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles,
|
||||||
},
|
},
|
||||||
betaFeatures: this.frontendConfig.betaFeatures,
|
|
||||||
easyAIWorkflowOnboarded: false,
|
easyAIWorkflowOnboarded: false,
|
||||||
partialExecution: this.globalConfig.partialExecutions,
|
partialExecution: this.globalConfig.partialExecutions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
"@codemirror/view": "^6.26.3",
|
"@codemirror/view": "^6.26.3",
|
||||||
"@fontsource/open-sans": "^4.5.0",
|
"@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",
|
"@lezer/common": "^1.0.4",
|
||||||
"@n8n/api-types": "workspace:*",
|
"@n8n/api-types": "workspace:*",
|
||||||
"@n8n/chat": "workspace:*",
|
"@n8n/chat": "workspace:*",
|
||||||
|
|||||||
@@ -100,22 +100,22 @@ watch(defaultLocale, (newLocale) => {
|
|||||||
<BannerStack v-if="!isDemoMode" />
|
<BannerStack v-if="!isDemoMode" />
|
||||||
</div>
|
</div>
|
||||||
<div id="header" :class="$style.header">
|
<div id="header" :class="$style.header">
|
||||||
<router-view name="header"></router-view>
|
<RouterView name="header" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="usersStore.currentUser" id="sidebar" :class="$style.sidebar">
|
<div v-if="usersStore.currentUser" id="sidebar" :class="$style.sidebar">
|
||||||
<router-view name="sidebar"></router-view>
|
<RouterView name="sidebar" />
|
||||||
</div>
|
</div>
|
||||||
<div id="content" :class="$style.content">
|
<div id="content" :class="$style.content">
|
||||||
<div :class="$style.contentWrapper">
|
<div :class="$style.contentWrapper">
|
||||||
<router-view v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
<keep-alive v-if="$route.meta.keepWorkflowAlive" include="NodeViewSwitcher" :max="1">
|
<KeepAlive v-if="$route.meta.keepWorkflowAlive" include="NodeView" :max="1">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</keep-alive>
|
</KeepAlive>
|
||||||
<component :is="Component" v-else />
|
<component :is="Component" v-else />
|
||||||
</router-view>
|
</RouterView>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasContentFooter" :class="$style.contentFooter">
|
<div v-if="hasContentFooter" :class="$style.contentFooter">
|
||||||
<router-view name="footer" />
|
<RouterView name="footer" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
|
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
import type { NotificationOptions as ElementNotificationOptions } from 'element-plus';
|
import type { NotificationOptions as ElementNotificationOptions } from 'element-plus';
|
||||||
import type { Connection } from '@jsplumb/core';
|
|
||||||
import type {
|
import type {
|
||||||
BannerName,
|
BannerName,
|
||||||
FrontendSettings,
|
FrontendSettings,
|
||||||
@@ -1457,16 +1456,6 @@ export type ToggleNodeCreatorOptions = {
|
|||||||
export type AppliedThemeOption = 'light' | 'dark';
|
export type AppliedThemeOption = 'light' | 'dark';
|
||||||
export type ThemeOption = AppliedThemeOption | 'system';
|
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 =
|
export type EnterpriseEditionFeatureKey =
|
||||||
| 'AdvancedExecutionFilters'
|
| 'AdvancedExecutionFilters'
|
||||||
| 'Sharing'
|
| 'Sharing'
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ export const defaultSettings: FrontendSettings = {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
credits: 0,
|
credits: 0,
|
||||||
},
|
},
|
||||||
betaFeatures: [],
|
|
||||||
easyAIWorkflowOnboarded: false,
|
easyAIWorkflowOnboarded: false,
|
||||||
partialExecution: {
|
partialExecution: {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
INodeIssues,
|
INodeIssues,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionType, NodeHelpers, Workflow } 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 { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
@@ -5,7 +5,7 @@ import WorkflowExecutionsSidebar from '@/components/executions/workflow/Workflow
|
|||||||
import { MAIN_HEADER_TABS, VIEWS } from '@/constants';
|
import { MAIN_HEADER_TABS, VIEWS } from '@/constants';
|
||||||
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
import { getNodeViewTab } from '@/utils/nodeViewUtils';
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { useUIStore } from '@/stores/ui.store';
|
|||||||
|
|
||||||
import { onMounted, onUnmounted, nextTick } from 'vue';
|
import { onMounted, onUnmounted, nextTick } from 'vue';
|
||||||
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
import { getNodeViewTab } from '@/utils/nodeViewUtils';
|
||||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
import { useTelemetry } from './useTelemetry';
|
import { useTelemetry } from './useTelemetry';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
|
|||||||
@@ -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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import { ref, nextTick } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import type { Connection, ConnectionDetachedParams } from '@jsplumb/core';
|
|
||||||
import { useHistoryStore } from '@/stores/history.store';
|
import { useHistoryStore } from '@/stores/history.store';
|
||||||
import {
|
import {
|
||||||
CUSTOM_API_CALL_KEY,
|
CUSTOM_API_CALL_KEY,
|
||||||
FORM_TRIGGER_NODE_TYPE,
|
|
||||||
NODE_OUTPUT_DEFAULT_KEY,
|
|
||||||
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||||
SPLIT_IN_BATCHES_NODE_TYPE,
|
SPLIT_IN_BATCHES_NODE_TYPE,
|
||||||
WEBHOOK_NODE_TYPE,
|
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import { NodeHelpers, ExpressionEvaluatorProxy, NodeConnectionType } from 'n8n-workflow';
|
import { NodeHelpers, ExpressionEvaluatorProxy, NodeConnectionType } from 'n8n-workflow';
|
||||||
@@ -30,11 +25,7 @@ import type {
|
|||||||
INodePropertyOptions,
|
INodePropertyOptions,
|
||||||
INodeCredentialsDetails,
|
INodeCredentialsDetails,
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
ITaskData,
|
|
||||||
IConnections,
|
|
||||||
INodeTypeNameVersion,
|
INodeTypeNameVersion,
|
||||||
IConnection,
|
|
||||||
IPinData,
|
|
||||||
NodeParameterValue,
|
NodeParameterValue,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
@@ -53,16 +44,10 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import { get } from 'lodash-es';
|
import { get } from 'lodash-es';
|
||||||
import { useI18n } from './useI18n';
|
import { useI18n } from './useI18n';
|
||||||
import { AddNodeCommand, EnableNodeToggleCommand, RemoveConnectionCommand } from '@/models/history';
|
import { EnableNodeToggleCommand } from '@/models/history';
|
||||||
import { useTelemetry } from './useTelemetry';
|
import { useTelemetry } from './useTelemetry';
|
||||||
import { hasPermission } from '@/utils/rbac/permissions';
|
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 { 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 {
|
declare namespace HttpRequestNode {
|
||||||
namespace V2 {
|
namespace V2 {
|
||||||
@@ -81,8 +66,6 @@ export function useNodeHelpers() {
|
|||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
const sourceControlStore = useSourceControlStore();
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
const isInsertingNodes = ref(false);
|
const isInsertingNodes = ref(false);
|
||||||
const credentialsUpdated = ref(false);
|
const credentialsUpdated = ref(false);
|
||||||
@@ -125,23 +108,6 @@ export function useNodeHelpers() {
|
|||||||
return NodeHelpers.displayParameterPath(nodeValues, parameter, path, node, displayKey);
|
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(
|
function getNodeIssues(
|
||||||
nodeType: INodeTypeDescription | null,
|
nodeType: INodeTypeDescription | null,
|
||||||
node: INodeUi,
|
node: INodeUi,
|
||||||
@@ -766,73 +732,6 @@ export function useNodeHelpers() {
|
|||||||
return undefined;
|
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) {
|
function matchCredentials(node: INodeUi) {
|
||||||
if (!node.credentials) {
|
if (!node.credentials) {
|
||||||
return;
|
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> {
|
async function loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
|
||||||
const allNodes: INodeTypeDescription[] = nodeTypesStore.allNodeTypes;
|
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) {
|
function assignNodeId(node: INodeUi) {
|
||||||
const id = window.crypto.randomUUID();
|
const id = window.crypto.randomUUID();
|
||||||
node.id = id;
|
node.id = id;
|
||||||
@@ -1332,21 +887,12 @@ export function useNodeHelpers() {
|
|||||||
getNodeSubtitle,
|
getNodeSubtitle,
|
||||||
updateNodesCredentialsIssues,
|
updateNodesCredentialsIssues,
|
||||||
getNodeInputData,
|
getNodeInputData,
|
||||||
setSuccessOutput,
|
|
||||||
matchCredentials,
|
matchCredentials,
|
||||||
isInsertingNodes,
|
isInsertingNodes,
|
||||||
credentialsUpdated,
|
credentialsUpdated,
|
||||||
isProductionExecutionPreview,
|
isProductionExecutionPreview,
|
||||||
pullConnActiveNodeName,
|
pullConnActiveNodeName,
|
||||||
deleteJSPlumbConnection,
|
|
||||||
loadNodesProperties,
|
loadNodesProperties,
|
||||||
addNodes,
|
|
||||||
addConnections,
|
|
||||||
addConnection,
|
|
||||||
removeConnection,
|
|
||||||
removeConnectionByConnectionInfo,
|
|
||||||
addPinDataConnections,
|
|
||||||
removePinDataConnections,
|
|
||||||
getNodeTaskData,
|
getNodeTaskData,
|
||||||
assignNodeId,
|
assignNodeId,
|
||||||
assignWebhookId,
|
assignWebhookId,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import '@vue-flow/minimap/dist/style.css';
|
|||||||
import '@vue-flow/node-resizer/dist/style.css';
|
import '@vue-flow/node-resizer/dist/style.css';
|
||||||
|
|
||||||
import 'vue-json-pretty/lib/styles.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/index.scss';
|
||||||
// import 'n8n-design-system/css/tailwind/index.css';
|
// import 'n8n-design-system/css/tailwind/index.css';
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ import { GlobalDirectivesPlugin } from './plugins/directives';
|
|||||||
import { FontAwesomePlugin } from './plugins/icons';
|
import { FontAwesomePlugin } from './plugins/icons';
|
||||||
|
|
||||||
import { createPinia, PiniaVuePlugin } from 'pinia';
|
import { createPinia, PiniaVuePlugin } from 'pinia';
|
||||||
import { JsPlumbPlugin } from '@/plugins/jsplumb';
|
|
||||||
import { ChartJSPlugin } from '@/plugins/chartjs';
|
import { ChartJSPlugin } from '@/plugins/chartjs';
|
||||||
import { SentryPlugin } from '@/plugins/sentry';
|
import { SentryPlugin } from '@/plugins/sentry';
|
||||||
|
|
||||||
@@ -41,7 +39,6 @@ app.use(PiniaVuePlugin);
|
|||||||
app.use(FontAwesomePlugin);
|
app.use(FontAwesomePlugin);
|
||||||
app.use(GlobalComponentsPlugin);
|
app.use(GlobalComponentsPlugin);
|
||||||
app.use(GlobalDirectivesPlugin);
|
app.use(GlobalDirectivesPlugin);
|
||||||
app.use(JsPlumbPlugin);
|
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(i18nInstance);
|
app.use(i18nInstance);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: () => {},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -27,7 +27,7 @@ const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordV
|
|||||||
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
||||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||||
const CanvasChat = async () => await import('@/components/CanvasChat/CanvasChat.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 WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||||
const WorkflowExecutionsLandingPage = async () =>
|
const WorkflowExecutionsLandingPage = async () =>
|
||||||
await import('@/components/executions/workflow/WorkflowExecutionsLandingPage.vue');
|
await import('@/components/executions/workflow/WorkflowExecutionsLandingPage.vue');
|
||||||
|
|||||||
42
packages/editor-ui/src/shims-jsplumb.d.ts
vendored
42
packages/editor-ui/src/shims-jsplumb.d.ts
vendored
@@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,359 +1,33 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
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 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';
|
import { useLoadingService } from '@/composables/useLoadingService';
|
||||||
|
|
||||||
export const useCanvasStore = defineStore('canvas', () => {
|
export const useCanvasStore = defineStore('canvas', () => {
|
||||||
const workflowStore = useWorkflowsStore();
|
const workflowStore = useWorkflowsStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
|
||||||
const uiStore = useUIStore();
|
|
||||||
const historyStore = useHistoryStore();
|
|
||||||
const sourceControlStore = useSourceControlStore();
|
|
||||||
const loadingService = useLoadingService();
|
const loadingService = useLoadingService();
|
||||||
|
|
||||||
const jsPlumbInstanceRef = ref<BrowserJsPlumbInstance>();
|
|
||||||
const isDragging = ref<boolean>(false);
|
|
||||||
const lastSelectedConnection = ref<Connection>();
|
|
||||||
const newNodeInsertPosition = ref<XYPosition | null>(null);
|
const newNodeInsertPosition = ref<XYPosition | null>(null);
|
||||||
const panelHeight = ref(0);
|
const panelHeight = ref(0);
|
||||||
|
|
||||||
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
|
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[]>(() =>
|
const aiNodes = computed<INodeUi[]>(() =>
|
||||||
nodes.value.filter((node) => node.type.includes('langchain')),
|
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) {
|
function setPanelHeight(height: number) {
|
||||||
panelHeight.value = height;
|
panelHeight.value = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDemo,
|
|
||||||
nodeViewScale,
|
|
||||||
canvasAddButtonPosition,
|
|
||||||
newNodeInsertPosition,
|
newNodeInsertPosition,
|
||||||
jsPlumbInstance,
|
|
||||||
isLoading: loadingService.isLoading,
|
isLoading: loadingService.isLoading,
|
||||||
aiNodes,
|
aiNodes,
|
||||||
lastSelectedConnection: lastSelectedConnectionComputed,
|
|
||||||
panelHeight: computed(() => panelHeight.value),
|
panelHeight: computed(() => panelHeight.value),
|
||||||
setPanelHeight,
|
setPanelHeight,
|
||||||
setReadOnly,
|
|
||||||
setLastSelectedConnection,
|
|
||||||
startLoading: loadingService.startLoading,
|
startLoading: loadingService.startLoading,
|
||||||
setLoadingText: loadingService.setLoadingText,
|
setLoadingText: loadingService.setLoadingText,
|
||||||
stopLoading: loadingService.stopLoading,
|
stopLoading: loadingService.stopLoading,
|
||||||
setRecenteredCanvasAddButtonPosition,
|
|
||||||
getNodesWithPlaceholderNode,
|
|
||||||
canvasPositionFromPagePosition,
|
|
||||||
setZoomLevel,
|
|
||||||
resetZoom,
|
|
||||||
zoomIn,
|
|
||||||
zoomOut,
|
|
||||||
zoomToFit,
|
|
||||||
wheelScroll,
|
|
||||||
initInstance,
|
|
||||||
getAutoAddManualTriggerNode,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -185,10 +185,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
|
|
||||||
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
|
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
|
||||||
|
|
||||||
const isCanvasV2Enabled = computed(() =>
|
|
||||||
(settings.value.betaFeatures ?? []).includes('canvas_v2'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const setSettings = (newSettings: FrontendSettings) => {
|
const setSettings = (newSettings: FrontendSettings) => {
|
||||||
settings.value = newSettings;
|
settings.value = newSettings;
|
||||||
userManagement.value = newSettings.userManagement;
|
userManagement.value = newSettings.userManagement;
|
||||||
@@ -436,7 +432,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
saveDataProgressExecution,
|
saveDataProgressExecution,
|
||||||
isCommunityPlan,
|
isCommunityPlan,
|
||||||
isAskAiEnabled,
|
isAskAiEnabled,
|
||||||
isCanvasV2Enabled,
|
|
||||||
isAiCreditsEnabled,
|
isAiCreditsEnabled,
|
||||||
aiCreditsQuota,
|
aiCreditsQuota,
|
||||||
reset,
|
reset,
|
||||||
|
|||||||
@@ -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
@@ -6,8 +6,6 @@ import type {
|
|||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { nodeConnectionTypes } from 'n8n-workflow';
|
import { nodeConnectionTypes } from 'n8n-workflow';
|
||||||
import type { IExecutionResponse, ICredentialsResponse, NewCredentialsModal } from '@/Interface';
|
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 { Connection as VueFlowConnection } from '@vue-flow/core';
|
||||||
import type { RouteLocationRaw } from 'vue-router';
|
import type { RouteLocationRaw } from 'vue-router';
|
||||||
import type { CanvasConnectionMode } from '@/types';
|
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);
|
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 {
|
export function isDateObject(date: unknown): date is Date {
|
||||||
return (
|
return (
|
||||||
!!date && Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date as number)
|
!!date && Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date as number)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
h,
|
h,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
|
||||||
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
|
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useUIStore } from '@/stores/ui.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 { useTagsStore } from '@/stores/tags.store';
|
||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.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 CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
|
||||||
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
|
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
|
||||||
import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue';
|
import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue';
|
||||||
import { nodeViewEventBus } from '@/event-bus';
|
import { nodeViewEventBus } from '@/event-bus';
|
||||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
|
||||||
import { tryToParseNumber } from '@/utils/typesUtils';
|
import { tryToParseNumber } from '@/utils/typesUtils';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import { createEventBus, N8nCallout } from 'n8n-design-system';
|
import { createEventBus, N8nCallout } from 'n8n-design-system';
|
||||||
@@ -113,6 +112,10 @@ import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
|
|||||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'NodeView',
|
||||||
|
});
|
||||||
|
|
||||||
const LazyNodeCreation = defineAsyncComponent(
|
const LazyNodeCreation = defineAsyncComponent(
|
||||||
async () => await import('@/components/Node/NodeCreation.vue'),
|
async () => await import('@/components/Node/NodeCreation.vue'),
|
||||||
);
|
);
|
||||||
@@ -902,7 +905,7 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: IWork
|
|||||||
|
|
||||||
initializeWorkspace({
|
initializeWorkspace({
|
||||||
...workflowData,
|
...workflowData,
|
||||||
nodes: NodeViewUtils.getFixedNodesList<INodeUi>(workflowData.nodes),
|
nodes: getFixedNodesList<INodeUi>(workflowData.nodes),
|
||||||
} as IWorkflowDb);
|
} as IWorkflowDb);
|
||||||
|
|
||||||
fitView();
|
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
|
* Lifecycle
|
||||||
*/
|
*/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
@@ -13,8 +13,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
|
||||||
|
|
||||||
const executionsStore = useExecutionsStore();
|
const executionsStore = useExecutionsStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
@@ -26,8 +25,7 @@ const router = useRouter();
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
|
|
||||||
const workflowHelpers = useWorkflowHelpers({ router });
|
const { initializeWorkspace } = useCanvasOperations({ router });
|
||||||
const nodeHelpers = useNodeHelpers();
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const loadingMore = ref(false);
|
const loadingMore = ref(false);
|
||||||
@@ -139,8 +137,7 @@ async function fetchWorkflow() {
|
|||||||
try {
|
try {
|
||||||
await workflowsStore.fetchActiveWorkflows();
|
await workflowsStore.fetchActiveWorkflows();
|
||||||
const data = await workflowsStore.fetchWorkflow(workflowId.value);
|
const data = await workflowsStore.fetchWorkflow(workflowId.value);
|
||||||
workflowHelpers.initState(data);
|
initializeWorkspace(data);
|
||||||
await nodeHelpers.addNodes(data.nodes, data.connections);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title'));
|
toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title'));
|
||||||
}
|
}
|
||||||
|
|||||||
48
pnpm-lock.yaml
generated
48
pnpm-lock.yaml
generated
@@ -1429,21 +1429,6 @@ importers:
|
|||||||
'@fortawesome/vue-fontawesome':
|
'@fortawesome/vue-fontawesome':
|
||||||
specifier: '*'
|
specifier: '*'
|
||||||
version: 3.0.3(@fortawesome/fontawesome-svg-core@1.2.36)(vue@3.5.13(typescript@5.7.2))
|
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':
|
'@lezer/common':
|
||||||
specifier: ^1.0.4
|
specifier: ^1.0.4
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
@@ -3912,21 +3897,6 @@ packages:
|
|||||||
'@jsdevtools/ono@7.1.3':
|
'@jsdevtools/ono@7.1.3':
|
||||||
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
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':
|
'@kafkajs/confluent-schema-registry@1.0.6':
|
||||||
resolution: {integrity: sha512-NrZL1peOIlmlLKvheQcJAx9PHdnc4kaW+9+Yt4jXUfbbYR9EFNCZt6yApI4SwlFilaiZieReM6XslWy1LZAvoQ==}
|
resolution: {integrity: sha512-NrZL1peOIlmlLKvheQcJAx9PHdnc4kaW+9+Yt4jXUfbbYR9EFNCZt6yApI4SwlFilaiZieReM6XslWy1LZAvoQ==}
|
||||||
|
|
||||||
@@ -16526,24 +16496,6 @@ snapshots:
|
|||||||
|
|
||||||
'@jsdevtools/ono@7.1.3': {}
|
'@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':
|
'@kafkajs/confluent-schema-registry@1.0.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
avsc: 5.7.6
|
avsc: 5.7.6
|
||||||
|
|||||||
Reference in New Issue
Block a user