feat(editor): Add context menu to canvas v2 (no-changelog) (#10088)

This commit is contained in:
Elias Meire
2024-07-18 13:00:54 +02:00
committed by GitHub
parent 45affe5d89
commit 5b440a7679
16 changed files with 573 additions and 168 deletions

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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