Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/png-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openworkflowspec/diagram-editor": minor
---

add png export button for react flow diagram
1 change: 1 addition & 0 deletions packages/open-workflow-diagram-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"clsx": "catalog:",
"elkjs": "catalog:",
"fast-equals": "catalog:",
"html-to-image": "catalog:",
"js-yaml": "catalog:",
"radix-ui": "catalog:",
"sonner": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const en = {
"sidebar.exportMermaid.copy": "Copy Mermaid Code",
"sidebar.exportMermaid.download": "Download as Mermaid File",
"sidebar.exportMermaid.copied": "Copied!",
"sidebar.exportPng.download": "Download as PNG",
"aria.minimap.hide": "Hide minimap",
"aria.minimap.show": "Show minimap",
"aria.badge": "Badge:",
Expand Down
91 changes: 91 additions & 0 deletions packages/open-workflow-diagram-editor/src/lib/exportPng.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Copyright 2021-Present The Open 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 { toPng } from "html-to-image";
Comment thread
cheryl7114 marked this conversation as resolved.
import type { ReactFlowInstance } from "@xyflow/react";

const PADDING = 40;
const SCALE = 3;

export async function exportDiagramAsPng(
reactFlowInstance: ReactFlowInstance,
filename: string,
container?: HTMLElement | null,
): Promise<void> {
if (typeof document === "undefined") {
throw new Error("Document API is not available in this environment");
}

const root = container ?? document;
const viewport = root.querySelector<HTMLElement>(".react-flow__viewport");
if (!viewport) {
throw new Error("React Flow viewport element not found");
}

const nodes = reactFlowInstance.getNodes();
if (nodes.length === 0) {
throw new Error("No nodes to export");
}

const { x: minX, y: minY, width, height } = reactFlowInstance.getNodesBounds(nodes);
const contentWidth = width + PADDING * 2;
const contentHeight = height + PADDING * 2;

// Edge colours are defined via CSS custom properties and Tailwind classes on
// ancestor elements. When html-to-image serialises the SVG, those rules are
// no longer in scope and stroke colours are lost. Fix: read the browser's
// fully-resolved computed stroke from each live element and set it as an
// inline style so the value is self-contained in the serialised output.
viewport.querySelectorAll<SVGElement>(".edge-line").forEach((el) => {
el.style.stroke = getComputedStyle(el).stroke;
});

const backgroundColor =
getComputedStyle(viewport).getPropertyValue("--dec-canvas-bg").trim() || "#ffffff";

let dataUrl: string;
try {
dataUrl = await toPng(viewport, {
backgroundColor,
width: contentWidth,
height: contentHeight,
pixelRatio: SCALE,
style: {
transform: `translate(${-minX + PADDING}px, ${-minY + PADDING}px)`,
width: `${contentWidth}px`,
height: `${contentHeight}px`,
},
filter: (node) => {
if (node instanceof HTMLLinkElement && node.rel === "stylesheet") {
return new URL(node.href, document.baseURI).origin === globalThis.location?.origin;
}
return true;
},
});
} finally {
// Clear inline strokes by re-querying the live DOM. Refs captured before
// toPng are stale — React Flow may have replaced SVG elements during the
// await when isExporting triggered a re-render.
viewport.querySelectorAll<SVGElement>(".edge-line").forEach((el) => {
el.style.stroke = "";
});
}

const link = document.createElement("a");
link.download = filename;
link.href = dataUrl;
link.click();
}
8 changes: 8 additions & 0 deletions packages/open-workflow-diagram-editor/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,11 @@ import { type ClassValue, clsx } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}

