mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
feat(editor): Add drag and drop from nodes panel (#3123)
* ✨ Added support for drag and drop from nodes main panel. ✨ Added node draggable placeholder. * ✨ Added snapping to grid. Changed how draggable ghost follows the cursor. * 💄 Changed node drag anchor position to be centered. * ✨ Added drag and drop animation. Added event cancellation when dropping node on main panel. * ♻️ Simplified drag and drop code and cleaned up prop-drilling. * 🐛 Added check for nodeTypeName in dataTransfer when draging and dropping nodes. * 🐛 Ensured MS Edge compatibility. MS edge does not send datatransfer in ondragover event. Co-authored-by: Mutasem <mutdmour@gmail.com>
This commit is contained in:
parent
f4e9562451
commit
f566569299
|
@ -5,8 +5,8 @@
|
|||
clickable: props.clickable,
|
||||
active: props.active,
|
||||
}"
|
||||
@click="listeners['click']"
|
||||
>
|
||||
@click="listeners.click"
|
||||
>
|
||||
<CategoryItem
|
||||
v-if="props.item.type === 'category'"
|
||||
:item="props.item"
|
||||
|
@ -21,7 +21,9 @@
|
|||
v-else-if="props.item.type === 'node'"
|
||||
:nodeType="props.item.properties.nodeType"
|
||||
:bordered="!props.lastNode"
|
||||
></NodeItem>
|
||||
@dragstart="listeners.dragstart"
|
||||
@dragend="listeners.dragend"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -54,4 +56,4 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
@ -8,7 +8,12 @@
|
|||
@before-leave="beforeLeave"
|
||||
@leave="leave"
|
||||
>
|
||||
<div v-for="(item, index) in elements" :key="item.key" :class="item.type" :data-key="item.key">
|
||||
<div
|
||||
v-for="(item, index) in elements"
|
||||
:key="item.key"
|
||||
:class="item.type"
|
||||
:data-key="item.key"
|
||||
>
|
||||
<CreatorItem
|
||||
:item="item"
|
||||
:active="activeIndex === index && !disabled"
|
||||
|
@ -16,7 +21,9 @@
|
|||
:lastNode="
|
||||
index === elements.length - 1 || elements[index + 1].type !== 'node'
|
||||
"
|
||||
@click="() => selected(item)"
|
||||
@click="$emit('selected', item)"
|
||||
@dragstart="emit('dragstart', item, $event)"
|
||||
@dragend="emit('dragend', item, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -36,12 +43,12 @@ export default Vue.extend({
|
|||
},
|
||||
props: ['elements', 'activeIndex', 'disabled', 'transitionsEnabled'],
|
||||
methods: {
|
||||
selected(element: INodeCreateElement) {
|
||||
emit(eventName: string, element: INodeCreateElement, event: Event) {
|
||||
if (this.$props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('selected', element);
|
||||
this.$emit(eventName, { element, event });
|
||||
},
|
||||
beforeEnter(el: HTMLElement) {
|
||||
el.style.height = '0';
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
<template>
|
||||
<div @click="onClickInside" class="container">
|
||||
<div
|
||||
class="container"
|
||||
ref="mainPanelContainer"
|
||||
@click="onClickInside"
|
||||
>
|
||||
<SlideTransition>
|
||||
<SubcategoryPanel v-if="activeSubcategory" :elements="subcategorizedNodes" :title="activeSubcategory.properties.subcategory" :activeIndex="activeSubcategoryIndex" @close="onSubcategoryClose" @selected="selected" />
|
||||
<SubcategoryPanel
|
||||
v-if="activeSubcategory"
|
||||
:elements="subcategorizedNodes"
|
||||
:title="activeSubcategory.properties.subcategory"
|
||||
:activeIndex="activeSubcategoryIndex"
|
||||
@close="onSubcategoryClose"
|
||||
@selected="selected"
|
||||
/>
|
||||
</SlideTransition>
|
||||
<div class="main-panel">
|
||||
<SearchBar
|
||||
|
@ -35,7 +46,10 @@
|
|||
@selected="selected"
|
||||
/>
|
||||
</div>
|
||||
<NoResults v-else @nodeTypeSelected="nodeTypeSelected" />
|
||||
<NoResults
|
||||
v-else
|
||||
@nodeTypeSelected="$emit('nodeTypeSelected', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -56,7 +70,6 @@ import { ALL_NODE_FILTER, CORE_NODES_CATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE
|
|||
import SlideTransition from '../transitions/SlideTransition.vue';
|
||||
import { matchesNodeType, matchesSelectType } from './helpers';
|
||||
|
||||
|
||||
export default mixins(externalHooks).extend({
|
||||
name: 'NodeCreateList',
|
||||
components: {
|
||||
|
@ -235,18 +248,13 @@ export default mixins(externalHooks).extend({
|
|||
},
|
||||
selected(element: INodeCreateElement) {
|
||||
if (element.type === 'node') {
|
||||
const properties = element.properties as INodeItemProps;
|
||||
|
||||
this.nodeTypeSelected(properties.nodeType.name);
|
||||
this.$emit('nodeTypeSelected', (element.properties as INodeItemProps).nodeType.name);
|
||||
} else if (element.type === 'category') {
|
||||
this.onCategorySelected(element.category);
|
||||
} else if (element.type === 'subcategory') {
|
||||
this.onSubcategorySelected(element);
|
||||
}
|
||||
},
|
||||
nodeTypeSelected(nodeTypeName: string) {
|
||||
this.$emit('nodeTypeSelected', nodeTypeName);
|
||||
},
|
||||
onCategorySelected(category: string) {
|
||||
if (this.activeCategory.includes(category)) {
|
||||
this.activeCategory = this.activeCategory.filter(
|
||||
|
|
|
@ -1,8 +1,20 @@
|
|||
<template>
|
||||
<div>
|
||||
<SlideTransition>
|
||||
<div class="node-creator" v-if="active" v-click-outside="onClickOutside">
|
||||
<MainPanel @nodeTypeSelected="nodeTypeSelected" :categorizedItems="categorizedItems" :categoriesWithNodes="categoriesWithNodes" :searchItems="searchItems"></MainPanel>
|
||||
<div
|
||||
v-if="active"
|
||||
class="node-creator"
|
||||
ref="nodeCreator"
|
||||
v-click-outside="onClickOutside"
|
||||
@dragover="onDragOver"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<MainPanel
|
||||
@nodeTypeSelected="nodeTypeSelected"
|
||||
:categorizedItems="categorizedItems"
|
||||
:categoriesWithNodes="categoriesWithNodes"
|
||||
:searchItems="searchItems"
|
||||
/>
|
||||
</div>
|
||||
</SlideTransition>
|
||||
</div>
|
||||
|
@ -94,6 +106,22 @@ export default Vue.extend({
|
|||
nodeTypeSelected (nodeTypeName: string) {
|
||||
this.$emit('nodeTypeSelected', nodeTypeName);
|
||||
},
|
||||
onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
},
|
||||
onDrop(event: DragEvent) {
|
||||
if (!event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
|
||||
const nodeCreatorBoundingRect = (this.$refs.nodeCreator as Element).getBoundingClientRect();
|
||||
|
||||
// Abort drag end event propagation if dropped inside nodes panel
|
||||
if (nodeTypeName && event.pageX >= nodeCreatorBoundingRect.x && event.pageY >= nodeCreatorBoundingRect.y) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
nodeTypes(newList) {
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
<template>
|
||||
<div :class="{[$style['node-item']]: true, [$style.bordered]: bordered}">
|
||||
<div
|
||||
draggable
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
:class="{[$style['node-item']]: true, [$style.bordered]: bordered}"
|
||||
>
|
||||
<NodeIcon :class="$style['node-icon']" :nodeType="nodeType" />
|
||||
<div>
|
||||
<div :class="$style.details">
|
||||
|
@ -11,7 +16,7 @@
|
|||
}}
|
||||
</span>
|
||||
<span :class="$style['trigger-icon']">
|
||||
<TriggerIcon v-if="$options.isTrigger(nodeType)" />
|
||||
<TriggerIcon v-if="isTrigger" />
|
||||
</span>
|
||||
</div>
|
||||
<div :class="$style.description">
|
||||
|
@ -21,14 +26,26 @@
|
|||
})
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div :class="$style['draggable-data-transfer']" ref="draggableDataTransfer" />
|
||||
<transition name="node-item-transition">
|
||||
<div
|
||||
:class="$style.draggable"
|
||||
:style="draggableStyle"
|
||||
ref="draggable"
|
||||
v-show="dragging"
|
||||
>
|
||||
<NodeIcon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import {getNewNodePosition, NODE_SIZE} from '@/views/canvasHelpers';
|
||||
import Vue from 'vue';
|
||||
import { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import NodeIcon from '../NodeIcon.vue';
|
||||
import TriggerIcon from '../TriggerIcon.vue';
|
||||
|
@ -44,14 +61,73 @@ export default Vue.extend({
|
|||
'nodeType',
|
||||
'bordered',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
dragging: false,
|
||||
draggablePosition: {
|
||||
x: -100,
|
||||
y: -100,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
shortNodeType() {
|
||||
shortNodeType(): string {
|
||||
return this.$locale.shortNodeType(this.nodeType.name);
|
||||
},
|
||||
isTrigger (): boolean {
|
||||
return this.nodeType.group.includes('trigger');
|
||||
},
|
||||
draggableStyle(): { top: string; left: string; } {
|
||||
return {
|
||||
top: `${this.draggablePosition.y}px`,
|
||||
left: `${this.draggablePosition.x}px`,
|
||||
};
|
||||
},
|
||||
},
|
||||
// @ts-ignore
|
||||
isTrigger (nodeType: INodeTypeDescription): boolean {
|
||||
return nodeType.group.includes('trigger');
|
||||
mounted() {
|
||||
/**
|
||||
* Workaround for firefox, that doesn't attach the pageX and pageY coordinates to "ondrag" event.
|
||||
* All browsers attach the correct page coordinates to the "dragover" event.
|
||||
* @bug https://bugzilla.mozilla.org/show_bug.cgi?id=505521
|
||||
*/
|
||||
document.body.addEventListener("dragover", this.onDragOver);
|
||||
},
|
||||
destroyed() {
|
||||
document.body.removeEventListener("dragover", this.onDragOver);
|
||||
},
|
||||
methods: {
|
||||
onDragStart(event: DragEvent): void {
|
||||
const { pageX: x, pageY: y } = event;
|
||||
|
||||
this.$emit('dragstart', event);
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
event.dataTransfer.setData('nodeTypeName', this.nodeType.name);
|
||||
event.dataTransfer.setDragImage(this.$refs.draggableDataTransfer as Element, 0, 0);
|
||||
}
|
||||
|
||||
this.dragging = true;
|
||||
this.draggablePosition = { x, y };
|
||||
},
|
||||
onDragOver(event: DragEvent): void {
|
||||
if (!this.dragging || event.pageX === 0 && event.pageY === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [x,y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
|
||||
|
||||
this.draggablePosition = { x, y };
|
||||
},
|
||||
onDragEnd(event: DragEvent): void {
|
||||
this.$emit('dragend', event);
|
||||
|
||||
this.dragging = false;
|
||||
setTimeout(() => {
|
||||
this.draggablePosition = { x: -100, y: -100 };
|
||||
}, 300);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -100,4 +176,39 @@ export default Vue.extend({
|
|||
display: flex;
|
||||
}
|
||||
|
||||
.draggable {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
opacity: 0.66;
|
||||
border: 2px solid var(--color-foreground-xdark);
|
||||
border-radius: var(--border-radius-large);
|
||||
background-color: var(--color-background-xlight);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.draggable-data-transfer {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node-item-transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition-property: opacity, transform;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
&-enter,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -13,7 +13,9 @@
|
|||
<ItemIterator
|
||||
:elements="elements"
|
||||
:activeIndex="activeIndex"
|
||||
@selected="selected"
|
||||
@selected="$emit('selected', $event)"
|
||||
@dragstart="$emit('dragstart', $event)"
|
||||
@dragend="$emit('dragend', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -38,9 +40,6 @@ export default Vue.extend({
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
selected(element: INodeCreateElement) {
|
||||
this.$emit('selected', element);
|
||||
},
|
||||
onBackArrowClick() {
|
||||
this.$emit('close');
|
||||
},
|
||||
|
@ -101,4 +100,4 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<div class="node-view-root">
|
||||
<div
|
||||
class="node-view-root"
|
||||
@dragover="onDragOver"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div
|
||||
class="node-view-wrapper"
|
||||
:class="workflowClasses"
|
||||
|
@ -10,27 +14,31 @@
|
|||
v-touch:tap="touchTap"
|
||||
@mouseup="mouseUp"
|
||||
@wheel="wheelScroll"
|
||||
>
|
||||
<div id="node-view-background" class="node-view-background" :style="backgroundStyle" />
|
||||
<div
|
||||
id="node-view"
|
||||
class="node-view"
|
||||
:style="workflowStyle"
|
||||
>
|
||||
<div id="node-view-background" class="node-view-background" :style="backgroundStyle"></div>
|
||||
<div id="node-view" class="node-view" :style="workflowStyle">
|
||||
<node
|
||||
v-for="nodeData in nodes"
|
||||
@duplicateNode="duplicateNode"
|
||||
@deselectAllNodes="deselectAllNodes"
|
||||
@deselectNode="nodeDeselectedByName"
|
||||
@nodeSelected="nodeSelectedByName"
|
||||
@removeNode="removeNode"
|
||||
@runWorkflow="runWorkflow"
|
||||
@moved="onNodeMoved"
|
||||
@run="onNodeRun"
|
||||
:id="'node-' + getNodeIndex(nodeData.name)"
|
||||
:key="getNodeIndex(nodeData.name)"
|
||||
:name="nodeData.name"
|
||||
:isReadOnly="isReadOnly"
|
||||
:instance="instance"
|
||||
:isActive="!!activeNode && activeNode.name === nodeData.name"
|
||||
:hideActions="pullConnActive"
|
||||
></node>
|
||||
v-for="nodeData in nodes"
|
||||
@duplicateNode="duplicateNode"
|
||||
@deselectAllNodes="deselectAllNodes"
|
||||
@deselectNode="nodeDeselectedByName"
|
||||
@nodeSelected="nodeSelectedByName"
|
||||
@removeNode="removeNode"
|
||||
@runWorkflow="runWorkflow"
|
||||
@moved="onNodeMoved"
|
||||
@run="onNodeRun"
|
||||
:id="'node-' + getNodeIndex(nodeData.name)"
|
||||
:key="getNodeIndex(nodeData.name)"
|
||||
:name="nodeData.name"
|
||||
:isReadOnly="isReadOnly"
|
||||
:instance="instance"
|
||||
:isActive="!!activeNode && activeNode.name === nodeData.name"
|
||||
:hideActions="pullConnActive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DataDisplay :renaming="renamingActive" @valueChanged="valueChanged"/>
|
||||
|
@ -41,7 +49,7 @@
|
|||
:active="createNodeActive"
|
||||
@nodeTypeSelected="nodeTypeSelected"
|
||||
@closeNodeCreator="closeNodeCreator"
|
||||
></node-creator>
|
||||
/>
|
||||
<div :class="{ 'zoom-menu': true, 'regular-zoom-menu': !isDemo, 'demo-zoom-menu': isDemo, expanded: !sidebarMenuCollapsed }">
|
||||
<button @click="zoomToFit" class="button-white" :title="$locale.baseText('nodeView.zoomToFit')">
|
||||
<font-awesome-icon icon="expand"/>
|
||||
|
@ -177,6 +185,11 @@ import {
|
|||
import '../plugins/N8nCustomConnectorType';
|
||||
import '../plugins/PlusEndpointType';
|
||||
|
||||
interface AddNodeOptions {
|
||||
position?: XYPosition;
|
||||
dragAndDrop?: boolean;
|
||||
}
|
||||
|
||||
export default mixins(
|
||||
copyPaste,
|
||||
externalHooks,
|
||||
|
@ -1227,6 +1240,27 @@ export default mixins(
|
|||
this.createNodeActive = false;
|
||||
},
|
||||
|
||||
onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
},
|
||||
|
||||
onDrop(event: DragEvent) {
|
||||
if (!event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
|
||||
if (nodeTypeName) {
|
||||
const mousePosition = this.getMousePositionWithinNodeView(event);
|
||||
|
||||
this.addNodeButton(nodeTypeName, {
|
||||
position: [mousePosition[0] - CanvasHelpers.NODE_SIZE / 2, mousePosition[1] - CanvasHelpers.NODE_SIZE / 2],
|
||||
dragAndDrop: true,
|
||||
});
|
||||
this.createNodeActive = false;
|
||||
}
|
||||
},
|
||||
|
||||
nodeDeselectedByName (nodeName: string) {
|
||||
const node = this.$store.getters.getNodeByName(nodeName);
|
||||
if (node) {
|
||||
|
@ -1267,7 +1301,7 @@ export default mixins(
|
|||
duration: 0,
|
||||
});
|
||||
},
|
||||
async injectNode (nodeTypeName: string) {
|
||||
async injectNode (nodeTypeName: string, options: AddNodeOptions = {}) {
|
||||
const nodeTypeData: INodeTypeDescription | null = this.$store.getters.nodeType(nodeTypeName);
|
||||
|
||||
if (nodeTypeData === null) {
|
||||
|
@ -1297,7 +1331,10 @@ export default mixins(
|
|||
|
||||
// when pulling new connection from node or injecting into a connection
|
||||
const lastSelectedNode = this.lastSelectedNode;
|
||||
if (lastSelectedNode) {
|
||||
|
||||
if (options.position) {
|
||||
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, options.position);
|
||||
} else if (lastSelectedNode) {
|
||||
const lastSelectedConnection = this.lastSelectedConnection;
|
||||
if (lastSelectedConnection) { // set when injecting into a connection
|
||||
const [diffX] = CanvasHelpers.getConnectorLengths(lastSelectedConnection);
|
||||
|
@ -1308,10 +1345,12 @@ export default mixins(
|
|||
|
||||
// set when pulling connections
|
||||
if (this.newNodeInsertPosition) {
|
||||
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, [this.newNodeInsertPosition[0] + CanvasHelpers.GRID_SIZE, this.newNodeInsertPosition[1] - CanvasHelpers.NODE_SIZE / 2]);
|
||||
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, [
|
||||
this.newNodeInsertPosition[0] + CanvasHelpers.GRID_SIZE,
|
||||
this.newNodeInsertPosition[1] - CanvasHelpers.NODE_SIZE / 2,
|
||||
]);
|
||||
this.newNodeInsertPosition = null;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
let yOffset = 0;
|
||||
|
||||
if (lastSelectedConnection) {
|
||||
|
@ -1353,7 +1392,11 @@ export default mixins(
|
|||
this.$store.commit('setStateDirty', true);
|
||||
|
||||
this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName });
|
||||
this.$telemetry.trackNodesPanel('nodeView.addNodeButton', { node_type: nodeTypeName, workflow_id: this.$store.getters.workflowId });
|
||||
this.$telemetry.trackNodesPanel('nodeView.addNodeButton', {
|
||||
node_type: nodeTypeName,
|
||||
workflow_id: this.$store.getters.workflowId,
|
||||
drag_and_drop: options.dragAndDrop,
|
||||
} as IDataObject);
|
||||
|
||||
// Automatically deselect all nodes and select the current one and also active
|
||||
// current node
|
||||
|
@ -1396,7 +1439,7 @@ export default mixins(
|
|||
|
||||
this.__addConnection(connectionData, true);
|
||||
},
|
||||
async addNodeButton (nodeTypeName: string) {
|
||||
async addNodeButton (nodeTypeName: string, options: AddNodeOptions = {}) {
|
||||
if (this.editAllowedCheck() === false) {
|
||||
return;
|
||||
}
|
||||
|
@ -1405,7 +1448,7 @@ export default mixins(
|
|||
const lastSelectedNode = this.lastSelectedNode;
|
||||
const lastSelectedNodeOutputIndex = this.$store.getters.lastSelectedNodeOutputIndex;
|
||||
|
||||
const newNodeData = await this.injectNode(nodeTypeName);
|
||||
const newNodeData = await this.injectNode(nodeTypeName, options);
|
||||
if (!newNodeData) {
|
||||
return;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue