Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/serverless-workflow-diagram-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@xyflow/react": "catalog:",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"elkjs": "catalog:",
"js-yaml": "catalog:",
"radix-ui": "catalog:"
},
Expand Down
62 changes: 62 additions & 0 deletions packages/serverless-workflow-diagram-editor/src/core/elkjs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2021-Present The Serverless Workflow Specification Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js";

const elk = new ELK();

export async function processElkLayout(
graph: ElkNode,
signal?: AbortSignal,
): Promise<ElkNode | null> {
try {
// Check if already aborted before starting
if (signal?.aborted) {
throw new DOMException("Layout operation aborted", "AbortError");
}

// Create a promise that rejects when the signal is aborted
const abortPromise = new Promise<never>((_, reject) => {
if (signal) {
signal.addEventListener("abort", () => {
reject(new DOMException("Layout operation aborted", "AbortError"));
});
}
});

// Race between layout calculation and abort signal
const layoutPromise = elk.layout(graph);

// If signal is provided, race the promises; otherwise just await layout
const result = signal ? await Promise.race([layoutPromise, abortPromise]) : await layoutPromise;

return result;
} catch (error: unknown) {
// Re-throw abort errors so they can be handled appropriately
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}

// Type-safe error handling for other errors
if (error instanceof Error) {
console.error("ELK Layout failed:", error.message);
Comment thread
handreyrc marked this conversation as resolved.
} else {
console.error("An unexpected error occurred:", String(error));
}
// Return a fallback, null, or rethrow the error as needed
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
export * from "./workflowSdk";
export * from "./graph";
export * from "./taskSubType";
export * from "./elkjs";
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import * as React from "react";
import { ReactFlowProvider } from "@xyflow/react";
import { Diagram, DiagramRef } from "../react-flow/diagram/Diagram";
import { DiagramEditorContextProvider } from "../store/DiagramEditorContextProvider";
import { I18nProvider, detectLocale, useI18n } from "@serverlessworkflow/i18n";
Expand Down Expand Up @@ -105,22 +106,24 @@ export const DiagramEditor = (props: DiagramEditorProps) => {
};
return (
<DiagramEditorErrorBoundary {...errorBoundaryProps} resetKey={props.content}>
<DiagramEditorContextProvider
content={props.content}
isReadOnly={props.isReadOnly}
locale={locale}
>
<SidebarProvider defaultOpen={false}>
<div className="dec-diagram-content">
<DiagramEditorContent
diagramRef={diagramRef}
diagramDivRef={diagramDivRef}
colorMode={resolvedColorMode}
/>
</div>
<SidePanel />
</SidebarProvider>
</DiagramEditorContextProvider>
<ReactFlowProvider>
<DiagramEditorContextProvider
content={props.content}
isReadOnly={props.isReadOnly}
locale={locale}
>
<SidebarProvider defaultOpen={false}>
<div className="dec-diagram-content">
<DiagramEditorContent
diagramRef={diagramRef}
diagramDivRef={diagramDivRef}
colorMode={resolvedColorMode}
/>
</div>
<SidePanel />
</SidebarProvider>
</DiagramEditorContextProvider>
</ReactFlowProvider>
</DiagramEditorErrorBoundary>
);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ReactFlowEdgeTypes } from "../edges/Edges";
import { useDiagramEditorContext } from "../../store/DiagramEditorContext";
import { buildDiagramElements } from "./diagramBuilder";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { applyAutoLayout } from "./autoLayout";

const FIT_VIEW_OPTIONS: RF.FitViewOptions = {
maxZoom: 1,
Expand All @@ -45,6 +46,7 @@ export type DiagramProps = {
};

export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
const reactFlowInstance: RF.ReactFlowInstance = RF.useReactFlow();
const { model, nodes, edges, setNodes, setEdges } = useDiagramEditorContext();

const [minimapVisible, setMinimapVisible] = React.useState(false);
Expand All @@ -68,12 +70,60 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
[setEdges],
);

// Rebuild nodes and edges as model changes
// Rebuild nodes and edges as model changes with debouncing
React.useEffect(() => {
const { nodes, edges } = buildDiagramElements(model);
setNodes(nodes);
setEdges(edges);
}, [model, setNodes, setEdges]);
let isActive = true;
let debounceTimeoutId: ReturnType<typeof setTimeout> | null = null;
let fitViewTimeoutId: ReturnType<typeof setTimeout> | null = null;
let abortController: AbortController | null = null;

