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