diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index 7d783c1d3c..ff9788349b 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -356,5 +356,5 @@ export function openContextMenu( } export function clickContextMenuAction(action: string) { - getContextMenuAction(action).click(); + getContextMenuAction(action).click({ force: true }); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nKeyboardShortcut/N8nKeyboardShortcut.vue b/packages/frontend/@n8n/design-system/src/components/N8nKeyboardShortcut/N8nKeyboardShortcut.vue index ea237e5531..b95eff1b0e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nKeyboardShortcut/N8nKeyboardShortcut.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nKeyboardShortcut/N8nKeyboardShortcut.vue @@ -14,16 +14,16 @@ const keys = computed(() => { allKeys.unshift('⌘'); } - if (props.shiftKey) { - allKeys.unshift('⇧'); + if (props.metaKey && !isMacOs) { + allKeys.unshift('Ctrl'); } if (props.altKey) { allKeys.unshift(isMacOs ? '⌥' : 'Alt'); } - if (props.metaKey && !isMacOs) { - allKeys.unshift('Ctrl'); + if (props.shiftKey) { + allKeys.unshift('⇧'); } return allKeys; diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 3e2ab0662f..1b4b6fd0a2 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -29,6 +29,7 @@ "@codemirror/search": "^6.5.6", "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.26.3", + "@dagrejs/dagre": "^1.1.4", "@fontsource/open-sans": "^4.5.0", "@lezer/common": "1.1.0", "@n8n/api-types": "workspace:*", diff --git a/packages/frontend/editor-ui/src/__tests__/data/canvas.ts b/packages/frontend/editor-ui/src/__tests__/data/canvas.ts index fbbbb8b0aa..08330730c3 100644 --- a/packages/frontend/editor-ui/src/__tests__/data/canvas.ts +++ b/packages/frontend/editor-ui/src/__tests__/data/canvas.ts @@ -12,9 +12,9 @@ import type { } from '@/types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; import { NodeConnectionType } from 'n8n-workflow'; +import type { GraphEdge, GraphNode, ViewportTransform } from '@vue-flow/core'; import type { EventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus'; -import type { ViewportTransform } from '@vue-flow/core'; export function createCanvasNodeData({ id = 'node', @@ -69,6 +69,35 @@ export function createCanvasNodeElement({ }; } +export function createCanvasGraphNode({ + id = '1', + type = 'default', + label = 'Node', + position = { x: 100, y: 100 }, + dimensions = { width: 100, height: 100 }, + data, + ...rest +}: Partial< + Omit, 'data'> & { data: Partial } +> = {}): GraphNode { + return { + id, + type, + label, + position, + computedPosition: { ...position, z: 0 }, + dimensions, + dragging: false, + isParent: false, + selected: false, + resizing: false, + handleBounds: {}, + events: {}, + data: createCanvasNodeData({ id, type, ...data }), + ...rest, + }; +} + export function createCanvasNodeProps({ id = 'node', label = 'Test Node', @@ -196,3 +225,30 @@ export function createCanvasConnection( ...(nodeBInput ? { targetHandle: `inputs/${nodeBInput.type}/${nodeBInput.index}` } : {}), }; } + +export function createCanvasGraphEdge( + nodeA: GraphNode, + nodeB: GraphNode, + { sourceIndex = 0, targetIndex = 0 } = {}, +): GraphEdge { + const nodeAOutput = nodeA.data?.outputs[sourceIndex]; + const nodeBInput = nodeA.data?.inputs[targetIndex]; + + return { + id: `${nodeA.id}-${nodeB.id}`, + source: nodeA.id, + target: nodeB.id, + sourceX: nodeA.position.x, + sourceY: nodeA.position.y, + targetX: nodeB.position.x, + targetY: nodeB.position.y, + type: 'default', + selected: false, + sourceNode: nodeA, + targetNode: nodeB, + data: {}, + events: {}, + ...(nodeAOutput ? { sourceHandle: `outputs/${nodeAOutput.type}/${nodeAOutput.index}` } : {}), + ...(nodeBInput ? { targetHandle: `inputs/${nodeBInput.type}/${nodeBInput.index}` } : {}), + }; +} diff --git a/packages/frontend/editor-ui/src/components/TidyUpIcon.vue b/packages/frontend/editor-ui/src/components/TidyUpIcon.vue new file mode 100644 index 0000000000..a6faf6b561 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/TidyUpIcon.vue @@ -0,0 +1,8 @@ + diff --git a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue index a9d1056a01..918b0b06a3 100644 --- a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue @@ -18,7 +18,17 @@ import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core'; import { MiniMap } from '@vue-flow/minimap'; import Node from './elements/nodes/CanvasNode.vue'; import Edge from './elements/edges/CanvasEdge.vue'; -import { computed, onMounted, onUnmounted, provide, ref, toRef, useCssModule, watch } from 'vue'; +import { + computed, + nextTick, + onMounted, + onUnmounted, + provide, + ref, + toRef, + useCssModule, + watch, +} from 'vue'; import type { EventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus'; import { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; @@ -37,6 +47,7 @@ import CanvasBackground from './elements/background/CanvasBackground.vue'; import { useCanvasTraversal } from '@/composables/useCanvasTraversal'; import { NodeConnectionType } from 'n8n-workflow'; import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover'; +import { useCanvasLayout } from '@/composables/useCanvasLayout'; const $style = useCssModule(); @@ -139,6 +150,7 @@ const { getDownstreamNodes, getUpstreamNodes, } = useCanvasTraversal(vueFlow); +const { layout } = useCanvasLayout({ id: props.id }); const isPaneReady = ref(false); @@ -245,38 +257,43 @@ function selectUpstreamNodes(id: string) { onSelectNodes({ ids: [...upstreamNodes.map((node) => node.id), id] }); } -const keyMap = computed(() => ({ - ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)), - enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)), - ctrl_a: () => addSelectedNodes(graphNodes.value), - // Support both key and code for zooming in and out - 'shift_+|+|=|shift_Equal|Equal': async () => await onZoomIn(), - 'shift+_|-|_|shift_Minus|Minus': async () => await onZoomOut(), - 0: async () => await onResetZoom(), - 1: async () => await onFitView(), - ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode), - ArrowDown: emitWithLastSelectedNode(selectLowerSiblingNode), - ArrowLeft: emitWithLastSelectedNode(selectLeftNode), - ArrowRight: emitWithLastSelectedNode(selectRightNode), - shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes), - shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes), +const keyMap = computed(() => { + const readOnlyKeymap = { + ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)), + enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)), + ctrl_a: () => addSelectedNodes(graphNodes.value), + // Support both key and code for zooming in and out + 'shift_+|+|=|shift_Equal|Equal': async () => await onZoomIn(), + 'shift+_|-|_|shift_Minus|Minus': async () => await onZoomOut(), + 0: async () => await onResetZoom(), + 1: async () => await onFitView(), + ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode), + ArrowDown: emitWithLastSelectedNode(selectLowerSiblingNode), + ArrowLeft: emitWithLastSelectedNode(selectLeftNode), + ArrowRight: emitWithLastSelectedNode(selectRightNode), + shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes), + shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes), + }; - ...(props.readOnly - ? {} - : { - ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)), - 'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)), - ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)), - d: emitWithSelectedNodes((ids) => emit('update:nodes:enabled', ids)), - p: emitWithSelectedNodes((ids) => emit('update:nodes:pin', ids, 'keyboard-shortcut')), - f2: emitWithLastSelectedNode((id) => emit('update:node:name', id)), - tab: () => emit('create:node', 'tab'), - shift_s: () => emit('create:sticky'), - ctrl_alt_n: () => emit('create:workflow'), - ctrl_enter: () => emit('run:workflow'), - ctrl_s: () => emit('save:workflow'), - }), -})); + if (props.readOnly) return readOnlyKeymap; + + const fullKeymap = { + ...readOnlyKeymap, + ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)), + 'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)), + ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)), + d: emitWithSelectedNodes((ids) => emit('update:nodes:enabled', ids)), + p: emitWithSelectedNodes((ids) => emit('update:nodes:pin', ids, 'keyboard-shortcut')), + f2: emitWithLastSelectedNode((id) => emit('update:node:name', id)), + tab: () => emit('create:node', 'tab'), + shift_s: () => emit('create:sticky'), + ctrl_alt_n: () => emit('create:workflow'), + ctrl_enter: () => emit('run:workflow'), + ctrl_s: () => emit('save:workflow'), + shift_alt_t: onTidyUp, + }; + return fullKeymap; +}); useKeybindings(keyMap, { disabled: disableKeyBindings }); @@ -589,7 +606,7 @@ function onOpenNodeContextMenu( contextMenu.open(event, { source, nodeId: id }); } -function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) { +async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) { switch (action) { case 'add_node': return emit('create:node', 'context_menu'); @@ -617,6 +634,20 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) { return emit('update:node:name', nodeIds[0]); case 'change_color': return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' }); + case 'tidy_up': + return await onTidyUp(); + } +} + +async function onTidyUp() { + const applyOnSelection = selectedNodes.value.length > 1; + const { nodes } = layout(applyOnSelection ? 'selection' : 'all'); + + onUpdateNodesPosition(nodes.map((node) => ({ id: node.id, position: { x: node.x, y: node.y } }))); + + if (!applyOnSelection) { + await nextTick(); + await onFitView(); } } @@ -840,6 +871,7 @@ provide(CanvasKey, { @zoom-in="onZoomIn" @zoom-out="onZoomOut" @reset-zoom="onResetZoom" + @tidy-up="onTidyUp" /> diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasControlButtons.vue b/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasControlButtons.vue index 5d9a7a3435..ddc7cce8c8 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasControlButtons.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasControlButtons.vue @@ -1,6 +1,7 @@ + +