// Debounce layout calculation to avoid excessive CPU usage on rapid changes
debounceTimeoutId = setTimeout(() => {
// Create abort controller for this layout operation
abortController = new AbortController();

const graph = buildDiagramElements(model);
applyAutoLayout(graph, abortController.signal)
.then(({ nodes, edges }) => {
// Only update if this effect is still active (not cancelled by cleanup)
if (isActive && !abortController?.signal.aborted) {
setNodes(nodes);
setEdges(edges);

// Queue fitView to run after React updates the DOM
fitViewTimeoutId = setTimeout(() => reactFlowInstance.fitView(), 0);
}
})
.catch((error) => {
// Ignore abort errors as they are expected when cancelling
if (error.name === "AbortError") {
return;
}
// Handle other auto-layout errors to prevent unhandled promise rejections
console.error("Failed to apply auto-layout:", error);
});
}, 100); // 150ms debounce delay

// Cleanup function to cancel stale updates and clear timeouts
return () => {
isActive = false;

// Cancel debounce timer
if (debounceTimeoutId !== null) {
clearTimeout(debounceTimeoutId);
}

// Cancel fitView timer
if (fitViewTimeoutId !== null) {
clearTimeout(fitViewTimeoutId);
}

// Abort in-flight layout calculation
if (abortController) {
abortController.abort();
}
};
}, [model, reactFlowInstance, setNodes, setEdges]);

