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,
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>

View file

@ -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';

View file

@ -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(

View file

@ -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) {

View file

@ -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>

View file

@ -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>

View file

@ -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;
}