export function sanitizeFilename(name: string | undefined): string {
return (name || "workflow")
.replace(/[/\\:*?"<>|]/g, "_")
.replace(/\s+/g, "_")
.trim()
.substring(0, 200);
}
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@
}

.dec-root .edge-line.condition {
@apply dec:stroke-blue-500;
stroke: var(--dec-edge-selected-condition);
}

/* Override React Flow's default selected edge styling to preserve original colors */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => {
submitModel,
pendingViewportRestore,
clearPendingViewportRestore,
isExporting,
} = useDiagramEditorContext();

const [minimapVisible, setMinimapVisible] = React.useState(false);
Expand Down Expand Up @@ -223,7 +224,7 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => {
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onSelectionChange={onSelectionChange}
onlyRenderVisibleElements={true}
onlyRenderVisibleElements={!isExporting}
zoomOnDoubleClick={false}
elementsSelectable={true}
panOnScroll={true}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
import { useDiagramEditorContext } from "@/store/DiagramEditorContext";
import { WorkflowInfoView } from "@/side-panel/WorkflowInfoView";
import { NodeDetailsView } from "@/side-panel/NodeDetailsView";
import { MermaidActions } from "@/side-panel/MermaidActions";
import { WorkflowActions } from "@/side-panel/WorkflowActions";
import { getNodeVisualConfig } from "@/react-flow/nodes/taskNodeConfig";
import type { BaseNodeData } from "@/react-flow/nodes/Nodes";
import "./SidePanel.css";
Expand Down Expand Up @@ -104,7 +104,7 @@ export function SidePanel() {
</SidebarContent>
{model !== null && selectedNodeId === null ? (
<SidebarFooter aria-label={t("aria.panel.exportActions")}>
<MermaidActions model={model} />
<WorkflowActions model={model} />
</SidebarFooter>
) : null}
</Sidebar>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,25 @@

Comment thread
cheryl7114 marked this conversation as resolved.
import * as React from "react";
import { useI18n } from "@openworkflowspec/i18n";
import { ClipboardPen, Download, ClipboardCheck } from "lucide-react";
import { ClipboardPen, Download, ClipboardCheck, FileImage } from "lucide-react";
import { useReactFlow, useStore } from "@xyflow/react";
import { Button } from "@/components/ui/button";
import { exportToMermaid } from "@/core";
import { copyToClipboard } from "@/lib/clipboard";
import { downloadFile } from "@/lib/download";
import { exportDiagramAsPng } from "@/lib/exportPng";
import { sanitizeFilename } from "@/lib/utils";
import { useDiagramEditorContext } from "@/store/DiagramEditorContext";
import type { Specification } from "@openworkflowspec/sdk";
import { toast } from "sonner";

export function MermaidActions({ model }: { model: Specification.Workflow }): React.JSX.Element {
export function WorkflowActions({ model }: { model: Specification.Workflow }): React.JSX.Element {
const { t } = useI18n();
const [isCopied, setIsCopied] = React.useState(false);
const copyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const reactFlowInstance = useReactFlow();
const diagramDomNode = useStore((s) => s.domNode);
const { isExporting, setIsExporting } = useDiagramEditorContext();

React.useEffect(() => {
return () => {
Expand Down Expand Up @@ -61,12 +68,7 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re
const handleDownloadMermaid = () => {
try {
const mermaidCode = exportToMermaid(model);
const sanitizedName = (model.document?.name || "workflow")
.replace(/[/\\:*?"<>|]/g, "_")
.replace(/\s+/g, "_")
.trim()
.substring(0, 200);
const filename = `${sanitizedName}.mmd`;
const filename = `${sanitizeFilename(model.document?.name)}.mmd`;
downloadFile(mermaidCode, filename);
toast.success(t("toast.download.success"));
} catch (error) {
Expand All @@ -76,6 +78,25 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re
}
};

const handleExportPng = async () => {
try {
setIsExporting(true);
await new Promise((resolve) => setTimeout(resolve, 50));
await exportDiagramAsPng(
reactFlowInstance,
`${sanitizeFilename(model.document?.name)}.png`,
diagramDomNode,
);
toast.success(t("toast.download.success"));
} catch (error) {
toast.error(t("toast.download.error"), {
description: error instanceof Error ? error.message : undefined,
});
} finally {
setIsExporting(false);
}
};

return (
<>
<Button
Expand All @@ -96,6 +117,16 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re
<Download />
{t("sidebar.exportMermaid.download")}
</Button>
<Button
onClick={handleExportPng}
variant="outline"
size="sm"
className="dec:cursor-pointer"
disabled={isExporting}
>
<FileImage />
{t("sidebar.exportPng.download")}
</Button>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ export type DiagramEditorContextType = {
edges: RF.Edge[];
taskReferences: Set<string>;
selectedNodeId: string | null;
isExporting: boolean;

setLocale: React.Dispatch<React.SetStateAction<string>>;
setNodes: React.Dispatch<React.SetStateAction<RF.Node[]>>;
setEdges: React.Dispatch<React.SetStateAction<RF.Edge[]>>;
setSelectedNodeId: React.Dispatch<React.SetStateAction<string | null>>;
setIsExporting: React.Dispatch<React.SetStateAction<boolean>>;

// Undo/redo — history API
submitModel: (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const DiagramEditorContextProvider = React.forwardRef<
const [nodes, setNodes] = React.useState([] as RF.Node[]);
const [edges, setEdges] = React.useState([] as RF.Edge[]);
const [selectedNodeId, setSelectedNodeId] = React.useState<string | null>(null);
const [isExporting, setIsExporting] = React.useState(false);

// Read isReadOnly directly from props — no local state copy.
// This ensures useWorkflowHistory always receives the current value without
Expand Down Expand Up @@ -191,6 +192,8 @@ export const DiagramEditorContextProvider = React.forwardRef<
pendingViewportRestore,
clearPendingViewportRestore,
setContent,
isExporting,
setIsExporting,
}),
[
isReadOnly,
Expand All @@ -214,6 +217,8 @@ export const DiagramEditorContextProvider = React.forwardRef<
pendingViewportRestore,
clearPendingViewportRestore,
setContent,
isExporting,
setIsExporting,
],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ do:
- listenToGossips:
listen:
to:
any: []
any:
- with:
type: com.fake-gossip-api.events.gossip.v1
until: "${ false }"
foreach:
item: event
Expand Down
91 changes: 91 additions & 0 deletions packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Copyright 2021-Present The Open 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 { exportDiagramAsPng } from "../../src/lib/exportPng";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { toPng } from "html-to-image";
import type { ReactFlowInstance } from "@xyflow/react";

vi.mock("html-to-image", () => ({
toPng: vi.fn().mockResolvedValue("data:image/png;base64,mock"),
}));

function makeViewport(): void {
const viewport = document.createElement("div");
viewport.className = "react-flow__viewport";
document.body.appendChild(viewport);
}

function makeInstance(nodes: object[]): ReactFlowInstance {
return {
getNodes: vi.fn().mockReturnValue(nodes),
getNodesBounds: vi.fn().mockReturnValue({ x: 0, y: 0, width: 100, height: 100 }),
} as unknown as ReactFlowInstance;
}

describe("exportDiagramAsPng", () => {
let mockClick: ReturnType<typeof vi.fn>;
let mockLink: HTMLAnchorElement;

beforeEach(() => {
makeViewport();

mockClick = vi.fn();
mockLink = { click: mockClick, href: "", download: "" } as unknown as HTMLAnchorElement;
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
if (tag === "a") return mockLink;
return originalCreateElement(tag);
});
Comment thread
cheryl7114 marked this conversation as resolved.
});

afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
});

it("triggers a download with the given filename", async () => {
const instance = makeInstance([{ id: "1" }]);
await exportDiagramAsPng(instance, "diagram.png");

expect(mockLink.download).toBe("diagram.png");
expect(mockLink.href).toBe("data:image/png;base64,mock");
expect(mockClick).toHaveBeenCalledOnce();
});

it("falls back to #ffffff when --dec-canvas-bg is not set", async () => {
const instance = makeInstance([{ id: "1" }]);
await exportDiagramAsPng(instance, "diagram.png");

expect(toPng).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.objectContaining({ backgroundColor: "#ffffff" }),
);
});

it("throws when there are no nodes", async () => {
const instance = makeInstance([]);
await expect(exportDiagramAsPng(instance, "diagram.png")).rejects.toThrow("No nodes to export");
});

it("throws when the viewport element is not found", async () => {
document.body.innerHTML = "";
const instance = makeInstance([{ id: "1" }]);
await expect(exportDiagramAsPng(instance, "diagram.png")).rejects.toThrow(
"React Flow viewport element not found",
);
});
});
Loading