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

This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user