return (
<div ref={divRef} className="dec:h-full dec:relative" data-testid={"diagram-container"}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* limitations under the License.
*/

import type { ElkNode, LayoutOptions, ElkExtendedEdge } from "elkjs/lib/elk.bundled.js";
import { processElkLayout } from "@/core";
import { ReactFlowGraph } from "./diagramBuilder";

// Defaults
Expand All @@ -36,19 +38,139 @@ export type Size = {

export type WayPoints = Point[];

export function applyAutoLayout(graph: ReactFlowGraph): ReactFlowGraph {
const graphClone = structuredClone(graph);
export const ROOT_LAYOUT_OPTIONS: LayoutOptions = {
"elk.algorithm": "org.eclipse.elk.layered",
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
"elk.direction": "DOWN",
"org.eclipse.elk.layered.layering.strategy": "INTERACTIVE",
"org.eclipse.elk.edgeRouting": "ORTHOGONAL",
"elk.layered.unnecessaryBendpoints": "true",
"org.eclipse.elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED",
"org.eclipse.elk.layered.nodePlacement.bk.edgeStraightening": "IMPROVE_STRAIGHTNESS",
"org.eclipse.elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
"org.eclipse.elk.insideSelfLoops.activate": "true",
"elk.separateConnectedComponents": "false",
"org.eclipse.elk.layered.nodePlacement.favorStraightEdges": "true",
"org.eclipse.elk.layered.considerModelOrder.strategy": "EDGES",
"org.eclipse.elk.layered.considerModelOrder.crossingCounterNodeInfluence": "0.001",
"elk.layered.crossingMinimization.strategy": "INTERACTIVE",
spacing: "75",
"spacing.componentComponent": "70",
"spacing.nodeNodeBetweenLayers": "80",
"elk.layered.spacing.edgeNodeBetweenLayers": "40",
"org.eclipse.elk.spacing.edgeNode": "24",
"org.eclipse.elk.layered.mergeEdges": "true",
};

// TODO: This is just a temporary implementation until the actual auto-layout engine is integrated
let position: Position = { x: 0, y: 0 };
export function buildElkGraphFromReactFlowGraph(reactFlowGraph: ReactFlowGraph): ElkNode {
// Create a map for easy lookup
const nodeMap = new Map(
reactFlowGraph.nodes.map((node) => [
node.id,
{
id: node.id,
width: node.measured?.width ?? DEFAULT_NODE_SIZE.width,
height: node.measured?.height ?? DEFAULT_NODE_SIZE.height,
children: [] as ElkNode[],
},
]),
);

// TODO: Containment is not supported for now.
graphClone.nodes.forEach((node) => {
node.height = DEFAULT_NODE_SIZE.height;
node.width = DEFAULT_NODE_SIZE.width;
node.position = { ...position };
position.y = position.y + 100;
const rootChildren: ElkNode[] = [];
// Nest children based on parentId
reactFlowGraph.nodes.forEach((node) => {
const elkNode = nodeMap.get(node.id)!;
if (node.parentId && nodeMap.has(node.parentId)) {
nodeMap.get(node.parentId)!.children.push(elkNode);
} else {
rootChildren.push(elkNode);
}
});

return graphClone;
// edges
const elkEdges: ElkExtendedEdge[] = reactFlowGraph.edges.map((edge) => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
}));

return {
id: "root",
layoutOptions: ROOT_LAYOUT_OPTIONS,
children: rootChildren,
edges: elkEdges,
};
}

// Helper function to recursively build a flat map of all ELK nodes
function buildElkNodeMap(
elkNode: ElkNode,
map: Map<string, ElkNode> = new Map(),
): Map<string, ElkNode> {
map.set(elkNode.id, elkNode);
if (elkNode.children) {
for (const child of elkNode.children) {
buildElkNodeMap(child, map);
}
}
return map;
}

// set
export function matchReactFlowGraphWithElkLayoutedGraph(
graph: ReactFlowGraph,
layoutedGraph: ElkNode,
): ReactFlowGraph {
// Build flat maps for O(1) lookups
const elkNodeMap = buildElkNodeMap(layoutedGraph);
const elkEdgeMap = new Map(layoutedGraph.edges?.map((e) => [e.id, e]) || []);

// Map node positions
const layoutedNodes = graph.nodes.map((node) => {
const elkNode = elkNodeMap.get(node.id);
if (elkNode && elkNode.x !== undefined && elkNode.y !== undefined) {
return {
...node,
position: { x: elkNode.x, y: elkNode.y },
...(elkNode.height !== undefined && { height: elkNode.height }),
...(elkNode.width !== undefined && { width: elkNode.width }),
};
}
return node;
});

// Map edge waypoints (bend points)
const layoutedEdges = graph.edges.map((edge) => {
const elkEdge = elkEdgeMap.get(edge.id);
if (elkEdge) {
// Reconstruct data without old wayPoints to avoid stale routing whenever ELK produced this edge.
const { wayPoints: _oldWayPoints, ...restData } = edge.data || {};
const bendPoints = elkEdge.sections?.flatMap((section) => section.bendPoints || []) || [];
return {
...edge,
data: {
...restData,
...(bendPoints.length > 0 && { wayPoints: bendPoints }),
},
};
}
return edge;
});
Comment thread
handreyrc marked this conversation as resolved.
Comment thread
handreyrc marked this conversation as resolved.

return { nodes: layoutedNodes, edges: layoutedEdges };
}

export async function applyAutoLayout(
graph: ReactFlowGraph,
signal?: AbortSignal,
): Promise<ReactFlowGraph> {
const elkGraph = buildElkGraphFromReactFlowGraph(graph);
const layoutedGraph = await processElkLayout(elkGraph, signal);

// it is not possible to calculate auto-layout
if (!layoutedGraph) {
return graph;
}

return matchReactFlowGraphWithElkLayoutedGraph(graph, layoutedGraph);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { buildFlatGraph } from "../../core";
import { BaseNodeData, CATCH_CONTAINER_NODE_TYPE, ReactFlowNodeTypes } from "../nodes/Nodes";
import { BaseEdgeData, EdgeTypes } from "../edges/Edges";
import * as sdk from "@serverlessworkflow/sdk";
import { applyAutoLayout, DEFAULT_NODE_SIZE } from "./autoLayout";
import { DEFAULT_NODE_SIZE } from "./autoLayout";

export type ReactFlowGraph = {
nodes: RF.Node[];
Expand Down Expand Up @@ -141,5 +141,5 @@ export function buildDiagramElements(model: sdk.Specification.Workflow | null):
});
}

return applyAutoLayout({ nodes, edges });
return { nodes, edges };
Comment thread
handreyrc marked this conversation as resolved.
}
Loading
Loading