mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 19:32:15 +00:00
feat(editor): Add context menu to canvas v2 (no-changelog) (#10088)
This commit is contained in:
@@ -1,13 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { type ContextMenuAction, useContextMenu } from '@/composables/useContextMenu';
|
import { type ContextMenuAction, useContextMenu } from '@/composables/useContextMenu';
|
||||||
import { N8nActionDropdown } from 'n8n-design-system';
|
import { N8nActionDropdown } from 'n8n-design-system';
|
||||||
import type { INode } from 'n8n-workflow';
|
|
||||||
import { watch, ref } from 'vue';
|
import { watch, ref } from 'vue';
|
||||||
|
|
||||||
const contextMenu = useContextMenu();
|
const contextMenu = useContextMenu();
|
||||||
const { position, isOpen, actions, target } = contextMenu;
|
const { position, isOpen, actions, target } = contextMenu;
|
||||||
const dropdown = ref<InstanceType<typeof N8nActionDropdown>>();
|
const dropdown = ref<InstanceType<typeof N8nActionDropdown>>();
|
||||||
const emit = defineEmits<{ action: [action: ContextMenuAction, nodes: INode[]] }>();
|
const emit = defineEmits<{ action: [action: ContextMenuAction, nodeIds: string[]] }>();
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -24,7 +23,13 @@ watch(
|
|||||||
function onActionSelect(item: string) {
|
function onActionSelect(item: string) {
|
||||||
const action = item as ContextMenuAction;
|
const action = item as ContextMenuAction;
|
||||||
contextMenu._dispatchAction(action);
|
contextMenu._dispatchAction(action);
|
||||||
emit('action', action, contextMenu.targetNodes.value);
|
emit('action', action, contextMenu.targetNodeIds.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickOutside(event: MouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
contextMenu.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onVisibleChange(open: boolean) {
|
function onVisibleChange(open: boolean) {
|
||||||
@@ -37,6 +42,7 @@ function onVisibleChange(open: boolean) {
|
|||||||
<template>
|
<template>
|
||||||
<Teleport v-if="isOpen" to="body">
|
<Teleport v-if="isOpen" to="body">
|
||||||
<div
|
<div
|
||||||
|
v-on-click-outside="onClickOutside"
|
||||||
:class="$style.contextMenu"
|
:class="$style.contextMenu"
|
||||||
:style="{
|
:style="{
|
||||||
left: `${position[0]}px`,
|
left: `${position[0]}px`,
|
||||||
@@ -48,7 +54,8 @@ function onVisibleChange(open: boolean) {
|
|||||||
:items="actions"
|
:items="actions"
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
data-test-id="context-menu"
|
data-test-id="context-menu"
|
||||||
:hide-arrow="target.source !== 'node-button'"
|
:hide-arrow="target?.source !== 'node-button'"
|
||||||
|
:teleported="false"
|
||||||
@select="onActionSelect"
|
@select="onActionSelect"
|
||||||
@visible-change="onVisibleChange"
|
@visible-change="onVisibleChange"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
|||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { EnableNodeToggleCommand } from '@/models/history';
|
import { EnableNodeToggleCommand } from '@/models/history';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||||
import { type ContextMenuTarget, useContextMenu } from '@/composables/useContextMenu';
|
import { useContextMenu } from '@/composables/useContextMenu';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { usePinnedData } from '@/composables/usePinnedData';
|
import { usePinnedData } from '@/composables/usePinnedData';
|
||||||
@@ -660,8 +660,8 @@ export default defineComponent({
|
|||||||
isContextMenuOpen(): boolean {
|
isContextMenuOpen(): boolean {
|
||||||
return (
|
return (
|
||||||
this.contextMenu.isOpen.value &&
|
this.contextMenu.isOpen.value &&
|
||||||
this.contextMenu.target.value.source === 'node-button' &&
|
this.contextMenu.target.value?.source === 'node-button' &&
|
||||||
this.contextMenu.target.value.node.name === this.data?.name
|
this.contextMenu.target.value.nodeId === this.data?.id
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
iconNodeType() {
|
iconNodeType() {
|
||||||
@@ -861,9 +861,9 @@ export default defineComponent({
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
openContextMenu(event: MouseEvent, source: ContextMenuTarget['source']) {
|
openContextMenu(event: MouseEvent, source: 'node-button' | 'node-right-click') {
|
||||||
if (this.data) {
|
if (this.data) {
|
||||||
this.contextMenu.open(event, { source, node: this.data });
|
this.contextMenu.open(event, { source, nodeId: this.data.id });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,19 +58,21 @@
|
|||||||
<font-awesome-icon icon="trash" />
|
<font-awesome-icon icon="trash" />
|
||||||
</div>
|
</div>
|
||||||
<n8n-popover
|
<n8n-popover
|
||||||
|
v-on-click-outside="() => setColorPopoverVisible(false)"
|
||||||
effect="dark"
|
effect="dark"
|
||||||
:popper-style="{ width: '208px' }"
|
|
||||||
trigger="click"
|
trigger="click"
|
||||||
placement="top"
|
placement="top"
|
||||||
|
:popper-style="{ width: '208px' }"
|
||||||
|
:visible="isColorPopoverVisible"
|
||||||
@show="onShowPopover"
|
@show="onShowPopover"
|
||||||
@hide="onHidePopover"
|
@hide="onHidePopover"
|
||||||
>
|
>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<div
|
<div
|
||||||
ref="colorPopoverTrigger"
|
|
||||||
class="option"
|
class="option"
|
||||||
data-test-id="change-sticky-color"
|
data-test-id="change-sticky-color"
|
||||||
:title="$locale.baseText('node.changeColor')"
|
:title="$locale.baseText('node.changeColor')"
|
||||||
|
@click="() => setColorPopoverVisible(!isColorPopoverVisible)"
|
||||||
>
|
>
|
||||||
<font-awesome-icon icon="palette" />
|
<font-awesome-icon icon="palette" />
|
||||||
</div>
|
</div>
|
||||||
@@ -174,15 +176,19 @@ export default defineComponent({
|
|||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const deviceSupport = useDeviceSupport();
|
const deviceSupport = useDeviceSupport();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const colorPopoverTrigger = ref<HTMLDivElement>();
|
|
||||||
const forceActions = ref(false);
|
const forceActions = ref(false);
|
||||||
|
const isColorPopoverVisible = ref(false);
|
||||||
const setForceActions = (value: boolean) => {
|
const setForceActions = (value: boolean) => {
|
||||||
forceActions.value = value;
|
forceActions.value = value;
|
||||||
};
|
};
|
||||||
|
const setColorPopoverVisible = (value: boolean) => {
|
||||||
|
isColorPopoverVisible.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
const contextMenu = useContextMenu((action) => {
|
const contextMenu = useContextMenu((action) => {
|
||||||
if (action === 'change_color') {
|
if (action === 'change_color') {
|
||||||
setForceActions(true);
|
setForceActions(true);
|
||||||
colorPopoverTrigger.value?.click();
|
setColorPopoverVisible(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,11 +203,12 @@ export default defineComponent({
|
|||||||
return {
|
return {
|
||||||
deviceSupport,
|
deviceSupport,
|
||||||
toast,
|
toast,
|
||||||
colorPopoverTrigger,
|
|
||||||
contextMenu,
|
contextMenu,
|
||||||
forceActions,
|
forceActions,
|
||||||
...nodeBase,
|
...nodeBase,
|
||||||
setForceActions,
|
setForceActions,
|
||||||
|
isColorPopoverVisible,
|
||||||
|
setColorPopoverVisible,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -416,7 +423,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
onContextMenu(e: MouseEvent): void {
|
onContextMenu(e: MouseEvent): void {
|
||||||
if (this.node && !this.isActive) {
|
if (this.node && !this.isActive) {
|
||||||
this.contextMenu.open(e, { source: 'node-right-click', node: this.node });
|
this.contextMenu.open(e, { source: 'node-right-click', nodeId: this.node.id });
|
||||||
} else {
|
} else {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import Canvas from '@/components/canvas/Canvas.vue';
|
import Canvas from '@/components/canvas/Canvas.vue';
|
||||||
@@ -7,19 +6,18 @@ import { createPinia, setActivePinia } from 'pinia';
|
|||||||
import type { CanvasConnection, CanvasNode } from '@/types';
|
import type { CanvasConnection, CanvasNode } from '@/types';
|
||||||
import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/data';
|
import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/data';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
import type { useDeviceSupport } from 'n8n-design-system';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
global.window = jsdom.window as unknown as Window & typeof globalThis;
|
global.window = jsdom.window as unknown as Window & typeof globalThis;
|
||||||
|
|
||||||
vi.mock('@/stores/nodeTypes.store', () => ({
|
vi.mock('n8n-design-system', async (importOriginal) => {
|
||||||
useNodeTypesStore: vi.fn(() => ({
|
const actual = await importOriginal<typeof useDeviceSupport>();
|
||||||
getNodeType: vi.fn(() => ({
|
return { ...actual, useDeviceSupport: vi.fn(() => ({ isCtrlKeyPressed: vi.fn() })) };
|
||||||
name: 'test',
|
});
|
||||||
description: 'Test Node Description',
|
|
||||||
})),
|
vi.mock('@/composables/useDeviceSupport');
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ import { Controls } from '@vue-flow/controls';
|
|||||||
import { MiniMap } from '@vue-flow/minimap';
|
import { MiniMap } from '@vue-flow/minimap';
|
||||||
import Node from './elements/nodes/CanvasNode.vue';
|
import Node from './elements/nodes/CanvasNode.vue';
|
||||||
import Edge from './elements/edges/CanvasEdge.vue';
|
import Edge from './elements/edges/CanvasEdge.vue';
|
||||||
import { onMounted, onUnmounted, ref, useCssModule } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, useCssModule } from 'vue';
|
||||||
import type { EventBus } from 'n8n-design-system';
|
import type { EventBus } from 'n8n-design-system';
|
||||||
import { createEventBus } from 'n8n-design-system';
|
import { createEventBus } from 'n8n-design-system';
|
||||||
|
import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu';
|
||||||
|
import { useKeybindings } from '@/composables/useKeybindings';
|
||||||
|
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
|
||||||
|
import type { NodeCreatorOpenSource } from '@/Interface';
|
||||||
|
import type { PinDataSource } from '@/composables/usePinnedData';
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
@@ -18,10 +23,19 @@ const emit = defineEmits<{
|
|||||||
'update:node:position': [id: string, position: XYPosition];
|
'update:node:position': [id: string, position: XYPosition];
|
||||||
'update:node:active': [id: string];
|
'update:node:active': [id: string];
|
||||||
'update:node:enabled': [id: string];
|
'update:node:enabled': [id: string];
|
||||||
'update:node:selected': [id?: string];
|
'update:node:selected': [id: string];
|
||||||
|
'update:node:name': [id: string];
|
||||||
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
|
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
|
||||||
'run:node': [id: string];
|
'run:node': [id: string];
|
||||||
'delete:node': [id: string];
|
'delete:node': [id: string];
|
||||||
|
'create:node': [source: NodeCreatorOpenSource];
|
||||||
|
'create:sticky': [];
|
||||||
|
'delete:nodes': [ids: string[]];
|
||||||
|
'update:nodes:enabled': [ids: string[]];
|
||||||
|
'copy:nodes': [ids: string[]];
|
||||||
|
'duplicate:nodes': [ids: string[]];
|
||||||
|
'update:nodes:pin': [ids: string[], source: PinDataSource];
|
||||||
|
'cut:nodes': [ids: string[]];
|
||||||
'delete:connection': [connection: Connection];
|
'delete:connection': [connection: Connection];
|
||||||
'create:connection:start': [handle: ConnectStartEvent];
|
'create:connection:start': [handle: ConnectStartEvent];
|
||||||
'create:connection': [connection: Connection];
|
'create:connection': [connection: Connection];
|
||||||
@@ -29,6 +43,9 @@ const emit = defineEmits<{
|
|||||||
'create:connection:cancelled': [handle: ConnectStartEvent];
|
'create:connection:cancelled': [handle: ConnectStartEvent];
|
||||||
'click:connection:add': [connection: Connection];
|
'click:connection:add': [connection: Connection];
|
||||||
'click:pane': [position: XYPosition];
|
'click:pane': [position: XYPosition];
|
||||||
|
'run:workflow': [];
|
||||||
|
'save:workflow': [];
|
||||||
|
'create:workflow': [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -48,16 +65,43 @@ const props = withDefaults(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { getSelectedEdges, getSelectedNodes, viewportRef, fitView, project, onPaneReady } =
|
const {
|
||||||
useVueFlow({
|
getSelectedNodes: selectedNodes,
|
||||||
id: props.id,
|
addSelectedNodes,
|
||||||
});
|
removeSelectedNodes,
|
||||||
|
viewportRef,
|
||||||
|
fitView,
|
||||||
|
project,
|
||||||
|
nodes: graphNodes,
|
||||||
|
onPaneReady,
|
||||||
|
} = useVueFlow({ id: props.id, deleteKeyCode: null });
|
||||||
|
|
||||||
onPaneReady(async () => {
|
useKeybindings({
|
||||||
await onFitView();
|
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
|
||||||
paneReady.value = true;
|
ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)),
|
||||||
|
'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)),
|
||||||
|
ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)),
|
||||||
|
d: emitWithSelectedNodes((ids) => emit('update:nodes:enabled', ids)),
|
||||||
|
p: emitWithSelectedNodes((ids) => emit('update:nodes:pin', ids, 'keyboard-shortcut')),
|
||||||
|
enter: () => emitWithLastSelectedNode((id) => emit('update:node:active', id)),
|
||||||
|
f2: () => emitWithLastSelectedNode((id) => emit('update:node:name', id)),
|
||||||
|
tab: () => emit('create:node', 'tab'),
|
||||||
|
shift_s: () => emit('create:sticky'),
|
||||||
|
ctrl_alt_n: () => emit('create:workflow'),
|
||||||
|
ctrl_enter: () => emit('run:workflow'),
|
||||||
|
ctrl_s: () => emit('save:workflow'),
|
||||||
|
ctrl_a: () => addSelectedNodes(graphNodes.value),
|
||||||
|
// @TODO implement arrow key shortcuts to modify selection
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const contextMenu = useContextMenu();
|
||||||
|
|
||||||
|
const lastSelectedNode = computed(() => selectedNodes.value[selectedNodes.value.length - 1]);
|
||||||
|
|
||||||
|
const hasSelection = computed(() => selectedNodes.value.length > 0);
|
||||||
|
|
||||||
|
const selectedNodeIds = computed(() => selectedNodes.value.map((node) => node.id));
|
||||||
|
|
||||||
const paneReady = ref(false);
|
const paneReady = ref(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,8 +127,8 @@ function onSetNodeActive(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSelectNode() {
|
function onSelectNode() {
|
||||||
const selectedNodeId = getSelectedNodes.value[getSelectedNodes.value.length - 1]?.id;
|
if (!lastSelectedNode.value) return;
|
||||||
emit('update:node:selected', selectedNodeId);
|
emit('update:node:selected', lastSelectedNode.value.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onToggleNodeEnabled(id: string) {
|
function onToggleNodeEnabled(id: string) {
|
||||||
@@ -166,14 +210,23 @@ function onRunNode(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keyboard events
|
* Emit helpers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function emitWithSelectedNodes(emitFn: (ids: string[]) => void) {
|
||||||
if (e.key === 'Delete') {
|
return () => {
|
||||||
getSelectedEdges.value.forEach(onDeleteConnection);
|
if (hasSelection.value) {
|
||||||
getSelectedNodes.value.forEach(({ id }) => onDeleteNode(id));
|
emitFn(selectedNodeIds.value);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitWithLastSelectedNode(emitFn: (id: string) => void) {
|
||||||
|
return () => {
|
||||||
|
if (lastSelectedNode.value) {
|
||||||
|
emitFn(lastSelectedNode.value.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,18 +247,73 @@ async function onFitView() {
|
|||||||
await fitView({ maxZoom: 1.2, padding: 0.1 });
|
await fitView({ maxZoom: 1.2, padding: 0.1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context menu
|
||||||
|
*/
|
||||||
|
|
||||||
|
function onOpenContextMenu(event: MouseEvent) {
|
||||||
|
contextMenu.open(event, {
|
||||||
|
source: 'canvas',
|
||||||
|
nodeIds: selectedNodeIds.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenNodeContextMenu(
|
||||||
|
id: string,
|
||||||
|
event: MouseEvent,
|
||||||
|
source: 'node-button' | 'node-right-click',
|
||||||
|
) {
|
||||||
|
if (selectedNodeIds.value.includes(id)) {
|
||||||
|
onOpenContextMenu(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
contextMenu.open(event, { source, nodeId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
|
||||||
|
switch (action) {
|
||||||
|
case 'add_node':
|
||||||
|
return emit('create:node', 'context_menu');
|
||||||
|
case 'add_sticky':
|
||||||
|
return emit('create:sticky');
|
||||||
|
case 'copy':
|
||||||
|
return emit('copy:nodes', nodeIds);
|
||||||
|
case 'delete':
|
||||||
|
return emit('delete:nodes', nodeIds);
|
||||||
|
case 'select_all':
|
||||||
|
return addSelectedNodes(graphNodes.value);
|
||||||
|
case 'deselect_all':
|
||||||
|
return removeSelectedNodes(selectedNodes.value);
|
||||||
|
case 'duplicate':
|
||||||
|
return emit('duplicate:nodes', nodeIds);
|
||||||
|
case 'toggle_pin':
|
||||||
|
return emit('update:nodes:pin', nodeIds, 'context-menu');
|
||||||
|
case 'execute':
|
||||||
|
return emit('run:node', nodeIds[0]);
|
||||||
|
case 'toggle_activation':
|
||||||
|
return emit('update:nodes:enabled', nodeIds);
|
||||||
|
case 'open':
|
||||||
|
return emit('update:node:active', nodeIds[0]);
|
||||||
|
case 'rename':
|
||||||
|
return emit('update:node:name', nodeIds[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle
|
* Lifecycle
|
||||||
*/
|
*/
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', onKeyDown);
|
|
||||||
props.eventBus.on('fitView', onFitView);
|
props.eventBus.on('fitView', onFitView);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
props.eventBus.off('fitView', onFitView);
|
props.eventBus.off('fitView', onFitView);
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
});
|
||||||
|
|
||||||
|
onPaneReady(async () => {
|
||||||
|
await onFitView();
|
||||||
|
paneReady.value = true;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -230,6 +338,7 @@ onUnmounted(() => {
|
|||||||
@connect="onConnect"
|
@connect="onConnect"
|
||||||
@connect-end="onConnectEnd"
|
@connect-end="onConnectEnd"
|
||||||
@pane-click="onClickPane"
|
@pane-click="onClickPane"
|
||||||
|
@contextmenu="onOpenContextMenu"
|
||||||
>
|
>
|
||||||
<template #node-canvas-node="canvasNodeProps">
|
<template #node-canvas-node="canvasNodeProps">
|
||||||
<Node
|
<Node
|
||||||
@@ -239,6 +348,7 @@ onUnmounted(() => {
|
|||||||
@select="onSelectNode"
|
@select="onSelectNode"
|
||||||
@toggle="onToggleNodeEnabled"
|
@toggle="onToggleNodeEnabled"
|
||||||
@activate="onSetNodeActive"
|
@activate="onSetNodeActive"
|
||||||
|
@open:contextmenu="onOpenNodeContextMenu"
|
||||||
@update="onUpdateNodeParameters"
|
@update="onUpdateNodeParameters"
|
||||||
@move="onUpdateNodePosition"
|
@move="onUpdateNodePosition"
|
||||||
/>
|
/>
|
||||||
@@ -263,6 +373,10 @@ onUnmounted(() => {
|
|||||||
:position="controlsPosition"
|
:position="controlsPosition"
|
||||||
@fit-view="onFitView"
|
@fit-view="onFitView"
|
||||||
></Controls>
|
></Controls>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<ContextMenu @action="onContextMenuAction" />
|
||||||
|
</Suspense>
|
||||||
</VueFlow>
|
</VueFlow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRen
|
|||||||
import HandleRenderer from '@/components/canvas/elements/handles/HandleRenderer.vue';
|
import HandleRenderer from '@/components/canvas/elements/handles/HandleRenderer.vue';
|
||||||
import { useNodeConnections } from '@/composables/useNodeConnections';
|
import { useNodeConnections } from '@/composables/useNodeConnections';
|
||||||
import { CanvasNodeKey } from '@/constants';
|
import { CanvasNodeKey } from '@/constants';
|
||||||
|
import { useContextMenu } from '@/composables/useContextMenu';
|
||||||
import { Position } from '@vue-flow/core';
|
import { Position } from '@vue-flow/core';
|
||||||
import type { XYPosition, NodeProps } from '@vue-flow/core';
|
import type { XYPosition, NodeProps } from '@vue-flow/core';
|
||||||
|
|
||||||
@@ -17,12 +18,14 @@ const emit = defineEmits<{
|
|||||||
select: [id: string, selected: boolean];
|
select: [id: string, selected: boolean];
|
||||||
toggle: [id: string];
|
toggle: [id: string];
|
||||||
activate: [id: string];
|
activate: [id: string];
|
||||||
|
'open:contextmenu': [id: string, event: MouseEvent, source: 'node-button' | 'node-right-click'];
|
||||||
update: [id: string, parameters: Record<string, unknown>];
|
update: [id: string, parameters: Record<string, unknown>];
|
||||||
move: [id: string, position: XYPosition];
|
move: [id: string, position: XYPosition];
|
||||||
}>();
|
}>();
|
||||||
const props = defineProps<NodeProps<CanvasNodeData>>();
|
const props = defineProps<NodeProps<CanvasNodeData>>();
|
||||||
|
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
const contextMenu = useContextMenu();
|
||||||
|
|
||||||
const inputs = computed(() => props.data.inputs);
|
const inputs = computed(() => props.data.inputs);
|
||||||
const outputs = computed(() => props.data.outputs);
|
const outputs = computed(() => props.data.outputs);
|
||||||
@@ -110,6 +113,13 @@ function onActivate() {
|
|||||||
emit('activate', props.id);
|
emit('activate', props.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onOpenContextMenuFromToolbar(event: MouseEvent) {
|
||||||
|
emit('open:contextmenu', props.id, event, 'node-button');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenContextMenuFromNode(event: MouseEvent) {
|
||||||
|
emit('open:contextmenu', props.id, event, 'node-right-click');
|
||||||
|
}
|
||||||
function onUpdate(parameters: Record<string, unknown>) {
|
function onUpdate(parameters: Record<string, unknown>) {
|
||||||
emit('update', props.id, parameters);
|
emit('update', props.id, parameters);
|
||||||
}
|
}
|
||||||
@@ -135,6 +145,11 @@ provide(CanvasNodeKey, {
|
|||||||
nodeType,
|
nodeType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showToolbar = computed(() => {
|
||||||
|
const target = contextMenu.target.value;
|
||||||
|
return contextMenu.isOpen && target?.source === 'node-button' && target.nodeId === id.value;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle
|
* Lifecycle
|
||||||
*/
|
*/
|
||||||
@@ -148,7 +163,10 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.canvasNode" data-test-id="canvas-node">
|
<div
|
||||||
|
:class="[$style.canvasNode, { [$style.showToolbar]: showToolbar }]"
|
||||||
|
data-test-id="canvas-node"
|
||||||
|
>
|
||||||
<template v-for="source in outputsWithPosition" :key="`${source.type}/${source.index}`">
|
<template v-for="source in outputsWithPosition" :key="`${source.type}/${source.index}`">
|
||||||
<HandleRenderer
|
<HandleRenderer
|
||||||
mode="output"
|
mode="output"
|
||||||
@@ -182,9 +200,15 @@ watch(
|
|||||||
@delete="onDelete"
|
@delete="onDelete"
|
||||||
@toggle="onDisabledToggle"
|
@toggle="onDisabledToggle"
|
||||||
@run="onRun"
|
@run="onRun"
|
||||||
|
@open:contextmenu="onOpenContextMenuFromToolbar"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CanvasNodeRenderer @dblclick="onActivate" @move="onMove" @update="onUpdate">
|
<CanvasNodeRenderer
|
||||||
|
@dblclick="onActivate"
|
||||||
|
@move="onMove"
|
||||||
|
@update="onUpdate"
|
||||||
|
@open:contextmenu="onOpenContextMenuFromNode"
|
||||||
|
>
|
||||||
<NodeIcon
|
<NodeIcon
|
||||||
v-if="nodeType"
|
v-if="nodeType"
|
||||||
:node-type="nodeType"
|
:node-type="nodeType"
|
||||||
@@ -199,7 +223,8 @@ watch(
|
|||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.canvasNode {
|
.canvasNode {
|
||||||
&:hover {
|
&:hover,
|
||||||
|
&.showToolbar {
|
||||||
.canvasNodeToolbar {
|
.canvasNodeToolbar {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -214,8 +239,4 @@ watch(
|
|||||||
transform: translate(-50%, -100%);
|
transform: translate(-50%, -100%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasNodeToolbar:focus-within {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -38,75 +38,59 @@ describe('CanvasNodeToolbar', () => {
|
|||||||
expect(queryByTestId('execute-node-button')).not.toBeInTheDocument();
|
expect(queryByTestId('execute-node-button')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call executeNode function when execute node button is clicked', async () => {
|
it('should emit "run" when execute node button is clicked', async () => {
|
||||||
const executeNode = vi.fn();
|
const { getByTestId, emitted } = renderComponent({
|
||||||
const { getByTestId } = renderComponent({
|
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide(),
|
...createCanvasNodeProvide(),
|
||||||
},
|
},
|
||||||
mocks: {
|
|
||||||
executeNode,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(getByTestId('execute-node-button'));
|
await fireEvent.click(getByTestId('execute-node-button'));
|
||||||
|
|
||||||
expect(executeNode).toHaveBeenCalled();
|
expect(emitted('run')[0]).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call toggleDisableNode function when disable node button is clicked', async () => {
|
it('should emit "toggle" when disable node button is clicked', async () => {
|
||||||
const onToggleNode = vi.fn();
|
const { getByTestId, emitted } = renderComponent({
|
||||||
const { getByTestId } = renderComponent({
|
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide(),
|
...createCanvasNodeProvide(),
|
||||||
},
|
},
|
||||||
mocks: {
|
|
||||||
onToggleNode,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(getByTestId('disable-node-button'));
|
await fireEvent.click(getByTestId('disable-node-button'));
|
||||||
|
|
||||||
expect(onToggleNode).toHaveBeenCalled();
|
expect(emitted('toggle')[0]).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call deleteNode function when delete node button is clicked', async () => {
|
it('should emit "delete" when delete node button is clicked', async () => {
|
||||||
const onDeleteNode = vi.fn();
|
const { getByTestId, emitted } = renderComponent({
|
||||||
const { getByTestId } = renderComponent({
|
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide(),
|
...createCanvasNodeProvide(),
|
||||||
},
|
},
|
||||||
mocks: {
|
|
||||||
onDeleteNode,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(getByTestId('delete-node-button'));
|
await fireEvent.click(getByTestId('delete-node-button'));
|
||||||
|
|
||||||
expect(onDeleteNode).toHaveBeenCalled();
|
expect(emitted('delete')[0]).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call openContextMenu function when overflow node button is clicked', async () => {
|
it('should emit "open:contextmenu" when overflow node button is clicked', async () => {
|
||||||
const openContextMenu = vi.fn();
|
const { getByTestId, emitted } = renderComponent({
|
||||||
const { getByTestId } = renderComponent({
|
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
...createCanvasNodeProvide(),
|
...createCanvasNodeProvide(),
|
||||||
},
|
},
|
||||||
mocks: {
|
|
||||||
openContextMenu,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(getByTestId('overflow-node-button'));
|
await fireEvent.click(getByTestId('overflow-node-button'));
|
||||||
|
|
||||||
expect(openContextMenu).toHaveBeenCalled();
|
expect(emitted('open:contextmenu')[0]).toEqual([expect.any(MouseEvent)]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const emit = defineEmits<{
|
|||||||
delete: [];
|
delete: [];
|
||||||
toggle: [];
|
toggle: [];
|
||||||
run: [];
|
run: [];
|
||||||
|
'open:contextmenu': [event: MouseEvent];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
@@ -45,8 +46,9 @@ function onDeleteNode() {
|
|||||||
emit('delete');
|
emit('delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
// @TODO
|
function onOpenContextMenu(event: MouseEvent) {
|
||||||
function openContextMenu(_e: MouseEvent, _type: string) {}
|
emit('open:contextmenu', event);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -88,7 +90,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
|
|||||||
size="small"
|
size="small"
|
||||||
text
|
text
|
||||||
icon="ellipsis-h"
|
icon="ellipsis-h"
|
||||||
@click="(e: MouseEvent) => openContextMenu(e, 'node-button')"
|
@click="onOpenContextMenu"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import type { CanvasNodeDefaultRender } from '@/types';
|
|||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'open:contextmenu': [event: MouseEvent];
|
||||||
|
}>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
label,
|
label,
|
||||||
inputs,
|
inputs,
|
||||||
@@ -79,10 +83,14 @@ const dataTestId = computed(() => {
|
|||||||
|
|
||||||
return `canvas-${type}-node`;
|
return `canvas-${type}-node`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function openContextMenu(event: MouseEvent) {
|
||||||
|
emit('open:contextmenu', event);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="classes" :style="styles" :data-test-id="dataTestId">
|
<div :class="classes" :style="styles" :data-test-id="dataTestId" @contextmenu="openContextMenu">
|
||||||
<slot />
|
<slot />
|
||||||
<N8nTooltip v-if="renderOptions.trigger" placement="bottom">
|
<N8nTooltip v-if="renderOptions.trigger" placement="bottom">
|
||||||
<template #content>
|
<template #content>
|
||||||
|
|||||||
@@ -56,90 +56,97 @@ describe('useContextMenu', () => {
|
|||||||
const mockEvent = new MouseEvent('contextmenu', { clientX: 500, clientY: 300 });
|
const mockEvent = new MouseEvent('contextmenu', { clientX: 500, clientY: 300 });
|
||||||
|
|
||||||
it('should support opening and closing (default = right click on canvas)', () => {
|
it('should support opening and closing (default = right click on canvas)', () => {
|
||||||
const { open, close, isOpen, actions, position, target, targetNodes } = useContextMenu();
|
const { open, close, isOpen, actions, position, target, targetNodeIds } = useContextMenu();
|
||||||
expect(isOpen.value).toBe(false);
|
expect(isOpen.value).toBe(false);
|
||||||
expect(actions.value).toEqual([]);
|
expect(actions.value).toEqual([]);
|
||||||
expect(position.value).toEqual([0, 0]);
|
expect(position.value).toEqual([0, 0]);
|
||||||
expect(targetNodes.value).toEqual([]);
|
expect(targetNodeIds.value).toEqual([]);
|
||||||
|
|
||||||
open(mockEvent);
|
const nodeIds = selectedNodes.map((n) => n.id);
|
||||||
|
open(mockEvent, { source: 'canvas', nodeIds });
|
||||||
expect(isOpen.value).toBe(true);
|
expect(isOpen.value).toBe(true);
|
||||||
expect(useContextMenu().isOpen.value).toEqual(true);
|
expect(useContextMenu().isOpen.value).toEqual(true);
|
||||||
expect(actions.value).toMatchSnapshot();
|
expect(actions.value).toMatchSnapshot();
|
||||||
expect(position.value).toEqual([500, 300]);
|
expect(position.value).toEqual([500, 300]);
|
||||||
expect(target.value).toEqual({ source: 'canvas' });
|
expect(target.value).toEqual({ source: 'canvas', nodeIds });
|
||||||
expect(targetNodes.value).toEqual(selectedNodes);
|
expect(targetNodeIds.value).toEqual(nodeIds);
|
||||||
|
|
||||||
close();
|
close();
|
||||||
expect(isOpen.value).toBe(false);
|
expect(isOpen.value).toBe(false);
|
||||||
expect(useContextMenu().isOpen.value).toEqual(false);
|
expect(useContextMenu().isOpen.value).toEqual(false);
|
||||||
expect(actions.value).toEqual([]);
|
expect(actions.value).toEqual([]);
|
||||||
expect(position.value).toEqual([0, 0]);
|
expect(position.value).toEqual([0, 0]);
|
||||||
expect(targetNodes.value).toEqual([]);
|
expect(targetNodeIds.value).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the correct actions when right clicking a sticky', () => {
|
it('should return the correct actions when right clicking a sticky', () => {
|
||||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||||
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
|
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
|
||||||
open(mockEvent, { source: 'node-right-click', node: sticky });
|
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);
|
||||||
|
open(mockEvent, { source: 'node-right-click', nodeId: sticky.id });
|
||||||
|
|
||||||
expect(isOpen.value).toBe(true);
|
expect(isOpen.value).toBe(true);
|
||||||
expect(actions.value).toMatchSnapshot();
|
expect(actions.value).toMatchSnapshot();
|
||||||
expect(targetNodes.value).toEqual([sticky]);
|
expect(targetNodeIds.value).toEqual([sticky.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should disable pinning for node that has other inputs then "main"', () => {
|
it('should disable pinning for node that has other inputs then "main"', () => {
|
||||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||||
const basicChain = nodeFactory({ type: BASIC_CHAIN_NODE_TYPE });
|
const basicChain = nodeFactory({ type: BASIC_CHAIN_NODE_TYPE });
|
||||||
|
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(basicChain);
|
||||||
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue(['main', 'ai_languageModel']);
|
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue(['main', 'ai_languageModel']);
|
||||||
open(mockEvent, { source: 'node-right-click', node: basicChain });
|
open(mockEvent, { source: 'node-right-click', nodeId: basicChain.id });
|
||||||
|
|
||||||
expect(isOpen.value).toBe(true);
|
expect(isOpen.value).toBe(true);
|
||||||
expect(actions.value.find((action) => action.id === 'toggle_pin')?.disabled).toBe(true);
|
expect(actions.value.find((action) => action.id === 'toggle_pin')?.disabled).toBe(true);
|
||||||
expect(targetNodes.value).toEqual([basicChain]);
|
expect(targetNodeIds.value).toEqual([basicChain.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the correct actions when right clicking a Node', () => {
|
it('should return the correct actions when right clicking a Node', () => {
|
||||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||||
const node = nodeFactory();
|
const node = nodeFactory();
|
||||||
open(mockEvent, { source: 'node-right-click', node });
|
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
|
||||||
|
open(mockEvent, { source: 'node-right-click', nodeId: node.id });
|
||||||
|
|
||||||
expect(isOpen.value).toBe(true);
|
expect(isOpen.value).toBe(true);
|
||||||
expect(actions.value).toMatchSnapshot();
|
expect(actions.value).toMatchSnapshot();
|
||||||
expect(targetNodes.value).toEqual([node]);
|
expect(targetNodeIds.value).toEqual([node.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the correct actions opening the menu from the button', () => {
|
it('should return the correct actions opening the menu from the button', () => {
|
||||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||||
const node = nodeFactory();
|
const node = nodeFactory();
|
||||||
open(mockEvent, { source: 'node-button', node });
|
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
|
||||||
|
open(mockEvent, { source: 'node-button', nodeId: node.id });
|
||||||
|
|
||||||
expect(isOpen.value).toBe(true);
|
expect(isOpen.value).toBe(true);
|
||||||
expect(actions.value).toMatchSnapshot();
|
expect(actions.value).toMatchSnapshot();
|
||||||
expect(targetNodes.value).toEqual([node]);
|
expect(targetNodeIds.value).toEqual([node.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Read-only mode', () => {
|
describe('Read-only mode', () => {
|
||||||
it('should return the correct actions when right clicking a sticky', () => {
|
it('should return the correct actions when right clicking a sticky', () => {
|
||||||
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
|
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
|
||||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||||
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
|
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
|
||||||
open(mockEvent, { source: 'node-right-click', node: sticky });
|
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);
|
||||||
|
open(mockEvent, { source: 'node-right-click', nodeId: sticky.id });
|
||||||
|
|
||||||
expect(isOpen.value).toBe(true);
|
expect(isOpen.value).toBe(true);
|
||||||
expect(actions.value).toMatchSnapshot();
|
expect(actions.value).toMatchSnapshot();
|
||||||
expect(targetNodes.value).toEqual([sticky]);
|
expect(targetNodeIds.value).toEqual([sticky.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the correct actions when right clicking a Node', () => {
|
it('should return the correct actions when right clicking a Node', () => {
|
||||||
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
|
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
|
||||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||||
const node = nodeFactory();
|
const node = nodeFactory();
|
||||||
open(mockEvent, { source: 'node-right-click', node });
|
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
|
||||||
|
open(mockEvent, { source: 'node-right-click', nodeId: node.id });
|
||||||
|
|
||||||
expect(isOpen.value).toBe(true);
|
expect(isOpen.value).toBe(true);
|
||||||
expect(actions.value).toMatchSnapshot();
|
expect(actions.value).toMatchSnapshot();
|
||||||
expect(targetNodes.value).toEqual([node]);
|
expect(targetNodeIds.value).toEqual([node.id]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { renderComponent } from '@/__tests__/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { defineComponent, h } from 'vue';
|
||||||
|
import { useKeybindings } from '../useKeybindings';
|
||||||
|
|
||||||
|
const renderTestComponent = async (...args: Parameters<typeof useKeybindings>) => {
|
||||||
|
return renderComponent(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
useKeybindings(...args);
|
||||||
|
return () => h('div', [h('input')]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useKeybindings', () => {
|
||||||
|
it('should trigger case-insensitive keyboard shortcuts', async () => {
|
||||||
|
const saveSpy = vi.fn();
|
||||||
|
const saveAllSpy = vi.fn();
|
||||||
|
await renderTestComponent({ Ctrl_s: saveSpy, ctrl_Shift_S: saveAllSpy });
|
||||||
|
await userEvent.keyboard('{Control>}s');
|
||||||
|
expect(saveSpy).toHaveBeenCalled();
|
||||||
|
expect(saveAllSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Control>}{Shift>}s');
|
||||||
|
expect(saveAllSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not trigger shortcuts when an input element has focus', async () => {
|
||||||
|
const saveSpy = vi.fn();
|
||||||
|
const saveAllSpy = vi.fn();
|
||||||
|
const { getByRole } = await renderTestComponent({ Ctrl_s: saveSpy, ctrl_Shift_S: saveAllSpy });
|
||||||
|
|
||||||
|
getByRole('textbox').focus();
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Control>}s');
|
||||||
|
await userEvent.keyboard('{Control>}{Shift>}s');
|
||||||
|
|
||||||
|
expect(saveSpy).not.toHaveBeenCalled();
|
||||||
|
expect(saveAllSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
* @TODO Remove this notice when Canvas V2 is the only one in use
|
* @TODO Remove this notice when Canvas V2 is the only one in use
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CanvasConnectionMode } from '@/types';
|
|
||||||
import type { CanvasConnectionCreateData, CanvasNode, CanvasConnection } from '@/types';
|
|
||||||
import type {
|
import type {
|
||||||
AddedNodesAndConnections,
|
AddedNodesAndConnections,
|
||||||
INodeUi,
|
INodeUi,
|
||||||
@@ -13,6 +11,14 @@ import type {
|
|||||||
IWorkflowDb,
|
IWorkflowDb,
|
||||||
XYPosition,
|
XYPosition,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
|
import { useDataSchema } from '@/composables/useDataSchema';
|
||||||
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
import { usePinnedData, type PinDataSource } from '@/composables/usePinnedData';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
import {
|
import {
|
||||||
FORM_TRIGGER_NODE_TYPE,
|
FORM_TRIGGER_NODE_TYPE,
|
||||||
QUICKSTART_NOTE_NAME,
|
QUICKSTART_NOTE_NAME,
|
||||||
@@ -20,11 +26,6 @@ import {
|
|||||||
UPDATE_WEBHOOK_ID_NODE_TYPES,
|
UPDATE_WEBHOOK_ID_NODE_TYPES,
|
||||||
WEBHOOK_NODE_TYPE,
|
WEBHOOK_NODE_TYPE,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import { useHistoryStore } from '@/stores/history.store';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
|
||||||
import {
|
import {
|
||||||
AddNodeCommand,
|
AddNodeCommand,
|
||||||
MoveNodeCommand,
|
MoveNodeCommand,
|
||||||
@@ -32,7 +33,20 @@ import {
|
|||||||
RemoveNodeCommand,
|
RemoveNodeCommand,
|
||||||
RenameNodeCommand,
|
RenameNodeCommand,
|
||||||
} from '@/models/history';
|
} from '@/models/history';
|
||||||
import type { Connection } from '@vue-flow/core';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
|
import { useHistoryStore } from '@/stores/history.store';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { useRootStore } from '@/stores/root.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useTagsStore } from '@/stores/tags.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import type { CanvasConnection, CanvasConnectionCreateData, CanvasNode } from '@/types';
|
||||||
|
import { CanvasConnectionMode } from '@/types';
|
||||||
import {
|
import {
|
||||||
createCanvasConnectionHandleString,
|
createCanvasConnectionHandleString,
|
||||||
getUniqueNodeName,
|
getUniqueNodeName,
|
||||||
@@ -40,10 +54,15 @@ import {
|
|||||||
mapLegacyConnectionsToCanvasConnections,
|
mapLegacyConnectionsToCanvasConnections,
|
||||||
parseCanvasConnectionHandleString,
|
parseCanvasConnectionHandleString,
|
||||||
} from '@/utils/canvasUtilsV2';
|
} from '@/utils/canvasUtilsV2';
|
||||||
|
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||||
|
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||||
|
import { isPresent } from '@/utils/typesUtils';
|
||||||
|
import type { Connection } from '@vue-flow/core';
|
||||||
import type {
|
import type {
|
||||||
ConnectionTypes,
|
ConnectionTypes,
|
||||||
IConnection,
|
IConnection,
|
||||||
IConnections,
|
IConnections,
|
||||||
|
INode,
|
||||||
INodeConnections,
|
INodeConnections,
|
||||||
INodeInputConfiguration,
|
INodeInputConfiguration,
|
||||||
INodeOutputConfiguration,
|
INodeOutputConfiguration,
|
||||||
@@ -51,31 +70,14 @@ import type {
|
|||||||
INodeTypeNameVersion,
|
INodeTypeNameVersion,
|
||||||
ITelemetryTrackProperties,
|
ITelemetryTrackProperties,
|
||||||
IWorkflowBase,
|
IWorkflowBase,
|
||||||
Workflow,
|
|
||||||
INode,
|
|
||||||
NodeParameterValueType,
|
NodeParameterValueType,
|
||||||
|
Workflow,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
|
import { NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
|
||||||
import { useToast } from '@/composables/useToast';
|
|
||||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
|
||||||
import type { useRouter } from 'vue-router';
|
import type { useRouter } from 'vue-router';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
|
||||||
import { usePinnedData } from '@/composables/usePinnedData';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import { useTagsStore } from '@/stores/tags.store';
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
|
||||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
|
||||||
import { useExecutionsStore } from '@/stores/executions.store';
|
|
||||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
|
||||||
|
|
||||||
type AddNodeData = Partial<INodeUi> & {
|
type AddNodeData = Partial<INodeUi> & {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -218,6 +220,12 @@ export function useCanvasOperations({
|
|||||||
trackDeleteNode(id);
|
trackDeleteNode(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteNodes(ids: string[]) {
|
||||||
|
historyStore.startRecordingUndo();
|
||||||
|
ids.forEach((id) => deleteNode(id, { trackHistory: true, trackBulk: false }));
|
||||||
|
historyStore.stopRecordingUndo();
|
||||||
|
}
|
||||||
|
|
||||||
function revertDeleteNode(node: INodeUi) {
|
function revertDeleteNode(node: INodeUi) {
|
||||||
workflowsStore.addNode(node);
|
workflowsStore.addNode(node);
|
||||||
}
|
}
|
||||||
@@ -284,16 +292,33 @@ export function useCanvasOperations({
|
|||||||
uiStore.lastSelectedNode = node.name;
|
uiStore.lastSelectedNode = node.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleNodeDisabled(
|
function toggleNodesDisabled(
|
||||||
id: string,
|
ids: string[],
|
||||||
{ trackHistory = true }: { trackHistory?: boolean } = {},
|
{ trackHistory = true }: { trackHistory?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const node = workflowsStore.getNodeById(id);
|
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent);
|
||||||
if (!node) {
|
nodeHelpers.disableNodes(nodes, trackHistory);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeHelpers.disableNodes([node], trackHistory);
|
function toggleNodesPinned(ids: string[], source: PinDataSource) {
|
||||||
|
historyStore.startRecordingUndo();
|
||||||
|
|
||||||
|
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent);
|
||||||
|
const nextStatePinned = nodes.some((node) => !workflowsStore.pinDataByNodeName(node.name));
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const pinnedDataForNode = usePinnedData(node);
|
||||||
|
if (nextStatePinned) {
|
||||||
|
const dataToPin = useDataSchema().getInputDataWithPinned(node);
|
||||||
|
if (dataToPin.length !== 0) {
|
||||||
|
pinnedDataForNode.setData(dataToPin, source);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pinnedDataForNode.unsetData(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
historyStore.stopRecordingUndo();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addNodes(
|
async function addNodes(
|
||||||
@@ -1403,11 +1428,13 @@ export function useCanvasOperations({
|
|||||||
setNodeActive,
|
setNodeActive,
|
||||||
setNodeActiveByName,
|
setNodeActiveByName,
|
||||||
setNodeSelected,
|
setNodeSelected,
|
||||||
|
toggleNodesDisabled,
|
||||||
|
toggleNodesPinned,
|
||||||
setNodeParameters,
|
setNodeParameters,
|
||||||
toggleNodeDisabled,
|
|
||||||
renameNode,
|
renameNode,
|
||||||
revertRenameNode,
|
revertRenameNode,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
|
deleteNodes,
|
||||||
revertDeleteNode,
|
revertDeleteNode,
|
||||||
addConnections,
|
addConnections,
|
||||||
createConnection,
|
createConnection,
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import { computed, ref, watch } from 'vue';
|
|||||||
import { getMousePosition } from '../utils/nodeViewUtils';
|
import { getMousePosition } from '../utils/nodeViewUtils';
|
||||||
import { useI18n } from './useI18n';
|
import { useI18n } from './useI18n';
|
||||||
import { usePinnedData } from './usePinnedData';
|
import { usePinnedData } from './usePinnedData';
|
||||||
|
import { isPresent } from '../utils/typesUtils';
|
||||||
|
|
||||||
export type ContextMenuTarget =
|
export type ContextMenuTarget =
|
||||||
| { source: 'canvas' }
|
| { source: 'canvas'; nodeIds: string[] }
|
||||||
| { source: 'node-right-click'; node: INode }
|
| { source: 'node-right-click'; nodeId: string }
|
||||||
| { source: 'node-button'; node: INode };
|
| { source: 'node-button'; nodeId: string };
|
||||||
export type ContextMenuActionCallback = (action: ContextMenuAction, targets: INode[]) => void;
|
export type ContextMenuActionCallback = (action: ContextMenuAction, nodeIds: string[]) => void;
|
||||||
|
|
||||||
export type ContextMenuAction =
|
export type ContextMenuAction =
|
||||||
| 'open'
|
| 'open'
|
||||||
| 'copy'
|
| 'copy'
|
||||||
@@ -32,7 +34,7 @@ export type ContextMenuAction =
|
|||||||
|
|
||||||
const position = ref<XYPosition>([0, 0]);
|
const position = ref<XYPosition>([0, 0]);
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
const target = ref<ContextMenuTarget>({ source: 'canvas' });
|
const target = ref<ContextMenuTarget>();
|
||||||
const actions = ref<ActionDropdownItem[]>([]);
|
const actions = ref<ActionDropdownItem[]>([]);
|
||||||
const actionCallback = ref<ContextMenuActionCallback>(() => {});
|
const actionCallback = ref<ContextMenuActionCallback>(() => {});
|
||||||
|
|
||||||
@@ -48,22 +50,17 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
|||||||
() => sourceControlStore.preferences.branchReadOnly || uiStore.isReadOnlyView,
|
() => sourceControlStore.preferences.branchReadOnly || uiStore.isReadOnlyView,
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetNodes = computed(() => {
|
const targetNodeIds = computed(() => {
|
||||||
if (!isOpen.value) return [];
|
if (!isOpen.value || !target.value) return [];
|
||||||
const selectedNodes = uiStore.selectedNodes.map((node) =>
|
|
||||||
workflowsStore.getNodeByName(node.name),
|
|
||||||
) as INode[];
|
|
||||||
const currentTarget = target.value;
|
|
||||||
if (currentTarget.source === 'canvas') {
|
|
||||||
return selectedNodes;
|
|
||||||
} else if (currentTarget.source === 'node-right-click') {
|
|
||||||
const isNodeInSelection = selectedNodes.some((node) => node.name === currentTarget.node.name);
|
|
||||||
return isNodeInSelection ? selectedNodes : [currentTarget.node];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [currentTarget.node];
|
const currentTarget = target.value;
|
||||||
|
return currentTarget.source === 'canvas' ? currentTarget.nodeIds : [currentTarget.nodeId];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const targetNodes = computed(() =>
|
||||||
|
targetNodeIds.value.map((nodeId) => workflowsStore.getNodeById(nodeId)).filter(isPresent),
|
||||||
|
);
|
||||||
|
|
||||||
const canAddNodeOfType = (nodeType: INodeTypeDescription) => {
|
const canAddNodeOfType = (nodeType: INodeTypeDescription) => {
|
||||||
const sameTypeNodes = workflowsStore.allNodes.filter((n) => n.type === nodeType.name);
|
const sameTypeNodes = workflowsStore.allNodes.filter((n) => n.type === nodeType.name);
|
||||||
return nodeType.maxNodes === undefined || sameTypeNodes.length < nodeType.maxNodes;
|
return nodeType.maxNodes === undefined || sameTypeNodes.length < nodeType.maxNodes;
|
||||||
@@ -80,17 +77,18 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
|||||||
const hasPinData = (node: INode): boolean => {
|
const hasPinData = (node: INode): boolean => {
|
||||||
return !!workflowsStore.pinDataByNodeName(node.name);
|
return !!workflowsStore.pinDataByNodeName(node.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
target.value = { source: 'canvas' };
|
target.value = undefined;
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
actions.value = [];
|
actions.value = [];
|
||||||
position.value = [0, 0];
|
position.value = [0, 0];
|
||||||
};
|
};
|
||||||
|
|
||||||
const open = (event: MouseEvent, menuTarget: ContextMenuTarget = { source: 'canvas' }) => {
|
const open = (event: MouseEvent, menuTarget: ContextMenuTarget) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
if (isOpen.value && menuTarget.source === target.value.source) {
|
if (isOpen.value && menuTarget.source === target.value?.source) {
|
||||||
// Close context menu, let browser open native context menu
|
// Close context menu, let browser open native context menu
|
||||||
close();
|
close();
|
||||||
return;
|
return;
|
||||||
@@ -225,8 +223,8 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const _dispatchAction = (action: ContextMenuAction) => {
|
const _dispatchAction = (a: ContextMenuAction) => {
|
||||||
actionCallback.value(action, targetNodes.value);
|
actionCallback.value(a, targetNodeIds.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -241,7 +239,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
|||||||
position,
|
position,
|
||||||
target,
|
target,
|
||||||
actions,
|
actions,
|
||||||
targetNodes,
|
targetNodeIds,
|
||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
_dispatchAction,
|
_dispatchAction,
|
||||||
|
|||||||
77
packages/editor-ui/src/composables/useKeybindings.ts
Normal file
77
packages/editor-ui/src/composables/useKeybindings.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useActiveElement, useEventListener } from '@vueuse/core';
|
||||||
|
import { useDeviceSupport } from 'n8n-design-system';
|
||||||
|
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
||||||
|
|
||||||
|
type KeyMap = Record<string, (event: KeyboardEvent) => void>;
|
||||||
|
|
||||||
|
export const useKeybindings = (keymap: MaybeRefOrGetter<KeyMap>) => {
|
||||||
|
const activeElement = useActiveElement();
|
||||||
|
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||||
|
|
||||||
|
const ignoreKeyPresses = computed(() => {
|
||||||
|
if (!activeElement.value) return false;
|
||||||
|
|
||||||
|
const active = activeElement.value;
|
||||||
|
const isInput = ['INPUT', 'TEXTAREA'].includes(active.tagName);
|
||||||
|
const isContentEditable = active.closest('[contenteditable]') !== null;
|
||||||
|
const isIgnoreClass = active.closest('.ignore-key-press') !== null;
|
||||||
|
|
||||||
|
return isInput || isContentEditable || isIgnoreClass;
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedKeymap = computed(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(toValue(keymap))
|
||||||
|
.map(([shortcut, handler]) => {
|
||||||
|
const shortcuts = shortcut.split('|');
|
||||||
|
return shortcuts.map((s) => [normalizeShortcutString(s), handler]);
|
||||||
|
})
|
||||||
|
.flat(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizeShortcutString(shortcut: string) {
|
||||||
|
return shortcut
|
||||||
|
.split(/[+_-]/)
|
||||||
|
.map((key) => key.toLowerCase())
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toShortcutString(event: KeyboardEvent) {
|
||||||
|
const { shiftKey, altKey } = event;
|
||||||
|
const ctrlKey = isCtrlKeyPressed(event);
|
||||||
|
const keys = [event.key];
|
||||||
|
const modifiers: string[] = [];
|
||||||
|
|
||||||
|
if (shiftKey) {
|
||||||
|
modifiers.push('shift');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctrlKey) {
|
||||||
|
modifiers.push('ctrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (altKey) {
|
||||||
|
modifiers.push('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeShortcutString([...modifiers, ...keys].join('+'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (ignoreKeyPresses.value) return;
|
||||||
|
|
||||||
|
const shortcutString = toShortcutString(event);
|
||||||
|
|
||||||
|
const handler = normalizedKeymap.value[shortcutString];
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener(document, 'keydown', onKeyDown);
|
||||||
|
};
|
||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
IWorkflowDataUpdate,
|
IWorkflowDataUpdate,
|
||||||
IWorkflowDb,
|
IWorkflowDb,
|
||||||
IWorkflowTemplate,
|
IWorkflowTemplate,
|
||||||
|
NodeCreatorOpenSource,
|
||||||
ToggleNodeCreatorOptions,
|
ToggleNodeCreatorOptions,
|
||||||
XYPosition,
|
XYPosition,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
NODE_CREATOR_OPEN_SOURCES,
|
NODE_CREATOR_OPEN_SOURCES,
|
||||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||||
START_NODE_TYPE,
|
START_NODE_TYPE,
|
||||||
|
STICKY_NODE_TYPE,
|
||||||
VIEWS,
|
VIEWS,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
@@ -80,6 +82,7 @@ import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
|||||||
import { tryToParseNumber } from '@/utils/typesUtils';
|
import { tryToParseNumber } from '@/utils/typesUtils';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import { createEventBus } from 'n8n-design-system';
|
import { createEventBus } from 'n8n-design-system';
|
||||||
|
import type { PinDataSource } from '@/composables/usePinnedData';
|
||||||
|
|
||||||
const NodeCreation = defineAsyncComponent(
|
const NodeCreation = defineAsyncComponent(
|
||||||
async () => await import('@/components/Node/NodeCreation.vue'),
|
async () => await import('@/components/Node/NodeCreation.vue'),
|
||||||
@@ -133,9 +136,11 @@ const {
|
|||||||
revertRenameNode,
|
revertRenameNode,
|
||||||
setNodeActive,
|
setNodeActive,
|
||||||
setNodeSelected,
|
setNodeSelected,
|
||||||
toggleNodeDisabled,
|
toggleNodesDisabled,
|
||||||
|
toggleNodesPinned,
|
||||||
setNodeParameters,
|
setNodeParameters,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
|
deleteNodes,
|
||||||
revertDeleteNode,
|
revertDeleteNode,
|
||||||
addNodes,
|
addNodes,
|
||||||
createConnection,
|
createConnection,
|
||||||
@@ -438,6 +443,10 @@ function onDeleteNode(id: string) {
|
|||||||
deleteNode(id, { trackHistory: true });
|
deleteNode(id, { trackHistory: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDeleteNodes(ids: string[]) {
|
||||||
|
deleteNodes(ids);
|
||||||
|
}
|
||||||
|
|
||||||
function onRevertDeleteNode({ node }: { node: INodeUi }) {
|
function onRevertDeleteNode({ node }: { node: INodeUi }) {
|
||||||
revertDeleteNode(node);
|
revertDeleteNode(node);
|
||||||
}
|
}
|
||||||
@@ -447,7 +456,15 @@ function onToggleNodeDisabled(id: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleNodeDisabled(id);
|
toggleNodesDisabled([id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleNodesDisabled(ids: string[]) {
|
||||||
|
if (!checkIfEditingIsAllowed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNodesDisabled(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSetNodeActive(id: string) {
|
function onSetNodeActive(id: string) {
|
||||||
@@ -458,12 +475,77 @@ function onSetNodeSelected(id?: string) {
|
|||||||
setNodeSelected(id);
|
setNodeSelected(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onCopyNodes(_ids: string[]) {
|
||||||
|
// @TODO: implement this
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCutNodes(_ids: string[]) {
|
||||||
|
// @TODO: implement this
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDuplicateNodes(_ids: string[]) {
|
||||||
|
// @TODO: implement this
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPinNodes(ids: string[], source: PinDataSource) {
|
||||||
|
if (!checkIfEditingIsAllowed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNodesPinned(ids, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveWorkflow() {
|
||||||
|
await workflowHelpers.saveCurrentWorkflow();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateWorkflow() {
|
||||||
|
await router.push({ name: VIEWS.NEW_WORKFLOW });
|
||||||
|
}
|
||||||
|
|
||||||
function onRenameNode(parameterData: IUpdateInformation) {
|
function onRenameNode(parameterData: IUpdateInformation) {
|
||||||
if (parameterData.name === 'name' && parameterData.oldValue) {
|
if (parameterData.name === 'name' && parameterData.oldValue) {
|
||||||
void renameNode(parameterData.oldValue as string, parameterData.value as string);
|
void renameNode(parameterData.oldValue as string, parameterData.value as string);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onOpenRenameNodeModal(id: string) {
|
||||||
|
const currentName = workflowsStore.getNodeById(id)?.name ?? '';
|
||||||
|
try {
|
||||||
|
const promptResponsePromise = message.prompt(
|
||||||
|
i18n.baseText('nodeView.prompt.newName') + ':',
|
||||||
|
i18n.baseText('nodeView.prompt.renameNode') + `: ${currentName}`,
|
||||||
|
{
|
||||||
|
customClass: 'rename-prompt',
|
||||||
|
confirmButtonText: i18n.baseText('nodeView.prompt.rename'),
|
||||||
|
cancelButtonText: i18n.baseText('nodeView.prompt.cancel'),
|
||||||
|
inputErrorMessage: i18n.baseText('nodeView.prompt.invalidName'),
|
||||||
|
inputValue: currentName,
|
||||||
|
inputValidator: (value: string) => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return i18n.baseText('nodeView.prompt.invalidName');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait till input is displayed
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Focus and select input content
|
||||||
|
const nameInput = document.querySelector<HTMLInputElement>('.rename-prompt .el-input__inner');
|
||||||
|
nameInput?.focus();
|
||||||
|
nameInput?.select();
|
||||||
|
|
||||||
|
const promptResponse = await promptResponsePromise;
|
||||||
|
|
||||||
|
if (promptResponse.action === MODAL_CONFIRM) {
|
||||||
|
await renameNode(currentName, promptResponse.value, { trackHistory: true });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
async function onRevertRenameNode({
|
async function onRevertRenameNode({
|
||||||
currentName,
|
currentName,
|
||||||
newName,
|
newName,
|
||||||
@@ -626,10 +708,18 @@ async function onOpenSelectiveNodeCreator(node: string, connectionType: NodeConn
|
|||||||
nodeCreatorStore.openSelectiveNodeCreator({ node, connectionType });
|
nodeCreatorStore.openSelectiveNodeCreator({ node, connectionType });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onOpenNodeCreatorFromCanvas(source: NodeCreatorOpenSource) {
|
||||||
|
onOpenNodeCreator({ createNodeActive: true, source });
|
||||||
|
}
|
||||||
|
|
||||||
function onOpenNodeCreator(options: ToggleNodeCreatorOptions) {
|
function onOpenNodeCreator(options: ToggleNodeCreatorOptions) {
|
||||||
nodeCreatorStore.openNodeCreator(options);
|
nodeCreatorStore.openNodeCreator(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onCreateSticky() {
|
||||||
|
void onAddNodesAndConnections({ nodes: [{ type: STICKY_NODE_TYPE }], connections: [] });
|
||||||
|
}
|
||||||
|
|
||||||
function onClickConnectionAdd(connection: Connection) {
|
function onClickConnectionAdd(connection: Connection) {
|
||||||
nodeCreatorStore.openNodeCreatorForConnectingNode({
|
nodeCreatorStore.openNodeCreatorForConnectingNode({
|
||||||
connection,
|
connection,
|
||||||
@@ -1156,6 +1246,7 @@ onBeforeUnmount(() => {
|
|||||||
@update:node:active="onSetNodeActive"
|
@update:node:active="onSetNodeActive"
|
||||||
@update:node:selected="onSetNodeSelected"
|
@update:node:selected="onSetNodeSelected"
|
||||||
@update:node:enabled="onToggleNodeDisabled"
|
@update:node:enabled="onToggleNodeDisabled"
|
||||||
|
@update:node:name="onOpenRenameNodeModal"
|
||||||
@update:node:parameters="onUpdateNodeParameters"
|
@update:node:parameters="onUpdateNodeParameters"
|
||||||
@run:node="onRunWorkflowToNode"
|
@run:node="onRunWorkflowToNode"
|
||||||
@delete:node="onDeleteNode"
|
@delete:node="onDeleteNode"
|
||||||
@@ -1164,6 +1255,17 @@ onBeforeUnmount(() => {
|
|||||||
@delete:connection="onDeleteConnection"
|
@delete:connection="onDeleteConnection"
|
||||||
@click:connection:add="onClickConnectionAdd"
|
@click:connection:add="onClickConnectionAdd"
|
||||||
@click:pane="onClickPane"
|
@click:pane="onClickPane"
|
||||||
|
@create:node="onOpenNodeCreatorFromCanvas"
|
||||||
|
@create:sticky="onCreateSticky"
|
||||||
|
@delete:nodes="onDeleteNodes"
|
||||||
|
@update:nodes:enabled="onToggleNodesDisabled"
|
||||||
|
@update:nodes:pin="onPinNodes"
|
||||||
|
@duplicate:nodes="onDuplicateNodes"
|
||||||
|
@copy:nodes="onCopyNodes"
|
||||||
|
@cut:nodes="onCutNodes"
|
||||||
|
@run:workflow="onRunWorkflow"
|
||||||
|
@save:workflow="onSaveWorkflow"
|
||||||
|
@create:workflow="onCreateWorkflow"
|
||||||
>
|
>
|
||||||
<div :class="$style.executionButtons">
|
<div :class="$style.executionButtons">
|
||||||
<CanvasRunWorkflowButton
|
<CanvasRunWorkflowButton
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
@touchmove="canvasPanning.onMouseMove"
|
@touchmove="canvasPanning.onMouseMove"
|
||||||
@mousedown="mouseDown"
|
@mousedown="mouseDown"
|
||||||
@mouseup="mouseUp"
|
@mouseup="mouseUp"
|
||||||
@contextmenu="contextMenu.open"
|
@contextmenu="onContextMenu"
|
||||||
@wheel="canvasStore.wheelScroll"
|
@wheel="canvasStore.wheelScroll"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -374,7 +374,7 @@ import { useDeviceSupport } from 'n8n-design-system';
|
|||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import { useExecutionsStore } from '@/stores/executions.store';
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
import { useCanvasPanning } from '@/composables/useCanvasPanning';
|
import { useCanvasPanning } from '@/composables/useCanvasPanning';
|
||||||
import { tryToParseNumber } from '@/utils/typesUtils';
|
import { isPresent, tryToParseNumber } from '@/utils/typesUtils';
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
@@ -4583,7 +4583,16 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onContextMenuAction(action: ContextMenuAction, nodes: INode[]): void {
|
onContextMenu(event: MouseEvent) {
|
||||||
|
this.contextMenu.open(event, {
|
||||||
|
source: 'canvas',
|
||||||
|
nodeIds: this.uiStore.selectedNodes.map((node) => node.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onContextMenuAction(action: ContextMenuAction, nodeIds: string[]): void {
|
||||||
|
const nodes = nodeIds
|
||||||
|
.map((nodeId) => this.workflowsStore.getNodeById(nodeId))
|
||||||
|
.filter(isPresent);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'copy':
|
case 'copy':
|
||||||
this.copyNodes(nodes);
|
this.copyNodes(nodes);
|
||||||
|
|||||||
Reference in New Issue
Block a user