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) {
|
export function clickContextMenuAction(action: string) {
|
||||||
getContextMenuAction(action).click();
|
getContextMenuAction(action).click({ force: true });
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,16 +14,16 @@ const keys = computed(() => {
|
||||||
allKeys.unshift('⌘');
|
allKeys.unshift('⌘');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.shiftKey) {
|
if (props.metaKey && !isMacOs) {
|
||||||
allKeys.unshift('⇧');
|
allKeys.unshift('Ctrl');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.altKey) {
|
if (props.altKey) {
|
||||||
allKeys.unshift(isMacOs ? '⌥' : 'Alt');
|
allKeys.unshift(isMacOs ? '⌥' : 'Alt');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.metaKey && !isMacOs) {
|
if (props.shiftKey) {
|
||||||
allKeys.unshift('Ctrl');
|
allKeys.unshift('⇧');
|
||||||
}
|
}
|
||||||
|
|
||||||
return allKeys;
|
return allKeys;
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
"@codemirror/search": "^6.5.6",
|
"@codemirror/search": "^6.5.6",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
"@codemirror/view": "^6.26.3",
|
"@codemirror/view": "^6.26.3",
|
||||||
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
"@fontsource/open-sans": "^4.5.0",
|
"@fontsource/open-sans": "^4.5.0",
|
||||||
"@lezer/common": "1.1.0",
|
"@lezer/common": "1.1.0",
|
||||||
"@n8n/api-types": "workspace:*",
|
"@n8n/api-types": "workspace:*",
|
||||||
|
|
|
@ -12,9 +12,9 @@ import type {
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
|
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
import type { GraphEdge, GraphNode, ViewportTransform } from '@vue-flow/core';
|
||||||
import type { EventBus } from '@n8n/utils/event-bus';
|
import type { EventBus } from '@n8n/utils/event-bus';
|
||||||
import { createEventBus } from '@n8n/utils/event-bus';
|
import { createEventBus } from '@n8n/utils/event-bus';
|
||||||
import type { ViewportTransform } from '@vue-flow/core';
|
|
||||||
|
|
||||||
export function createCanvasNodeData({
|
export function createCanvasNodeData({
|
||||||
id = 'node',
|
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({
|
export function createCanvasNodeProps({
|
||||||
id = 'node',
|
id = 'node',
|
||||||
label = 'Test Node',
|
label = 'Test Node',
|
||||||
|
@ -196,3 +225,30 @@ export function createCanvasConnection(
|
||||||
...(nodeBInput ? { targetHandle: `inputs/${nodeBInput.type}/${nodeBInput.index}` } : {}),
|
...(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 { 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';
|
||||||
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 type { EventBus } from '@n8n/utils/event-bus';
|
||||||
import { createEventBus } from '@n8n/utils/event-bus';
|
import { createEventBus } from '@n8n/utils/event-bus';
|
||||||
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||||
|
@ -37,6 +47,7 @@ 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';
|
||||||
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
|
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
|
||||||
|
import { useCanvasLayout } from '@/composables/useCanvasLayout';
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
|
@ -139,6 +150,7 @@ const {
|
||||||
getDownstreamNodes,
|
getDownstreamNodes,
|
||||||
getUpstreamNodes,
|
getUpstreamNodes,
|
||||||
} = useCanvasTraversal(vueFlow);
|
} = useCanvasTraversal(vueFlow);
|
||||||
|
const { layout } = useCanvasLayout({ id: props.id });
|
||||||
|
|
||||||
const isPaneReady = ref(false);
|
const isPaneReady = ref(false);
|
||||||
|
|
||||||
|
@ -245,7 +257,8 @@ function selectUpstreamNodes(id: string) {
|
||||||
onSelectNodes({ ids: [...upstreamNodes.map((node) => node.id), id] });
|
onSelectNodes({ ids: [...upstreamNodes.map((node) => node.id), id] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyMap = computed(() => ({
|
const keyMap = computed(() => {
|
||||||
|
const readOnlyKeymap = {
|
||||||
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
|
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
|
||||||
enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)),
|
enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)),
|
||||||
ctrl_a: () => addSelectedNodes(graphNodes.value),
|
ctrl_a: () => addSelectedNodes(graphNodes.value),
|
||||||
|
@ -260,10 +273,12 @@ const keyMap = computed(() => ({
|
||||||
ArrowRight: emitWithLastSelectedNode(selectRightNode),
|
ArrowRight: emitWithLastSelectedNode(selectRightNode),
|
||||||
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
|
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
|
||||||
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
|
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
|
||||||
|
};
|
||||||
|
|
||||||
...(props.readOnly
|
if (props.readOnly) return readOnlyKeymap;
|
||||||
? {}
|
|
||||||
: {
|
const fullKeymap = {
|
||||||
|
...readOnlyKeymap,
|
||||||
ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)),
|
ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)),
|
||||||
'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)),
|
'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)),
|
||||||
ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)),
|
ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)),
|
||||||
|
@ -275,8 +290,10 @@ const keyMap = computed(() => ({
|
||||||
ctrl_alt_n: () => emit('create:workflow'),
|
ctrl_alt_n: () => emit('create:workflow'),
|
||||||
ctrl_enter: () => emit('run:workflow'),
|
ctrl_enter: () => emit('run:workflow'),
|
||||||
ctrl_s: () => emit('save:workflow'),
|
ctrl_s: () => emit('save:workflow'),
|
||||||
}),
|
shift_alt_t: onTidyUp,
|
||||||
}));
|
};
|
||||||
|
return fullKeymap;
|
||||||
|
});
|
||||||
|
|
||||||
useKeybindings(keyMap, { disabled: disableKeyBindings });
|
useKeybindings(keyMap, { disabled: disableKeyBindings });
|
||||||
|
|
||||||
|
@ -589,7 +606,7 @@ function onOpenNodeContextMenu(
|
||||||
contextMenu.open(event, { source, nodeId: id });
|
contextMenu.open(event, { source, nodeId: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
|
async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'add_node':
|
case 'add_node':
|
||||||
return emit('create:node', 'context_menu');
|
return emit('create:node', 'context_menu');
|
||||||
|
@ -617,6 +634,20 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
|
||||||
return emit('update:node:name', nodeIds[0]);
|
return emit('update:node:name', nodeIds[0]);
|
||||||
case 'change_color':
|
case 'change_color':
|
||||||
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky: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-in="onZoomIn"
|
||||||
@zoom-out="onZoomOut"
|
@zoom-out="onZoomOut"
|
||||||
@reset-zoom="onResetZoom"
|
@reset-zoom="onResetZoom"
|
||||||
|
@tidy-up="onTidyUp"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Controls } from '@vue-flow/controls';
|
import { Controls } from '@vue-flow/controls';
|
||||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||||
|
import TidyUpIcon from '@/components/TidyUpIcon.vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
|
@ -18,6 +19,7 @@ const emit = defineEmits<{
|
||||||
'zoom-in': [];
|
'zoom-in': [];
|
||||||
'zoom-out': [];
|
'zoom-out': [];
|
||||||
'zoom-to-fit': [];
|
'zoom-to-fit': [];
|
||||||
|
'tidy-up': [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
@ -39,6 +41,10 @@ function onZoomOut() {
|
||||||
function onZoomToFit() {
|
function onZoomToFit() {
|
||||||
emit('zoom-to-fit');
|
emit('zoom-to-fit');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onTidyUp() {
|
||||||
|
emit('tidy-up');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Controls :show-zoom="false" :show-fit-view="false">
|
<Controls :show-zoom="false" :show-fit-view="false">
|
||||||
|
@ -85,9 +91,37 @@ function onZoomToFit() {
|
||||||
@click="onResetZoom"
|
@click="onResetZoom"
|
||||||
/>
|
/>
|
||||||
</KeyboardShortcutTooltip>
|
</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>
|
</Controls>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.iconButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.vue-flow__controls {
|
.vue-flow__controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -20,6 +20,10 @@ exports[`CanvasControlButtons > should render correctly 1`] = `
|
||||||
</button>
|
</button>
|
||||||
<!--teleport start-->
|
<!--teleport start-->
|
||||||
<!--teleport end-->
|
<!--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>"
|
</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,
|
"metaKey": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"divided": true,
|
||||||
|
"id": "tidy_up",
|
||||||
|
"label": "Tidy up workflow",
|
||||||
|
"shortcut": {
|
||||||
|
"altKey": true,
|
||||||
|
"keys": [
|
||||||
|
"T",
|
||||||
|
],
|
||||||
|
"shiftKey": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"divided": true,
|
"divided": true,
|
||||||
|
@ -136,6 +148,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe
|
||||||
"metaKey": true,
|
"metaKey": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"divided": true,
|
||||||
|
"id": "tidy_up",
|
||||||
|
"label": "Tidy up workflow",
|
||||||
|
"shortcut": {
|
||||||
|
"altKey": true,
|
||||||
|
"keys": [
|
||||||
|
"T",
|
||||||
|
],
|
||||||
|
"shiftKey": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"divided": true,
|
"divided": true,
|
||||||
|
@ -234,6 +258,18 @@ exports[`useContextMenu > should return the correct actions opening the menu fro
|
||||||
"metaKey": true,
|
"metaKey": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"divided": true,
|
||||||
|
"id": "tidy_up",
|
||||||
|
"label": "Tidy up workflow",
|
||||||
|
"shortcut": {
|
||||||
|
"altKey": true,
|
||||||
|
"keys": [
|
||||||
|
"T",
|
||||||
|
],
|
||||||
|
"shiftKey": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"divided": true,
|
"divided": true,
|
||||||
|
@ -332,6 +368,18 @@ exports[`useContextMenu > should return the correct actions when right clicking
|
||||||
"metaKey": true,
|
"metaKey": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"divided": true,
|
||||||
|
"id": "tidy_up",
|
||||||
|
"label": "Tidy up workflow",
|
||||||
|
"shortcut": {
|
||||||
|
"altKey": true,
|
||||||
|
"keys": [
|
||||||
|
"T",
|
||||||
|
],
|
||||||
|
"shiftKey": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"divided": true,
|
"divided": true,
|
||||||
|
@ -401,6 +449,18 @@ exports[`useContextMenu > should return the correct actions when right clicking
|
||||||
"metaKey": true,
|
"metaKey": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"divided": true,
|
||||||
|
"id": "tidy_up",
|
||||||
|
"label": "Tidy up workflow",
|
||||||
|
"shortcut": {
|
||||||
|
"altKey": true,
|
||||||
|
"keys": [
|
||||||
|
"T",
|
||||||
|
],
|
||||||
|
"shiftKey": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"divided": true,
|
"divided": true,
|
||||||
|
@ -475,6 +535,18 @@ exports[`useContextMenu > should support opening and closing (default = right cl
|
||||||
"metaKey": true,
|
"metaKey": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"divided": true,
|
||||||
|
"id": "tidy_up",
|
||||||
|
"label": "Tidy up selection",
|
||||||
|
"shortcut": {
|
||||||
|
"altKey": true,
|
||||||
|
"keys": [
|
||||||
|
"T",
|
||||||
|
],
|
||||||
|
"shiftKey": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"divided": true,
|
"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'
|
| 'deselect_all'
|
||||||
| 'add_node'
|
| 'add_node'
|
||||||
| 'add_sticky'
|
| 'add_sticky'
|
||||||
| 'change_color';
|
| 'change_color'
|
||||||
|
| 'tidy_up';
|
||||||
|
|
||||||
const position = ref<XYPosition>([0, 0]);
|
const position = ref<XYPosition>([0, 0]);
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
|
@ -120,7 +121,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectionActions = [
|
const selectionActions: ActionDropdownItem[] = [
|
||||||
{
|
{
|
||||||
id: 'select_all',
|
id: 'select_all',
|
||||||
divided: true,
|
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) {
|
if (nodes.length === 0) {
|
||||||
actions.value = [
|
actions.value = [
|
||||||
{
|
{
|
||||||
|
@ -149,6 +161,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||||
label: i18n.baseText('contextMenu.addSticky'),
|
label: i18n.baseText('contextMenu.addSticky'),
|
||||||
disabled: isReadOnly.value,
|
disabled: isReadOnly.value,
|
||||||
},
|
},
|
||||||
|
...layoutActions,
|
||||||
...selectionActions,
|
...selectionActions,
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
|
@ -180,6 +193,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||||
shortcut: { metaKey: true, keys: ['D'] },
|
shortcut: { metaKey: true, keys: ['D'] },
|
||||||
disabled: isReadOnly.value || !nodes.every(canDuplicateNode),
|
disabled: isReadOnly.value || !nodes.every(canDuplicateNode),
|
||||||
},
|
},
|
||||||
|
...layoutActions,
|
||||||
...selectionActions,
|
...selectionActions,
|
||||||
{
|
{
|
||||||
id: 'delete',
|
id: 'delete',
|
||||||
|
|
|
@ -1332,6 +1332,7 @@
|
||||||
"nodeView.redirecting": "Redirecting",
|
"nodeView.redirecting": "Redirecting",
|
||||||
"nodeView.refresh": "Refresh",
|
"nodeView.refresh": "Refresh",
|
||||||
"nodeView.resetZoom": "Reset Zoom",
|
"nodeView.resetZoom": "Reset Zoom",
|
||||||
|
"nodeView.tidyUp": "Tidy Up",
|
||||||
"nodeView.runButtonText.executeWorkflow": "Test workflow",
|
"nodeView.runButtonText.executeWorkflow": "Test workflow",
|
||||||
"nodeView.runButtonText.executingWorkflow": "Executing workflow",
|
"nodeView.runButtonText.executingWorkflow": "Executing workflow",
|
||||||
"nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event",
|
"nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event",
|
||||||
|
@ -1381,6 +1382,8 @@
|
||||||
"contextMenu.sticky": "sticky note | sticky notes",
|
"contextMenu.sticky": "sticky note | sticky notes",
|
||||||
"contextMenu.selectAll": "Select all",
|
"contextMenu.selectAll": "Select all",
|
||||||
"contextMenu.deselectAll": "Clear selection",
|
"contextMenu.deselectAll": "Clear selection",
|
||||||
|
"contextMenu.tidyUpWorkflow": "Tidy up workflow",
|
||||||
|
"contextMenu.tidyUpSelection": "Tidy up selection",
|
||||||
"contextMenu.duplicate": "Duplicate | Duplicate {count} {subject}",
|
"contextMenu.duplicate": "Duplicate | Duplicate {count} {subject}",
|
||||||
"contextMenu.open": "Open...",
|
"contextMenu.open": "Open...",
|
||||||
"contextMenu.test": "Test step",
|
"contextMenu.test": "Test step",
|
||||||
|
|
|
@ -1576,6 +1576,9 @@ importers:
|
||||||
'@codemirror/view':
|
'@codemirror/view':
|
||||||
specifier: ^6.26.3
|
specifier: ^6.26.3
|
||||||
version: 6.26.3
|
version: 6.26.3
|
||||||
|
'@dagrejs/dagre':
|
||||||
|
specifier: ^1.1.4
|
||||||
|
version: 1.1.4
|
||||||
'@fontsource/open-sans':
|
'@fontsource/open-sans':
|
||||||
specifier: ^4.5.0
|
specifier: ^4.5.0
|
||||||
version: 4.5.12
|
version: 4.5.12
|
||||||
|
@ -3343,6 +3346,13 @@ packages:
|
||||||
'@dabh/diagnostics@2.0.3':
|
'@dabh/diagnostics@2.0.3':
|
||||||
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
|
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':
|
'@element-plus/icons-vue@2.3.1':
|
||||||
resolution: {integrity: sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==}
|
resolution: {integrity: sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -15752,6 +15762,12 @@ snapshots:
|
||||||
enabled: 2.0.0
|
enabled: 2.0.0
|
||||||
kuler: 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))':
|
'@element-plus/icons-vue@2.3.1(vue@3.5.13(typescript@5.7.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.13(typescript@5.7.2)
|
vue: 3.5.13(typescript@5.7.2)
|
||||||
|
|
Loading…
Reference in a new issue