feat(editor): Support adding nodes via drag and drop from node creator on new canvas (#12197)

This commit is contained in:
Alex Grozav 2024-12-13 11:20:53 +02:00 committed by GitHub
parent 65b8e20049
commit 1bfd9c0e91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 123 additions and 44 deletions

View file

@ -9,7 +9,6 @@ import {
} from '@/constants';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import NodeIcon from '@/components/NodeIcon.vue';
@ -93,15 +92,6 @@ const isTrigger = computed<boolean>(() => {
});
function onDragStart(event: DragEvent): void {
/**
* 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', onDragOver);
const { pageX: x, pageY: y } = event;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.dropEffect = 'copy';
@ -113,22 +103,9 @@ function onDragStart(event: DragEvent): void {
}
dragging.value = true;
draggablePosition.value = { x, y };
}
function onDragOver(event: DragEvent): void {
if (!dragging.value || (event.pageX === 0 && event.pageY === 0)) {
return;
}
const [x, y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
draggablePosition.value = { x, y };
}
function onDragEnd(): void {
document.body.removeEventListener('dragover', onDragOver);
dragging.value = false;
setTimeout(() => {
draggablePosition.value = { x: -100, y: -100 };
@ -144,7 +121,7 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
<template>
<!-- Node Item is draggable only if it doesn't contain actions -->
<n8n-node-creator-node
<N8nNodeCreatorNode
:draggable="!showActionArrow"
:class="$style.nodeItem"
:description="description"
@ -176,12 +153,16 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
/>
</template>
<template #dragContent>
<div ref="draggableDataTransfer" :class="$style.draggableDataTransfer" />
<div v-show="dragging" :class="$style.draggable" :style="draggableStyle">
<div
ref="draggableDataTransfer"
v-show="dragging"
:class="$style.draggable"
:style="draggableStyle"
>
<NodeIcon :node-type="nodeType" :size="40" :shrink="false" @click.capture.stop />
</div>
</template>
</n8n-node-creator-node>
</N8nNodeCreatorNode>
</template>
<style lang="scss" module>

View file

@ -8,7 +8,6 @@ import type {
} from '@/types';
import type { Connection, XYPosition, NodeDragEvent, GraphNode } from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { MiniMap } from '@vue-flow/minimap';
import Node from './elements/nodes/CanvasNode.vue';
import Edge from './elements/edges/CanvasEdge.vue';
@ -25,7 +24,7 @@ import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { CanvasKey } from '@/constants';
import { onKeyDown, onKeyUp, useThrottleFn } from '@vueuse/core';
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import CanvasBackgroundStripedPattern from './elements/CanvasBackgroundStripedPattern.vue';
import CanvasBackground from './elements/background/CanvasBackground.vue';
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
import { NodeConnectionType } from 'n8n-workflow';
@ -65,6 +64,7 @@ const emit = defineEmits<{
'run:workflow': [];
'save:workflow': [];
'create:workflow': [];
'drag-and-drop': [position: XYPosition, event: DragEvent];
}>();
const props = withDefaults(
@ -535,6 +535,20 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
}
}
/**
* Drag and drop
*/
function onDragOver(event: DragEvent) {
event.preventDefault();
}
function onDrop(event: DragEvent) {
const position = getProjectedPosition(event);
emit('drag-and-drop', position, event);
}
/**
* Minimap
*/
@ -626,6 +640,7 @@ provide(CanvasKey, {
:id="id"
:nodes="nodes"
:edges="connections"
:class="classes"
:apply-changes="false"
:connection-line-options="{ markerEnd: MarkerType.ArrowClosed }"
:connection-radius="60"
@ -635,7 +650,6 @@ provide(CanvasKey, {
:snap-grid="[GRID_SIZE, GRID_SIZE]"
:min-zoom="0"
:max-zoom="4"
:class="classes"
:selection-key-code="selectionKeyCode"
:pan-activation-key-code="panningKeyCode"
:disable-keyboard-a11y="true"
@ -649,6 +663,8 @@ provide(CanvasKey, {
@move-end="onPaneMoveEnd"
@node-drag-stop="onNodeDragStop"
@selection-drag-stop="onSelectionDragStop"
@dragover="onDragOver"
@drop="onDrop"
>
<template #node-canvas-node="nodeProps">
<Node
@ -687,16 +703,7 @@ provide(CanvasKey, {
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE">
<template v-if="readOnly" #pattern-container="patternProps">
<CanvasBackgroundStripedPattern
:id="patternProps.id"
:x="viewport.x"
:y="viewport.y"
:zoom="viewport.zoom"
/>
</template>
</Background>
<CanvasBackground :viewport="viewport" :striped="readOnly" />
<Transition name="minimap">
<MiniMap
@ -736,6 +743,8 @@ provide(CanvasKey, {
<style lang="scss" module>
.canvas {
width: 100%;
height: 100%;
opacity: 0;
&.ready {

View file

@ -0,0 +1,33 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasBackground from '@/components/canvas/elements/background/CanvasBackground.vue';
const renderComponent = createComponentRenderer(CanvasBackground);
describe('CanvasBackground', () => {
it('should render the background with the correct gap', () => {
const { getByTestId, html } = renderComponent({
props: { striped: false, viewport: { x: 0, y: 0, zoom: 1 } },
});
const background = getByTestId('canvas-background');
expect(background).toBeInTheDocument();
expect(html()).toMatchSnapshot();
});
it('should render the striped pattern when striped is true', () => {
const { getByTestId } = renderComponent({
props: { striped: true, viewport: { x: 0, y: 0, zoom: 1 } },
});
const pattern = getByTestId('canvas-background-striped-pattern');
expect(pattern).toBeInTheDocument();
});
it('should not render the striped pattern when striped is false', () => {
const { getByTestId } = renderComponent({
props: { striped: false, viewport: { x: 0, y: 0, zoom: 1 } },
});
expect(() => getByTestId('canvas-background-striped-pattern')).toThrow();
});
});

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
import { GRID_SIZE } from '@/utils/nodeViewUtils';
import CanvasBackgroundStripedPattern from './CanvasBackgroundStripedPattern.vue';
import { Background } from '@vue-flow/background';
import type { ViewportTransform } from '@vue-flow/core';
defineProps<{
striped: boolean;
viewport: ViewportTransform;
}>();
</script>
<template>
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE">
<template v-if="striped" #pattern-container="patternProps">
<CanvasBackgroundStripedPattern
:id="patternProps.id"
data-test-id="canvas-background-striped-pattern"
:x="viewport.x"
:y="viewport.y"
:zoom="viewport.zoom"
/>
</template>
</Background>
</template>

View file

@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasBackground > should render the background with the correct gap 1`] = `
"<svg class="vue-flow__background vue-flow__container" style="height: 100%; width: 100%;" data-test-id="canvas-background">
<pattern id="pattern-vue-flow-0" x="0" y="0" width="20" height="20" patternTransform="translate(-11,-11)" patternUnits="userSpaceOnUse">
<circle cx="0.5" cy="0.5" r="0.5" fill="#aaa"></circle>
<!---->
</pattern>
<rect x="0" y="0" width="100%" height="100%" fill="url(#pattern-vue-flow-0)"></rect>
</svg>"
`;

View file

@ -49,6 +49,7 @@ import type {
import { CanvasNodeRenderType, CanvasConnectionMode } from '@/types';
import {
CHAT_TRIGGER_NODE_TYPE,
DRAG_EVENT_DATA_KEY,
EnterpriseEditionFeature,
MAIN_HEADER_TABS,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
@ -1442,6 +1443,28 @@ function onClickPane(position: CanvasNode['position']) {
setNodeSelected();
}
/**
* Drag and Drop events
*/
async function onDragAndDrop(position: VueFlowXYPosition, event: DragEvent) {
if (!event.dataTransfer) {
return;
}
const dropData = jsonParse<AddedNodesAndConnections>(
event.dataTransfer.getData(DRAG_EVENT_DATA_KEY),
);
if (dropData) {
const insertNodePosition: XYPosition = [position.x, position.y];
await onAddNodesAndConnections(dropData, true, insertNodePosition);
onToggleNodeCreator({ createNodeActive: false, hasAddedNodes: true });
}
}
/**
* Custom Actions
*/
@ -1615,6 +1638,7 @@ onBeforeUnmount(() => {
@save:workflow="onSaveWorkflow"
@create:workflow="onCreateWorkflow"
@viewport-change="onViewportChange"
@drag-and-drop="onDragAndDrop"
>
<div v-if="!isCanvasReadOnly" :class="$style.executionButtons">
<CanvasRunWorkflowButton

View file

@ -2056,10 +2056,7 @@ export default defineComponent({
);
if (dropData) {
const mousePosition = this.getMousePositionWithinNodeView(event);
const insertNodePosition = [
mousePosition[0] - NodeViewUtils.NODE_SIZE / 2 + NodeViewUtils.GRID_SIZE,
mousePosition[1] - NodeViewUtils.NODE_SIZE / 2,
] as XYPosition;
const insertNodePosition: XYPosition = [mousePosition[0], mousePosition[1]];
await this.onAddNodes(dropData, true, insertNodePosition);
this.createNodeActive = false;