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:
Alex Grozav 2022-04-19 13:28:31 +03:00 committed by GitHub
parent f4e9562451
commit f566569299
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 259 additions and 61 deletions

View file

@ -5,8 +5,8 @@
clickable: props.clickable, clickable: props.clickable,
active: props.active, active: props.active,
}" }"
@click="listeners['click']" @click="listeners.click"
> >
<CategoryItem <CategoryItem
v-if="props.item.type === 'category'" v-if="props.item.type === 'category'"
:item="props.item" :item="props.item"
@ -21,7 +21,9 @@
v-else-if="props.item.type === 'node'" v-else-if="props.item.type === 'node'"
:nodeType="props.item.properties.nodeType" :nodeType="props.item.properties.nodeType"
:bordered="!props.lastNode" :bordered="!props.lastNode"
></NodeItem> @dragstart="listeners.dragstart"
@dragend="listeners.dragend"
/>
</div> </div>
</template> </template>
@ -54,4 +56,4 @@ export default {
} }
} }
</style> </style>

View file

@ -8,7 +8,12 @@
@before-leave="beforeLeave" @before-leave="beforeLeave"
@leave="leave" @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 <CreatorItem
:item="item" :item="item"
:active="activeIndex === index && !disabled" :active="activeIndex === index && !disabled"
@ -16,7 +21,9 @@
:lastNode=" :lastNode="
index === elements.length - 1 || elements[index + 1].type !== 'node' 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>
</div> </div>
@ -36,12 +43,12 @@ export default Vue.extend({
}, },
props: ['elements', 'activeIndex', 'disabled', 'transitionsEnabled'], props: ['elements', 'activeIndex', 'disabled', 'transitionsEnabled'],
methods: { methods: {
selected(element: INodeCreateElement) { emit(eventName: string, element: INodeCreateElement, event: Event) {
if (this.$props.disabled) { if (this.$props.disabled) {
return; return;
} }
this.$emit('selected', element); this.$emit(eventName, { element, event });
}, },
beforeEnter(el: HTMLElement) { beforeEnter(el: HTMLElement) {
el.style.height = '0'; el.style.height = '0';

View file

@ -1,7 +1,18 @@
<template> <template>
<div @click="onClickInside" class="container"> <div
class="container"
ref="mainPanelContainer"
@click="onClickInside"
>
<SlideTransition> <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> </SlideTransition>
<div class="main-panel"> <div class="main-panel">
<SearchBar <SearchBar
@ -35,7 +46,10 @@
@selected="selected" @selected="selected"
/> />
</div> </div>
<NoResults v-else @nodeTypeSelected="nodeTypeSelected" /> <NoResults
v-else
@nodeTypeSelected="$emit('nodeTypeSelected', $event)"
/>
</div> </div>
</div> </div>
</template> </template>
@ -56,7 +70,6 @@ import { ALL_NODE_FILTER, CORE_NODES_CATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE
import SlideTransition from '../transitions/SlideTransition.vue'; import SlideTransition from '../transitions/SlideTransition.vue';
import { matchesNodeType, matchesSelectType } from './helpers'; import { matchesNodeType, matchesSelectType } from './helpers';
export default mixins(externalHooks).extend({ export default mixins(externalHooks).extend({
name: 'NodeCreateList', name: 'NodeCreateList',
components: { components: {
@ -235,18 +248,13 @@ export default mixins(externalHooks).extend({
}, },
selected(element: INodeCreateElement) { selected(element: INodeCreateElement) {
if (element.type === 'node') { if (element.type === 'node') {
const properties = element.properties as INodeItemProps; this.$emit('nodeTypeSelected', (element.properties as INodeItemProps).nodeType.name);
this.nodeTypeSelected(properties.nodeType.name);
} else if (element.type === 'category') { } else if (element.type === 'category') {
this.onCategorySelected(element.category); this.onCategorySelected(element.category);
} else if (element.type === 'subcategory') { } else if (element.type === 'subcategory') {
this.onSubcategorySelected(element); this.onSubcategorySelected(element);
} }
}, },
nodeTypeSelected(nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
onCategorySelected(category: string) { onCategorySelected(category: string) {
if (this.activeCategory.includes(category)) { if (this.activeCategory.includes(category)) {
this.activeCategory = this.activeCategory.filter( this.activeCategory = this.activeCategory.filter(

View file

@ -1,8 +1,20 @@
<template> <template>
<div> <div>
<SlideTransition> <SlideTransition>
<div class="node-creator" v-if="active" v-click-outside="onClickOutside"> <div
<MainPanel @nodeTypeSelected="nodeTypeSelected" :categorizedItems="categorizedItems" :categoriesWithNodes="categoriesWithNodes" :searchItems="searchItems"></MainPanel> 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> </div>
</SlideTransition> </SlideTransition>
</div> </div>
@ -94,6 +106,22 @@ export default Vue.extend({
nodeTypeSelected (nodeTypeName: string) { nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName); 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: { watch: {
nodeTypes(newList) { nodeTypes(newList) {

View file

@ -1,5 +1,10 @@
<template> <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" /> <NodeIcon :class="$style['node-icon']" :nodeType="nodeType" />
<div> <div>
<div :class="$style.details"> <div :class="$style.details">
@ -11,7 +16,7 @@
}} }}
</span> </span>
<span :class="$style['trigger-icon']"> <span :class="$style['trigger-icon']">
<TriggerIcon v-if="$options.isTrigger(nodeType)" /> <TriggerIcon v-if="isTrigger" />
</span> </span>
</div> </div>
<div :class="$style.description"> <div :class="$style.description">
@ -21,14 +26,26 @@
}) })
}} }}
</div> </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>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import {getNewNodePosition, NODE_SIZE} from '@/views/canvasHelpers';
import Vue from 'vue'; import Vue from 'vue';
import { INodeTypeDescription } from 'n8n-workflow';
import NodeIcon from '../NodeIcon.vue'; import NodeIcon from '../NodeIcon.vue';
import TriggerIcon from '../TriggerIcon.vue'; import TriggerIcon from '../TriggerIcon.vue';
@ -44,14 +61,73 @@ export default Vue.extend({
'nodeType', 'nodeType',
'bordered', 'bordered',
], ],
data() {
return {
dragging: false,
draggablePosition: {
x: -100,
y: -100,
},
};
},
computed: { computed: {
shortNodeType() { shortNodeType(): string {
return this.$locale.shortNodeType(this.nodeType.name); 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 mounted() {
isTrigger (nodeType: INodeTypeDescription): boolean { /**
return nodeType.group.includes('trigger'); * 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> </script>
@ -100,4 +176,39 @@ export default Vue.extend({
display: flex; 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> </style>

View file

@ -13,7 +13,9 @@
<ItemIterator <ItemIterator
:elements="elements" :elements="elements"
:activeIndex="activeIndex" :activeIndex="activeIndex"
@selected="selected" @selected="$emit('selected', $event)"
@dragstart="$emit('dragstart', $event)"
@dragend="$emit('dragend', $event)"
/> />
</div> </div>
</div> </div>
@ -38,9 +40,6 @@ export default Vue.extend({
}, },
}, },
methods: { methods: {
selected(element: INodeCreateElement) {
this.$emit('selected', element);
},
onBackArrowClick() { onBackArrowClick() {
this.$emit('close'); this.$emit('close');
}, },
@ -101,4 +100,4 @@ export default Vue.extend({
} }
} }
</style> </style>

View file

@ -1,5 +1,9 @@
<template> <template>
<div class="node-view-root"> <div
class="node-view-root"
@dragover="onDragOver"
@drop="onDrop"
>
<div <div
class="node-view-wrapper" class="node-view-wrapper"
:class="workflowClasses" :class="workflowClasses"
@ -10,27 +14,31 @@
v-touch:tap="touchTap" v-touch:tap="touchTap"
@mouseup="mouseUp" @mouseup="mouseUp"
@wheel="wheelScroll" @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 <node
v-for="nodeData in nodes" v-for="nodeData in nodes"
@duplicateNode="duplicateNode" @duplicateNode="duplicateNode"
@deselectAllNodes="deselectAllNodes" @deselectAllNodes="deselectAllNodes"
@deselectNode="nodeDeselectedByName" @deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName" @nodeSelected="nodeSelectedByName"
@removeNode="removeNode" @removeNode="removeNode"
@runWorkflow="runWorkflow" @runWorkflow="runWorkflow"
@moved="onNodeMoved" @moved="onNodeMoved"
@run="onNodeRun" @run="onNodeRun"
:id="'node-' + getNodeIndex(nodeData.name)" :id="'node-' + getNodeIndex(nodeData.name)"
:key="getNodeIndex(nodeData.name)" :key="getNodeIndex(nodeData.name)"
:name="nodeData.name" :name="nodeData.name"
:isReadOnly="isReadOnly" :isReadOnly="isReadOnly"
:instance="instance" :instance="instance"
:isActive="!!activeNode && activeNode.name === nodeData.name" :isActive="!!activeNode && activeNode.name === nodeData.name"
:hideActions="pullConnActive" :hideActions="pullConnActive"
></node> />
</div> </div>
</div> </div>
<DataDisplay :renaming="renamingActive" @valueChanged="valueChanged"/> <DataDisplay :renaming="renamingActive" @valueChanged="valueChanged"/>
@ -41,7 +49,7 @@
:active="createNodeActive" :active="createNodeActive"
@nodeTypeSelected="nodeTypeSelected" @nodeTypeSelected="nodeTypeSelected"
@closeNodeCreator="closeNodeCreator" @closeNodeCreator="closeNodeCreator"
></node-creator> />
<div :class="{ 'zoom-menu': true, 'regular-zoom-menu': !isDemo, 'demo-zoom-menu': isDemo, expanded: !sidebarMenuCollapsed }"> <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')"> <button @click="zoomToFit" class="button-white" :title="$locale.baseText('nodeView.zoomToFit')">
<font-awesome-icon icon="expand"/> <font-awesome-icon icon="expand"/>
@ -177,6 +185,11 @@ import {
import '../plugins/N8nCustomConnectorType'; import '../plugins/N8nCustomConnectorType';
import '../plugins/PlusEndpointType'; import '../plugins/PlusEndpointType';
interface AddNodeOptions {
position?: XYPosition;
dragAndDrop?: boolean;
}
export default mixins( export default mixins(
copyPaste, copyPaste,
externalHooks, externalHooks,
@ -1227,6 +1240,27 @@ export default mixins(
this.createNodeActive = false; 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) { nodeDeselectedByName (nodeName: string) {
const node = this.$store.getters.getNodeByName(nodeName); const node = this.$store.getters.getNodeByName(nodeName);
if (node) { if (node) {
@ -1267,7 +1301,7 @@ export default mixins(
duration: 0, duration: 0,
}); });
}, },
async injectNode (nodeTypeName: string) { async injectNode (nodeTypeName: string, options: AddNodeOptions = {}) {
const nodeTypeData: INodeTypeDescription | null = this.$store.getters.nodeType(nodeTypeName); const nodeTypeData: INodeTypeDescription | null = this.$store.getters.nodeType(nodeTypeName);
if (nodeTypeData === null) { if (nodeTypeData === null) {
@ -1297,7 +1331,10 @@ export default mixins(
// when pulling new connection from node or injecting into a connection // when pulling new connection from node or injecting into a connection
const lastSelectedNode = this.lastSelectedNode; const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode) {
if (options.position) {
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, options.position);
} else if (lastSelectedNode) {
const lastSelectedConnection = this.lastSelectedConnection; const lastSelectedConnection = this.lastSelectedConnection;
if (lastSelectedConnection) { // set when injecting into a connection if (lastSelectedConnection) { // set when injecting into a connection
const [diffX] = CanvasHelpers.getConnectorLengths(lastSelectedConnection); const [diffX] = CanvasHelpers.getConnectorLengths(lastSelectedConnection);
@ -1308,10 +1345,12 @@ export default mixins(
// set when pulling connections // set when pulling connections
if (this.newNodeInsertPosition) { 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; this.newNodeInsertPosition = null;
} } else {
else {
let yOffset = 0; let yOffset = 0;
if (lastSelectedConnection) { if (lastSelectedConnection) {
@ -1353,7 +1392,11 @@ export default mixins(
this.$store.commit('setStateDirty', true); this.$store.commit('setStateDirty', true);
this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName }); 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 // Automatically deselect all nodes and select the current one and also active
// current node // current node
@ -1396,7 +1439,7 @@ export default mixins(
this.__addConnection(connectionData, true); this.__addConnection(connectionData, true);
}, },
async addNodeButton (nodeTypeName: string) { async addNodeButton (nodeTypeName: string, options: AddNodeOptions = {}) {
if (this.editAllowedCheck() === false) { if (this.editAllowedCheck() === false) {
return; return;
} }
@ -1405,7 +1448,7 @@ export default mixins(
const lastSelectedNode = this.lastSelectedNode; const lastSelectedNode = this.lastSelectedNode;
const lastSelectedNodeOutputIndex = this.$store.getters.lastSelectedNodeOutputIndex; const lastSelectedNodeOutputIndex = this.$store.getters.lastSelectedNodeOutputIndex;
const newNodeData = await this.injectNode(nodeTypeName); const newNodeData = await this.injectNode(nodeTypeName, options);
if (!newNodeData) { if (!newNodeData) {
return; return;
} }