feat: Added SVG export, fixes #211

This commit is contained in:
Stan
2026-01-22 19:17:16 +00:00
parent 35aaa2c614
commit b14832f541
3 changed files with 80 additions and 18 deletions

10
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "fossflow-monorepo",
"version": "1.9.1",
"version": "1.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fossflow-monorepo",
"version": "1.9.1",
"version": "1.9.2",
"workspaces": [
"packages/*"
],
@@ -18835,7 +18835,7 @@
}
},
"packages/fossflow-app": {
"version": "1.9.1",
"version": "1.9.2",
"dependencies": {
"@isoflow/isopacks": "^0.0.10",
"fossflow": "*",
@@ -18883,7 +18883,7 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
},
"packages/fossflow-backend": {
"version": "1.9.1",
"version": "1.9.2",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.6.1",
@@ -18896,7 +18896,7 @@
},
"packages/fossflow-lib": {
"name": "fossflow",
"version": "1.9.1",
"version": "1.9.2",
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.14.0",

View File

@@ -24,6 +24,7 @@ import {
import { useModelStore } from 'src/stores/modelStore';
import {
exportAsImage,
exportAsSVG,
downloadFile as downloadFileUtil,
base64ToBlob,
generateGenericFilename,
@@ -57,6 +58,7 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
const [dragStart, setDragStart] = useState<Coords | null>(null);
const currentView = useUiStateStore((state) => state.view);
const [imageData, setImageData] = React.useState<string>();
const [svgData, setSvgData] = useState<string>();
const [croppedImageData, setCroppedImageData] = useState<string>();
const [exportError, setExportError] = useState(false);
const { getUnprojectedBounds } = useDiagramUtils();
@@ -100,7 +102,7 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
customVars.customPalette.diagramBg
);
const exportImage = useCallback(() => {
const exportImage = useCallback(async () => {
if (!containerRef.current || isExporting.current) {
return;
}
@@ -113,16 +115,23 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
height: bounds.height
};
exportAsImage(containerRef.current as HTMLDivElement, containerSize, exportScale, transparentBackground ? 'transparent' : backgroundColor)
.then((data) => {
setImageData(data);
isExporting.current = false;
})
.catch((err) => {
console.error(err);
setExportError(true);
isExporting.current = false;
});
const bgColor = transparentBackground ? 'transparent' : backgroundColor;
try {
// Export both PNG and SVG in parallel
const [pngData, svgDataResult] = await Promise.all([
exportAsImage(containerRef.current as HTMLDivElement, containerSize, exportScale, bgColor),
exportAsSVG(containerRef.current as HTMLDivElement, containerSize, bgColor)
]);
setImageData(pngData);
setSvgData(svgDataResult);
isExporting.current = false;
} catch (err) {
console.error(err);
setExportError(true);
isExporting.current = false;
}
}, [bounds, exportScale, transparentBackground, backgroundColor]);
// Crop the image based on selected area
@@ -381,6 +390,7 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
useEffect(() => {
if (!cropToContent) {
setImageData(undefined);
setSvgData(undefined);
setExportError(false);
isExporting.current = false;
const timer = setTimeout(() => {
@@ -411,6 +421,20 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
downloadFileUtil(data, generateGenericFilename('png'));
}, [imageData, croppedImageData]);
const downloadSvgFile = useCallback(async () => {
if (!svgData) return;
try {
// Fetch the data URL as a blob to handle encoding properly
const response = await fetch(svgData);
const blob = await response.blob();
downloadFileUtil(blob, generateGenericFilename('svg'));
} catch (error) {
console.error('SVG download failed:', error);
setExportError(true);
}
}, [svgData]);
const displayImage = croppedImageData || imageData;
return (
@@ -680,7 +704,14 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
<Button variant="text" onClick={onClose}>
Cancel
</Button>
<Button
<Button
variant="outlined"
onClick={downloadSvgFile}
disabled={!svgData || (cropToContent && isInCropMode && !croppedImageData)}
>
Download as SVG
</Button>
<Button
onClick={downloadFile}
disabled={cropToContent && isInCropMode && !croppedImageData}
>

View File

@@ -4,7 +4,7 @@ import { Model, Size } from '../types';
import { icons as availableIcons } from '../examples/initialData';
export const generateGenericFilename = (extension: string) => {
return `isoflow-export-${new Date().toISOString()}.${extension}`;
return `fossflow-export-${new Date().toISOString()}.${extension}`;
};
export const base64ToBlob = (
@@ -213,3 +213,34 @@ export const exportAsImage = async (
});
}
};
export const exportAsSVG = async (
el: HTMLDivElement,
size?: Size,
bgcolor: string = '#ffffff'
) => {
const width = size ? size.width : el.clientWidth;
const height = size ? size.height : el.clientHeight;
const options = {
width,
height,
cacheBust: true,
bgcolor,
quality: 1.0
};
try {
const svgData = await domtoimage.toSvg(el, options);
return svgData;
} catch (error) {
console.error('SVG export failed, trying fallback method:', error);
// Fallback: try with minimal options
return await domtoimage.toSvg(el, {
width,
height,
cacheBust: true,
bgcolor
});
}
};