mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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';
|
||||
|
||||
export interface IVersionNotificationSettings {
|
||||
@@ -176,7 +175,6 @@ export interface FrontendSettings {
|
||||
security: {
|
||||
blockFileAccessToN8nFiles: boolean;
|
||||
};
|
||||
betaFeatures: FrontendBetaFeatures[];
|
||||
easyAIWorkflowOnboarded: boolean;
|
||||
partialExecution: {
|
||||
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 { SecurityConfig } from './configs/security.config';
|
||||
export { ExecutionsConfig } from './configs/executions.config';
|
||||
export { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config';
|
||||
export { S3Config } from './configs/external-storage.config';
|
||||
export { LOG_SCOPES } from './configs/logging.config';
|
||||
export type { LogScope } from './configs/logging.config';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FrontendSettings, ITelemetrySettings } from '@n8n/api-types';
|
||||
import { GlobalConfig, FrontendConfig, SecurityConfig } from '@n8n/config';
|
||||
import { GlobalConfig, SecurityConfig } from '@n8n/config';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { mkdir } from 'fs/promises';
|
||||
@@ -44,7 +44,6 @@ export class FrontendService {
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly securityConfig: SecurityConfig,
|
||||
private readonly frontendConfig: FrontendConfig,
|
||||
) {
|
||||
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
|
||||
void this.generateTypes();
|
||||
@@ -232,7 +231,6 @@ export class FrontendService {
|
||||
security: {
|
||||
blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles,
|
||||
},
|
||||
betaFeatures: this.frontendConfig.betaFeatures,
|
||||
easyAIWorkflowOnboarded: false,
|
||||
partialExecution: this.globalConfig.partialExecutions,
|
||||
};
|
||||
|
||||
@@ -29,11 +29,6 @@
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.26.3",
|
||||
"@fontsource/open-sans": "^4.5.0",
|
||||
"@jsplumb/browser-ui": "^5.13.2",
|
||||
"@jsplumb/common": "^5.13.2",
|
||||
"@jsplumb/connector-bezier": "^5.13.2",
|
||||
"@jsplumb/core": "^5.13.2",
|
||||
"@jsplumb/util": "^5.13.2",
|
||||
"@lezer/common": "^1.0.4",
|
||||
"@n8n/api-types": "workspace:*",
|
||||
"@n8n/chat": "workspace:*",
|
||||
|
||||
@@ -100,22 +100,22 @@ watch(defaultLocale, (newLocale) => {
|
||||
<BannerStack v-if="!isDemoMode" />
|
||||
</div>
|
||||
<div id="header" :class="$style.header">
|
||||
<router-view name="header"></router-view>
|
||||
<RouterView name="header" />
|
||||
</div>
|
||||
<div v-if="usersStore.currentUser" id="sidebar" :class="$style.sidebar">
|
||||
<router-view name="sidebar"></router-view>
|
||||
<RouterView name="sidebar" />
|
||||
</div>
|
||||
<div id="content" :class="$style.content">
|
||||
<div :class="$style.contentWrapper">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive v-if="$route.meta.keepWorkflowAlive" include="NodeViewSwitcher" :max="1">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<KeepAlive v-if="$route.meta.keepWorkflowAlive" include="NodeView" :max="1">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</KeepAlive>
|
||||
<component :is="Component" v-else />
|
||||
</router-view>
|
||||
</RouterView>
|
||||
</div>
|
||||
<div v-if="hasContentFooter" :class="$style.contentFooter">
|
||||
<router-view name="footer" />
|
||||
<RouterView name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Component } from 'vue';
|
||||
import type { NotificationOptions as ElementNotificationOptions } from 'element-plus';
|
||||
import type { Connection } from '@jsplumb/core';
|
||||
import type {
|
||||
BannerName,
|
||||
FrontendSettings,
|
||||
@@ -1457,16 +1456,6 @@ export type ToggleNodeCreatorOptions = {
|
||||
export type AppliedThemeOption = 'light' | 'dark';
|
||||
export type ThemeOption = AppliedThemeOption | 'system';
|
||||
|
||||
export type NewConnectionInfo = {
|
||||
sourceId: string;
|
||||
index: number;
|
||||
eventSource: NodeCreatorOpenSource;
|
||||
connection?: Connection;
|
||||
nodeCreatorView?: NodeFilterType;
|
||||
outputType?: NodeConnectionType;
|
||||
endpointUuid?: string;
|
||||
};
|
||||
|
||||
export type EnterpriseEditionFeatureKey =
|
||||
| 'AdvancedExecutionFilters'
|
||||
| 'Sharing'
|
||||
|
||||
@@ -135,7 +135,6 @@ export const defaultSettings: FrontendSettings = {
|
||||
enabled: false,
|
||||
credits: 0,
|
||||
},
|
||||
betaFeatures: [],
|
||||
easyAIWorkflowOnboarded: false,
|
||||
partialExecution: {
|
||||
version: 1,
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
INodeIssues,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, NodeHelpers, Workflow } from 'n8n-workflow';
|
||||
import { uuid } from '@jsplumb/util';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
|
||||
@@ -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 type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
||||
import { getNodeViewTab } from '@/utils/nodeViewUtils';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -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 { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
||||
import { getNodeViewTab } from '@/utils/nodeViewUtils';
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import { useTelemetry } from './useTelemetry';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
|
||||
@@ -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 { useRoute } from 'vue-router';
|
||||
import type { Connection, ConnectionDetachedParams } from '@jsplumb/core';
|
||||
import { ref } from 'vue';
|
||||
import { useHistoryStore } from '@/stores/history.store';
|
||||
import {
|
||||
CUSTOM_API_CALL_KEY,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
NODE_OUTPUT_DEFAULT_KEY,
|
||||
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||
SPLIT_IN_BATCHES_NODE_TYPE,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
|
||||
import { NodeHelpers, ExpressionEvaluatorProxy, NodeConnectionType } from 'n8n-workflow';
|
||||
@@ -30,11 +25,7 @@ import type {
|
||||
INodePropertyOptions,
|
||||
INodeCredentialsDetails,
|
||||
INodeParameters,
|
||||
ITaskData,
|
||||
IConnections,
|
||||
INodeTypeNameVersion,
|
||||
IConnection,
|
||||
IPinData,
|
||||
NodeParameterValue,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
@@ -53,16 +44,10 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { get } from 'lodash-es';
|
||||
import { useI18n } from './useI18n';
|
||||
import { AddNodeCommand, EnableNodeToggleCommand, RemoveConnectionCommand } from '@/models/history';
|
||||
import { EnableNodeToggleCommand } from '@/models/history';
|
||||
import { useTelemetry } from './useTelemetry';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import type { N8nPlusEndpoint } from '@/plugins/jsplumb/N8nPlusEndpointType';
|
||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { getEndpointScope } from '@/utils/nodeViewUtils';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { getConnectionInfo } from '@/utils/canvasUtils';
|
||||
import type { UnpinNodeDataEvent } from '@/event-bus/data-pinning';
|
||||
|
||||
declare namespace HttpRequestNode {
|
||||
namespace V2 {
|
||||
@@ -81,8 +66,6 @@ export function useNodeHelpers() {
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const i18n = useI18n();
|
||||
const canvasStore = useCanvasStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const route = useRoute();
|
||||
|
||||
const isInsertingNodes = ref(false);
|
||||
const credentialsUpdated = ref(false);
|
||||
@@ -125,23 +108,6 @@ export function useNodeHelpers() {
|
||||
return NodeHelpers.displayParameterPath(nodeValues, parameter, path, node, displayKey);
|
||||
}
|
||||
|
||||
function refreshNodeIssues(): void {
|
||||
const nodes = workflowsStore.allNodes;
|
||||
const workflow = workflowsStore.getCurrentWorkflow();
|
||||
let nodeType: INodeTypeDescription | null;
|
||||
let foundNodeIssues: INodeIssues | null;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.disabled === true) return;
|
||||
|
||||
nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
foundNodeIssues = getNodeIssues(nodeType, node, workflow);
|
||||
if (foundNodeIssues !== null) {
|
||||
node.issues = foundNodeIssues;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getNodeIssues(
|
||||
nodeType: INodeTypeDescription | null,
|
||||
node: INodeUi,
|
||||
@@ -766,73 +732,6 @@ export function useNodeHelpers() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setSuccessOutput(data: ITaskData[], sourceNode: INodeUi | null) {
|
||||
if (!sourceNode) {
|
||||
throw new Error('Source node is null or not defined');
|
||||
}
|
||||
|
||||
const allNodeConnections = workflowsStore.outgoingConnectionsByNodeName(sourceNode.name);
|
||||
|
||||
const connectionType = Object.keys(allNodeConnections)[0] as NodeConnectionType;
|
||||
const nodeConnections = allNodeConnections[connectionType];
|
||||
const outputMap = NodeViewUtils.getOutputSummary(
|
||||
data,
|
||||
nodeConnections || [],
|
||||
connectionType ?? NodeConnectionType.Main,
|
||||
);
|
||||
const sourceNodeType = nodeTypesStore.getNodeType(sourceNode.type, sourceNode.typeVersion);
|
||||
|
||||
Object.keys(outputMap).forEach((sourceOutputIndex: string) => {
|
||||
Object.keys(outputMap[sourceOutputIndex]).forEach((targetNodeName: string) => {
|
||||
Object.keys(outputMap[sourceOutputIndex][targetNodeName]).forEach(
|
||||
(targetInputIndex: string) => {
|
||||
if (targetNodeName) {
|
||||
const targetNode = workflowsStore.getNodeByName(targetNodeName);
|
||||
const connection = NodeViewUtils.getJSPlumbConnection(
|
||||
sourceNode,
|
||||
parseInt(sourceOutputIndex, 10),
|
||||
targetNode,
|
||||
parseInt(targetInputIndex, 10),
|
||||
connectionType,
|
||||
sourceNodeType,
|
||||
canvasStore.jsPlumbInstance,
|
||||
);
|
||||
|
||||
if (connection) {
|
||||
const output = outputMap[sourceOutputIndex][targetNodeName][targetInputIndex];
|
||||
|
||||
if (output.isArtificialRecoveredEventItem) {
|
||||
NodeViewUtils.recoveredConnection(connection);
|
||||
} else if (!output?.total && !output.isArtificialRecoveredEventItem) {
|
||||
NodeViewUtils.resetConnection(connection);
|
||||
} else {
|
||||
NodeViewUtils.addConnectionOutputSuccess(connection, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = NodeViewUtils.getPlusEndpoint(
|
||||
sourceNode,
|
||||
parseInt(sourceOutputIndex, 10),
|
||||
canvasStore.jsPlumbInstance,
|
||||
);
|
||||
if (endpoint?.endpoint) {
|
||||
const output = outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY][0];
|
||||
|
||||
if (output && output.total > 0) {
|
||||
(endpoint.endpoint as N8nPlusEndpoint).setSuccessOutput(
|
||||
NodeViewUtils.getRunItemsLabel(output),
|
||||
);
|
||||
} else {
|
||||
(endpoint.endpoint as N8nPlusEndpoint).clearSuccessOutput();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function matchCredentials(node: INodeUi) {
|
||||
if (!node.credentials) {
|
||||
return;
|
||||
@@ -888,31 +787,6 @@ export function useNodeHelpers() {
|
||||
);
|
||||
}
|
||||
|
||||
function deleteJSPlumbConnection(connection: Connection, trackHistory = false) {
|
||||
// Make sure to remove the overlay else after the second move
|
||||
// it visibly stays behind free floating without a connection.
|
||||
connection.removeOverlays();
|
||||
|
||||
pullConnActiveNodeName.value = null; // prevent new connections when connectionDetached is triggered
|
||||
canvasStore.jsPlumbInstance?.deleteConnection(connection); // on delete, triggers connectionDetached event which applies mutation to store
|
||||
if (trackHistory && connection.__meta) {
|
||||
const connectionData: [IConnection, IConnection] = [
|
||||
{
|
||||
index: connection.__meta?.sourceOutputIndex,
|
||||
node: connection.__meta.sourceNodeName,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
{
|
||||
index: connection.__meta?.targetOutputIndex,
|
||||
node: connection.__meta.targetNodeName,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
];
|
||||
const removeCommand = new RemoveConnectionCommand(connectionData);
|
||||
historyStore.pushCommandToUndo(removeCommand);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
|
||||
const allNodes: INodeTypeDescription[] = nodeTypesStore.allNodeTypes;
|
||||
|
||||
@@ -938,325 +812,6 @@ export function useNodeHelpers() {
|
||||
}
|
||||
}
|
||||
|
||||
function addConnectionsTestData() {
|
||||
canvasStore.jsPlumbInstance?.connections.forEach((connection) => {
|
||||
NodeViewUtils.addConnectionTestData(
|
||||
connection.source,
|
||||
connection.target,
|
||||
connection?.connector?.hasOwnProperty('canvas') ? connection?.connector.canvas : undefined,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function processConnectionBatch(batchedConnectionData: Array<[IConnection, IConnection]>) {
|
||||
const batchSize = 100;
|
||||
|
||||
for (let i = 0; i < batchedConnectionData.length; i += batchSize) {
|
||||
const batch = batchedConnectionData.slice(i, i + batchSize);
|
||||
|
||||
batch.forEach((connectionData) => {
|
||||
addConnection(connectionData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addPinDataConnections(pinData?: IPinData) {
|
||||
if (!pinData) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(pinData).forEach((nodeName) => {
|
||||
const node = workflowsStore.getNodeByName(nodeName);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeElement = document.getElementById(node.id);
|
||||
if (!nodeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasRun = workflowsStore.getWorkflowResultDataByNodeName(nodeName) !== null;
|
||||
// In case we are showing a production execution preview we want
|
||||
// to show pinned data connections as they wouldn't have been pinned
|
||||
const classNames = isProductionExecutionPreview.value ? [] : ['pinned'];
|
||||
|
||||
if (hasRun) {
|
||||
classNames.push('has-run');
|
||||
}
|
||||
|
||||
const connections = canvasStore.jsPlumbInstance?.getConnections({
|
||||
source: nodeElement,
|
||||
});
|
||||
|
||||
const connectionsArray = Array.isArray(connections)
|
||||
? connections
|
||||
: Object.values(connections);
|
||||
|
||||
connectionsArray.forEach((connection) => {
|
||||
NodeViewUtils.addConnectionOutputSuccess(connection, {
|
||||
total: pinData[nodeName].length,
|
||||
iterations: 0,
|
||||
classNames,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removePinDataConnections(event: UnpinNodeDataEvent) {
|
||||
for (const nodeName of event.nodeNames) {
|
||||
const node = workflowsStore.getNodeByName(nodeName);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeElement = document.getElementById(node.id);
|
||||
if (!nodeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connections = canvasStore.jsPlumbInstance?.getConnections({
|
||||
source: nodeElement,
|
||||
});
|
||||
|
||||
const connectionsArray = Array.isArray(connections)
|
||||
? connections
|
||||
: Object.values(connections);
|
||||
|
||||
canvasStore.jsPlumbInstance.setSuspendDrawing(true);
|
||||
connectionsArray.forEach(NodeViewUtils.resetConnection);
|
||||
canvasStore.jsPlumbInstance.setSuspendDrawing(false, true);
|
||||
}
|
||||
}
|
||||
|
||||
function getOutputEndpointUUID(
|
||||
nodeName: string,
|
||||
connectionType: NodeConnectionType,
|
||||
index: number,
|
||||
): string | null {
|
||||
const node = workflowsStore.getNodeByName(nodeName);
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return NodeViewUtils.getOutputEndpointUUID(node.id, connectionType, index);
|
||||
}
|
||||
function getInputEndpointUUID(
|
||||
nodeName: string,
|
||||
connectionType: NodeConnectionType,
|
||||
index: number,
|
||||
) {
|
||||
const node = workflowsStore.getNodeByName(nodeName);
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return NodeViewUtils.getInputEndpointUUID(node.id, connectionType, index);
|
||||
}
|
||||
|
||||
function addConnection(connection: [IConnection, IConnection]) {
|
||||
const outputUuid = getOutputEndpointUUID(
|
||||
connection[0].node,
|
||||
connection[0].type,
|
||||
connection[0].index,
|
||||
);
|
||||
const inputUuid = getInputEndpointUUID(
|
||||
connection[1].node,
|
||||
connection[1].type,
|
||||
connection[1].index,
|
||||
);
|
||||
if (!outputUuid || !inputUuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uuids: [string, string] = [outputUuid, inputUuid];
|
||||
// Create connections in DOM
|
||||
canvasStore.jsPlumbInstance?.connect({
|
||||
uuids,
|
||||
detachable: !route?.meta?.readOnlyCanvas && !sourceControlStore.preferences.branchReadOnly,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
addPinDataConnections(workflowsStore.pinnedWorkflowData);
|
||||
});
|
||||
}
|
||||
|
||||
function removeConnection(
|
||||
connection: [IConnection, IConnection],
|
||||
removeVisualConnection = false,
|
||||
) {
|
||||
if (removeVisualConnection) {
|
||||
const sourceNode = workflowsStore.getNodeByName(connection[0].node);
|
||||
const targetNode = workflowsStore.getNodeByName(connection[1].node);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceElement = document.getElementById(sourceNode.id);
|
||||
const targetElement = document.getElementById(targetNode.id);
|
||||
|
||||
if (sourceElement && targetElement) {
|
||||
const connections = canvasStore.jsPlumbInstance?.getConnections({
|
||||
source: sourceElement,
|
||||
target: targetElement,
|
||||
});
|
||||
|
||||
if (Array.isArray(connections)) {
|
||||
connections.forEach((connectionInstance: Connection) => {
|
||||
if (connectionInstance.__meta) {
|
||||
// Only delete connections from specific indexes (if it can be determined by meta)
|
||||
if (
|
||||
connectionInstance.__meta.sourceOutputIndex === connection[0].index &&
|
||||
connectionInstance.__meta.targetOutputIndex === connection[1].index
|
||||
) {
|
||||
deleteJSPlumbConnection(connectionInstance);
|
||||
}
|
||||
} else {
|
||||
deleteJSPlumbConnection(connectionInstance);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workflowsStore.removeConnection({ connection });
|
||||
}
|
||||
|
||||
function removeConnectionByConnectionInfo(
|
||||
info: ConnectionDetachedParams,
|
||||
removeVisualConnection = false,
|
||||
trackHistory = false,
|
||||
) {
|
||||
const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info);
|
||||
|
||||
if (connectionInfo) {
|
||||
if (removeVisualConnection) {
|
||||
deleteJSPlumbConnection(info.connection, trackHistory);
|
||||
} else if (trackHistory) {
|
||||
historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo));
|
||||
}
|
||||
workflowsStore.removeConnection({ connection: connectionInfo });
|
||||
}
|
||||
}
|
||||
|
||||
async function addConnections(connections: IConnections) {
|
||||
const batchedConnectionData: Array<[IConnection, IConnection]> = [];
|
||||
|
||||
for (const sourceNode in connections) {
|
||||
for (const type in connections[sourceNode]) {
|
||||
connections[sourceNode][type].forEach((outwardConnections, sourceIndex) => {
|
||||
if (outwardConnections) {
|
||||
outwardConnections.forEach((targetData) => {
|
||||
batchedConnectionData.push([
|
||||
{
|
||||
node: sourceNode,
|
||||
type: getEndpointScope(type) ?? NodeConnectionType.Main,
|
||||
index: sourceIndex,
|
||||
},
|
||||
{ node: targetData.node, type: targetData.type, index: targetData.index },
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process the connections in batches
|
||||
await processConnectionBatch(batchedConnectionData);
|
||||
setTimeout(addConnectionsTestData, 0);
|
||||
}
|
||||
|
||||
async function addNodes(nodes: INodeUi[], connections?: IConnections, trackHistory = false) {
|
||||
if (!nodes?.length) {
|
||||
return;
|
||||
}
|
||||
isInsertingNodes.value = true;
|
||||
// Before proceeding we must check if all nodes contain the `properties` attribute.
|
||||
// Nodes are loaded without this information so we must make sure that all nodes
|
||||
// being added have this information.
|
||||
await loadNodesProperties(
|
||||
nodes.map((node) => ({ name: node.type, version: node.typeVersion })),
|
||||
);
|
||||
|
||||
// Add the node to the node-list
|
||||
let nodeType: INodeTypeDescription | null;
|
||||
nodes.forEach((node) => {
|
||||
const newNode: INodeUi = {
|
||||
...node,
|
||||
};
|
||||
|
||||
if (!newNode.id) {
|
||||
assignNodeId(newNode);
|
||||
}
|
||||
|
||||
nodeType = nodeTypesStore.getNodeType(newNode.type, newNode.typeVersion);
|
||||
|
||||
// Make sure that some properties always exist
|
||||
if (!newNode.hasOwnProperty('disabled')) {
|
||||
newNode.disabled = false;
|
||||
}
|
||||
|
||||
if (!newNode.hasOwnProperty('parameters')) {
|
||||
newNode.parameters = {};
|
||||
}
|
||||
|
||||
// Load the default parameter values because only values which differ
|
||||
// from the defaults get saved
|
||||
if (nodeType !== null) {
|
||||
let nodeParameters = null;
|
||||
try {
|
||||
nodeParameters = NodeHelpers.getNodeParameters(
|
||||
nodeType.properties,
|
||||
newNode.parameters,
|
||||
true,
|
||||
false,
|
||||
node,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
i18n.baseText('nodeView.thereWasAProblemLoadingTheNodeParametersOfNode') +
|
||||
`: "${newNode.name}"`,
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
newNode.parameters = nodeParameters ?? {};
|
||||
|
||||
// if it's a webhook and the path is empty set the UUID as the default path
|
||||
if (
|
||||
[WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE].includes(newNode.type) &&
|
||||
newNode.parameters.path === ''
|
||||
) {
|
||||
newNode.parameters.path = newNode.webhookId as string;
|
||||
}
|
||||
}
|
||||
|
||||
// check and match credentials, apply new format if old is used
|
||||
matchCredentials(newNode);
|
||||
workflowsStore.addNode(newNode);
|
||||
if (trackHistory) {
|
||||
historyStore.pushCommandToUndo(new AddNodeCommand(newNode));
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the nodes to be rendered
|
||||
await nextTick();
|
||||
|
||||
canvasStore.jsPlumbInstance?.setSuspendDrawing(true);
|
||||
|
||||
if (connections) {
|
||||
await addConnections(connections);
|
||||
}
|
||||
// Add the node issues at the end as the node-connections are required
|
||||
refreshNodeIssues();
|
||||
updateNodesInputIssues();
|
||||
/////////////////////////////this.resetEndpointsErrors();
|
||||
isInsertingNodes.value = false;
|
||||
|
||||
// Now it can draw again
|
||||
canvasStore.jsPlumbInstance?.setSuspendDrawing(false, true);
|
||||
}
|
||||
|
||||
function assignNodeId(node: INodeUi) {
|
||||
const id = window.crypto.randomUUID();
|
||||
node.id = id;
|
||||
@@ -1332,21 +887,12 @@ export function useNodeHelpers() {
|
||||
getNodeSubtitle,
|
||||
updateNodesCredentialsIssues,
|
||||
getNodeInputData,
|
||||
setSuccessOutput,
|
||||
matchCredentials,
|
||||
isInsertingNodes,
|
||||
credentialsUpdated,
|
||||
isProductionExecutionPreview,
|
||||
pullConnActiveNodeName,
|
||||
deleteJSPlumbConnection,
|
||||
loadNodesProperties,
|
||||
addNodes,
|
||||
addConnections,
|
||||
addConnection,
|
||||
removeConnection,
|
||||
removeConnectionByConnectionInfo,
|
||||
addPinDataConnections,
|
||||
removePinDataConnections,
|
||||
getNodeTaskData,
|
||||
assignNodeId,
|
||||
assignWebhookId,
|
||||
|
||||
@@ -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-json-pretty/lib/styles.css';
|
||||
import '@jsplumb/browser-ui/css/jsplumbtoolkit.css';
|
||||
import 'n8n-design-system/css/index.scss';
|
||||
// import 'n8n-design-system/css/tailwind/index.css';
|
||||
|
||||
@@ -27,7 +26,6 @@ import { GlobalDirectivesPlugin } from './plugins/directives';
|
||||
import { FontAwesomePlugin } from './plugins/icons';
|
||||
|
||||
import { createPinia, PiniaVuePlugin } from 'pinia';
|
||||
import { JsPlumbPlugin } from '@/plugins/jsplumb';
|
||||
import { ChartJSPlugin } from '@/plugins/chartjs';
|
||||
import { SentryPlugin } from '@/plugins/sentry';
|
||||
|
||||
@@ -41,7 +39,6 @@ app.use(PiniaVuePlugin);
|
||||
app.use(FontAwesomePlugin);
|
||||
app.use(GlobalComponentsPlugin);
|
||||
app.use(GlobalDirectivesPlugin);
|
||||
app.use(JsPlumbPlugin);
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(i18nInstance);
|
||||
|
||||
@@ -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 MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||
const CanvasChat = async () => await import('@/components/CanvasChat/CanvasChat.vue');
|
||||
const NodeView = async () => await import('@/views/NodeViewSwitcher.vue');
|
||||
const NodeView = async () => await import('@/views/NodeView.v2.vue');
|
||||
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||
const WorkflowExecutionsLandingPage = async () =>
|
||||
await import('@/components/executions/workflow/WorkflowExecutionsLandingPage.vue');
|
||||
|
||||
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 { v4 as uuid } from 'uuid';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useHistoryStore } from '@/stores/history.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import type { INodeUi, XYPosition } from '@/Interface';
|
||||
import {
|
||||
applyScale,
|
||||
getScaleFromWheelEventDelta,
|
||||
normalizeWheelEventDelta,
|
||||
scaleBigger,
|
||||
scaleReset,
|
||||
scaleSmaller,
|
||||
} from '@/utils/canvasUtils';
|
||||
import { MANUAL_TRIGGER_NODE_TYPE, START_NODE_TYPE } from '@/constants';
|
||||
import type {
|
||||
BeforeStartEventParams,
|
||||
BrowserJsPlumbInstance,
|
||||
ConstrainFunction,
|
||||
DragStopEventParams,
|
||||
} from '@jsplumb/browser-ui';
|
||||
import { newInstance } from '@jsplumb/browser-ui';
|
||||
import type { Connection } from '@jsplumb/core';
|
||||
import { MoveNodeCommand } from '@/models/history';
|
||||
import {
|
||||
DEFAULT_PLACEHOLDER_TRIGGER_BUTTON,
|
||||
getMidCanvasPosition,
|
||||
getNewNodePosition,
|
||||
getZoomToFit,
|
||||
PLACEHOLDER_TRIGGER_NODE_SIZE,
|
||||
CONNECTOR_FLOWCHART_TYPE,
|
||||
GRID_SIZE,
|
||||
CONNECTOR_PAINT_STYLE_DEFAULT,
|
||||
CONNECTOR_PAINT_STYLE_PRIMARY,
|
||||
CONNECTOR_ARROW_OVERLAYS,
|
||||
getMousePosition,
|
||||
SIDEBAR_WIDTH,
|
||||
SIDEBAR_WIDTH_EXPANDED,
|
||||
} from '@/utils/nodeViewUtils';
|
||||
import type { PointXY } from '@jsplumb/util';
|
||||
import { useLoadingService } from '@/composables/useLoadingService';
|
||||
|
||||
export const useCanvasStore = defineStore('canvas', () => {
|
||||
const workflowStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
const historyStore = useHistoryStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const loadingService = useLoadingService();
|
||||
|
||||
const jsPlumbInstanceRef = ref<BrowserJsPlumbInstance>();
|
||||
const isDragging = ref<boolean>(false);
|
||||
const lastSelectedConnection = ref<Connection>();
|
||||
const newNodeInsertPosition = ref<XYPosition | null>(null);
|
||||
const panelHeight = ref(0);
|
||||
|
||||
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
|
||||
const triggerNodes = computed<INodeUi[]>(() =>
|
||||
nodes.value.filter(
|
||||
(node) => node.type === START_NODE_TYPE || nodeTypesStore.isTriggerNode(node.type),
|
||||
),
|
||||
);
|
||||
const aiNodes = computed<INodeUi[]>(() =>
|
||||
nodes.value.filter((node) => node.type.includes('langchain')),
|
||||
);
|
||||
const isDemo = ref<boolean>(false);
|
||||
const nodeViewScale = ref<number>(1);
|
||||
const canvasAddButtonPosition = ref<XYPosition>([1, 1]);
|
||||
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
|
||||
const lastSelectedConnectionComputed = computed<Connection | undefined>(
|
||||
() => lastSelectedConnection.value,
|
||||
);
|
||||
|
||||
const setReadOnly = (readOnly: boolean) => {
|
||||
if (jsPlumbInstanceRef.value) {
|
||||
jsPlumbInstanceRef.value.elementsDraggable = !readOnly;
|
||||
jsPlumbInstanceRef.value.setDragConstrainFunction(((pos: PointXY) =>
|
||||
readOnly ? null : pos) as ConstrainFunction);
|
||||
}
|
||||
};
|
||||
|
||||
const setLastSelectedConnection = (connection: Connection | undefined) => {
|
||||
lastSelectedConnection.value = connection;
|
||||
};
|
||||
|
||||
const setRecenteredCanvasAddButtonPosition = (offset?: XYPosition) => {
|
||||
const position = getMidCanvasPosition(nodeViewScale.value, offset ?? [0, 0]);
|
||||
|
||||
position[0] -= PLACEHOLDER_TRIGGER_NODE_SIZE / 2;
|
||||
position[1] -= PLACEHOLDER_TRIGGER_NODE_SIZE / 2;
|
||||
|
||||
canvasAddButtonPosition.value = getNewNodePosition(nodes.value, position);
|
||||
};
|
||||
|
||||
const getPlaceholderTriggerNodeUI = (): INodeUi => {
|
||||
setRecenteredCanvasAddButtonPosition();
|
||||
|
||||
return {
|
||||
id: uuid(),
|
||||
...DEFAULT_PLACEHOLDER_TRIGGER_BUTTON,
|
||||
position: canvasAddButtonPosition.value,
|
||||
};
|
||||
};
|
||||
|
||||
const getAutoAddManualTriggerNode = (): INodeUi | null => {
|
||||
const manualTriggerNode = nodeTypesStore.getNodeType(MANUAL_TRIGGER_NODE_TYPE);
|
||||
|
||||
if (!manualTriggerNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: uuid(),
|
||||
name: manualTriggerNode.defaults.name?.toString() ?? manualTriggerNode.displayName,
|
||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
||||
parameters: {},
|
||||
position: canvasAddButtonPosition.value,
|
||||
typeVersion: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const getNodesWithPlaceholderNode = (): INodeUi[] =>
|
||||
triggerNodes.value.length > 0 ? nodes.value : [getPlaceholderTriggerNodeUI(), ...nodes.value];
|
||||
|
||||
const canvasPositionFromPagePosition = (position: XYPosition): XYPosition => {
|
||||
const sidebarWidth = isDemo.value
|
||||
? 0
|
||||
: uiStore.sidebarMenuCollapsed
|
||||
? SIDEBAR_WIDTH
|
||||
: SIDEBAR_WIDTH_EXPANDED;
|
||||
|
||||
const relativeX = position[0] - sidebarWidth;
|
||||
const relativeY = isDemo.value
|
||||
? position[1]
|
||||
: position[1] - uiStore.bannersHeight - uiStore.headerHeight;
|
||||
|
||||
return [relativeX, relativeY];
|
||||
};
|
||||
|
||||
const setZoomLevel = (zoomLevel: number, offset: XYPosition) => {
|
||||
nodeViewScale.value = zoomLevel;
|
||||
jsPlumbInstanceRef.value?.setZoom(zoomLevel);
|
||||
uiStore.nodeViewOffsetPosition = offset;
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
const { scale, offset } = scaleReset({
|
||||
scale: nodeViewScale.value,
|
||||
offset: uiStore.nodeViewOffsetPosition,
|
||||
origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]),
|
||||
});
|
||||
setZoomLevel(scale, offset);
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
const { scale, offset } = scaleBigger({
|
||||
scale: nodeViewScale.value,
|
||||
offset: uiStore.nodeViewOffsetPosition,
|
||||
origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]),
|
||||
});
|
||||
setZoomLevel(scale, offset);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
const { scale, offset } = scaleSmaller({
|
||||
scale: nodeViewScale.value,
|
||||
offset: uiStore.nodeViewOffsetPosition,
|
||||
origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]),
|
||||
});
|
||||
setZoomLevel(scale, offset);
|
||||
};
|
||||
|
||||
const zoomToFit = () => {
|
||||
const nodes = getNodesWithPlaceholderNode();
|
||||
if (!nodes.length) {
|
||||
// some unknown workflow executions
|
||||
return;
|
||||
}
|
||||
const { zoomLevel, offset } = getZoomToFit(nodes, !isDemo.value);
|
||||
setZoomLevel(zoomLevel, offset);
|
||||
};
|
||||
|
||||
const wheelMoveWorkflow = (deltaX: number, deltaY: number, shiftKeyPressed = false) => {
|
||||
const offsetPosition = uiStore.nodeViewOffsetPosition;
|
||||
const nodeViewOffsetPositionX = offsetPosition[0] - (shiftKeyPressed ? deltaY : deltaX);
|
||||
const nodeViewOffsetPositionY = offsetPosition[1] - (shiftKeyPressed ? deltaX : deltaY);
|
||||
uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY];
|
||||
};
|
||||
|
||||
const wheelScroll = (e: WheelEvent) => {
|
||||
// Prevent browser back/forward gesture, default pinch to zoom etc.
|
||||
e.preventDefault();
|
||||
|
||||
const { deltaX, deltaY } = normalizeWheelEventDelta(e);
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const scaleFactor = getScaleFromWheelEventDelta(deltaY);
|
||||
const { scale, offset } = applyScale(scaleFactor)({
|
||||
scale: nodeViewScale.value,
|
||||
offset: uiStore.nodeViewOffsetPosition,
|
||||
origin: canvasPositionFromPagePosition(getMousePosition(e)),
|
||||
});
|
||||
setZoomLevel(scale, offset);
|
||||
return;
|
||||
}
|
||||
wheelMoveWorkflow(deltaX, deltaY, e.shiftKey);
|
||||
};
|
||||
|
||||
function initInstance(container: Element) {
|
||||
// Make sure to clean-up previous instance if it exists
|
||||
if (jsPlumbInstanceRef.value) {
|
||||
jsPlumbInstanceRef.value.destroy();
|
||||
jsPlumbInstanceRef.value.reset();
|
||||
jsPlumbInstanceRef.value = undefined;
|
||||
}
|
||||
|
||||
jsPlumbInstanceRef.value = newInstance({
|
||||
container,
|
||||
connector: CONNECTOR_FLOWCHART_TYPE,
|
||||
resizeObserver: false,
|
||||
endpoint: {
|
||||
type: 'Dot',
|
||||
options: { radius: 5 },
|
||||
},
|
||||
paintStyle: CONNECTOR_PAINT_STYLE_DEFAULT,
|
||||
hoverPaintStyle: CONNECTOR_PAINT_STYLE_PRIMARY,
|
||||
connectionOverlays: CONNECTOR_ARROW_OVERLAYS,
|
||||
elementsDraggable: !readOnlyEnv.value,
|
||||
dragOptions: {
|
||||
cursor: 'pointer',
|
||||
grid: { w: GRID_SIZE, h: GRID_SIZE },
|
||||
start: (params: BeforeStartEventParams) => {
|
||||
const draggedNode = params.drag.getDragElement();
|
||||
const nodeName = draggedNode.getAttribute('data-name');
|
||||
if (!nodeName) return;
|
||||
isDragging.value = true;
|
||||
|
||||
const isSelected = uiStore.isNodeSelected[nodeName];
|
||||
|
||||
if (params.e && !isSelected) {
|
||||
// Only the node which gets dragged directly gets an event, for all others it is
|
||||
// undefined. So check if the currently dragged node is selected and if not clear
|
||||
// the drag-selection.
|
||||
jsPlumbInstanceRef.value?.clearDragSelection();
|
||||
uiStore.resetSelectedNodes();
|
||||
}
|
||||
|
||||
uiStore.addActiveAction('dragActive');
|
||||
return true;
|
||||
},
|
||||
stop: (params: DragStopEventParams) => {
|
||||
const draggedNode = params.drag.getDragElement();
|
||||
const nodeName = draggedNode.getAttribute('data-name');
|
||||
if (!nodeName) return;
|
||||
const nodeData = workflowStore.getNodeByName(nodeName);
|
||||
isDragging.value = false;
|
||||
if (uiStore.isActionActive.dragActive && nodeData) {
|
||||
const moveNodes = uiStore.getSelectedNodes.slice();
|
||||
const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name);
|
||||
if (!selectedNodeNames.includes(nodeData.name)) {
|
||||
// If the current node is not in selected add it to the nodes which
|
||||
// got moved manually
|
||||
moveNodes.push(nodeData);
|
||||
}
|
||||
|
||||
if (moveNodes.length > 1) {
|
||||
historyStore.startRecordingUndo();
|
||||
}
|
||||
// This does for some reason just get called once for the node that got clicked
|
||||
// even though "start" and "drag" gets called for all. So lets do for now
|
||||
// some dirty DOM query to get the new positions till I have more time to
|
||||
// create a proper solution
|
||||
let newNodePosition: XYPosition;
|
||||
moveNodes.forEach((node: INodeUi) => {
|
||||
const element = document.getElementById(node.id);
|
||||
if (element === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
newNodePosition = [
|
||||
parseInt(element.style.left.slice(0, -2), 10),
|
||||
parseInt(element.style.top.slice(0, -2), 10),
|
||||
];
|
||||
|
||||
const updateInformation = {
|
||||
name: node.name,
|
||||
properties: {
|
||||
position: newNodePosition,
|
||||
},
|
||||
};
|
||||
const oldPosition = node.position;
|
||||
if (oldPosition[0] !== newNodePosition[0] || oldPosition[1] !== newNodePosition[1]) {
|
||||
historyStore.pushCommandToUndo(
|
||||
new MoveNodeCommand(node.name, oldPosition, newNodePosition),
|
||||
);
|
||||
workflowStore.updateNodeProperties(updateInformation);
|
||||
}
|
||||
});
|
||||
if (moveNodes.length > 1) {
|
||||
historyStore.stopRecordingUndo();
|
||||
}
|
||||
if (uiStore.isActionActive.dragActive) {
|
||||
uiStore.removeActiveAction('dragActive');
|
||||
}
|
||||
}
|
||||
},
|
||||
filter: '.node-description, .node-description .node-name, .node-description .node-subtitle',
|
||||
},
|
||||
});
|
||||
jsPlumbInstanceRef.value?.setDragConstrainFunction(((pos: PointXY) => {
|
||||
const isReadOnly = uiStore.isReadOnlyView;
|
||||
if (isReadOnly) {
|
||||
// Do not allow to move nodes in readOnly mode
|
||||
return null;
|
||||
}
|
||||
return pos;
|
||||
}) as ConstrainFunction);
|
||||
}
|
||||
|
||||
const jsPlumbInstance = computed(() => jsPlumbInstanceRef.value as BrowserJsPlumbInstance);
|
||||
|
||||
watch(readOnlyEnv, setReadOnly);
|
||||
|
||||
function setPanelHeight(height: number) {
|
||||
panelHeight.value = height;
|
||||
}
|
||||
|
||||
return {
|
||||
isDemo,
|
||||
nodeViewScale,
|
||||
canvasAddButtonPosition,
|
||||
newNodeInsertPosition,
|
||||
jsPlumbInstance,
|
||||
isLoading: loadingService.isLoading,
|
||||
aiNodes,
|
||||
lastSelectedConnection: lastSelectedConnectionComputed,
|
||||
panelHeight: computed(() => panelHeight.value),
|
||||
setPanelHeight,
|
||||
setReadOnly,
|
||||
setLastSelectedConnection,
|
||||
startLoading: loadingService.startLoading,
|
||||
setLoadingText: loadingService.setLoadingText,
|
||||
stopLoading: loadingService.stopLoading,
|
||||
setRecenteredCanvasAddButtonPosition,
|
||||
getNodesWithPlaceholderNode,
|
||||
canvasPositionFromPagePosition,
|
||||
setZoomLevel,
|
||||
resetZoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomToFit,
|
||||
wheelScroll,
|
||||
initInstance,
|
||||
getAutoAddManualTriggerNode,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -185,10 +185,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
|
||||
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
|
||||
|
||||
const isCanvasV2Enabled = computed(() =>
|
||||
(settings.value.betaFeatures ?? []).includes('canvas_v2'),
|
||||
);
|
||||
|
||||
const setSettings = (newSettings: FrontendSettings) => {
|
||||
settings.value = newSettings;
|
||||
userManagement.value = newSettings.userManagement;
|
||||
@@ -436,7 +432,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
saveDataProgressExecution,
|
||||
isCommunityPlan,
|
||||
isAskAiEnabled,
|
||||
isCanvasV2Enabled,
|
||||
isAiCreditsEnabled,
|
||||
aiCreditsQuota,
|
||||
reset,
|
||||
|
||||
@@ -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';
|
||||
import { nodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { IExecutionResponse, ICredentialsResponse, NewCredentialsModal } from '@/Interface';
|
||||
import type { jsPlumbDOMElement } from '@jsplumb/browser-ui';
|
||||
import type { Connection } from '@jsplumb/core';
|
||||
import type { Connection as VueFlowConnection } from '@vue-flow/core';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import type { CanvasConnectionMode } from '@/types';
|
||||
@@ -53,14 +51,6 @@ export const isResourceMapperValue = (value: unknown): value is string | number
|
||||
return ['string', 'number', 'boolean'].includes(typeof value);
|
||||
};
|
||||
|
||||
export const isJSPlumbEndpointElement = (element: Node): element is jsPlumbDOMElement => {
|
||||
return 'jtk' in element && 'endpoint' in (element.jtk as object);
|
||||
};
|
||||
|
||||
export const isJSPlumbConnection = (connection: unknown): connection is Connection => {
|
||||
return connection !== null && typeof connection === 'object' && 'connector' in connection;
|
||||
};
|
||||
|
||||
export function isDateObject(date: unknown): date is Date {
|
||||
return (
|
||||
!!date && Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date as number)
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
h,
|
||||
onBeforeUnmount,
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
|
||||
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
@@ -95,12 +95,11 @@ import { sourceControlEventBus } from '@/event-bus/source-control';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
||||
import { getFixedNodesList, getNodeViewTab } from '@/utils/nodeViewUtils';
|
||||
import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
|
||||
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
|
||||
import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue';
|
||||
import { nodeViewEventBus } from '@/event-bus';
|
||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||
import { tryToParseNumber } from '@/utils/typesUtils';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { createEventBus, N8nCallout } from 'n8n-design-system';
|
||||
@@ -113,6 +112,10 @@ import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
|
||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||
|
||||
defineOptions({
|
||||
name: 'NodeView',
|
||||
});
|
||||
|
||||
const LazyNodeCreation = defineAsyncComponent(
|
||||
async () => await import('@/components/Node/NodeCreation.vue'),
|
||||
);
|
||||
@@ -902,7 +905,7 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: IWork
|
||||
|
||||
initializeWorkspace({
|
||||
...workflowData,
|
||||
nodes: NodeViewUtils.getFixedNodesList<INodeUi>(workflowData.nodes),
|
||||
nodes: getFixedNodesList<INodeUi>(workflowData.nodes),
|
||||
} as IWorkflowDb);
|
||||
|
||||
fitView();
|
||||
@@ -1595,6 +1598,42 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeRouteLeave(async (to, from, next) => {
|
||||
const toNodeViewTab = getNodeViewTab(to);
|
||||
|
||||
if (
|
||||
toNodeViewTab === MAIN_HEADER_TABS.EXECUTIONS ||
|
||||
from.name === VIEWS.TEMPLATE_IMPORT ||
|
||||
(toNodeViewTab === MAIN_HEADER_TABS.WORKFLOW && from.name === VIEWS.EXECUTION_DEBUG) ||
|
||||
isReadOnlyEnvironment.value
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
await workflowHelpers.promptSaveUnsavedWorkflowChanges(next, {
|
||||
async confirm() {
|
||||
if (from.name === VIEWS.NEW_WORKFLOW) {
|
||||
// Replace the current route with the new workflow route
|
||||
// before navigating to the new route when saving new workflow.
|
||||
await router.replace({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: workflowId.value },
|
||||
});
|
||||
|
||||
await router.push(to);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure workflow id is empty when leaving the editor
|
||||
workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Lifecycle
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 { useDebounce } from '@/composables/useDebounce';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
|
||||
const executionsStore = useExecutionsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
@@ -26,8 +25,7 @@ const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { callDebounced } = useDebounce();
|
||||
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const { initializeWorkspace } = useCanvasOperations({ router });
|
||||
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
@@ -139,8 +137,7 @@ async function fetchWorkflow() {
|
||||
try {
|
||||
await workflowsStore.fetchActiveWorkflows();
|
||||
const data = await workflowsStore.fetchWorkflow(workflowId.value);
|
||||
workflowHelpers.initState(data);
|
||||
await nodeHelpers.addNodes(data.nodes, data.connections);
|
||||
initializeWorkspace(data);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title'));
|
||||
}
|
||||
|
||||
48
pnpm-lock.yaml
generated
48
pnpm-lock.yaml
generated
@@ -1429,21 +1429,6 @@ importers:
|
||||
'@fortawesome/vue-fontawesome':
|
||||
specifier: '*'
|
||||
version: 3.0.3(@fortawesome/fontawesome-svg-core@1.2.36)(vue@3.5.13(typescript@5.7.2))
|
||||
'@jsplumb/browser-ui':
|
||||
specifier: ^5.13.2
|
||||
version: 5.13.2
|
||||
'@jsplumb/common':
|
||||
specifier: ^5.13.2
|
||||
version: 5.13.2
|
||||
'@jsplumb/connector-bezier':
|
||||
specifier: ^5.13.2
|
||||
version: 5.13.2
|
||||
'@jsplumb/core':
|
||||
specifier: ^5.13.2
|
||||
version: 5.13.2
|
||||
'@jsplumb/util':
|
||||
specifier: ^5.13.2
|
||||
version: 5.13.2
|
||||
'@lezer/common':
|
||||
specifier: ^1.0.4
|
||||
version: 1.1.0
|
||||
@@ -3912,21 +3897,6 @@ packages:
|
||||
'@jsdevtools/ono@7.1.3':
|
||||
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
||||
|
||||
'@jsplumb/browser-ui@5.13.2':
|
||||
resolution: {integrity: sha512-BZ76kPtxESMIdhcCtWXPdICMudJyBVzDxaKY4jlne93Zq1T2ErfpNQ3E6f3JZfvoyvlNbKgh0udYkZ7Yg7BmIQ==}
|
||||
|
||||
'@jsplumb/common@5.13.2':
|
||||
resolution: {integrity: sha512-ZX/EvvYi4HBkRVtsuSSAa/AuAz4p2wr3RrRz6l+r8yeElzX3lrrBx/fkERY2qwZPkKcOoLCr5ezZ7sslVMnl0Q==}
|
||||
|
||||
'@jsplumb/connector-bezier@5.13.2':
|
||||
resolution: {integrity: sha512-AALmOvkiP3ouGag6TGkBcd7SbCewPNwsKu9gku9AZqIq+fFu321zJ2IpfoyCFgkoFFSQjJ9jo1sWBbD3gnEXrg==}
|
||||
|
||||
'@jsplumb/core@5.13.2':
|
||||
resolution: {integrity: sha512-IODXQzhpq9QEzGKhPir6+ea8m4KeU3gzJsYjIu8oqSQ4jDhvEYF7TvSfeaNgy9sUAMt3OoKCqxCS4ga9J7LS5A==}
|
||||
|
||||
'@jsplumb/util@5.13.2':
|
||||
resolution: {integrity: sha512-POrqlZMOo821oa49Xbxb+pNmnxu0z2oS7FOeklRxKuYXR+7nsP0j9PpXjo8E8Ily4TaP+pdUnatb53vAaONO3g==}
|
||||
|
||||
'@kafkajs/confluent-schema-registry@1.0.6':
|
||||
resolution: {integrity: sha512-NrZL1peOIlmlLKvheQcJAx9PHdnc4kaW+9+Yt4jXUfbbYR9EFNCZt6yApI4SwlFilaiZieReM6XslWy1LZAvoQ==}
|
||||
|
||||
@@ -16526,24 +16496,6 @@ snapshots:
|
||||
|
||||
'@jsdevtools/ono@7.1.3': {}
|
||||
|
||||
'@jsplumb/browser-ui@5.13.2':
|
||||
dependencies:
|
||||
'@jsplumb/core': 5.13.2
|
||||
|
||||
'@jsplumb/common@5.13.2':
|
||||
dependencies:
|
||||
'@jsplumb/util': 5.13.2
|
||||
|
||||
'@jsplumb/connector-bezier@5.13.2':
|
||||
dependencies:
|
||||
'@jsplumb/core': 5.13.2
|
||||
|
||||
'@jsplumb/core@5.13.2':
|
||||
dependencies:
|
||||
'@jsplumb/common': 5.13.2
|
||||
|
||||
'@jsplumb/util@5.13.2': {}
|
||||
|
||||
'@kafkajs/confluent-schema-registry@1.0.6':
|
||||
dependencies:
|
||||
avsc: 5.7.6
|
||||
|
||||
Reference in New Issue
Block a user