mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-05 18:07:27 -08:00
feat(editor): Support adding nodes via drag and drop from node creator on new canvas (#12197)
This commit is contained in:
parent
65b8e20049
commit
1bfd9c0e91
|
@ -9,7 +9,6 @@ import {
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
|
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
|
||||||
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
|
|
||||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||||
import NodeIcon from '@/components/NodeIcon.vue';
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
|
|
||||||
|
@ -93,15 +92,6 @@ const isTrigger = computed<boolean>(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function onDragStart(event: DragEvent): void {
|
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) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.effectAllowed = 'copy';
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
event.dataTransfer.dropEffect = 'copy';
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
@ -113,22 +103,9 @@ function onDragStart(event: DragEvent): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
dragging.value = true;
|
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 {
|
function onDragEnd(): void {
|
||||||
document.body.removeEventListener('dragover', onDragOver);
|
|
||||||
|
|
||||||
dragging.value = false;
|
dragging.value = false;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
draggablePosition.value = { x: -100, y: -100 };
|
draggablePosition.value = { x: -100, y: -100 };
|
||||||
|
@ -144,7 +121,7 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Node Item is draggable only if it doesn't contain actions -->
|
<!-- Node Item is draggable only if it doesn't contain actions -->
|
||||||
<n8n-node-creator-node
|
<N8nNodeCreatorNode
|
||||||
:draggable="!showActionArrow"
|
:draggable="!showActionArrow"
|
||||||
:class="$style.nodeItem"
|
:class="$style.nodeItem"
|
||||||
:description="description"
|
:description="description"
|
||||||
|
@ -176,12 +153,16 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #dragContent>
|
<template #dragContent>
|
||||||
<div ref="draggableDataTransfer" :class="$style.draggableDataTransfer" />
|
<div
|
||||||
<div v-show="dragging" :class="$style.draggable" :style="draggableStyle">
|
ref="draggableDataTransfer"
|
||||||
|
v-show="dragging"
|
||||||
|
:class="$style.draggable"
|
||||||
|
:style="draggableStyle"
|
||||||
|
>
|
||||||
<NodeIcon :node-type="nodeType" :size="40" :shrink="false" @click.capture.stop />
|
<NodeIcon :node-type="nodeType" :size="40" :shrink="false" @click.capture.stop />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n8n-node-creator-node>
|
</N8nNodeCreatorNode>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
|
|
@ -8,7 +8,6 @@ import type {
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import type { Connection, XYPosition, NodeDragEvent, GraphNode } from '@vue-flow/core';
|
import type { Connection, XYPosition, NodeDragEvent, GraphNode } from '@vue-flow/core';
|
||||||
import { useVueFlow, VueFlow, PanelPosition, MarkerType } 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 { MiniMap } from '@vue-flow/minimap';
|
||||||
import Node from './elements/nodes/CanvasNode.vue';
|
import Node from './elements/nodes/CanvasNode.vue';
|
||||||
import Edge from './elements/edges/CanvasEdge.vue';
|
import Edge from './elements/edges/CanvasEdge.vue';
|
||||||
|
@ -25,7 +24,7 @@ import { GRID_SIZE } from '@/utils/nodeViewUtils';
|
||||||
import { CanvasKey } from '@/constants';
|
import { CanvasKey } from '@/constants';
|
||||||
import { onKeyDown, onKeyUp, useThrottleFn } from '@vueuse/core';
|
import { onKeyDown, onKeyUp, useThrottleFn } from '@vueuse/core';
|
||||||
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
|
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 { useCanvasTraversal } from '@/composables/useCanvasTraversal';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
|
@ -65,6 +64,7 @@ const emit = defineEmits<{
|
||||||
'run:workflow': [];
|
'run:workflow': [];
|
||||||
'save:workflow': [];
|
'save:workflow': [];
|
||||||
'create:workflow': [];
|
'create:workflow': [];
|
||||||
|
'drag-and-drop': [position: XYPosition, event: DragEvent];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = withDefaults(
|
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
|
* Minimap
|
||||||
*/
|
*/
|
||||||
|
@ -626,6 +640,7 @@ provide(CanvasKey, {
|
||||||
:id="id"
|
:id="id"
|
||||||
:nodes="nodes"
|
:nodes="nodes"
|
||||||
:edges="connections"
|
:edges="connections"
|
||||||
|
:class="classes"
|
||||||
:apply-changes="false"
|
:apply-changes="false"
|
||||||
:connection-line-options="{ markerEnd: MarkerType.ArrowClosed }"
|
:connection-line-options="{ markerEnd: MarkerType.ArrowClosed }"
|
||||||
:connection-radius="60"
|
:connection-radius="60"
|
||||||
|
@ -635,7 +650,6 @@ provide(CanvasKey, {
|
||||||
:snap-grid="[GRID_SIZE, GRID_SIZE]"
|
:snap-grid="[GRID_SIZE, GRID_SIZE]"
|
||||||
:min-zoom="0"
|
:min-zoom="0"
|
||||||
:max-zoom="4"
|
:max-zoom="4"
|
||||||
:class="classes"
|
|
||||||
:selection-key-code="selectionKeyCode"
|
:selection-key-code="selectionKeyCode"
|
||||||
:pan-activation-key-code="panningKeyCode"
|
:pan-activation-key-code="panningKeyCode"
|
||||||
:disable-keyboard-a11y="true"
|
:disable-keyboard-a11y="true"
|
||||||
|
@ -649,6 +663,8 @@ provide(CanvasKey, {
|
||||||
@move-end="onPaneMoveEnd"
|
@move-end="onPaneMoveEnd"
|
||||||
@node-drag-stop="onNodeDragStop"
|
@node-drag-stop="onNodeDragStop"
|
||||||
@selection-drag-stop="onSelectionDragStop"
|
@selection-drag-stop="onSelectionDragStop"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@drop="onDrop"
|
||||||
>
|
>
|
||||||
<template #node-canvas-node="nodeProps">
|
<template #node-canvas-node="nodeProps">
|
||||||
<Node
|
<Node
|
||||||
|
@ -687,16 +703,7 @@ provide(CanvasKey, {
|
||||||
|
|
||||||
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
|
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
|
||||||
|
|
||||||
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE">
|
<CanvasBackground :viewport="viewport" :striped="readOnly" />
|
||||||
<template v-if="readOnly" #pattern-container="patternProps">
|
|
||||||
<CanvasBackgroundStripedPattern
|
|
||||||
:id="patternProps.id"
|
|
||||||
:x="viewport.x"
|
|
||||||
:y="viewport.y"
|
|
||||||
:zoom="viewport.zoom"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Background>
|
|
||||||
|
|
||||||
<Transition name="minimap">
|
<Transition name="minimap">
|
||||||
<MiniMap
|
<MiniMap
|
||||||
|
@ -736,6 +743,8 @@ provide(CanvasKey, {
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.canvas {
|
.canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
||||||
&.ready {
|
&.ready {
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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>"
|
||||||
|
`;
|
|
@ -49,6 +49,7 @@ import type {
|
||||||
import { CanvasNodeRenderType, CanvasConnectionMode } from '@/types';
|
import { CanvasNodeRenderType, CanvasConnectionMode } from '@/types';
|
||||||
import {
|
import {
|
||||||
CHAT_TRIGGER_NODE_TYPE,
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
DRAG_EVENT_DATA_KEY,
|
||||||
EnterpriseEditionFeature,
|
EnterpriseEditionFeature,
|
||||||
MAIN_HEADER_TABS,
|
MAIN_HEADER_TABS,
|
||||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
@ -1442,6 +1443,28 @@ function onClickPane(position: CanvasNode['position']) {
|
||||||
setNodeSelected();
|
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
|
* Custom Actions
|
||||||
*/
|
*/
|
||||||
|
@ -1615,6 +1638,7 @@ onBeforeUnmount(() => {
|
||||||
@save:workflow="onSaveWorkflow"
|
@save:workflow="onSaveWorkflow"
|
||||||
@create:workflow="onCreateWorkflow"
|
@create:workflow="onCreateWorkflow"
|
||||||
@viewport-change="onViewportChange"
|
@viewport-change="onViewportChange"
|
||||||
|
@drag-and-drop="onDragAndDrop"
|
||||||
>
|
>
|
||||||
<div v-if="!isCanvasReadOnly" :class="$style.executionButtons">
|
<div v-if="!isCanvasReadOnly" :class="$style.executionButtons">
|
||||||
<CanvasRunWorkflowButton
|
<CanvasRunWorkflowButton
|
||||||
|
|
|
@ -2056,10 +2056,7 @@ export default defineComponent({
|
||||||
);
|
);
|
||||||
if (dropData) {
|
if (dropData) {
|
||||||
const mousePosition = this.getMousePositionWithinNodeView(event);
|
const mousePosition = this.getMousePositionWithinNodeView(event);
|
||||||
const insertNodePosition = [
|
const insertNodePosition: XYPosition = [mousePosition[0], mousePosition[1]];
|
||||||
mousePosition[0] - NodeViewUtils.NODE_SIZE / 2 + NodeViewUtils.GRID_SIZE,
|
|
||||||
mousePosition[1] - NodeViewUtils.NODE_SIZE / 2,
|
|
||||||
] as XYPosition;
|
|
||||||
|
|
||||||
await this.onAddNodes(dropData, true, insertNodePosition);
|
await this.onAddNodes(dropData, true, insertNodePosition);
|
||||||
this.createNodeActive = false;
|
this.createNodeActive = false;
|
||||||
|
|
Loading…
Reference in a new issue