feat(editor): Add node context menu (#7620)

![image](https://github.com/n8n-io/n8n/assets/8850410/5a601fae-cb8e-41bb-beca-ac9ab7065b75)
This commit is contained in:
Elias Meire
2023-11-20 14:37:12 +01:00
committed by GitHub
parent 4dbae0e2e9
commit 8d12c1ad8d
46 changed files with 1612 additions and 373 deletions

View File

@@ -6,45 +6,62 @@
[$style.demoZoomMenu]: isDemo,
}"
>
<n8n-icon-button
@click="zoomToFit"
type="tertiary"
size="large"
:title="$locale.baseText('nodeView.zoomToFit')"
icon="expand"
data-test-id="zoom-to-fit"
/>
<n8n-icon-button
@click="zoomIn"
type="tertiary"
size="large"
:title="$locale.baseText('nodeView.zoomIn')"
icon="search-plus"
data-test-id="zoom-in-button"
/>
<n8n-icon-button
@click="zoomOut"
type="tertiary"
size="large"
:title="$locale.baseText('nodeView.zoomOut')"
icon="search-minus"
data-test-id="zoom-out-button"
/>
<n8n-icon-button
v-if="nodeViewScale !== 1 && !isDemo"
@click="resetZoom"
type="tertiary"
size="large"
:title="$locale.baseText('nodeView.resetZoom')"
icon="undo"
data-test-id="reset-zoom-button"
/>
<keyboard-shortcut-tooltip
:label="$locale.baseText('nodeView.zoomToFit')"
:shortcut="{ keys: ['1'] }"
>
<n8n-icon-button
@click="zoomToFit"
type="tertiary"
size="large"
icon="expand"
data-test-id="zoom-to-fit"
/>
</keyboard-shortcut-tooltip>
<keyboard-shortcut-tooltip
:label="$locale.baseText('nodeView.zoomIn')"
:shortcut="{ keys: ['+'] }"
>
<n8n-icon-button
@click="zoomIn"
type="tertiary"
size="large"
icon="search-plus"
data-test-id="zoom-in-button"
/>
</keyboard-shortcut-tooltip>
<keyboard-shortcut-tooltip
:label="$locale.baseText('nodeView.zoomOut')"
:shortcut="{ keys: ['-'] }"
>
<n8n-icon-button
@click="zoomOut"
type="tertiary"
size="large"
icon="search-minus"
data-test-id="zoom-out-button"
/>
</keyboard-shortcut-tooltip>
<keyboard-shortcut-tooltip
:label="$locale.baseText('nodeView.resetZoom')"
:shortcut="{ keys: ['0'] }"
>
<n8n-icon-button
v-if="nodeViewScale !== 1 && !isDemo"
@click="resetZoom"
type="tertiary"
size="large"
icon="undo"
data-test-id="reset-zoom-button"
/>
</keyboard-shortcut-tooltip>
</div>
</template>
<script lang="ts" setup>
import { onBeforeMount, onBeforeUnmount } from 'vue';
import { storeToRefs } from 'pinia';
import { useCanvasStore } from '@/stores/canvas.store';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
const canvasStore = useCanvasStore();
const { zoomToFit, zoomIn, zoomOut, resetZoom } = canvasStore;

View File

@@ -0,0 +1,63 @@
<script lang="ts" setup>
import { type ContextMenuAction, useContextMenu } from '@/composables';
import { N8nActionDropdown } from 'n8n-design-system';
import type { INode } from 'n8n-workflow';
import { watch, ref } from 'vue';
const { isOpen, actions, position, targetNodes, target, close } = useContextMenu();
const contextMenu = ref<InstanceType<typeof N8nActionDropdown>>();
const emit = defineEmits<{ (event: 'action', action: ContextMenuAction, nodes: INode[]): void }>();
watch(
isOpen,
() => {
if (isOpen) {
contextMenu.value?.open();
} else {
contextMenu.value?.close();
}
},
{ flush: 'post' },
);
function onActionSelect(item: string) {
emit('action', item as ContextMenuAction, targetNodes.value);
}
function onVisibleChange(open: boolean) {
if (!open) {
close();
}
}
</script>
<template>
<Teleport v-if="isOpen" to="body">
<div :class="$style.contextMenu" :style="{ top: `${position[1]}px`, left: `${position[0]}px` }">
<n8n-action-dropdown
ref="contextMenu"
:items="actions"
placement="bottom-start"
data-test-id="context-menu"
:hideArrow="target.source !== 'node-button'"
@select="onActionSelect"
@visibleChange="onVisibleChange"
>
<template #activator>
<div :class="$style.activator"></div>
</template>
</n8n-action-dropdown>
</div>
</Teleport>
</template>
<style module lang="scss">
.contextMenu {
position: fixed;
}
.activator {
pointer-events: none;
opacity: 0;
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { Placement } from 'element-plus';
import type { KeyboardShortcut } from 'n8n-design-system/src/components/N8nKeyboardShortcut';
interface Props {
label: string;
shortcut: KeyboardShortcut;
placement?: Placement;
}
withDefaults(defineProps<Props>(), { placement: 'top' });
</script>
<template>
<n8n-tooltip :placement="placement" :show-after="500">
<template #content>
<div :class="$style.shortcut">
<div :class="$style.label">{{ label }}</div>
<n8n-keyboard-shortcut v-bind="shortcut"></n8n-keyboard-shortcut>
</div>
</template>
<slot />
</n8n-tooltip>
</template>
<style lang="scss" module>
.shortcut {
display: flex;
align-items: center;
font-size: var(--font-size-2xs);
gap: var(--spacing-2xs);
}
.label {
flex-shrink: 0;
}
</style>

View File

@@ -6,6 +6,7 @@
data-test-id="canvas-node"
:ref="data.name"
:data-name="data.name"
@contextmenu="(e: MouseEvent) => openContextMenu(e, 'node-right-click')"
>
<div class="select-background" v-show="isSelected"></div>
<div
@@ -13,6 +14,7 @@
'node-default': true,
'touch-active': isTouchActive,
'is-touch-device': isTouchDevice,
'menu-open': isContextMenuOpen,
}"
>
<div
@@ -35,7 +37,7 @@
:class="{ 'node-info-icon': true, 'shift-icon': shiftOutputCount }"
>
<div v-if="hasIssues" class="node-issues" data-test-id="node-issues">
<n8n-tooltip placement="bottom">
<n8n-tooltip :show-after="500" placement="bottom">
<template #content>
<titled-list :title="`${$locale.baseText('node.issues')}:`" :items="nodeIssues" />
</template>
@@ -70,6 +72,7 @@
<div class="node-trigger-tooltip__wrapper">
<n8n-tooltip
placement="top"
:show-after="500"
:visible="showTriggerNodeTooltip"
popper-class="node-trigger-tooltip__wrapper--item"
>
@@ -102,48 +105,22 @@
</div>
<div class="node-options no-select-on-click" v-if="!isReadOnly" v-show="!hideActions">
<div
v-touch:tap="deleteNode"
class="option"
:title="$locale.baseText('node.deleteNode')"
data-test-id="delete-node-button"
>
<font-awesome-icon icon="trash" />
</div>
<div
v-touch:tap="disableNode"
class="option"
:title="$locale.baseText('node.activateDeactivateNode')"
data-test-id="disable-node-button"
>
<font-awesome-icon :icon="nodeDisabledIcon" />
</div>
<div
v-touch:tap="duplicateNode"
class="option"
:title="$locale.baseText('node.duplicateNode')"
v-if="isDuplicatable"
data-test-id="duplicate-node-button"
>
<font-awesome-icon icon="clone" />
</div>
<div
v-touch:tap="setNodeActive"
class="option touch"
:title="$locale.baseText('node.editNode')"
data-test-id="activate-node-button"
>
<font-awesome-icon class="execute-icon" icon="cog" />
</div>
<div
v-touch:tap="executeNode"
class="option"
:title="$locale.baseText('node.executeNode')"
v-if="!workflowRunning && !isConfigNode"
<n8n-icon-button
data-test-id="execute-node-button"
>
<font-awesome-icon class="execute-icon" icon="play-circle" />
</div>
type="tertiary"
text
icon="play"
:disabled="workflowRunning || isConfigNode"
:title="$locale.baseText('node.executeNode')"
@click="executeNode"
/>
<n8n-icon-button
data-test-id="overflow-node-button"
type="tertiary"
text
icon="ellipsis-h"
@click="(e: MouseEvent) => openContextMenu(e, 'node-button')"
/>
</div>
<div
:class="{
@@ -208,9 +185,14 @@ import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { EnableNodeToggleCommand } from '@/models/history';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { type ContextMenuTarget, useContextMenu } from '@/composables';
export default defineComponent({
name: 'Node',
setup() {
const contextMenu = useContextMenu();
return { contextMenu };
},
mixins: [externalHooks, nodeBase, nodeHelpers, workflowHelpers, pinData, debounceHelper],
components: {
TitledList,
@@ -542,6 +524,13 @@ export default defineComponent({
!this.dragging
);
},
isContextMenuOpen(): boolean {
return (
this.contextMenu.isOpen.value &&
this.contextMenu.target.value.source === 'node-button' &&
this.contextMenu.target.value.node.name === this.data?.name
);
},
},
watch: {
isActive(newValue, oldValue) {
@@ -667,27 +656,6 @@ export default defineComponent({
workflow_id: this.workflowsStore.workflowId,
});
},
async deleteNode() {
this.$telemetry.track('User clicked node hover button', {
node_type: this.data.type,
button_name: 'delete',
workflow_id: this.workflowsStore.workflowId,
});
// Wait a tick else vue causes problems because the data is gone
await this.$nextTick();
this.$emit('removeNode', this.data.name);
},
async duplicateNode() {
this.$telemetry.track('User clicked node hover button', {
node_type: this.data.type,
button_name: 'duplicate',
workflow_id: this.workflowsStore.workflowId,
});
// Wait a tick else vue causes problems because the data is gone
await this.$nextTick();
this.$emit('duplicateNode', this.data.name);
},
onClick(event: MouseEvent) {
void this.callDebounced('onClickDebounced', { debounceTime: 50, trailing: true }, event);
@@ -714,11 +682,20 @@ export default defineComponent({
}, 2000);
}
},
openContextMenu(event: MouseEvent, source: ContextMenuTarget['source']) {
if (this.data) {
this.contextMenu.open(event, { source, node: this.data });
}
},
},
});
</script>
<style lang="scss" scoped>
.context-menu {
position: absolute;
}
.node-wrapper {
--node-width: 100px;
/*
@@ -792,13 +769,11 @@ export default defineComponent({
}
&.touch-active,
&:hover {
.node-execute {
display: initial;
}
&:hover,
&.menu-open {
.node-options {
display: initial;
pointer-events: all;
opacity: 1;
}
}
@@ -860,19 +835,27 @@ export default defineComponent({
}
.node-options {
display: none;
--node-options-height: 26px;
:deep(.button) {
--button-font-color: var(--color-text-light);
}
position: absolute;
top: -25px;
left: -10px;
width: calc(var(--node-width) + 20px);
height: 26px;
font-size: 0.9em;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2xs);
transition: opacity 100ms ease-in;
opacity: 0;
pointer-events: none;
top: calc(-1 * (var(--node-options-height) + var(--spacing-4xs)));
left: 0;
width: var(--node-width);
height: var(--node-options-height);
font-size: var(--font-size-s);
z-index: 10;
color: #aaa;
text-align: center;
.option {
width: 28px;
display: inline-block;
&.touch {
@@ -885,8 +868,7 @@ export default defineComponent({
.execute-icon {
position: relative;
top: 2px;
font-size: 1.2em;
font-size: var(----font-size-xl);
}
}

View File

@@ -10,6 +10,7 @@ import {
import { useUIStore } from '@/stores/ui.store';
import type { AddedNodesAndConnections, ToggleNodeCreatorOptions } from '@/Interface';
import { useActions } from './NodeCreator/composables/useActions';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
type Props = {
nodeViewScale: number;
@@ -105,24 +106,31 @@ function nodeTypeSelected(nodeTypes: string[]) {
@mouseenter="onCreateMenuHoverIn"
>
<div :class="$style.nodeCreatorButton" data-test-id="node-creator-plus-button">
<n8n-icon-button
size="xlarge"
icon="plus"
type="tertiary"
:class="$style.nodeCreatorPlus"
@click="openNodeCreator"
:title="$locale.baseText('nodeView.addNode')"
/>
<keyboard-shortcut-tooltip
:label="$locale.baseText('nodeView.openNodesPanel')"
:shortcut="{ keys: ['Tab'] }"
placement="left"
>
<n8n-icon-button
size="xlarge"
icon="plus"
type="tertiary"
:class="$style.nodeCreatorPlus"
@click="openNodeCreator"
/>
</keyboard-shortcut-tooltip>
<div
:class="[$style.addStickyButton, state.showStickyButton ? $style.visibleButton : '']"
@click="addStickyNote"
data-test-id="add-sticky-button"
>
<n8n-icon-button
type="tertiary"
:icon="['far', 'note-sticky']"
:title="$locale.baseText('nodeView.addSticky')"
/>
<keyboard-shortcut-tooltip
:label="$locale.baseText('nodeView.addStickyHint')"
:shortcut="{ keys: ['s'], shiftKey: true }"
placement="left"
>
<n8n-icon-button type="tertiary" :icon="['far', 'note-sticky']" />
</keyboard-shortcut-tooltip>
</div>
</div>
</div>

View File

@@ -170,7 +170,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import useDeviceSupport from '@/composables/useDeviceSupport';
import { useDeviceSupport } from 'n8n-design-system';
import { useMessage } from '@/composables';
export default defineComponent({

View File

@@ -1,22 +1,32 @@
<template>
<span :class="$style.container" data-test-id="save-button">
<span :class="$style.saved" v-if="saved">{{ $locale.baseText('saveButton.saved') }}</span>
<n8n-button
<keyboard-shortcut-tooltip
:label="$locale.baseText('saveButton.hint')"
:shortcut="{ keys: ['s'], metaKey: true }"
placement="bottom"
v-else
:label="saveButtonLabel"
:loading="isSaving"
:disabled="disabled"
:class="$style.button"
:type="type"
/>
>
<n8n-button
:label="saveButtonLabel"
:loading="isSaving"
:disabled="disabled"
:class="$style.button"
:type="type"
/>
</keyboard-shortcut-tooltip>
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
export default defineComponent({
name: 'SaveButton',
components: {
KeyboardShortcutTooltip,
},
props: {
saved: {
type: Boolean,

View File

@@ -19,6 +19,7 @@
<div
class="sticky-box"
@click.left="mouseLeftClick"
@contextmenu="onContextMenu"
v-touch:start="touchStart"
v-touch:end="touchEnd"
>
@@ -120,11 +121,15 @@ import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useContextMenu } from '@/composables';
export default defineComponent({
name: 'Sticky',
mixins: [externalHooks, nodeBase, nodeHelpers, workflowHelpers],
setup() {
const contextMenu = useContextMenu();
return { contextMenu };
},
props: {
nodeViewScale: {
type: Number,
@@ -310,6 +315,11 @@ export default defineComponent({
}, 2000);
}
},
onContextMenu(e: MouseEvent): void {
if (this.node) {
this.contextMenu.open(e, { source: 'node-right-click', node: this.node });
}
},
},
});
</script>