Skip to content

Commit

Permalink
[4430] Add the PNG export of a diagram
Browse files Browse the repository at this point in the history
Bug: #4430
Signed-off-by: Guillaume Coutable <[email protected]>
  • Loading branch information
gcoutable committed Jan 16, 2025
1 parent 689cf68 commit aae8c84
Show file tree
Hide file tree
Showing 4 changed files with 81 additions and 18 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Some log messages have been updated in order to provide more information and mak
The configuration property `sirius.web.graphql.tracing` has also been added to active the tracing mode of the GraphQL API.
It can be activated using `sirius.web.graphql.tracing=true` since it is not enabled by default to not have any impact on the performance of the application.
Some additional log has also been contributed on the frontend in order to view more easily the order and time of the GraphQL requests and responses.
- https://github.com/eclipse-sirius/sirius-web/issues/4430[#4430] [diagram] Add the PNG export of a diagram.


=== Improvements
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*******************************************************************************
* Copyright (c) 2023, 2024 Obeo.
* Copyright (c) 2023, 2025 Obeo.
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v2.0
* which accompanies this distribution, and is available at
Expand All @@ -19,21 +19,26 @@ import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
import GridOffIcon from '@mui/icons-material/GridOff';
import GridOnIcon from '@mui/icons-material/GridOn';
import ImageIcon from '@mui/icons-material/Image';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import ShareIcon from '@mui/icons-material/Share';
import TonalityIcon from '@mui/icons-material/Tonality';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
import { ListItemText, MenuItem } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import Paper from '@mui/material/Paper';
import Tooltip from '@mui/material/Tooltip';
import { Edge, Node, Panel, useNodesInitialized, useReactFlow } from '@xyflow/react';
import { memo, useContext, useEffect, useState } from 'react';
import { memo, useContext, useEffect, useRef, useState } from 'react';
import { DiagramContext } from '../../contexts/DiagramContext';
import { DiagramContextValue } from '../../contexts/DiagramContext.types';
import { HelperLinesIcon } from '../../icons/HelperLinesIcon';
import { HelperLinesIconOff } from '../../icons/HelperLinesIconOff';
import { SmartEdgeIcon } from '../../icons/SmartEdgeIcon';
import { SmoothStepEdgeIcon } from '../../icons/SmoothStepEdgeIcon';
import { UnpinIcon } from '../../icons/UnpinIcon';
import { EdgeData, NodeData } from '../DiagramRenderer.types';
import { useFadeDiagramElements } from '../fade/useFadeDiagramElements';
Expand All @@ -44,8 +49,6 @@ import { usePinDiagramElements } from '../pin/usePinDiagramElements';
import { DiagramPanelActionProps, DiagramPanelProps, DiagramPanelState } from './DiagramPanel.types';
import { diagramPanelActionExtensionPoint } from './DiagramPanelExtensionPoints';
import { useExportToImage } from './useExportToImage';
import { SmartEdgeIcon } from '../../icons/SmartEdgeIcon';
import { SmoothStepEdgeIcon } from '../../icons/SmoothStepEdgeIcon';

