aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/graph
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2024-02-22 21:09:46 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2024-02-22 21:09:46 +0100
commit76144b74991110e8f655ce6289b2a2a85d1d9019 (patch)
tree4aa3727d17ec007559a96488f233eb0c26c59753 /subprojects/frontend/src/graph
parentMerge pull request #53 from kris7t/imports (diff)
downloadrefinery-76144b74991110e8f655ce6289b2a2a85d1d9019.tar.gz
refinery-76144b74991110e8f655ce6289b2a2a85d1d9019.tar.zst
refinery-76144b74991110e8f655ce6289b2a2a85d1d9019.zip
feat(web): SVG export
Diffstat (limited to 'subprojects/frontend/src/graph')
-rw-r--r--subprojects/frontend/src/graph/DotGraphVisualizer.tsx5
-rw-r--r--subprojects/frontend/src/graph/ExportButton.tsx168
-rw-r--r--subprojects/frontend/src/graph/GraphArea.tsx14
-rw-r--r--subprojects/frontend/src/graph/GraphTheme.tsx94
-rw-r--r--subprojects/frontend/src/graph/postProcessSVG.ts6
5 files changed, 252 insertions, 35 deletions
diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
index 72ac58fa..cc8b5116 100644
--- a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
+++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
@@ -30,11 +30,13 @@ function DotGraphVisualizer({
30 fitZoom, 30 fitZoom,
31 transitionTime, 31 transitionTime,
32 animateThreshold, 32 animateThreshold,
33 setSvgContainer,
33}: { 34}: {
34 graph: GraphStore; 35 graph: GraphStore;
35 fitZoom?: FitZoomCallback; 36 fitZoom?: FitZoomCallback;
36 transitionTime?: number; 37 transitionTime?: number;
37 animateThreshold?: number; 38 animateThreshold?: number;
39 setSvgContainer?: (container: HTMLElement | undefined) => void;
38}): JSX.Element { 40}): JSX.Element {
39 const transitionTimeOrDefault = 41 const transitionTimeOrDefault =
40 transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; 42 transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime;
@@ -48,6 +50,7 @@ function DotGraphVisualizer({
48 50
49 const setElement = useCallback( 51 const setElement = useCallback(
50 (element: HTMLDivElement | null) => { 52 (element: HTMLDivElement | null) => {
53 setSvgContainer?.(element ?? undefined);
51 if (disposerRef.current !== undefined) { 54 if (disposerRef.current !== undefined) {
52 disposerRef.current(); 55 disposerRef.current();
53 disposerRef.current = undefined; 56 disposerRef.current = undefined;
@@ -147,6 +150,7 @@ function DotGraphVisualizer({
147 transitionTimeOrDefault, 150 transitionTimeOrDefault,
148 animateThresholdOrDefault, 151 animateThresholdOrDefault,
149 animate, 152 animate,
153 setSvgContainer,
150 ], 154 ],
151 ); 155 );
152 156
@@ -157,6 +161,7 @@ DotGraphVisualizer.defaultProps = {
157 fitZoom: undefined, 161 fitZoom: undefined,
158 transitionTime: 250, 162 transitionTime: 250,
159 animateThreshold: 100, 163 animateThreshold: 100,
164 setSvgContainer: undefined,
160}; 165};
161 166
162export default observer(DotGraphVisualizer); 167export default observer(DotGraphVisualizer);
diff --git a/subprojects/frontend/src/graph/ExportButton.tsx b/subprojects/frontend/src/graph/ExportButton.tsx
new file mode 100644
index 00000000..91445d00
--- /dev/null
+++ b/subprojects/frontend/src/graph/ExportButton.tsx
@@ -0,0 +1,168 @@
1/*
2 * SPDX-FileCopyrightText: 2024 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import createCache from '@emotion/cache';
8import { serializeStyles } from '@emotion/serialize';
9import type { StyleSheet } from '@emotion/utils';
10import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw';
11import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw';
12import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw';
13import SaveAltIcon from '@mui/icons-material/SaveAlt';
14import IconButton from '@mui/material/IconButton';
15import { styled, useTheme, type Theme } from '@mui/material/styles';
16import { useCallback } from 'react';
17
18import { createGraphTheme } from './GraphTheme';
19import { SVG_NS } from './postProcessSVG';
20
21const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>';
22
23const ExportButtonRoot = styled('div', {
24 name: 'ExportButton-Root',
25})(({ theme }) => ({
26 position: 'absolute',
27 padding: theme.spacing(1),
28 top: 0,
29 right: 0,
30 overflow: 'hidden',
31 display: 'flex',
32 flexDirection: 'column',
33 alignItems: 'start',
34}));
35
36const ICONS: Map<string, Element> = new Map();
37
38function importSVG(svgSource: string, className: string): void {
39 const parser = new DOMParser();
40 const svgDocument = parser.parseFromString(svgSource, 'image/svg+xml');
41 const root = svgDocument.children[0];
42 if (root === undefined) {
43 return;
44 }
45 root.id = className;
46 root.classList.add(className);
47 ICONS.set(className, root);
48}
49
50importSVG(labelSVG, 'icon-TRUE');
51importSVG(labelOutlinedSVG, 'icon-UNKNOWN');
52importSVG(cancelSVG, 'icon-ERROR');
53
54function appendStyles(
55 svgDocument: XMLDocument,
56 svg: SVGSVGElement,
57 theme: Theme,
58): void {
59 const cache = createCache({
60 key: 'refinery',
61 container: svg,
62 prepend: true,
63 });
64 // @ts-expect-error `CSSObject` types don't match up between `@mui/material` and
65 // `@emotion/serialize`, but they are compatible in practice.
66 const styles = serializeStyles([createGraphTheme], cache.registered, {
67 theme,
68 colorNodes: true,
69 noEmbedIcons: true,
70 });
71 const rules: string[] = [];
72 const sheet = {
73 insert(rule) {
74 rules.push(rule);
75 },
76 } as StyleSheet;
77 cache.insert('', styles, sheet, false);
78 const styleElement = svgDocument.createElementNS(SVG_NS, 'style');
79 svg.prepend(styleElement);
80 styleElement.innerHTML = rules.join('');
81}
82
83function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void {
84 const foreignObjects: SVGForeignObjectElement[] = [];
85 svg
86 .querySelectorAll('foreignObject')
87 .forEach((object) => foreignObjects.push(object));
88 foreignObjects.forEach((object) => {
89 const useElement = svgDocument.createElementNS(SVG_NS, 'use');
90 let x = Number(object.getAttribute('x') ?? '0');
91 let y = Number(object.getAttribute('y') ?? '0');
92 const width = Number(object.getAttribute('width') ?? '0');
93 const height = Number(object.getAttribute('height') ?? '0');
94 const size = Math.min(width, height);
95 x += (width - size) / 2;
96 y += (height - size) / 2;
97 useElement.setAttribute('x', String(x));
98 useElement.setAttribute('y', String(y));
99 useElement.setAttribute('width', String(size));
100 useElement.setAttribute('height', String(size));
101 useElement.id = object.id;
102 object.children[0]?.classList?.forEach((className) => {
103 useElement.classList.add(className);
104 if (ICONS.has(className)) {
105 useElement.setAttribute('href', `#${className}`);
106 }
107 });
108 object.replaceWith(useElement);
109 });
110 const defs = svgDocument.createElementNS(SVG_NS, 'defs');
111 svg.prepend(defs);
112 ICONS.forEach((value) => {
113 const importedValue = svgDocument.importNode(value, true);
114 defs.appendChild(importedValue);
115 });
116}
117
118function downloadSVG(svgDocument: XMLDocument) {
119 const serializer = new XMLSerializer();
120 const svgText = `${PROLOG}\n${serializer.serializeToString(svgDocument)}`;
121 const blob = new Blob([svgText], {
122 type: 'image/svg+xml',
123 });
124 const link = document.createElement('a');
125 link.href = window.URL.createObjectURL(blob);
126 link.download = 'graph.svg';
127 link.style.display = 'none';
128 document.body.appendChild(link);
129 link.click();
130 document.body.removeChild(link);
131}
132
133export default function ExportButton({
134 svgContainer,
135}: {
136 svgContainer: HTMLElement | undefined;
137}): JSX.Element {
138 const theme = useTheme();
139 const saveCallback = useCallback(() => {
140 const svg = svgContainer?.querySelector('svg');
141 if (!svg) {
142 return;
143 }
144 const svgDocument = document.implementation.createDocument(
145 SVG_NS,
146 'svg',
147 null,
148 );
149 const copyOfSVG = svgDocument.importNode(svg, true);
150 const originalRoot = svgDocument.childNodes[0];
151 if (originalRoot === undefined) {
152 svgDocument.appendChild(copyOfSVG);
153 } else {
154 svgDocument.replaceChild(copyOfSVG, originalRoot);
155 }
156 fixForeignObjects(svgDocument, copyOfSVG);
157 appendStyles(svgDocument, copyOfSVG, theme);
158 downloadSVG(svgDocument);
159 }, [theme, svgContainer]);
160
161 return (
162 <ExportButtonRoot>
163 <IconButton aria-label="Save SVG" onClick={saveCallback}>
164 <SaveAltIcon />
165 </IconButton>
166 </ExportButtonRoot>
167 );
168}
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx
index d5801b9a..2bf40d1a 100644
--- a/subprojects/frontend/src/graph/GraphArea.tsx
+++ b/subprojects/frontend/src/graph/GraphArea.tsx
@@ -1,5 +1,5 @@
1/* 1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> 2 * SPDX-FileCopyrightText: 2023-2024 The Refinery Authors <https://refinery.tools/>
3 * 3 *
4 * SPDX-License-Identifier: EPL-2.0 4 * SPDX-License-Identifier: EPL-2.0
5 */ 5 */
@@ -7,9 +7,11 @@
7import Box from '@mui/material/Box'; 7import Box from '@mui/material/Box';
8import { useTheme } from '@mui/material/styles'; 8import { useTheme } from '@mui/material/styles';
9import { observer } from 'mobx-react-lite'; 9import { observer } from 'mobx-react-lite';
10import { useState } from 'react';
10import { useResizeDetector } from 'react-resize-detector'; 11import { useResizeDetector } from 'react-resize-detector';
11 12
12import DotGraphVisualizer from './DotGraphVisualizer'; 13import DotGraphVisualizer from './DotGraphVisualizer';
14import ExportButton from './ExportButton';
13import type GraphStore from './GraphStore'; 15import type GraphStore from './GraphStore';
14import VisibilityPanel from './VisibilityPanel'; 16import VisibilityPanel from './VisibilityPanel';
15import ZoomCanvas from './ZoomCanvas'; 17import ZoomCanvas from './ZoomCanvas';
@@ -19,6 +21,7 @@ function GraphArea({ graph }: { graph: GraphStore }): JSX.Element {
19 const { ref, width, height } = useResizeDetector({ 21 const { ref, width, height } = useResizeDetector({
20 refreshMode: 'debounce', 22 refreshMode: 'debounce',
21 }); 23 });
24 const [svgContainer, setSvgContainer] = useState<HTMLElement | undefined>();
22 25
23 const breakpoint = breakpoints.values.sm; 26 const breakpoint = breakpoints.values.sm;
24 const dialog = 27 const dialog =
@@ -36,9 +39,16 @@ function GraphArea({ graph }: { graph: GraphStore }): JSX.Element {
36 ref={ref} 39 ref={ref}
37 > 40 >
38 <ZoomCanvas> 41 <ZoomCanvas>
39 {(fitZoom) => <DotGraphVisualizer graph={graph} fitZoom={fitZoom} />} 42 {(fitZoom) => (
43 <DotGraphVisualizer
44 graph={graph}
45 fitZoom={fitZoom}
46 setSvgContainer={setSvgContainer}
47 />
48 )}
40 </ZoomCanvas> 49 </ZoomCanvas>
41 <VisibilityPanel graph={graph} dialog={dialog} /> 50 <VisibilityPanel graph={graph} dialog={dialog} />
51 <ExportButton svgContainer={svgContainer} />
42 </Box> 52 </Box>
43 ); 53 );
44} 54}
diff --git a/subprojects/frontend/src/graph/GraphTheme.tsx b/subprojects/frontend/src/graph/GraphTheme.tsx
index 7334f559..b3f55a35 100644
--- a/subprojects/frontend/src/graph/GraphTheme.tsx
+++ b/subprojects/frontend/src/graph/GraphTheme.tsx
@@ -52,11 +52,34 @@ function createTypeHashStyles(theme: Theme, colorNodes: boolean): CSSObject {
52 return result; 52 return result;
53} 53}
54 54
55export default styled('div', { 55function iconStyle(
56 name: 'GraphTheme', 56 svg: string,
57})<{ colorNodes: boolean }>(({ theme, colorNodes }) => ({ 57 color: string,
58 '& svg': { 58 noEmbedIcons?: boolean,
59 userSelect: 'none', 59): CSSObject {
60 if (noEmbedIcons) {
61 return {
62 fill: color,
63 };
64 }
65 return {
66 maskImage: svgURL(svg),
67 background: color,
68 };
69}
70
71export function createGraphTheme({
72 theme,
73 colorNodes,
74 noEmbedIcons,
75}: {
76 theme: Theme;
77 colorNodes: boolean;
78 noEmbedIcons?: boolean;
79}): CSSObject {
80 const shadowAlapha = theme.palette.mode === 'dark' ? 0.32 : 0.24;
81
82 return {
60 '.node': { 83 '.node': {
61 '& text': { 84 '& text': {
62 fontFamily: theme.typography.fontFamily, 85 fontFamily: theme.typography.fontFamily,
@@ -80,12 +103,15 @@ export default styled('div', {
80 strokeWidth: 2, 103 strokeWidth: 2,
81 }, 104 },
82 }, 105 },
83 '.node-shadow[fill="white"]': { 106 '.node-shadow[fill="white"]': noEmbedIcons
84 fill: alpha( 107 ? {
85 theme.palette.text.primary, 108 // Inkscape can't handle opacity in exported SVG.
86 theme.palette.mode === 'dark' ? 0.32 : 0.24, 109 fill: theme.palette.text.primary,
87 ), 110 opacity: shadowAlapha,
88 }, 111 }
112 : {
113 fill: alpha(theme.palette.text.primary, shadowAlapha),
114 },
89 '.node-exists-UNKNOWN [stroke="black"]': { 115 '.node-exists-UNKNOWN [stroke="black"]': {
90 strokeDasharray: '5 2', 116 strokeDasharray: '5 2',
91 }, 117 },
@@ -104,30 +130,38 @@ export default styled('div', {
104 }, 130 },
105 ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'), 131 ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'),
106 ...createEdgeColor('ERROR', theme.palette.error.main), 132 ...createEdgeColor('ERROR', theme.palette.error.main),
107 '.icon': { 133 ...(noEmbedIcons
108 maskSize: '12px 12px', 134 ? {}
109 maskPosition: '50% 50%', 135 : {
110 maskRepeat: 'no-repeat', 136 '.icon': {
111 width: '100%', 137 maskSize: '12px 12px',
112 height: '100%', 138 maskPosition: '50% 50%',
113 }, 139 maskRepeat: 'no-repeat',
114 '.icon-TRUE': { 140 width: '100%',
115 maskImage: svgURL(labelSVG), 141 height: '100%',
116 background: theme.palette.text.primary, 142 },
117 }, 143 }),
118 '.icon-UNKNOWN': { 144 '.icon-TRUE': iconStyle(labelSVG, theme.palette.text.primary, noEmbedIcons),
119 maskImage: svgURL(labelOutlinedSVG), 145 '.icon-UNKNOWN': iconStyle(
120 background: theme.palette.text.secondary, 146 labelOutlinedSVG,
121 }, 147 theme.palette.text.secondary,
122 '.icon-ERROR': { 148 noEmbedIcons,
123 maskImage: svgURL(cancelSVG), 149 ),
124 background: theme.palette.error.main, 150 '.icon-ERROR': iconStyle(cancelSVG, theme.palette.error.main, noEmbedIcons),
125 },
126 'text.label-UNKNOWN': { 151 'text.label-UNKNOWN': {
127 fill: theme.palette.text.secondary, 152 fill: theme.palette.text.secondary,
128 }, 153 },
129 'text.label-ERROR': { 154 'text.label-ERROR': {
130 fill: theme.palette.error.main, 155 fill: theme.palette.error.main,
131 }, 156 },
157 };
158}
159
160export default styled('div', {
161 name: 'GraphTheme',
162})<{ colorNodes: boolean }>((args) => ({
163 '& svg': {
164 userSelect: 'none',
165 ...createGraphTheme(args),
132 }, 166 },
133})); 167}));
diff --git a/subprojects/frontend/src/graph/postProcessSVG.ts b/subprojects/frontend/src/graph/postProcessSVG.ts
index a580f5c6..f434f80b 100644
--- a/subprojects/frontend/src/graph/postProcessSVG.ts
+++ b/subprojects/frontend/src/graph/postProcessSVG.ts
@@ -1,13 +1,13 @@
1/* 1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> 2 * SPDX-FileCopyrightText: 2023-2024 The Refinery Authors <https://refinery.tools/>
3 * 3 *
4 * SPDX-License-Identifier: EPL-2.0 4 * SPDX-License-Identifier: EPL-2.0
5 */ 5 */
6 6
7import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; 7import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox';
8 8
9const SVG_NS = 'http://www.w3.org/2000/svg'; 9export const SVG_NS = 'http://www.w3.org/2000/svg';
10const XLINK_NS = 'http://www.w3.org/1999/xlink'; 10export const XLINK_NS = 'http://www.w3.org/1999/xlink';
11 11
12function modifyAttribute(element: Element, attribute: string, change: number) { 12function modifyAttribute(element: Element, attribute: string, change: number) {
13 const valueString = element.getAttribute(attribute); 13 const valueString = element.getAttribute(attribute);