From e009bb6f75be0d2910c41d0c932706d8582c1906 Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Mon, 24 Aug 2026 12:17:32 +0100 Subject: [PATCH 01/10] feat: add png export utility Signed-off-by: Cheryl Kong --- .../open-workflow-diagram-editor/package.json | 1 + .../src/lib/exportPng.ts | 65 +++++++++++++++++++ pnpm-lock.yaml | 11 ++++ pnpm-workspace.yaml | 1 + 4 files changed, 78 insertions(+) create mode 100644 packages/open-workflow-diagram-editor/src/lib/exportPng.ts diff --git a/packages/open-workflow-diagram-editor/package.json b/packages/open-workflow-diagram-editor/package.json index f78d9e7b..3e5285e5 100644 --- a/packages/open-workflow-diagram-editor/package.json +++ b/packages/open-workflow-diagram-editor/package.json @@ -49,6 +49,7 @@ "clsx": "catalog:", "elkjs": "catalog:", "fast-equals": "catalog:", + "html-to-image": "catalog:", "js-yaml": "catalog:", "radix-ui": "catalog:", "sonner": "catalog:", diff --git a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts new file mode 100644 index 00000000..a69ec5ff --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts @@ -0,0 +1,65 @@ +import { toPng } from "html-to-image"; +import type { ReactFlowInstance } from "@xyflow/react"; + +const PADDING = 40; +const SCALE = 3; + +export async function exportDiagramAsPng( + reactFlowInstance: ReactFlowInstance, + filename: string, +): Promise { + if (typeof document === "undefined") { + throw new Error("Document API is not available in this environment"); + } + + const viewport = document.querySelector(".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"); + } + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + + for (const node of nodes) { + const w = node.measured?.width ?? node.width ?? 150; + const h = node.measured?.height ?? node.height ?? 50; + minX = Math.min(minX, node.position.x); + minY = Math.min(minY, node.position.y); + maxX = Math.max(maxX, node.position.x + w); + maxY = Math.max(maxY, node.position.y + h); + } + + const contentWidth = maxX - minX + PADDING * 2; + const contentHeight = maxY - minY + PADDING * 2; + + const dataUrl = await toPng(viewport, { + backgroundColor: "#ffffff", + 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") { + const href = node.href ?? ""; + return !href.startsWith("http") && !href.startsWith("//"); + } + return true; + }, + }); + + const link = document.createElement("a"); + link.download = filename; + link.href = dataUrl; + link.click(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c835471b..315ccd5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ catalogs: fast-equals: specifier: ^6.0.2 version: 6.0.2 + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 husky: specifier: ^9.1.7 version: 9.1.7 @@ -291,6 +294,9 @@ importers: fast-equals: specifier: 'catalog:' version: 6.0.2 + html-to-image: + specifier: 'catalog:' + version: 1.11.13 js-yaml: specifier: 'catalog:' version: 5.2.3 @@ -3069,6 +3075,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + human-id@4.2.0: resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} hasBin: true @@ -6420,6 +6429,8 @@ snapshots: html-escaper@2.0.2: {} + html-to-image@1.11.13: {} + human-id@4.2.0: {} husky@9.1.7: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e8666b8f..57c78a16 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,6 +30,7 @@ catalog: clsx: ^2.1.1 elkjs: ^0.12.0 fast-equals: ^6.0.2 + html-to-image: ^1.11.13 husky: ^9.1.7 "js-yaml": ^5.2.3 jsdom: ^30.0.1 From 0bd736e4feff3ba7efe52c45039e01619516cbbd Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Mon, 24 Aug 2026 17:28:55 +0100 Subject: [PATCH 02/10] feat: add png export button to side panel Signed-off-by: Cheryl Kong --- .../src/i18n/locales/en.ts | 1 + .../src/lib/exportPng.ts | 70 ++++++++++++++----- .../src/react-flow/diagram/Diagram.tsx | 5 ++ .../{MermaidActions.tsx => ExportActions.tsx} | 34 ++++++++- .../src/side-panel/SidePanel.tsx | 4 +- .../src/store/DiagramEditorContext.tsx | 2 + .../store/DiagramEditorContextProvider.tsx | 7 ++ ...ctions.test.tsx => ExportActions.test.tsx} | 12 ++-- 8 files changed, 107 insertions(+), 28 deletions(-) rename packages/open-workflow-diagram-editor/src/side-panel/{MermaidActions.tsx => ExportActions.tsx} (71%) rename packages/open-workflow-diagram-editor/tests/side-panel/{MermaidActions.test.tsx => ExportActions.test.tsx} (91%) diff --git a/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts b/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts index 55298bd5..4dbe7c83 100644 --- a/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts +++ b/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts @@ -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:", diff --git a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts index a69ec5ff..8a195e95 100644 --- a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts +++ b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts @@ -39,24 +39,58 @@ export async function exportDiagramAsPng( const contentWidth = maxX - minX + PADDING * 2; const contentHeight = maxY - minY + PADDING * 2; - const dataUrl = await toPng(viewport, { - backgroundColor: "#ffffff", - 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") { - const href = node.href ?? ""; - return !href.startsWith("http") && !href.startsWith("//"); - } - return true; - }, - }); + // Resolve CSS custom properties from the live DOM — html-to-image serialises + // the SVG to a string and cannot resolve var() references that are defined + // outside the captured element (e.g. on .dec-root). + const rootStyle = getComputedStyle(document.documentElement); + const edgeColor = rootStyle.getPropertyValue("--dec-edge-selected").trim() || "#aea6a6"; + const edgeColorCondition = + rootStyle.getPropertyValue("--dec-edge-selected-condition").trim() || edgeColor; + const edgeColorError = rootStyle.getPropertyValue("--dec-error-accent").trim() || "#ef4444"; + + // Directly inline the resolved colours onto every edge path before capture, + // then restore the original inline style afterwards. This is necessary because + // html-to-image v1 has no clone hook — we must mutate the live DOM temporarily. + type EdgePatch = { el: SVGElement; previous: string }; + const patches: EdgePatch[] = []; + + const patchEdges = (selector: string, color: string) => { + viewport.querySelectorAll(selector).forEach((el) => { + patches.push({ el, previous: el.style.stroke }); + el.style.stroke = color; + }); + }; + + patchEdges(".edge-line", edgeColor); + patchEdges(".edge-line.condition", edgeColorCondition); + patchEdges(".edge-line.error", edgeColorError); + + let dataUrl: string; + try { + dataUrl = await toPng(viewport, { + backgroundColor: "#ffffff", + 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") { + const href = node.href ?? ""; + return !href.startsWith("http") && !href.startsWith("//"); + } + return true; + }, + }); + } finally { + // Always restore original inline styles regardless of success or failure. + patches.forEach(({ el, previous }) => { + el.style.stroke = previous; + }); + } const link = document.createElement("a"); link.download = filename; diff --git a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx index 4d5bcbca..b5a3be81 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx +++ b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx @@ -63,6 +63,7 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => { submitModel, pendingViewportRestore, clearPendingViewportRestore, + setReactFlowInstance, } = useDiagramEditorContext(); const [minimapVisible, setMinimapVisible] = React.useState(false); @@ -198,6 +199,10 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => { }; }, [model, errors, setNodes, setEdges, reactFlowInstance]); + React.useEffect(() => { + setReactFlowInstance(reactFlowInstance); + }, [reactFlowInstance, setReactFlowInstance]); + if (layoutError) { return ( | null>(null); + const { reactFlowInstance } = useDiagramEditorContext(); React.useEffect(() => { return () => { @@ -76,6 +79,23 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re } }; + const handleExportPng = async () => { + if (reactFlowInstance === null) return; + try { + const sanitizedName = (model.document?.name || "workflow") + .replace(/[/\\:*?"<>|]/g, "_") + .replace(/\s+/g, "_") + .trim() + .substring(0, 200); + await exportDiagramAsPng(reactFlowInstance, `${sanitizedName}.png`); + toast.success(t("toast.download.success")); + } catch (error) { + toast.error(t("toast.download.error"), { + description: error instanceof Error ? error.message : undefined, + }); + } + }; + return ( <> + ); } diff --git a/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx b/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx index a353f919..c16355a1 100644 --- a/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx +++ b/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx @@ -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 { ExportActions } from "@/side-panel/ExportActions"; import { getNodeVisualConfig } from "@/react-flow/nodes/taskNodeConfig"; import type { BaseNodeData } from "@/react-flow/nodes/Nodes"; import "./SidePanel.css"; @@ -104,7 +104,7 @@ export function SidePanel() { {model !== null && selectedNodeId === null ? ( - + ) : null} diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx index dcf93340..1715b302 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx @@ -29,11 +29,13 @@ export type DiagramEditorContextType = { edges: RF.Edge[]; taskReferences: Set; selectedNodeId: string | null; + reactFlowInstance: RF.ReactFlowInstance | null; setLocale: React.Dispatch>; setNodes: React.Dispatch>; setEdges: React.Dispatch>; setSelectedNodeId: React.Dispatch>; + setReactFlowInstance: React.Dispatch>; // Undo/redo — history API submitModel: ( diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx index 845ca9b5..5351cc50 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -59,6 +59,9 @@ 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(null); + const [reactFlowInstance, setReactFlowInstance] = React.useState( + null, + ); // Read isReadOnly directly from props — no local state copy. // This ensures useWorkflowHistory always receives the current value without @@ -179,6 +182,8 @@ export const DiagramEditorContextProvider = React.forwardRef< edges, taskReferences, selectedNodeId, + reactFlowInstance, + setReactFlowInstance, setLocale, setNodes, setEdges, @@ -202,6 +207,8 @@ export const DiagramEditorContextProvider = React.forwardRef< edges, taskReferences, selectedNodeId, + reactFlowInstance, + setReactFlowInstance, setLocale, setNodes, setEdges, diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx similarity index 91% rename from packages/open-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx rename to packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx index b65c7e1c..20e5607a 100644 --- a/packages/open-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx @@ -17,7 +17,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { MermaidActions } from "../../src/side-panel/MermaidActions"; +import { ExportActions } from "../../src/side-panel/ExportActions"; import { parseWorkflow } from "../../src/core/workflowSdk"; import { renderWithProviders } from "../test-utils/render-helpers"; import { WORKFLOW_WITH_METADATA_JSON } from "../fixtures/workflows"; @@ -26,7 +26,7 @@ import * as core from "../../src/core"; import * as download from "../../src/lib/download"; import * as sonner from "sonner"; -describe("MermaidActions", () => { +describe("ExportActions", () => { const toastMock = vi.fn(); const MERMAID_CODE = "mermaid code"; @@ -41,7 +41,7 @@ describe("MermaidActions", () => { const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined); vi.spyOn(core, "exportToMermaid").mockReturnValue(MERMAID_CODE); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const copyButton = screen.getByRole("button", { name: /Copy Mermaid Code/i, @@ -60,7 +60,7 @@ describe("MermaidActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const copyButton = screen.getByRole("button", { name: /Copy Mermaid Code/i, @@ -79,7 +79,7 @@ describe("MermaidActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const downloadButton = screen.getByRole("button", { name: /Download as Mermaid File/i, @@ -101,7 +101,7 @@ describe("MermaidActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const downloadButton = screen.getByRole("button", { name: /Download as Mermaid File/i, From c21c2e307d192a82f177c71bacee7e8b3149a1da Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Tue, 25 Aug 2026 14:13:24 +0100 Subject: [PATCH 03/10] fix: render all elements and re-query edges during export Signed-off-by: Cheryl Kong --- .../src/lib/exportPng.ts | 36 +++++++++---------- .../src/react-flow/diagram/Diagram.tsx | 3 +- .../src/side-panel/ExportActions.tsx | 6 +++- .../src/store/DiagramEditorContext.tsx | 2 ++ .../store/DiagramEditorContextProvider.tsx | 5 +++ 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts index 8a195e95..31e63e0a 100644 --- a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts +++ b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts @@ -39,31 +39,31 @@ export async function exportDiagramAsPng( const contentWidth = maxX - minX + PADDING * 2; const contentHeight = maxY - minY + PADDING * 2; - // Resolve CSS custom properties from the live DOM — html-to-image serialises - // the SVG to a string and cannot resolve var() references that are defined - // outside the captured element (e.g. on .dec-root). + // Resolve CSS vars from the live DOM — html-to-image can't resolve var() + // references defined outside the captured element (e.g. on .dec-root). const rootStyle = getComputedStyle(document.documentElement); const edgeColor = rootStyle.getPropertyValue("--dec-edge-selected").trim() || "#aea6a6"; const edgeColorCondition = rootStyle.getPropertyValue("--dec-edge-selected-condition").trim() || edgeColor; const edgeColorError = rootStyle.getPropertyValue("--dec-error-accent").trim() || "#ef4444"; - // Directly inline the resolved colours onto every edge path before capture, - // then restore the original inline style afterwards. This is necessary because - // html-to-image v1 has no clone hook — we must mutate the live DOM temporarily. - type EdgePatch = { el: SVGElement; previous: string }; - const patches: EdgePatch[] = []; - - const patchEdges = (selector: string, color: string) => { + // Inline resolved colours before capture; re-query on restore rather than + // saving refs, since React Flow may replace SVG elements during the await. + const applyStroke = (selector: string, color: string) => { viewport.querySelectorAll(selector).forEach((el) => { - patches.push({ el, previous: el.style.stroke }); el.style.stroke = color; }); }; - patchEdges(".edge-line", edgeColor); - patchEdges(".edge-line.condition", edgeColorCondition); - patchEdges(".edge-line.error", edgeColorError); + const clearStroke = (selector: string) => { + viewport.querySelectorAll(selector).forEach((el) => { + el.style.stroke = ""; + }); + }; + + applyStroke(".edge-line", edgeColor); + applyStroke(".edge-line.condition", edgeColorCondition); + applyStroke(".edge-line.error", edgeColorError); let dataUrl: string; try { @@ -86,10 +86,10 @@ export async function exportDiagramAsPng( }, }); } finally { - // Always restore original inline styles regardless of success or failure. - patches.forEach(({ el, previous }) => { - el.style.stroke = previous; - }); + // Re-query after the await — refs captured before toPng may be stale. + clearStroke(".edge-line"); + clearStroke(".edge-line.condition"); + clearStroke(".edge-line.error"); } const link = document.createElement("a"); diff --git a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx index b5a3be81..e6f8b428 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx +++ b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx @@ -64,6 +64,7 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => { pendingViewportRestore, clearPendingViewportRestore, setReactFlowInstance, + isExporting, } = useDiagramEditorContext(); const [minimapVisible, setMinimapVisible] = React.useState(false); @@ -228,7 +229,7 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onSelectionChange={onSelectionChange} - onlyRenderVisibleElements={true} + onlyRenderVisibleElements={!isExporting} zoomOnDoubleClick={false} elementsSelectable={true} panOnScroll={true} diff --git a/packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx b/packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx index 68da6e5f..0bb4d38d 100644 --- a/packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx +++ b/packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx @@ -30,7 +30,7 @@ export function ExportActions({ model }: { model: Specification.Workflow }): Rea const { t } = useI18n(); const [isCopied, setIsCopied] = React.useState(false); const copyTimeoutRef = React.useRef | null>(null); - const { reactFlowInstance } = useDiagramEditorContext(); + const { reactFlowInstance, setIsExporting } = useDiagramEditorContext(); React.useEffect(() => { return () => { @@ -87,12 +87,16 @@ export function ExportActions({ model }: { model: Specification.Workflow }): Rea .replace(/\s+/g, "_") .trim() .substring(0, 200); + setIsExporting(true); + await new Promise((resolve) => setTimeout(resolve, 50)); await exportDiagramAsPng(reactFlowInstance, `${sanitizedName}.png`); toast.success(t("toast.download.success")); } catch (error) { toast.error(t("toast.download.error"), { description: error instanceof Error ? error.message : undefined, }); + } finally { + setIsExporting(false); } }; diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx index 1715b302..dbca789d 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx @@ -30,12 +30,14 @@ export type DiagramEditorContextType = { taskReferences: Set; selectedNodeId: string | null; reactFlowInstance: RF.ReactFlowInstance | null; + isExporting: boolean; setLocale: React.Dispatch>; setNodes: React.Dispatch>; setEdges: React.Dispatch>; setSelectedNodeId: React.Dispatch>; setReactFlowInstance: React.Dispatch>; + setIsExporting: React.Dispatch>; // Undo/redo — history API submitModel: ( diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx index 5351cc50..1cf491b0 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -62,6 +62,7 @@ export const DiagramEditorContextProvider = React.forwardRef< const [reactFlowInstance, setReactFlowInstance] = React.useState( 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 @@ -196,6 +197,8 @@ export const DiagramEditorContextProvider = React.forwardRef< pendingViewportRestore, clearPendingViewportRestore, setContent, + isExporting, + setIsExporting, }), [ isReadOnly, @@ -221,6 +224,8 @@ export const DiagramEditorContextProvider = React.forwardRef< pendingViewportRestore, clearPendingViewportRestore, setContent, + isExporting, + setIsExporting, ], ); From aaf317d8e2b444d81f7779110910784b80724a15 Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Tue, 25 Aug 2026 15:07:18 +0100 Subject: [PATCH 04/10] test: add unit tests for png export Signed-off-by: Cheryl Kong --- .../tests/side-panel/ExportActions.test.tsx | 53 +++++++++++++++++++ .../tests/test-utils/render-helpers.tsx | 4 ++ 2 files changed, 57 insertions(+) diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx index 20e5607a..a3efe463 100644 --- a/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx @@ -17,6 +17,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type * as RF from "@xyflow/react"; import { ExportActions } from "../../src/side-panel/ExportActions"; import { parseWorkflow } from "../../src/core/workflowSdk"; import { renderWithProviders } from "../test-utils/render-helpers"; @@ -24,6 +25,7 @@ import { WORKFLOW_WITH_METADATA_JSON } from "../fixtures/workflows"; import * as clipboard from "../../src/lib/clipboard"; import * as core from "../../src/core"; import * as download from "../../src/lib/download"; +import * as exportPng from "../../src/lib/exportPng"; import * as sonner from "sonner"; describe("ExportActions", () => { @@ -111,4 +113,55 @@ describe("ExportActions", () => { expect(toastMock).toHaveBeenCalledWith(expect.any(String), { description: "Download error" }); }); + + it("should disable the PNG button when reactFlowInstance is null", () => { + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + + renderWithProviders(, { model, reactFlowInstance: null }); + + expect(screen.getByRole("button", { name: /Download as PNG/i })).toBeDisabled(); + }); + + it("should call exportDiagramAsPng with sanitized filename and show success toast", async () => { + const user = userEvent.setup(); + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + const exportSpy = vi.spyOn(exportPng, "exportDiagramAsPng").mockResolvedValue(undefined); + const setIsExporting = vi.fn(); + vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); + vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); + + renderWithProviders(, { + model, + reactFlowInstance: {} as unknown as RF.ReactFlowInstance, + setIsExporting, + }); + + await user.click(screen.getByRole("button", { name: /Download as PNG/i })); + await vi.waitFor(() => expect(exportSpy).toHaveBeenCalled()); + + expect(exportSpy).toHaveBeenCalledWith(expect.anything(), "test-wf.png"); + expect(toastMock).toHaveBeenCalledWith(expect.any(String)); + expect(setIsExporting).toHaveBeenCalledWith(true); + expect(setIsExporting).toHaveBeenLastCalledWith(false); + }); + + it("should show error toast and still reset isExporting when PNG export fails", async () => { + const user = userEvent.setup(); + const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); + vi.spyOn(exportPng, "exportDiagramAsPng").mockRejectedValue(new Error("Export failed")); + const setIsExporting = vi.fn(); + vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); + vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); + + renderWithProviders(, { + model, + reactFlowInstance: {} as unknown as RF.ReactFlowInstance, + setIsExporting, + }); + + await user.click(screen.getByRole("button", { name: /Download as PNG/i })); + await vi.waitFor(() => expect(setIsExporting).toHaveBeenLastCalledWith(false)); + + expect(toastMock).toHaveBeenCalledWith(expect.any(String), { description: "Export failed" }); + }); }); diff --git a/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx b/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx index 3ab984dc..aa235570 100644 --- a/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx +++ b/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx @@ -42,12 +42,16 @@ export const createMockContextValue = ( edges: [], taskReferences: new Set(), selectedNodeId: null, + reactFlowInstance: null, + isExporting: false, // --- dispatch defaults --- setLocale: noop, setEdges: noop, setNodes: noop, setSelectedNodeId: noop, + setReactFlowInstance: noop, + setIsExporting: noop, setContent: noop, // --- history defaults --- From 3990e9777f6d0d445401d22aa1fc1189a9509761 Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Mon, 31 Aug 2026 09:59:32 +0100 Subject: [PATCH 05/10] fix: scope png export viewport query to diagram container Signed-off-by: Cheryl Kong --- .changeset/png-export.md | 5 ++ .../src/components/ui/sonner.css | 33 +++++++++---- .../src/diagram-editor/DiagramEditor.tsx | 1 + .../src/lib/exportPng.ts | 47 +++++++------------ .../src/react-flow/diagram/Diagram.css | 2 +- .../src/side-panel/ExportActions.tsx | 4 +- .../src/store/DiagramEditorContext.tsx | 1 + .../store/DiagramEditorContextProvider.tsx | 8 +++- .../listen-to-any-forever-foreach.yaml | 4 +- .../tests/side-panel/ExportActions.test.tsx | 2 +- .../tests/test-utils/render-helpers.tsx | 2 + 11 files changed, 63 insertions(+), 46 deletions(-) create mode 100644 .changeset/png-export.md diff --git a/.changeset/png-export.md b/.changeset/png-export.md new file mode 100644 index 00000000..8d331425 --- /dev/null +++ b/.changeset/png-export.md @@ -0,0 +1,5 @@ +--- +"@openworkflowspec/diagram-editor": minor +--- + +add png export button for react flow diagram diff --git a/packages/open-workflow-diagram-editor/src/components/ui/sonner.css b/packages/open-workflow-diagram-editor/src/components/ui/sonner.css index 661b1784..a760493a 100644 --- a/packages/open-workflow-diagram-editor/src/components/ui/sonner.css +++ b/packages/open-workflow-diagram-editor/src/components/ui/sonner.css @@ -17,15 +17,30 @@ @reference "../../styles.css"; @layer sonner { - /* sonner defaults unlayered, so these overrides need !important */ - .dec-root [data-sonner-toaster] { --width: 300px !important; } - .dec-root [data-sonner-toast] { background: var(--dec-toast-bg) !important; border-radius: 10px !important; border: 1px solid var(--dec-toast-border) !important; } - .dec-root [data-sonner-toast][data-type="success"] { border-left: 4px solid var(--dec-toast-success) !important; } - .dec-root [data-sonner-toast][data-type="error"] { border-left: 4px solid var(--dec-error-accent) !important; } - .dec-root [data-sonner-toast][data-type="warning"] { border-left: 4px solid var(--dec-toast-warning) !important; } - .dec-root [data-sonner-toast][data-type="info"] { border-left: 4px solid var(--dec-toast-info) !important; } - .dec-root [data-sonner-toast] [data-title], .dec-root [data-sonner-toast] [data-description] { color: var(--dec-toast-text) !important; } - .dec-root [data-sonner-toast] [data-close-button] { + /* + * Sonner portals [data-sonner-toaster] directly into document.body, so it is + * NOT a descendant of .dec-root. All selectors here target the toaster and + * its children directly (no .dec-root ancestor prefix). + * + * Explicitly re-assert position:fixed + inset so that host-page styles in + * embedded environments (e.g. Quarkus dev-ui) cannot shift the toaster away + * from the top-left corner that the position="top-left" prop requests. + */ + [data-sonner-toaster] { + --width: 300px !important; + position: fixed !important; + top: var(--offset, 32px) !important; + bottom: auto !important; + left: var(--offset, 32px) !important; + right: auto !important; + } + [data-sonner-toast] { background: var(--dec-toast-bg) !important; border-radius: 10px !important; border: 1px solid var(--dec-toast-border) !important; } + [data-sonner-toast][data-type="success"] { border-left: 4px solid var(--dec-toast-success) !important; } + [data-sonner-toast][data-type="error"] { border-left: 4px solid var(--dec-error-accent) !important; } + [data-sonner-toast][data-type="warning"] { border-left: 4px solid var(--dec-toast-warning) !important; } + [data-sonner-toast][data-type="info"] { border-left: 4px solid var(--dec-toast-info) !important; } + [data-sonner-toast] [data-title], [data-sonner-toast] [data-description] { color: var(--dec-toast-text) !important; } + [data-sonner-toast] [data-close-button] { position: absolute !important; top: 50% !important; right: 12px !important; diff --git a/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx b/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx index dafad7fe..fdaa2755 100644 --- a/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx +++ b/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx @@ -147,6 +147,7 @@ const DiagramEditorBody = ({ content={props.content} isReadOnly={props.isReadOnly} locale={props.locale} + diagramDivRef={diagramDivRef} >
diff --git a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts index 31e63e0a..479c50b9 100644 --- a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts +++ b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts @@ -7,12 +7,14 @@ const SCALE = 3; export async function exportDiagramAsPng( reactFlowInstance: ReactFlowInstance, filename: string, + container?: HTMLElement | null, ): Promise { if (typeof document === "undefined") { throw new Error("Document API is not available in this environment"); } - const viewport = document.querySelector(".react-flow__viewport"); + const root = container ?? document; + const viewport = root.querySelector(".react-flow__viewport"); if (!viewport) { throw new Error("React Flow viewport element not found"); } @@ -39,31 +41,14 @@ export async function exportDiagramAsPng( const contentWidth = maxX - minX + PADDING * 2; const contentHeight = maxY - minY + PADDING * 2; - // Resolve CSS vars from the live DOM — html-to-image can't resolve var() - // references defined outside the captured element (e.g. on .dec-root). - const rootStyle = getComputedStyle(document.documentElement); - const edgeColor = rootStyle.getPropertyValue("--dec-edge-selected").trim() || "#aea6a6"; - const edgeColorCondition = - rootStyle.getPropertyValue("--dec-edge-selected-condition").trim() || edgeColor; - const edgeColorError = rootStyle.getPropertyValue("--dec-error-accent").trim() || "#ef4444"; - - // Inline resolved colours before capture; re-query on restore rather than - // saving refs, since React Flow may replace SVG elements during the await. - const applyStroke = (selector: string, color: string) => { - viewport.querySelectorAll(selector).forEach((el) => { - el.style.stroke = color; - }); - }; - - const clearStroke = (selector: string) => { - viewport.querySelectorAll(selector).forEach((el) => { - el.style.stroke = ""; - }); - }; - - applyStroke(".edge-line", edgeColor); - applyStroke(".edge-line.condition", edgeColorCondition); - applyStroke(".edge-line.error", edgeColorError); + // 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(".edge-line").forEach((el) => { + el.style.stroke = getComputedStyle(el).stroke; + }); let dataUrl: string; try { @@ -86,10 +71,12 @@ export async function exportDiagramAsPng( }, }); } finally { - // Re-query after the await — refs captured before toPng may be stale. - clearStroke(".edge-line"); - clearStroke(".edge-line.condition"); - clearStroke(".edge-line.error"); + // 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(".edge-line").forEach((el) => { + el.style.stroke = ""; + }); } const link = document.createElement("a"); diff --git a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.css b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.css index 43dfd5e1..e8afa379 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.css +++ b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.css @@ -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 */ diff --git a/packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx b/packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx index 0bb4d38d..21f852e4 100644 --- a/packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx +++ b/packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx @@ -30,7 +30,7 @@ export function ExportActions({ model }: { model: Specification.Workflow }): Rea const { t } = useI18n(); const [isCopied, setIsCopied] = React.useState(false); const copyTimeoutRef = React.useRef | null>(null); - const { reactFlowInstance, setIsExporting } = useDiagramEditorContext(); + const { reactFlowInstance, setIsExporting, diagramDivRef } = useDiagramEditorContext(); React.useEffect(() => { return () => { @@ -89,7 +89,7 @@ export function ExportActions({ model }: { model: Specification.Workflow }): Rea .substring(0, 200); setIsExporting(true); await new Promise((resolve) => setTimeout(resolve, 50)); - await exportDiagramAsPng(reactFlowInstance, `${sanitizedName}.png`); + await exportDiagramAsPng(reactFlowInstance, `${sanitizedName}.png`, diagramDivRef.current); toast.success(t("toast.download.success")); } catch (error) { toast.error(t("toast.download.error"), { diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx index dbca789d..a10c56ff 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx @@ -31,6 +31,7 @@ export type DiagramEditorContextType = { selectedNodeId: string | null; reactFlowInstance: RF.ReactFlowInstance | null; isExporting: boolean; + diagramDivRef: React.RefObject; setLocale: React.Dispatch>; setNodes: React.Dispatch>; diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx index 1cf491b0..ff1c8fd0 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -28,7 +28,9 @@ import { DiagramEditorContext, DiagramEditorContextType } from "./DiagramEditorC import type * as RF from "@xyflow/react"; import { useWorkflowHistory } from "../react-flow/hooks/useWorkflowHistory"; -export type ContextProviderProps = DiagramEditorProps; +export type ContextProviderProps = DiagramEditorProps & { + diagramDivRef: React.RefObject; +}; /** * Resolves the currently selected node/edge ID against a new model. @@ -45,7 +47,7 @@ function resolveSelectedId(model: Specification.Workflow, currentId: string | nu export const DiagramEditorContextProvider = React.forwardRef< DiagramEditorRef, React.PropsWithChildren ->((props, ref) => { +>(({ diagramDivRef, ...props }, ref) => { // Detect the serialization format once from the initial content prop. // JSON content starts with `{` (after trimming); everything else is YAML. // useState keeps the format in sync with React's render cycle, so consumers @@ -199,6 +201,7 @@ export const DiagramEditorContextProvider = React.forwardRef< setContent, isExporting, setIsExporting, + diagramDivRef, }), [ isReadOnly, @@ -226,6 +229,7 @@ export const DiagramEditorContextProvider = React.forwardRef< setContent, isExporting, setIsExporting, + diagramDivRef, ], ); diff --git a/packages/open-workflow-diagram-editor/stories/examples/workflows/listen-to-any-forever-foreach.yaml b/packages/open-workflow-diagram-editor/stories/examples/workflows/listen-to-any-forever-foreach.yaml index 28e0cfcb..4b1b763c 100644 --- a/packages/open-workflow-diagram-editor/stories/examples/workflows/listen-to-any-forever-foreach.yaml +++ b/packages/open-workflow-diagram-editor/stories/examples/workflows/listen-to-any-forever-foreach.yaml @@ -22,7 +22,9 @@ do: - listenToGossips: listen: to: - any: [] + any: + - with: + type: com.fake-gossip-api.events.gossip.v1 until: "${ false }" foreach: item: event diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx index a3efe463..b878c531 100644 --- a/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx @@ -139,7 +139,7 @@ describe("ExportActions", () => { await user.click(screen.getByRole("button", { name: /Download as PNG/i })); await vi.waitFor(() => expect(exportSpy).toHaveBeenCalled()); - expect(exportSpy).toHaveBeenCalledWith(expect.anything(), "test-wf.png"); + expect(exportSpy).toHaveBeenCalledWith(expect.anything(), "test-wf.png", null); expect(toastMock).toHaveBeenCalledWith(expect.any(String)); expect(setIsExporting).toHaveBeenCalledWith(true); expect(setIsExporting).toHaveBeenLastCalledWith(false); diff --git a/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx b/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx index aa235570..ea1303cc 100644 --- a/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx +++ b/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import * as React from "react"; import { render, type RenderOptions } from "@testing-library/react"; import { I18nProvider } from "@openworkflowspec/i18n"; import { @@ -44,6 +45,7 @@ export const createMockContextValue = ( selectedNodeId: null, reactFlowInstance: null, isExporting: false, + diagramDivRef: { current: null } as React.RefObject, // --- dispatch defaults --- setLocale: noop, From 88303a2c22ddc6f278c705a215d362da0af5c9ea Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Mon, 31 Aug 2026 11:33:55 +0100 Subject: [PATCH 06/10] fix: fix copilot complaints Signed-off-by: Cheryl Kong --- .../src/lib/exportPng.ts | 19 +++++++++++++++++-- .../store/DiagramEditorContextProvider.tsx | 6 ++++-- .../tests/test-utils/render-helpers.tsx | 2 +- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts index 479c50b9..eba9ce8e 100644 --- a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts +++ b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts @@ -1,3 +1,19 @@ +/* + * 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"; import type { ReactFlowInstance } from "@xyflow/react"; @@ -64,8 +80,7 @@ export async function exportDiagramAsPng( }, filter: (node) => { if (node instanceof HTMLLinkElement && node.rel === "stylesheet") { - const href = node.href ?? ""; - return !href.startsWith("http") && !href.startsWith("//"); + return new URL(node.href, document.baseURI).origin === globalThis.location?.origin; } return true; }, diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx index ff1c8fd0..f6bd0b7b 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -29,7 +29,7 @@ import type * as RF from "@xyflow/react"; import { useWorkflowHistory } from "../react-flow/hooks/useWorkflowHistory"; export type ContextProviderProps = DiagramEditorProps & { - diagramDivRef: React.RefObject; + diagramDivRef?: React.RefObject; }; /** @@ -47,7 +47,9 @@ function resolveSelectedId(model: Specification.Workflow, currentId: string | nu export const DiagramEditorContextProvider = React.forwardRef< DiagramEditorRef, React.PropsWithChildren ->(({ diagramDivRef, ...props }, ref) => { +>(({ diagramDivRef: diagramDivRefProp, ...props }, ref) => { + const internalDivRef = React.useRef(null); + const diagramDivRef = diagramDivRefProp ?? internalDivRef; // Detect the serialization format once from the initial content prop. // JSON content starts with `{` (after trimming); everything else is YAML. // useState keeps the format in sync with React's render cycle, so consumers diff --git a/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx b/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx index ea1303cc..be17a619 100644 --- a/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx +++ b/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import * as React from "react"; +import type * as React from "react"; import { render, type RenderOptions } from "@testing-library/react"; import { I18nProvider } from "@openworkflowspec/i18n"; import { From 44930c2e4a8a4fd9156869af80b59629e0e317fd Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Tue, 1 Sep 2026 14:57:29 +0100 Subject: [PATCH 07/10] fix based on suggestions Signed-off-by: Cheryl Kong --- .../src/components/ui/sonner.css | 35 ++----- .../src/lib/exportPng.ts | 25 ++--- .../src/lib/utils.ts | 8 ++ .../src/side-panel/SidePanel.tsx | 4 +- ...{ExportActions.tsx => WorkflowActions.tsx} | 21 ++--- .../tests/lib/exportPng.test.ts | 91 +++++++++++++++++++ .../tests/lib/utils.test.ts | 35 +++++++ ...ions.test.tsx => WorkflowActions.test.tsx} | 18 ++-- 8 files changed, 171 insertions(+), 66 deletions(-) rename packages/open-workflow-diagram-editor/src/side-panel/{ExportActions.tsx => WorkflowActions.tsx} (86%) create mode 100644 packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts create mode 100644 packages/open-workflow-diagram-editor/tests/lib/utils.test.ts rename packages/open-workflow-diagram-editor/tests/side-panel/{ExportActions.test.tsx => WorkflowActions.test.tsx} (91%) diff --git a/packages/open-workflow-diagram-editor/src/components/ui/sonner.css b/packages/open-workflow-diagram-editor/src/components/ui/sonner.css index a760493a..c801116d 100644 --- a/packages/open-workflow-diagram-editor/src/components/ui/sonner.css +++ b/packages/open-workflow-diagram-editor/src/components/ui/sonner.css @@ -17,30 +17,15 @@ @reference "../../styles.css"; @layer sonner { - /* - * Sonner portals [data-sonner-toaster] directly into document.body, so it is - * NOT a descendant of .dec-root. All selectors here target the toaster and - * its children directly (no .dec-root ancestor prefix). - * - * Explicitly re-assert position:fixed + inset so that host-page styles in - * embedded environments (e.g. Quarkus dev-ui) cannot shift the toaster away - * from the top-left corner that the position="top-left" prop requests. - */ - [data-sonner-toaster] { - --width: 300px !important; - position: fixed !important; - top: var(--offset, 32px) !important; - bottom: auto !important; - left: var(--offset, 32px) !important; - right: auto !important; - } - [data-sonner-toast] { background: var(--dec-toast-bg) !important; border-radius: 10px !important; border: 1px solid var(--dec-toast-border) !important; } - [data-sonner-toast][data-type="success"] { border-left: 4px solid var(--dec-toast-success) !important; } - [data-sonner-toast][data-type="error"] { border-left: 4px solid var(--dec-error-accent) !important; } - [data-sonner-toast][data-type="warning"] { border-left: 4px solid var(--dec-toast-warning) !important; } - [data-sonner-toast][data-type="info"] { border-left: 4px solid var(--dec-toast-info) !important; } - [data-sonner-toast] [data-title], [data-sonner-toast] [data-description] { color: var(--dec-toast-text) !important; } - [data-sonner-toast] [data-close-button] { + /* sonner defaults unlayered, so these overrides need !important */ + .dec-root [data-sonner-toaster] { --width: 300px !important; } + .dec-root [data-sonner-toast] { background: var(--dec-toast-bg) !important; border-radius: 10px !important; border: 1px solid var(--dec-toast-border) !important; } + .dec-root [data-sonner-toast][data-type="success"] { border-left: 4px solid var(--dec-toast-success) !important; } + .dec-root [data-sonner-toast][data-type="error"] { border-left: 4px solid var(--dec-error-accent) !important; } + .dec-root [data-sonner-toast][data-type="warning"] { border-left: 4px solid var(--dec-toast-warning) !important; } + .dec-root [data-sonner-toast][data-type="info"] { border-left: 4px solid var(--dec-toast-info) !important; } + .dec-root [data-sonner-toast] [data-title], .dec-root [data-sonner-toast] [data-description] { color: var(--dec-toast-text) !important; } + .dec-root [data-sonner-toast] [data-close-button] { position: absolute !important; top: 50% !important; right: 12px !important; @@ -52,4 +37,4 @@ color: var(--dec-toast-close-btn); cursor: pointer; } -} +} \ No newline at end of file diff --git a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts index eba9ce8e..f8fc8c03 100644 --- a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts +++ b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts @@ -15,6 +15,7 @@ */ import { toPng } from "html-to-image"; +import { getNodesBounds } from "@xyflow/react"; import type { ReactFlowInstance } from "@xyflow/react"; const PADDING = 40; @@ -40,22 +41,9 @@ export async function exportDiagramAsPng( throw new Error("No nodes to export"); } - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - - for (const node of nodes) { - const w = node.measured?.width ?? node.width ?? 150; - const h = node.measured?.height ?? node.height ?? 50; - minX = Math.min(minX, node.position.x); - minY = Math.min(minY, node.position.y); - maxX = Math.max(maxX, node.position.x + w); - maxY = Math.max(maxY, node.position.y + h); - } - - const contentWidth = maxX - minX + PADDING * 2; - const contentHeight = maxY - minY + PADDING * 2; + const { x: minX, y: minY, width, height } = 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 @@ -66,10 +54,13 @@ export async function exportDiagramAsPng( 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: "#ffffff", + backgroundColor, width: contentWidth, height: contentHeight, pixelRatio: SCALE, diff --git a/packages/open-workflow-diagram-editor/src/lib/utils.ts b/packages/open-workflow-diagram-editor/src/lib/utils.ts index 534612bc..d1e08da0 100644 --- a/packages/open-workflow-diagram-editor/src/lib/utils.ts +++ b/packages/open-workflow-diagram-editor/src/lib/utils.ts @@ -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); +} diff --git a/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx b/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx index c16355a1..bf28cc3c 100644 --- a/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx +++ b/packages/open-workflow-diagram-editor/src/side-panel/SidePanel.tsx @@ -28,7 +28,7 @@ import { import { useDiagramEditorContext } from "@/store/DiagramEditorContext"; import { WorkflowInfoView } from "@/side-panel/WorkflowInfoView"; import { NodeDetailsView } from "@/side-panel/NodeDetailsView"; -import { ExportActions } from "@/side-panel/ExportActions"; +import { WorkflowActions } from "@/side-panel/WorkflowActions"; import { getNodeVisualConfig } from "@/react-flow/nodes/taskNodeConfig"; import type { BaseNodeData } from "@/react-flow/nodes/Nodes"; import "./SidePanel.css"; @@ -104,7 +104,7 @@ export function SidePanel() { {model !== null && selectedNodeId === null ? ( - + ) : null} diff --git a/packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx b/packages/open-workflow-diagram-editor/src/side-panel/WorkflowActions.tsx similarity index 86% rename from packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx rename to packages/open-workflow-diagram-editor/src/side-panel/WorkflowActions.tsx index 21f852e4..2e36b873 100644 --- a/packages/open-workflow-diagram-editor/src/side-panel/ExportActions.tsx +++ b/packages/open-workflow-diagram-editor/src/side-panel/WorkflowActions.tsx @@ -22,11 +22,12 @@ 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 ExportActions({ 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 | null>(null); @@ -64,12 +65,7 @@ export function ExportActions({ model }: { model: Specification.Workflow }): Rea 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) { @@ -82,14 +78,13 @@ export function ExportActions({ model }: { model: Specification.Workflow }): Rea const handleExportPng = async () => { if (reactFlowInstance === null) return; try { - const sanitizedName = (model.document?.name || "workflow") - .replace(/[/\\:*?"<>|]/g, "_") - .replace(/\s+/g, "_") - .trim() - .substring(0, 200); setIsExporting(true); await new Promise((resolve) => setTimeout(resolve, 50)); - await exportDiagramAsPng(reactFlowInstance, `${sanitizedName}.png`, diagramDivRef.current); + await exportDiagramAsPng( + reactFlowInstance, + `${sanitizeFilename(model.document?.name)}.png`, + diagramDivRef.current, + ); toast.success(t("toast.download.success")); } catch (error) { toast.error(t("toast.download.error"), { diff --git a/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts b/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts new file mode 100644 index 00000000..dd3d9119 --- /dev/null +++ b/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts @@ -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"), +})); + +vi.mock("@xyflow/react", () => ({ + getNodesBounds: vi.fn().mockReturnValue({ x: 0, y: 0, width: 100, height: 100 }), +})); + +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) } as unknown as ReactFlowInstance; +} + +describe("exportDiagramAsPng", () => { + let mockClick: ReturnType; + let mockLink: HTMLAnchorElement; + + beforeEach(() => { + makeViewport(); + + mockClick = vi.fn(); + mockLink = { click: mockClick, href: "", download: "" } as unknown as HTMLAnchorElement; + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + if (tag === "a") return mockLink; + return document.createElement.call(document, tag); + }); + }); + + 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", + ); + }); +}); diff --git a/packages/open-workflow-diagram-editor/tests/lib/utils.test.ts b/packages/open-workflow-diagram-editor/tests/lib/utils.test.ts new file mode 100644 index 00000000..6c4504ca --- /dev/null +++ b/packages/open-workflow-diagram-editor/tests/lib/utils.test.ts @@ -0,0 +1,35 @@ +/* + * 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 { sanitizeFilename } from "../../src/lib/utils"; +import { describe, it, expect } from "vitest"; + +describe("sanitizeFilename", () => { + it("falls back to 'workflow' when name is empty or undefined", () => { + expect(sanitizeFilename(undefined)).toBe("workflow"); + expect(sanitizeFilename("")).toBe("workflow"); + }); + + it("replaces forbidden characters and whitespace with underscores", () => { + expect(sanitizeFilename('my workflow/name:with*bad?"<>|chars')).toBe( + "my_workflow_name_with_bad____chars", + ); + }); + + it("truncates to 200 characters", () => { + expect(sanitizeFilename("a".repeat(250))).toHaveLength(200); + }); +}); diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowActions.test.tsx similarity index 91% rename from packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx rename to packages/open-workflow-diagram-editor/tests/side-panel/WorkflowActions.test.tsx index b878c531..ba34244d 100644 --- a/packages/open-workflow-diagram-editor/tests/side-panel/ExportActions.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowActions.test.tsx @@ -18,7 +18,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type * as RF from "@xyflow/react"; -import { ExportActions } from "../../src/side-panel/ExportActions"; +import { WorkflowActions } from "../../src/side-panel/WorkflowActions"; import { parseWorkflow } from "../../src/core/workflowSdk"; import { renderWithProviders } from "../test-utils/render-helpers"; import { WORKFLOW_WITH_METADATA_JSON } from "../fixtures/workflows"; @@ -28,7 +28,7 @@ import * as download from "../../src/lib/download"; import * as exportPng from "../../src/lib/exportPng"; import * as sonner from "sonner"; -describe("ExportActions", () => { +describe("WorkflowActions", () => { const toastMock = vi.fn(); const MERMAID_CODE = "mermaid code"; @@ -43,7 +43,7 @@ describe("ExportActions", () => { const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined); vi.spyOn(core, "exportToMermaid").mockReturnValue(MERMAID_CODE); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const copyButton = screen.getByRole("button", { name: /Copy Mermaid Code/i, @@ -62,7 +62,7 @@ describe("ExportActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const copyButton = screen.getByRole("button", { name: /Copy Mermaid Code/i, @@ -81,7 +81,7 @@ describe("ExportActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const downloadButton = screen.getByRole("button", { name: /Download as Mermaid File/i, @@ -103,7 +103,7 @@ describe("ExportActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { model }); + renderWithProviders(, { model }); const downloadButton = screen.getByRole("button", { name: /Download as Mermaid File/i, @@ -117,7 +117,7 @@ describe("ExportActions", () => { it("should disable the PNG button when reactFlowInstance is null", () => { const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); - renderWithProviders(, { model, reactFlowInstance: null }); + renderWithProviders(, { model, reactFlowInstance: null }); expect(screen.getByRole("button", { name: /Download as PNG/i })).toBeDisabled(); }); @@ -130,7 +130,7 @@ describe("ExportActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { + renderWithProviders(, { model, reactFlowInstance: {} as unknown as RF.ReactFlowInstance, setIsExporting, @@ -153,7 +153,7 @@ describe("ExportActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { + renderWithProviders(, { model, reactFlowInstance: {} as unknown as RF.ReactFlowInstance, setIsExporting, From a3d172ac8d8b46f5030754efb54f7fbcb1afdd1d Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Tue, 1 Sep 2026 15:04:27 +0100 Subject: [PATCH 08/10] rerun tests Signed-off-by: Cheryl Kong --- packages/open-workflow-diagram-editor/tests/lib/utils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/open-workflow-diagram-editor/tests/lib/utils.test.ts b/packages/open-workflow-diagram-editor/tests/lib/utils.test.ts index 6c4504ca..153a33db 100644 --- a/packages/open-workflow-diagram-editor/tests/lib/utils.test.ts +++ b/packages/open-workflow-diagram-editor/tests/lib/utils.test.ts @@ -25,7 +25,7 @@ describe("sanitizeFilename", () => { it("replaces forbidden characters and whitespace with underscores", () => { expect(sanitizeFilename('my workflow/name:with*bad?"<>|chars')).toBe( - "my_workflow_name_with_bad____chars", + "my_workflow_name_with_bad_____chars", ); }); From aa5600150410aa18f20e942d6f1f0e60287c9232 Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Tue, 1 Sep 2026 15:17:05 +0100 Subject: [PATCH 09/10] fix export test Signed-off-by: Cheryl Kong --- .../open-workflow-diagram-editor/tests/lib/exportPng.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts b/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts index dd3d9119..64c5de4b 100644 --- a/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts +++ b/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts @@ -46,9 +46,10 @@ describe("exportDiagramAsPng", () => { 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 document.createElement.call(document, tag); + return originalCreateElement(tag); }); }); From d232095a41a506da0b7a7f424c545a35d67160de Mon Sep 17 00:00:00 2001 From: Cheryl Kong Date: Tue, 1 Sep 2026 18:14:39 +0100 Subject: [PATCH 10/10] fix according to suggestions Signed-off-by: Cheryl Kong --- .../src/components/ui/sonner.css | 2 +- .../src/diagram-editor/DiagramEditor.tsx | 1 - .../src/lib/exportPng.ts | 3 +-- .../src/react-flow/diagram/Diagram.tsx | 5 ----- .../src/side-panel/WorkflowActions.tsx | 10 ++++++---- .../src/store/DiagramEditorContext.tsx | 3 --- .../src/store/DiagramEditorContextProvider.tsx | 17 ++--------------- .../tests/lib/exportPng.test.ts | 9 ++++----- .../tests/side-panel/WorkflowActions.test.tsx | 17 ++++------------- .../tests/test-utils/render-helpers.tsx | 16 ++++++++-------- 10 files changed, 26 insertions(+), 57 deletions(-) diff --git a/packages/open-workflow-diagram-editor/src/components/ui/sonner.css b/packages/open-workflow-diagram-editor/src/components/ui/sonner.css index c801116d..661b1784 100644 --- a/packages/open-workflow-diagram-editor/src/components/ui/sonner.css +++ b/packages/open-workflow-diagram-editor/src/components/ui/sonner.css @@ -37,4 +37,4 @@ color: var(--dec-toast-close-btn); cursor: pointer; } -} \ No newline at end of file +} diff --git a/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx b/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx index fdaa2755..dafad7fe 100644 --- a/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx +++ b/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx @@ -147,7 +147,6 @@ const DiagramEditorBody = ({ content={props.content} isReadOnly={props.isReadOnly} locale={props.locale} - diagramDivRef={diagramDivRef} >
diff --git a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts index f8fc8c03..db869c8d 100644 --- a/packages/open-workflow-diagram-editor/src/lib/exportPng.ts +++ b/packages/open-workflow-diagram-editor/src/lib/exportPng.ts @@ -15,7 +15,6 @@ */ import { toPng } from "html-to-image"; -import { getNodesBounds } from "@xyflow/react"; import type { ReactFlowInstance } from "@xyflow/react"; const PADDING = 40; @@ -41,7 +40,7 @@ export async function exportDiagramAsPng( throw new Error("No nodes to export"); } - const { x: minX, y: minY, width, height } = getNodesBounds(nodes); + const { x: minX, y: minY, width, height } = reactFlowInstance.getNodesBounds(nodes); const contentWidth = width + PADDING * 2; const contentHeight = height + PADDING * 2; diff --git a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx index e6f8b428..f45fc687 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx +++ b/packages/open-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx @@ -63,7 +63,6 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => { submitModel, pendingViewportRestore, clearPendingViewportRestore, - setReactFlowInstance, isExporting, } = useDiagramEditorContext(); @@ -200,10 +199,6 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => { }; }, [model, errors, setNodes, setEdges, reactFlowInstance]); - React.useEffect(() => { - setReactFlowInstance(reactFlowInstance); - }, [reactFlowInstance, setReactFlowInstance]); - if (layoutError) { return ( | null>(null); - const { reactFlowInstance, setIsExporting, diagramDivRef } = useDiagramEditorContext(); + const reactFlowInstance = useReactFlow(); + const diagramDomNode = useStore((s) => s.domNode); + const { isExporting, setIsExporting } = useDiagramEditorContext(); React.useEffect(() => { return () => { @@ -76,14 +79,13 @@ export function WorkflowActions({ model }: { model: Specification.Workflow }): R }; const handleExportPng = async () => { - if (reactFlowInstance === null) return; try { setIsExporting(true); await new Promise((resolve) => setTimeout(resolve, 50)); await exportDiagramAsPng( reactFlowInstance, `${sanitizeFilename(model.document?.name)}.png`, - diagramDivRef.current, + diagramDomNode, ); toast.success(t("toast.download.success")); } catch (error) { @@ -120,7 +122,7 @@ export function WorkflowActions({ model }: { model: Specification.Workflow }): R variant="outline" size="sm" className="dec:cursor-pointer" - disabled={reactFlowInstance === null} + disabled={isExporting} > {t("sidebar.exportPng.download")} diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx index a10c56ff..045c34b0 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContext.tsx @@ -29,15 +29,12 @@ export type DiagramEditorContextType = { edges: RF.Edge[]; taskReferences: Set; selectedNodeId: string | null; - reactFlowInstance: RF.ReactFlowInstance | null; isExporting: boolean; - diagramDivRef: React.RefObject; setLocale: React.Dispatch>; setNodes: React.Dispatch>; setEdges: React.Dispatch>; setSelectedNodeId: React.Dispatch>; - setReactFlowInstance: React.Dispatch>; setIsExporting: React.Dispatch>; // Undo/redo — history API diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx index f6bd0b7b..56bf2f2f 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -28,9 +28,7 @@ import { DiagramEditorContext, DiagramEditorContextType } from "./DiagramEditorC import type * as RF from "@xyflow/react"; import { useWorkflowHistory } from "../react-flow/hooks/useWorkflowHistory"; -export type ContextProviderProps = DiagramEditorProps & { - diagramDivRef?: React.RefObject; -}; +export type ContextProviderProps = DiagramEditorProps; /** * Resolves the currently selected node/edge ID against a new model. @@ -47,9 +45,7 @@ function resolveSelectedId(model: Specification.Workflow, currentId: string | nu export const DiagramEditorContextProvider = React.forwardRef< DiagramEditorRef, React.PropsWithChildren ->(({ diagramDivRef: diagramDivRefProp, ...props }, ref) => { - const internalDivRef = React.useRef(null); - const diagramDivRef = diagramDivRefProp ?? internalDivRef; +>((props, ref) => { // Detect the serialization format once from the initial content prop. // JSON content starts with `{` (after trimming); everything else is YAML. // useState keeps the format in sync with React's render cycle, so consumers @@ -63,9 +59,6 @@ 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(null); - const [reactFlowInstance, setReactFlowInstance] = React.useState( - null, - ); const [isExporting, setIsExporting] = React.useState(false); // Read isReadOnly directly from props — no local state copy. @@ -187,8 +180,6 @@ export const DiagramEditorContextProvider = React.forwardRef< edges, taskReferences, selectedNodeId, - reactFlowInstance, - setReactFlowInstance, setLocale, setNodes, setEdges, @@ -203,7 +194,6 @@ export const DiagramEditorContextProvider = React.forwardRef< setContent, isExporting, setIsExporting, - diagramDivRef, }), [ isReadOnly, @@ -215,8 +205,6 @@ export const DiagramEditorContextProvider = React.forwardRef< edges, taskReferences, selectedNodeId, - reactFlowInstance, - setReactFlowInstance, setLocale, setNodes, setEdges, @@ -231,7 +219,6 @@ export const DiagramEditorContextProvider = React.forwardRef< setContent, isExporting, setIsExporting, - diagramDivRef, ], ); diff --git a/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts b/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts index 64c5de4b..8680d98e 100644 --- a/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts +++ b/packages/open-workflow-diagram-editor/tests/lib/exportPng.test.ts @@ -23,10 +23,6 @@ vi.mock("html-to-image", () => ({ toPng: vi.fn().mockResolvedValue("data:image/png;base64,mock"), })); -vi.mock("@xyflow/react", () => ({ - getNodesBounds: vi.fn().mockReturnValue({ x: 0, y: 0, width: 100, height: 100 }), -})); - function makeViewport(): void { const viewport = document.createElement("div"); viewport.className = "react-flow__viewport"; @@ -34,7 +30,10 @@ function makeViewport(): void { } function makeInstance(nodes: object[]): ReactFlowInstance { - return { getNodes: vi.fn().mockReturnValue(nodes) } as unknown as 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", () => { diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowActions.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowActions.test.tsx index ba34244d..6eb63bd7 100644 --- a/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowActions.test.tsx +++ b/packages/open-workflow-diagram-editor/tests/side-panel/WorkflowActions.test.tsx @@ -17,7 +17,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type * as RF from "@xyflow/react"; import { WorkflowActions } from "../../src/side-panel/WorkflowActions"; import { parseWorkflow } from "../../src/core/workflowSdk"; import { renderWithProviders } from "../test-utils/render-helpers"; @@ -114,10 +113,10 @@ describe("WorkflowActions", () => { expect(toastMock).toHaveBeenCalledWith(expect.any(String), { description: "Download error" }); }); - it("should disable the PNG button when reactFlowInstance is null", () => { + it("should disable the PNG button when isExporting is true", () => { const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON); - renderWithProviders(, { model, reactFlowInstance: null }); + renderWithProviders(, { model, isExporting: true }); expect(screen.getByRole("button", { name: /Download as PNG/i })).toBeDisabled(); }); @@ -130,11 +129,7 @@ describe("WorkflowActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { - model, - reactFlowInstance: {} as unknown as RF.ReactFlowInstance, - setIsExporting, - }); + renderWithProviders(, { model, setIsExporting }); await user.click(screen.getByRole("button", { name: /Download as PNG/i })); await vi.waitFor(() => expect(exportSpy).toHaveBeenCalled()); @@ -153,11 +148,7 @@ describe("WorkflowActions", () => { vi.spyOn(sonner.toast, "error").mockImplementation(toastMock); vi.spyOn(sonner.toast, "success").mockImplementation(toastMock); - renderWithProviders(, { - model, - reactFlowInstance: {} as unknown as RF.ReactFlowInstance, - setIsExporting, - }); + renderWithProviders(, { model, setIsExporting }); await user.click(screen.getByRole("button", { name: /Download as PNG/i })); await vi.waitFor(() => expect(setIsExporting).toHaveBeenLastCalledWith(false)); diff --git a/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx b/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx index be17a619..9d13e895 100644 --- a/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx +++ b/packages/open-workflow-diagram-editor/tests/test-utils/render-helpers.tsx @@ -22,6 +22,7 @@ import { type DiagramEditorContextType, } from "../../src/store/DiagramEditorContext"; import { SidebarProvider } from "../../src/components/ui/sidebar"; +import { ReactFlowProvider } from "@xyflow/react"; import { en } from "../../src/i18n/locales/en"; const noop = () => {}; @@ -43,16 +44,13 @@ export const createMockContextValue = ( edges: [], taskReferences: new Set(), selectedNodeId: null, - reactFlowInstance: null, isExporting: false, - diagramDivRef: { current: null } as React.RefObject, // --- dispatch defaults --- setLocale: noop, setEdges: noop, setNodes: noop, setSelectedNodeId: noop, - setReactFlowInstance: noop, setIsExporting: noop, setContent: noop, @@ -80,11 +78,13 @@ export const renderWithProviders = ( const mockContext = createMockContextValue(contextValue); return render( - - - {ui} - - , + + + + {ui} + + + , renderOptions, ); };