mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
517 lines
15 KiB
TypeScript
517 lines
15 KiB
TypeScript
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 };
|
|
}
|