mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Automatically tidy up workflows (#13471)
This commit is contained in:
parent
aea2e79bf2
commit
f381a24145
|
@ -356,5 +356,5 @@ export function openContextMenu(
|
|||
}
|
||||
|
||||
export function clickContextMenuAction(action: string) {
|
||||
getContextMenuAction(action).click();
|
||||
getContextMenuAction(action).click({ force: true });
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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:*",
|
||||
|
|
|
@ -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<GraphNode<CanvasNodeData>, 'data'> & { data: Partial<CanvasNodeData> }
|
||||
> = {}): GraphNode<CanvasNodeData> {
|
||||
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}` } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M1.6.13c-.18-.17-.47-.18-.62 0L.56.57.14.98c-.2.15-.18.44 0 .62l3.63 3.6c.1.1.1.27 0 .37-.2.2-.53.52-.93.94-.56.57-.12 1.62.22 2.11.05.07.12.1.2.1.05-.01.1-.04.15-.08l5.23-5.22c.1-.1.1-.26-.02-.34-.5-.34-1.55-.78-2.12-.22-.42.4-.75.73-.94.93-.1.1-.27.1-.37 0L1.6.13ZM9.5 3.9c.07-.09.2-.1.3-.04l6.07 3.44c.15.08.18.29.05.4l-1.21 1.22a.26.26 0 0 1-.26.07l-2.18-.64a.26.26 0 0 0-.32.33l.76 2.02c.04.1.01.2-.06.27L7.7 15.92a.26.26 0 0 1-.41-.05L3.83 9.8a.26.26 0 0 1 .04-.3l5.62-5.6Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
|
@ -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"
|
||||
/>
|
||||
|
||||
<Suspense>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { Controls } from '@vue-flow/controls';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
import TidyUpIcon from '@/components/TidyUpIcon.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
|
@ -18,6 +19,7 @@ const emit = defineEmits<{
|
|||
'zoom-in': [];
|
||||
'zoom-out': [];
|
||||
'zoom-to-fit': [];
|
||||
'tidy-up': [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
@ -39,6 +41,10 @@ function onZoomOut() {
|
|||
function onZoomToFit() {
|
||||
emit('zoom-to-fit');
|
||||
}
|
||||
|
||||
function onTidyUp() {
|
||||
emit('tidy-up');
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Controls :show-zoom="false" :show-fit-view="false">
|
||||
|
@ -85,9 +91,37 @@ function onZoomToFit() {
|
|||
@click="onResetZoom"
|
||||
/>
|
||||
</KeyboardShortcutTooltip>
|
||||
<KeyboardShortcutTooltip
|
||||
:label="i18n.baseText('nodeView.tidyUp')"
|
||||
:shortcut="{ shiftKey: true, altKey: true, keys: ['T'] }"
|
||||
>
|
||||
<N8nButton
|
||||
square
|
||||
type="tertiary"
|
||||
size="large"
|
||||
data-test-id="tidy-up-button"
|
||||
:class="$style.iconButton"
|
||||
@click="onTidyUp"
|
||||
>
|
||||
<TidyUpIcon />
|
||||
</N8nButton>
|
||||
</KeyboardShortcutTooltip>
|
||||
</Controls>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.iconButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.vue-flow__controls {
|
||||
display: flex;
|
||||
|
|
|
@ -20,6 +20,10 @@ exports[`CanvasControlButtons > should render correctly 1`] = `
|
|||
</button>
|
||||
<!--teleport start-->
|
||||
<!--teleport end-->
|
||||
<!--v-if-->
|
||||
<!--v-if--><button class="button button tertiary large square iconButton el-tooltip__trigger el-tooltip__trigger iconButton el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="tidy-up-button">
|
||||
<!--v-if--><span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M1.6.13c-.18-.17-.47-.18-.62 0L.56.57.14.98c-.2.15-.18.44 0 .62l3.63 3.6c.1.1.1.27 0 .37-.2.2-.53.52-.93.94-.56.57-.12 1.62.22 2.11.05.07.12.1.2.1.05-.01.1-.04.15-.08l5.23-5.22c.1-.1.1-.26-.02-.34-.5-.34-1.55-.78-2.12-.22-.42.4-.75.73-.94.93-.1.1-.27.1-.37 0L1.6.13ZM9.5 3.9c.07-.09.2-.1.3-.04l6.07 3.44c.15.08.18.29.05.4l-1.21 1.22a.26.26 0 0 1-.26.07l-2.18-.64a.26.26 0 0 0-.32.33l.76 2.02c.04.1.01.2-.06.27L7.7 15.92a.26.26 0 0 1-.41-.05L3.83 9.8a.26.26 0 0 1 .04-.3l5.62-5.6Z"></path></svg></span>
|
||||
</button>
|
||||
<!--teleport start-->
|
||||
<!--teleport end-->
|
||||
</div>"
|
||||
`;
|
||||
|
|
|
@ -0,0 +1,171 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`useCanvasLayout > should layout a basic workflow 1`] = `
|
||||
{
|
||||
"boundingBox": {
|
||||
"height": 100,
|
||||
"width": 540,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"x": 320,
|
||||
"y": 100,
|
||||
},
|
||||
{
|
||||
"id": "node3",
|
||||
"x": 540,
|
||||
"y": 100,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`useCanvasLayout > should layout a basic workflow with selected nodes 1`] = `
|
||||
{
|
||||
"boundingBox": {
|
||||
"height": 100,
|
||||
"width": 540,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"x": 320,
|
||||
"y": 100,
|
||||
},
|
||||
{
|
||||
"id": "node3",
|
||||
"x": 540,
|
||||
"y": 100,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`useCanvasLayout > should layout a workflow with AI nodes 1`] = `
|
||||
{
|
||||
"boundingBox": {
|
||||
"height": 540,
|
||||
"width": 820,
|
||||
"x": 0,
|
||||
"y": 220,
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
},
|
||||
{
|
||||
"id": "aiTool1",
|
||||
"x": 320,
|
||||
"y": 320,
|
||||
},
|
||||
{
|
||||
"id": "aiTool2",
|
||||
"x": 460,
|
||||
"y": 320,
|
||||
},
|
||||
{
|
||||
"id": "aiTool3",
|
||||
"x": 600,
|
||||
"y": 540,
|
||||
},
|
||||
{
|
||||
"id": "aiAgent",
|
||||
"x": 460,
|
||||
"y": 100,
|
||||
},
|
||||
{
|
||||
"id": "configurableAiTool",
|
||||
"x": 600,
|
||||
"y": 320,
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"x": 820,
|
||||
"y": 100,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`useCanvasLayout > should layout a workflow with sticky notes 1`] = `
|
||||
{
|
||||
"boundingBox": {
|
||||
"height": 100,
|
||||
"width": 760,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"x": 220,
|
||||
"y": 0,
|
||||
},
|
||||
{
|
||||
"id": "node3",
|
||||
"x": 440,
|
||||
"y": 0,
|
||||
},
|
||||
{
|
||||
"id": "node4",
|
||||
"x": 660,
|
||||
"y": 0,
|
||||
},
|
||||
{
|
||||
"id": "sticky",
|
||||
"x": 130,
|
||||
"y": -240,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`useCanvasLayout > should not reorder nodes vertically as it affects execution order 1`] = `
|
||||
{
|
||||
"boundingBox": {
|
||||
"height": 300,
|
||||
"width": 320,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"x": 0,
|
||||
"y": -100,
|
||||
},
|
||||
{
|
||||
"id": "node3",
|
||||
"x": 220,
|
||||
"y": -200,
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"x": 220,
|
||||
"y": 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
|
@ -67,6 +67,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe
|
|||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"divided": true,
|
||||
"id": "tidy_up",
|
||||
"label": "Tidy up workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"T",
|
||||
],
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
|
@ -136,6 +148,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe
|
|||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"divided": true,
|
||||
"id": "tidy_up",
|
||||
"label": "Tidy up workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"T",
|
||||
],
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
|
@ -234,6 +258,18 @@ exports[`useContextMenu > should return the correct actions opening the menu fro
|
|||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"divided": true,
|
||||
"id": "tidy_up",
|
||||
"label": "Tidy up workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"T",
|
||||
],
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
|
@ -332,6 +368,18 @@ exports[`useContextMenu > should return the correct actions when right clicking
|
|||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"divided": true,
|
||||
"id": "tidy_up",
|
||||
"label": "Tidy up workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"T",
|
||||
],
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
|
@ -401,6 +449,18 @@ exports[`useContextMenu > should return the correct actions when right clicking
|
|||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"divided": true,
|
||||
"id": "tidy_up",
|
||||
"label": "Tidy up workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"T",
|
||||
],
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
|
@ -475,6 +535,18 @@ exports[`useContextMenu > should support opening and closing (default = right cl
|
|||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"divided": true,
|
||||
"id": "tidy_up",
|
||||
"label": "Tidy up selection",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"T",
|
||||
],
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
|
|
|
@ -0,0 +1,170 @@
|
|||
import { useVueFlow, type GraphNode, type VueFlowStore } from '@vue-flow/core';
|
||||
import { ref } from 'vue';
|
||||
import { createCanvasGraphEdge, createCanvasGraphNode } from '../__tests__/data';
|
||||
import { CanvasNodeRenderType, type CanvasNodeData } from '../types';
|
||||
import { useCanvasLayout, type LayoutResult } from './useCanvasLayout';
|
||||
import { STICKY_NODE_TYPE } from '../constants';
|
||||
import { GRID_SIZE } from '../utils/nodeViewUtils';
|
||||
|
||||
vi.mock('@vue-flow/core');
|
||||
|
||||
function matchesGrid(result: LayoutResult) {
|
||||
return result.nodes.every((node) => node.x % GRID_SIZE === 0 && node.y % GRID_SIZE === 0);
|
||||
}
|
||||
|
||||
describe('useCanvasLayout', () => {
|
||||
function createTestSetup(
|
||||
nodes: Array<GraphNode<CanvasNodeData>>,
|
||||
connections: Array<[string, string]>,
|
||||
selectedNodeIds?: string[],
|
||||
) {
|
||||
const nodesById = Object.fromEntries(nodes.map((node) => [node.id, node]));
|
||||
const edges = connections.map(([sourceId, targetId]) =>
|
||||
createCanvasGraphEdge(nodesById[sourceId], nodesById[targetId]),
|
||||
);
|
||||
const edgesById = Object.fromEntries(edges.map((edge) => [edge.id, edge]));
|
||||
|
||||
const selectedNodes = selectedNodeIds?.map((id) => nodesById[id]) ?? nodes;
|
||||
|
||||
const vueFlowStoreMock = {
|
||||
nodes: ref(nodes),
|
||||
edges: ref(edges),
|
||||
getSelectedNodes: ref(selectedNodes),
|
||||
findNode: (nodeId: string) => nodesById[nodeId],
|
||||
findEdge: (edgeId: string) => edgesById[edgeId],
|
||||
} as unknown as VueFlowStore;
|
||||
|
||||
vi.mocked(useVueFlow).mockReturnValue(vueFlowStoreMock);
|
||||
|
||||
const { layout } = useCanvasLayout();
|
||||
|
||||
return { layout };
|
||||
}
|
||||
|
||||
test('should layout a basic workflow', () => {
|
||||
const nodes = [
|
||||
createCanvasGraphNode({ id: 'node1' }),
|
||||
createCanvasGraphNode({ id: 'node2' }),
|
||||
createCanvasGraphNode({ id: 'node3' }),
|
||||
];
|
||||
|
||||
const connections: Array<[string, string]> = [
|
||||
['node1', 'node2'],
|
||||
['node2', 'node3'],
|
||||
];
|
||||
|
||||
const { layout } = createTestSetup(nodes, connections);
|
||||
const result = layout('all');
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(matchesGrid(result)).toBe(true);
|
||||
});
|
||||
|
||||
test('should layout a basic workflow with selected nodes', () => {
|
||||
const nodes = [
|
||||
createCanvasGraphNode({ id: 'node1' }),
|
||||
createCanvasGraphNode({ id: 'node2' }),
|
||||
createCanvasGraphNode({ id: 'node3' }),
|
||||
createCanvasGraphNode({ id: 'node4' }),
|
||||
];
|
||||
|
||||
const connections: Array<[string, string]> = [
|
||||
['node1', 'node2'],
|
||||
['node2', 'node3'],
|
||||
['node3', 'node4'],
|
||||
];
|
||||
|
||||
const { layout } = createTestSetup(nodes, connections, ['node1', 'node2', 'node3']);
|
||||
const result = layout('selection');
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(matchesGrid(result)).toBe(true);
|
||||
});
|
||||
|
||||
test('should layout a workflow with AI nodes', () => {
|
||||
const nodes = [
|
||||
createCanvasGraphNode({ id: 'node1' }),
|
||||
createCanvasGraphNode({
|
||||
id: 'aiAgent',
|
||||
data: { render: { type: CanvasNodeRenderType.Default, options: { configurable: true } } },
|
||||
}),
|
||||
createCanvasGraphNode({
|
||||
id: 'aiTool1',
|
||||
data: { render: { type: CanvasNodeRenderType.Default, options: { configuration: true } } },
|
||||
}),
|
||||
createCanvasGraphNode({
|
||||
id: 'aiTool2',
|
||||
data: { render: { type: CanvasNodeRenderType.Default, options: { configuration: true } } },
|
||||
}),
|
||||
createCanvasGraphNode({
|
||||
id: 'configurableAiTool',
|
||||
data: {
|
||||
render: {
|
||||
type: CanvasNodeRenderType.Default,
|
||||
options: { configurable: true, configuration: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
createCanvasGraphNode({
|
||||
id: 'aiTool3',
|
||||
data: { render: { type: CanvasNodeRenderType.Default, options: { configuration: true } } },
|
||||
}),
|
||||
createCanvasGraphNode({ id: 'node2' }),
|
||||
];
|
||||
|
||||
const connections: Array<[string, string]> = [
|
||||
['node1', 'aiAgent'],
|
||||
['aiTool1', 'aiAgent'],
|
||||
['aiTool2', 'aiAgent'],
|
||||
['configurableAiTool', 'aiAgent'],
|
||||
['aiTool3', 'configurableAiTool'],
|
||||
['aiAgent', 'node2'],
|
||||
];
|
||||
|
||||
const { layout } = createTestSetup(nodes, connections);
|
||||
const result = layout('all');
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(matchesGrid(result)).toBe(true);
|
||||
});
|
||||
|
||||
test('should layout a workflow with sticky notes', () => {
|
||||
const nodes = [
|
||||
createCanvasGraphNode({ id: 'node1', position: { x: 0, y: 0 } }),
|
||||
createCanvasGraphNode({ id: 'node2', position: { x: 500, y: 0 } }),
|
||||
createCanvasGraphNode({ id: 'node3', position: { x: 700, y: 0 } }),
|
||||
createCanvasGraphNode({ id: 'node4', position: { x: 1000, y: 0 } }),
|
||||
createCanvasGraphNode({
|
||||
id: 'sticky',
|
||||
data: { type: STICKY_NODE_TYPE },
|
||||
dimensions: { width: 500, height: 400 },
|
||||
position: { x: 400, y: -100 },
|
||||
}),
|
||||
];
|
||||
|
||||
const connections: Array<[string, string]> = [
|
||||
['node1', 'node2'],
|
||||
['node2', 'node3'],
|
||||
['node3', 'node4'],
|
||||
];
|
||||
|
||||
const { layout } = createTestSetup(nodes, connections);
|
||||
const result = layout('all');
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should not reorder nodes vertically as it affects execution order', () => {
|
||||
const nodes = [
|
||||
createCanvasGraphNode({ id: 'node1', position: { x: 0, y: 0 } }),
|
||||
createCanvasGraphNode({ id: 'node2', position: { x: 400, y: 200 } }),
|
||||
createCanvasGraphNode({ id: 'node3', position: { x: 400, y: -200 } }),
|
||||
];
|
||||
|
||||
const connections: Array<[string, string]> = [
|
||||
['node1', 'node3'],
|
||||
['node1', 'node2'],
|
||||
];
|
||||
|
||||
const { layout } = createTestSetup(nodes, connections);
|
||||
const result = layout('all');
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(matchesGrid(result)).toBe(true);
|
||||
});
|
||||
});
|
516
packages/frontend/editor-ui/src/composables/useCanvasLayout.ts
Normal file
516
packages/frontend/editor-ui/src/composables/useCanvasLayout.ts
Normal file
|
@ -0,0 +1,516 @@
|
|||
import dagre from '@dagrejs/dagre';
|
||||
|
||||
import { useVueFlow, type GraphEdge, type GraphNode, type XYPosition } from '@vue-flow/core';
|
||||
import { STICKY_NODE_TYPE } from '../constants';
|
||||
import {
|
||||
CanvasNodeRenderType,
|
||||
type BoundingBox,
|
||||
type CanvasConnection,
|
||||
type CanvasNodeData,
|
||||
} from '../types';
|
||||
import { isPresent } from '../utils/typesUtils';
|
||||
import { GRID_SIZE, NODE_SIZE } from '../utils/nodeViewUtils';
|
||||
|
||||
export type CanvasLayoutOptions = { id?: string };
|
||||
export type CanvasLayoutTarget = 'selection' | 'all';
|
||||
export type CanvasLayoutTargetData = {
|
||||
nodes: Array<GraphNode<CanvasNodeData>>;
|
||||
edges: CanvasConnection[];
|
||||
};
|
||||
|
||||
export type NodeLayoutResult = {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
export type LayoutResult = { boundingBox: BoundingBox; nodes: NodeLayoutResult[] };
|
||||
|
||||
export type CanvasNodeDictionary = Record<string, GraphNode<CanvasNodeData>>;
|
||||
|
||||
const NODE_X_SPACING = GRID_SIZE * 6;
|
||||
const NODE_Y_SPACING = GRID_SIZE * 5;
|
||||
const SUBGRAPH_SPACING = GRID_SIZE * 8;
|
||||
const AI_X_SPACING = GRID_SIZE * 2;
|
||||
const AI_Y_SPACING = GRID_SIZE * 6;
|
||||
const STICKY_BOTTOM_PADDING = GRID_SIZE * 3;
|
||||
|
||||
export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
|
||||
const {
|
||||
findNode,
|
||||
findEdge,
|
||||
getSelectedNodes,
|
||||
edges: allEdges,
|
||||
nodes: allNodes,
|
||||
} = useVueFlow({ id: canvasId });
|
||||
|
||||
function getTargetData(target: CanvasLayoutTarget): CanvasLayoutTargetData {
|
||||
if (target === 'selection') {
|
||||
return { nodes: getSelectedNodes.value, edges: allEdges.value };
|
||||
}
|
||||
return { nodes: allNodes.value, edges: allEdges.value };
|
||||
}
|
||||
|
||||
function sortByPosition(posA: XYPosition, posB: XYPosition): number {
|
||||
const yDiff = posA.y - posB.y;
|
||||
return yDiff === 0 ? posA.x - posB.x : yDiff;
|
||||
}
|
||||
|
||||
function sortNodesByPosition(nodeA: GraphNode, nodeB: GraphNode): number {
|
||||
const hasEdgesA = allEdges.value.some((edge) => edge.target === nodeA.id);
|
||||
const hasEdgesB = allEdges.value.some((edge) => edge.target === nodeB.id);
|
||||
|
||||
if (!hasEdgesA && hasEdgesB) return -1;
|
||||
if (hasEdgesA && !hasEdgesB) return 1;
|
||||
return sortByPosition(nodeA.position, nodeB.position);
|
||||
}
|
||||
|
||||
function sortEdgesByPosition(edgeA: GraphEdge, edgeB: GraphEdge): number {
|
||||
return sortByPosition(positionFromEdge(edgeA), positionFromEdge(edgeB));
|
||||
}
|
||||
|
||||
function positionFromEdge(edge: GraphEdge): XYPosition {
|
||||
return { x: edge.targetX, y: edge.targetY };
|
||||
}
|
||||
|
||||
function createDagreGraph({ nodes, edges }: CanvasLayoutTargetData) {
|
||||
const graph = new dagre.graphlib.Graph();
|
||||
graph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const graphNodes = nodes
|
||||
.map((node) => findNode<CanvasNodeData>(node.id))
|
||||
.filter(isPresent)
|
||||
.sort(sortNodesByPosition);
|
||||
|
||||
const nodeIdSet = new Set(nodes.map((node) => node.id));
|
||||
|
||||
graphNodes.forEach(({ id: nodeId, position: { x, y }, dimensions: { width, height } }) => {
|
||||
graph.setNode(nodeId, { width, height, x, y });
|
||||
});
|
||||
|
||||
edges
|
||||
.map((node) => findEdge<CanvasNodeData>(node.id))
|
||||
.filter(isPresent)
|
||||
.filter((edge) => nodeIdSet.has(edge.source) && nodeIdSet.has(edge.target))
|
||||
.sort(sortEdgesByPosition)
|
||||
.forEach((edge) => graph.setEdge(edge.source, edge.target));
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
function createDagreSubGraph({
|
||||
nodeIds,
|
||||
parent,
|
||||
}: { nodeIds: string[]; parent: dagre.graphlib.Graph }) {
|
||||
const subGraph = new dagre.graphlib.Graph();
|
||||
subGraph.setGraph({
|
||||
rankdir: 'LR',
|
||||
edgesep: NODE_Y_SPACING,
|
||||
nodesep: NODE_Y_SPACING,
|
||||
ranksep: NODE_X_SPACING,
|
||||
});
|
||||
subGraph.setDefaultEdgeLabel(() => ({}));
|
||||
const nodeIdSet = new Set(nodeIds);
|
||||
|
||||
parent
|
||||
.nodes()
|
||||
.filter((nodeId) => nodeIdSet.has(nodeId))
|
||||
.forEach((nodeId) => {
|
||||
subGraph.setNode(nodeId, parent.node(nodeId));
|
||||
});
|
||||
|
||||
parent
|
||||
.edges()
|
||||
.filter((edge) => nodeIdSet.has(edge.v) && nodeIdSet.has(edge.w))
|
||||
.forEach((edge) => subGraph.setEdge(edge.v, edge.w, parent.edge(edge)));
|
||||
|
||||
return subGraph;
|
||||
}
|
||||
|
||||
function createDagreVerticalGraph({ nodes }: { nodes: Array<{ id: string; box: BoundingBox }> }) {
|
||||
const subGraph = new dagre.graphlib.Graph();
|
||||
subGraph.setGraph({
|
||||
rankdir: 'TB',
|
||||
align: 'UL',
|
||||
edgesep: SUBGRAPH_SPACING,
|
||||
nodesep: SUBGRAPH_SPACING,
|
||||
ranksep: SUBGRAPH_SPACING,
|
||||
});
|
||||
subGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
nodes.forEach(({ id, box: { x, y, width, height } }) =>
|
||||
subGraph.setNode(id, { x, y, width, height }),
|
||||
);
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
if (!nodes[index + 1]) return;
|
||||
subGraph.setEdge(node.id, nodes[index + 1].id);
|
||||
});
|
||||
|
||||
return subGraph;
|
||||
}
|
||||
|
||||
function createAiSubGraph({
|
||||
parent,
|
||||
nodeIds,
|
||||
}: { parent: dagre.graphlib.Graph; nodeIds: string[] }) {
|
||||
const subGraph = new dagre.graphlib.Graph();
|
||||
subGraph.setGraph({
|
||||
rankdir: 'TB',
|
||||
edgesep: AI_X_SPACING,
|
||||
nodesep: AI_X_SPACING,
|
||||
ranksep: AI_Y_SPACING,
|
||||
});
|
||||
subGraph.setDefaultEdgeLabel(() => ({}));
|
||||
const nodeIdSet = new Set(nodeIds);
|
||||
|
||||
parent
|
||||
.nodes()
|
||||
.filter((nodeId) => nodeIdSet.has(nodeId))
|
||||
.forEach((nodeId) => {
|
||||
subGraph.setNode(nodeId, parent.node(nodeId));
|
||||
});
|
||||
|
||||
parent
|
||||
.edges()
|
||||
.filter((edge) => nodeIdSet.has(edge.v) && nodeIdSet.has(edge.w))
|
||||
.forEach((edge) => subGraph.setEdge(edge.w, edge.v));
|
||||
|
||||
return subGraph;
|
||||
}
|
||||
|
||||
// For a list of bounding boxes, return the bounding box that contains them all
|
||||
function compositeBoundingBox(boxes: BoundingBox[]): BoundingBox {
|
||||
const { minX, minY, maxX, maxY } = boxes.reduce(
|
||||
(bbox, node) => {
|
||||
const { x, y, width, height } = node;
|
||||
return {
|
||||
minX: Math.min(bbox.minX, x),
|
||||
maxX: Math.max(bbox.maxX, x + width),
|
||||
minY: Math.min(bbox.minY, y),
|
||||
maxY: Math.max(bbox.maxY, y + height),
|
||||
};
|
||||
},
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
);
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
|
||||
function boundingBoxFromCanvasNode(node: GraphNode<CanvasNodeData>): BoundingBox {
|
||||
return {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
width: node.dimensions.width,
|
||||
height: node.dimensions.height,
|
||||
};
|
||||
}
|
||||
|
||||
function boundingBoxFromDagreNode(node: dagre.Node): BoundingBox {
|
||||
return {
|
||||
x: node.x - node.width / 2,
|
||||
y: node.y - node.height / 2,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
};
|
||||
}
|
||||
|
||||
function boundingBoxFromGraph(graph: dagre.graphlib.Graph): BoundingBox {
|
||||
return compositeBoundingBox(
|
||||
graph.nodes().map((nodeId) => boundingBoxFromDagreNode(graph.node(nodeId))),
|
||||
);
|
||||
}
|
||||
|
||||
function boundingBoxFromCanvasNodes(nodes: Array<GraphNode<CanvasNodeData>>): BoundingBox {
|
||||
return compositeBoundingBox(nodes.map(boundingBoxFromCanvasNode));
|
||||
}
|
||||
|
||||
// Is the `child` bounding box completely contained in the `parent` bounding box
|
||||
function isCoveredBy(parent: BoundingBox, child: BoundingBox) {
|
||||
const childRight = child.x + child.width;
|
||||
const childBottom = child.y + child.height;
|
||||
const parentRight = parent.x + parent.width;
|
||||
const parentBottom = parent.y + parent.height;
|
||||
|
||||
return (
|
||||
child.x >= parent.x &&
|
||||
child.y >= parent.y &&
|
||||
childRight <= parentRight &&
|
||||
childBottom <= parentBottom
|
||||
);
|
||||
}
|
||||
|
||||
function centerHorizontally(container: BoundingBox, target: BoundingBox) {
|
||||
const containerCenter = container.x + container.width / 2;
|
||||
const newX = containerCenter - target.width / 2;
|
||||
return newX;
|
||||
}
|
||||
|
||||
function intersects(container: BoundingBox, target: BoundingBox, padding = 0): boolean {
|
||||
// Add padding to target box dimensions
|
||||
const targetWithPadding = {
|
||||
x: target.x - padding,
|
||||
y: target.y - padding,
|
||||
width: target.width + padding * 2,
|
||||
height: target.height + padding * 2,
|
||||
};
|
||||
|
||||
const noIntersection =
|
||||
targetWithPadding.x + targetWithPadding.width < container.x ||
|
||||
targetWithPadding.x > container.x + container.width ||
|
||||
targetWithPadding.y + targetWithPadding.height < container.y ||
|
||||
targetWithPadding.y > container.y + container.height;
|
||||
|
||||
return !noIntersection;
|
||||
}
|
||||
|
||||
function isAiParentNode(node: CanvasNodeData) {
|
||||
return (
|
||||
node.render.type === CanvasNodeRenderType.Default &&
|
||||
node.render.options.configurable &&
|
||||
!node.render.options.configuration
|
||||
);
|
||||
}
|
||||
|
||||
function isAiConfigNode(node: CanvasNodeData) {
|
||||
return node.render.type === CanvasNodeRenderType.Default && node.render.options.configuration;
|
||||
}
|
||||
|
||||
function getAllConnectedAiConfigNodes({
|
||||
graph,
|
||||
root,
|
||||
nodeById,
|
||||
}: {
|
||||
graph: dagre.graphlib.Graph;
|
||||
root: CanvasNodeData;
|
||||
nodeById: CanvasNodeDictionary;
|
||||
}): string[] {
|
||||
return (graph.predecessors(root.id) as unknown as string[])
|
||||
.map((successor) => nodeById[successor])
|
||||
.filter((node) => isAiConfigNode(node.data))
|
||||
.flatMap((node) => [
|
||||
node.id,
|
||||
...getAllConnectedAiConfigNodes({ graph, root: node.data, nodeById }),
|
||||
]);
|
||||
}
|
||||
|
||||
function layout(target: CanvasLayoutTarget): LayoutResult {
|
||||
const { nodes, edges } = getTargetData(target);
|
||||
|
||||
const nonStickyNodes = nodes
|
||||
.filter((node) => node.data.type !== STICKY_NODE_TYPE)
|
||||
.map((node) => findNode(node.id))
|
||||
.filter(isPresent);
|
||||
const boundingBoxBefore = boundingBoxFromCanvasNodes(nonStickyNodes);
|
||||
|
||||
const parentGraph = createDagreGraph({ nodes: nonStickyNodes, edges });
|
||||
const nodeById = nonStickyNodes.reduce((acc, node) => {
|
||||
acc[node.id] = node;
|
||||
return acc;
|
||||
}, {} as CanvasNodeDictionary);
|
||||
|
||||
// Divide workflow in to subgraphs
|
||||
// A subgraph contains a group of connected nodes that is not connected to any node outside of this group
|
||||
const subgraphs = dagre.graphlib.alg.components(parentGraph).map((nodeIds) => {
|
||||
const subgraph = createDagreSubGraph({ nodeIds, parent: parentGraph });
|
||||
const aiParentNodes = subgraph
|
||||
.nodes()
|
||||
.map((nodeId) => nodeById[nodeId].data)
|
||||
.filter(isAiParentNode);
|
||||
|
||||
// Create a subgraph for each AI (configurable) node and apply a top-bottom layout
|
||||
// Then add the bounding box of this layout back into the parent graph before doing layout
|
||||
const aiGraphs = aiParentNodes.map((aiParentNode) => {
|
||||
const configNodeIds = getAllConnectedAiConfigNodes({
|
||||
graph: subgraph,
|
||||
nodeById,
|
||||
root: aiParentNode,
|
||||
});
|
||||
const allAiNodeIds = configNodeIds.concat(aiParentNode.id);
|
||||
const aiGraph = createAiSubGraph({
|
||||
parent: subgraph,
|
||||
nodeIds: allAiNodeIds,
|
||||
});
|
||||
configNodeIds.forEach((nodeId) => subgraph.removeNode(nodeId));
|
||||
const rootEdges = subgraph
|
||||
.edges()
|
||||
.filter((edge) => edge.v === aiParentNode.id || edge.w === aiParentNode.id);
|
||||
|
||||
dagre.layout(aiGraph, { disableOptimalOrderHeuristic: true });
|
||||
const aiBoundingBox = boundingBoxFromGraph(aiGraph);
|
||||
subgraph.setNode(aiParentNode.id, {
|
||||
width: aiBoundingBox.width,
|
||||
height: aiBoundingBox.height,
|
||||
});
|
||||
rootEdges.forEach((edge) => subgraph.setEdge(edge));
|
||||
|
||||
return { graph: aiGraph, boundingBox: aiBoundingBox, aiParentNode };
|
||||
});
|
||||
|
||||
dagre.layout(subgraph, { disableOptimalOrderHeuristic: true });
|
||||
|
||||
return { graph: subgraph, aiGraphs, boundingBox: boundingBoxFromGraph(subgraph) };
|
||||
});
|
||||
|
||||
const compositeGraph = createDagreVerticalGraph({
|
||||
nodes: subgraphs.map(({ boundingBox }, index) => ({
|
||||
box: boundingBox,
|
||||
id: index.toString(),
|
||||
})),
|
||||
});
|
||||
|
||||
dagre.layout(compositeGraph, { disableOptimalOrderHeuristic: true });
|
||||
|
||||
const boundingBoxByNodeId = subgraphs
|
||||
.flatMap(({ graph, aiGraphs }, index) => {
|
||||
const subgraphPosition = compositeGraph.node(index.toString());
|
||||
|
||||
const aiParentNodes = new Set(aiGraphs.map(({ aiParentNode }) => aiParentNode.id));
|
||||
const offset = {
|
||||
x: 0,
|
||||
y: subgraphPosition.y - subgraphPosition.height / 2,
|
||||
};
|
||||
|
||||
return graph.nodes().flatMap((nodeId) => {
|
||||
const { x, y, width, height } = graph.node(nodeId);
|
||||
const positionedNode = {
|
||||
id: nodeId,
|
||||
boundingBox: {
|
||||
x: x + offset.x - width / 2,
|
||||
y: y + offset.y - height / 2,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
};
|
||||
|
||||
if (aiParentNodes.has(nodeId)) {
|
||||
const aiGraph = aiGraphs.find(({ aiParentNode }) => aiParentNode.id === nodeId);
|
||||
|
||||
if (!aiGraph) return [];
|
||||
|
||||
const aiParentNodeBox = positionedNode.boundingBox;
|
||||
|
||||
const parentOffset = {
|
||||
x: aiParentNodeBox.x,
|
||||
y: aiParentNodeBox.y,
|
||||
};
|
||||
|
||||
return aiGraph.graph.nodes().map((aiNodeId) => {
|
||||
const aiNode = aiGraph.graph.node(aiNodeId);
|
||||
const aiBoundingBox = {
|
||||
x: aiNode.x + parentOffset.x - aiNode.width / 2,
|
||||
y: aiNode.y + parentOffset.y - aiNode.height / 2,
|
||||
width: aiNode.width,
|
||||
height: aiNode.height,
|
||||
};
|
||||
|
||||
return {
|
||||
id: aiNodeId,
|
||||
boundingBox: aiBoundingBox,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return positionedNode;
|
||||
});
|
||||
})
|
||||
.reduce(
|
||||
(acc, node) => {
|
||||
acc[node.id] = node.boundingBox;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, BoundingBox>,
|
||||
);
|
||||
|
||||
// Post process AI node vertical position
|
||||
// The bounding box of the AI node sublayout is vertically centered with the other nodes, but we want it to be top-aligned when possible
|
||||
// We need to be careful to only do this when it would not overlap with other nodes
|
||||
subgraphs
|
||||
.flatMap(({ aiGraphs }) => aiGraphs)
|
||||
.forEach(({ graph }) => {
|
||||
const aiNodes = graph.nodes();
|
||||
const aiGraphBoundingBox = compositeBoundingBox(
|
||||
aiNodes.map((nodeId) => boundingBoxByNodeId[nodeId]).filter(isPresent),
|
||||
);
|
||||
const aiNodeVerticalCorrection = aiGraphBoundingBox.height / 2 - NODE_SIZE / 2;
|
||||
aiGraphBoundingBox.y += aiNodeVerticalCorrection;
|
||||
|
||||
const hasConflictingNodes = Object.entries(boundingBoxByNodeId)
|
||||
.filter(([id]) => !graph.hasNode(id))
|
||||
.some(([, nodeBoundingBox]) =>
|
||||
intersects(aiGraphBoundingBox, nodeBoundingBox, NODE_Y_SPACING),
|
||||
);
|
||||
|
||||
if (!hasConflictingNodes) {
|
||||
for (const aiNode of aiNodes) {
|
||||
boundingBoxByNodeId[aiNode].y += aiNodeVerticalCorrection;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const positionedNodes = Object.entries(boundingBoxByNodeId).map(([id, boundingBox]) => ({
|
||||
id,
|
||||
boundingBox,
|
||||
}));
|
||||
const boundingBoxAfter = compositeBoundingBox(positionedNodes.map((node) => node.boundingBox));
|
||||
|
||||
const anchor = {
|
||||
x: boundingBoxAfter.x - boundingBoxBefore.x,
|
||||
y: boundingBoxAfter.y - boundingBoxBefore.y,
|
||||
};
|
||||
|
||||
const stickies = nodes
|
||||
.filter((node) => node.data.type === STICKY_NODE_TYPE)
|
||||
.map((node) => findNode(node.id))
|
||||
.filter(isPresent);
|
||||
|
||||
const positionedStickies = stickies
|
||||
.map((sticky) => {
|
||||
const stickyBox = boundingBoxFromCanvasNode(sticky);
|
||||
const coveredNodes = nonStickyNodes.filter((node) =>
|
||||
isCoveredBy(boundingBoxFromCanvasNode(sticky), boundingBoxFromCanvasNode(node)),
|
||||
);
|
||||
|
||||
if (coveredNodes.length === 0) return null;
|
||||
|
||||
const coveredNodesBoxAfter = compositeBoundingBox(
|
||||
positionedNodes
|
||||
.filter((node) => coveredNodes.some((covered) => covered.id === node.id))
|
||||
.map(({ boundingBox }) => boundingBox),
|
||||
);
|
||||
return {
|
||||
id: sticky.id,
|
||||
boundingBox: {
|
||||
x: centerHorizontally(coveredNodesBoxAfter, stickyBox),
|
||||
y:
|
||||
coveredNodesBoxAfter.y +
|
||||
coveredNodesBoxAfter.height -
|
||||
stickyBox.height +
|
||||
STICKY_BOTTOM_PADDING,
|
||||
height: stickyBox.height,
|
||||
width: stickyBox.width,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(isPresent);
|
||||
|
||||
return {
|
||||
boundingBox: boundingBoxAfter,
|
||||
nodes: positionedNodes.concat(positionedStickies).map(({ id, boundingBox }) => {
|
||||
return {
|
||||
id,
|
||||
x: boundingBox.x - anchor.x,
|
||||
y: boundingBox.y - anchor.y,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return { layout };
|
||||
}
|
|
@ -31,7 +31,8 @@ export type ContextMenuAction =
|
|||
| 'deselect_all'
|
||||
| 'add_node'
|
||||
| 'add_sticky'
|
||||
| 'change_color';
|
||||
| 'change_color'
|
||||
| 'tidy_up';
|
||||
|
||||
const position = ref<XYPosition>([0, 0]);
|
||||
const isOpen = ref(false);
|
||||
|
@ -120,7 +121,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
|||
},
|
||||
};
|
||||
|
||||
const selectionActions = [
|
||||
const selectionActions: ActionDropdownItem[] = [
|
||||
{
|
||||
id: 'select_all',
|
||||
divided: true,
|
||||
|
@ -135,6 +136,17 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
|||
},
|
||||
];
|
||||
|
||||
const layoutActions: ActionDropdownItem[] = [
|
||||
{
|
||||
id: 'tidy_up',
|
||||
divided: true,
|
||||
label: i18n.baseText(
|
||||
nodes.length < 2 ? 'contextMenu.tidyUpWorkflow' : 'contextMenu.tidyUpSelection',
|
||||
),
|
||||
shortcut: { shiftKey: true, altKey: true, keys: ['T'] },
|
||||
},
|
||||
];
|
||||
|
||||
if (nodes.length === 0) {
|
||||
actions.value = [
|
||||
{
|
||||
|
@ -149,6 +161,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
|||
label: i18n.baseText('contextMenu.addSticky'),
|
||||
disabled: isReadOnly.value,
|
||||
},
|
||||
...layoutActions,
|
||||
...selectionActions,
|
||||
];
|
||||
} else {
|
||||
|
@ -180,6 +193,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
|||
shortcut: { metaKey: true, keys: ['D'] },
|
||||
disabled: isReadOnly.value || !nodes.every(canDuplicateNode),
|
||||
},
|
||||
...layoutActions,
|
||||
...selectionActions,
|
||||
{
|
||||
id: 'delete',
|
||||
|
|
|
@ -1332,6 +1332,7 @@
|
|||
"nodeView.redirecting": "Redirecting",
|
||||
"nodeView.refresh": "Refresh",
|
||||
"nodeView.resetZoom": "Reset Zoom",
|
||||
"nodeView.tidyUp": "Tidy Up",
|
||||
"nodeView.runButtonText.executeWorkflow": "Test workflow",
|
||||
"nodeView.runButtonText.executingWorkflow": "Executing workflow",
|
||||
"nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event",
|
||||
|
@ -1381,6 +1382,8 @@
|
|||
"contextMenu.sticky": "sticky note | sticky notes",
|
||||
"contextMenu.selectAll": "Select all",
|
||||
"contextMenu.deselectAll": "Clear selection",
|
||||
"contextMenu.tidyUpWorkflow": "Tidy up workflow",
|
||||
"contextMenu.tidyUpSelection": "Tidy up selection",
|
||||
"contextMenu.duplicate": "Duplicate | Duplicate {count} {subject}",
|
||||
"contextMenu.open": "Open...",
|
||||
"contextMenu.test": "Test step",
|
||||
|
|
|
@ -1576,6 +1576,9 @@ importers:
|
|||
'@codemirror/view':
|
||||
specifier: ^6.26.3
|
||||
version: 6.26.3
|
||||
'@dagrejs/dagre':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4
|
||||
'@fontsource/open-sans':
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.12
|
||||
|
@ -3343,6 +3346,13 @@ packages:
|
|||
'@dabh/diagnostics@2.0.3':
|
||||
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
|
||||
|
||||
'@dagrejs/dagre@1.1.4':
|
||||
resolution: {integrity: sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==}
|
||||
|
||||
'@dagrejs/graphlib@2.2.4':
|
||||
resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==}
|
||||
engines: {node: '>17.0.0'}
|
||||
|
||||
'@element-plus/icons-vue@2.3.1':
|
||||
resolution: {integrity: sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==}
|
||||
peerDependencies:
|
||||
|
@ -15752,6 +15762,12 @@ snapshots:
|
|||
enabled: 2.0.0
|
||||
kuler: 2.0.0
|
||||
|
||||
'@dagrejs/dagre@1.1.4':
|
||||
dependencies:
|
||||
'@dagrejs/graphlib': 2.2.4
|
||||
|
||||
'@dagrejs/graphlib@2.2.4': {}
|
||||
|
||||
'@element-plus/icons-vue@2.3.1(vue@3.5.13(typescript@5.7.2))':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.7.2)
|
||||
|
|
Loading…
Reference in a new issue