export const DiagramPanel = memo(
({
Expand Down Expand Up @@ -103,7 +106,17 @@ export const DiagramPanel = memo(
const onUnhideAll = () => hideDiagramElements([...getAllElementsIds()], false);
const onUnpinAll = () => pinDiagramElements([...getAllElementsIds()], false);

const { exportToImage } = useExportToImage();
const [exportImageMenuOpen, setExportImageMenuOpen] = useState<boolean>(false);
const anchorExportImageMenuRef = useRef<HTMLButtonElement | null>(null);
const handleExportImageMenuToggle = () => {
setExportImageMenuOpen((prevState) => !prevState);
};

const onCloseExportImageMenu = () => {
setExportImageMenuOpen(false);
};

const { exportToSVG, exportToPNG } = useExportToImage();
const { editingContextId, diagramId } = useContext<DiagramContextValue>(DiagramContext);

return (
Expand Down Expand Up @@ -150,10 +163,12 @@ export const DiagramPanel = memo(
<Tooltip title="Export to SVG">
<IconButton
size="small"
aria-label="export to svg"
onClick={exportToImage}
data-testid="export-diagram-to-svg">
aria-label="export to image"
onClick={handleExportImageMenuToggle}
data-testid="export-diagram-to-image"
ref={anchorExportImageMenuRef}>
<ImageIcon />
<KeyboardArrowDownIcon />
</IconButton>
</Tooltip>
{snapToGrid ? (
Expand Down Expand Up @@ -287,6 +302,19 @@ export const DiagramPanel = memo(
onClose={handleCloseDialog}
/>
) : null}
<Menu
open={exportImageMenuOpen}
anchorEl={anchorExportImageMenuRef.current}
data-testid="export-diagram-to-image-menu"
onClick={onCloseExportImageMenu}
onClose={onCloseExportImageMenu}>
<MenuItem onClick={exportToSVG} data-testid="export-diagram-to-svg">
<ListItemText primary="SVG" />
</MenuItem>
<MenuItem onClick={exportToPNG} data-testid="export-diagram-to-png">
<ListItemText primary="PNG" />
</MenuItem>
</Menu>
</>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*******************************************************************************
* Copyright (c) 2023, 2024 Obeo.
* Copyright (c) 2023, 2025 Obeo.
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v2.0
* which accompanies this distribution, and is available at
Expand All @@ -20,27 +20,27 @@ import {
getViewportForBounds,
useReactFlow,
} from '@xyflow/react';
import { toSvg } from 'html-to-image';
import { toPng, toSvg } from 'html-to-image';
import { useCallback } from 'react';
import { EdgeData, NodeData } from '../DiagramRenderer.types';
import { UseExportToImage } from './useExportToImage.types';

const downloadImage = (dataUrl: string) => {
const downloadImage = (dataUrl: string, fileName: string) => {
const a: HTMLAnchorElement = document.createElement('a');
a.setAttribute('download', 'diagram.svg');
a.setAttribute('download', fileName);
a.setAttribute('href', dataUrl);
a.click();
};

export const useExportToImage = (): UseExportToImage => {
const reactFlow = useReactFlow<ReactFlowNode<NodeData>, Edge<EdgeData>>();

const exportToImage = useCallback(() => {
const exportToSVG = useCallback(() => {
const nodesBounds: Rect = getNodesBounds(reactFlow.getNodes());
const imageWidth: number = nodesBounds.width;
const imageHeight: number = nodesBounds.height;

const viewport: Viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.5, 2, 2);
const viewport: Viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.5, 2, 0.05);

const edges: HTMLElement | null = document.querySelector<HTMLElement>('.react-flow__edges');
const edgeMarkersDefs: HTMLElement | null = document.getElementById('edge-markers-defs');
Expand All @@ -61,9 +61,42 @@ export const useExportToImage = (): UseExportToImage => {
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
},
})
.then(downloadImage)
.then((dataUrl) => downloadImage(dataUrl, 'diagram.svg'))
.finally(() => edges.removeChild(clonedEdgeMarkersDefs));
}
}, []);
return { exportToImage };

const exportToPNG = useCallback(() => {
const nodesBounds: Rect = getNodesBounds(reactFlow.getNodes());
const imageWidth: number = nodesBounds.width;
const imageHeight: number = nodesBounds.height;

const viewport: Viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.5, 2, 0.05);

const edges: HTMLElement | null = document.querySelector<HTMLElement>('.react-flow__edges');
const edgeMarkersDefs: HTMLElement | null = document.getElementById('edge-markers-defs');

const reactFlowNodeContainer: HTMLElement | null = document.querySelector<HTMLElement>('.react-flow__viewport');

if (reactFlowNodeContainer && edges && edgeMarkersDefs) {
const clonedEdgeMarkersDefs: Node = edgeMarkersDefs.cloneNode(true);
edges.insertBefore(clonedEdgeMarkersDefs, edges.firstChild);

toPng(reactFlowNodeContainer, {
backgroundColor: '#ffffff',
width: imageWidth,
height: imageHeight,
style: {
width: imageWidth.toString(),
height: imageHeight.toString(),
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
},
pixelRatio: 2,
})
.then((dataUrl) => downloadImage(dataUrl, 'diagram.png'))
.finally(() => edges.removeChild(clonedEdgeMarkersDefs));
}
}, []);

return { exportToSVG, exportToPNG };
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*******************************************************************************
* Copyright (c) 2023, 2024 Obeo.
* Copyright (c) 2023, 2025 Obeo.
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v2.0
* which accompanies this distribution, and is available at
Expand All @@ -12,5 +12,6 @@
*******************************************************************************/

export interface UseExportToImage {
exportToImage: () => void;
exportToSVG: () => void;
exportToPNG: () => void;
}

0 comments on commit aae8c84

Please sign in to comment.