aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2024-02-23 19:03:03 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2024-02-23 19:13:23 +0100
commitcf07f20847642aec1eafe6234c2ce35264bbaabb (patch)
tree3d89fbd37db49f815eba4215dfc3d3800ff93970
parentfeat(frontend): add top bar logo (diff)
downloadrefinery-cf07f20847642aec1eafe6234c2ce35264bbaabb.tar.gz
refinery-cf07f20847642aec1eafe6234c2ce35264bbaabb.tar.zst
refinery-cf07f20847642aec1eafe6234c2ce35264bbaabb.zip
feat(frontend): graph export formal selector
-rw-r--r--subprojects/frontend/src/RootStore.ts5
-rw-r--r--subprojects/frontend/src/graph/ExportPanel.tsx215
-rw-r--r--subprojects/frontend/src/graph/ExportSettingsStore.tsx46
-rw-r--r--subprojects/frontend/src/graph/GraphArea.tsx4
-rw-r--r--subprojects/frontend/src/graph/SlideInDialog.tsx109
-rw-r--r--subprojects/frontend/src/graph/SlideInPanel.tsx97
-rw-r--r--subprojects/frontend/src/graph/VisibilityDialog.tsx318
-rw-r--r--subprojects/frontend/src/graph/VisibilityPanel.tsx303
-rw-r--r--subprojects/frontend/src/graph/exportDiagram.tsx (renamed from subprojects/frontend/src/graph/ExportButton.tsx)218
-rw-r--r--subprojects/frontend/src/theme/ThemeProvider.tsx4
10 files changed, 871 insertions, 448 deletions
diff --git a/subprojects/frontend/src/RootStore.ts b/subprojects/frontend/src/RootStore.ts
index e277c808..27bff0de 100644
--- a/subprojects/frontend/src/RootStore.ts
+++ b/subprojects/frontend/src/RootStore.ts
@@ -9,6 +9,7 @@ import { makeAutoObservable, runInAction } from 'mobx';
9 9
10import PWAStore from './PWAStore'; 10import PWAStore from './PWAStore';
11import type EditorStore from './editor/EditorStore'; 11import type EditorStore from './editor/EditorStore';
12import ExportSettingsScotre from './graph/ExportSettingsStore';
12import Compressor from './persistence/Compressor'; 13import Compressor from './persistence/Compressor';
13import ThemeStore from './theme/ThemeStore'; 14import ThemeStore from './theme/ThemeStore';
14 15
@@ -29,16 +30,20 @@ export default class RootStore {
29 30
30 readonly themeStore: ThemeStore; 31 readonly themeStore: ThemeStore;
31 32
33 readonly exportSettingsStore: ExportSettingsScotre;
34
32 disposed = false; 35 disposed = false;
33 36
34 constructor() { 37 constructor() {
35 this.pwaStore = new PWAStore(); 38 this.pwaStore = new PWAStore();
36 this.themeStore = new ThemeStore(); 39 this.themeStore = new ThemeStore();
40 this.exportSettingsStore = new ExportSettingsScotre();
37 makeAutoObservable<RootStore, 'compressor' | 'editorStoreClass'>(this, { 41 makeAutoObservable<RootStore, 'compressor' | 'editorStoreClass'>(this, {
38 compressor: false, 42 compressor: false,
39 editorStoreClass: false, 43 editorStoreClass: false,
40 pwaStore: false, 44 pwaStore: false,
41 themeStore: false, 45 themeStore: false,
46 exportSettingsStore: false,
42 }); 47 });
43 (async () => { 48 (async () => {
44 const { default: EditorStore } = await import('./editor/EditorStore'); 49 const { default: EditorStore } = await import('./editor/EditorStore');
diff --git a/subprojects/frontend/src/graph/ExportPanel.tsx b/subprojects/frontend/src/graph/ExportPanel.tsx
new file mode 100644
index 00000000..2621deff
--- /dev/null
+++ b/subprojects/frontend/src/graph/ExportPanel.tsx
@@ -0,0 +1,215 @@
1/*
2 * SPDX-FileCopyrightText: 2024 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import ChevronRightIcon from '@mui/icons-material/ChevronRight';
8import ContentCopyIcon from '@mui/icons-material/ContentCopy';
9import DarkModeIcon from '@mui/icons-material/DarkMode';
10import ImageIcon from '@mui/icons-material/Image';
11import LightModeIcon from '@mui/icons-material/LightMode';
12import SaveAltIcon from '@mui/icons-material/SaveAlt';
13import ShapeLineIcon from '@mui/icons-material/ShapeLine';
14import Box from '@mui/material/Box';
15import Button from '@mui/material/Button';
16import FormControlLabel from '@mui/material/FormControlLabel';
17import Slider from '@mui/material/Slider';
18import Stack from '@mui/material/Stack';
19import Switch from '@mui/material/Switch';
20import ToggleButton from '@mui/material/ToggleButton';
21import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
22import Typography from '@mui/material/Typography';
23import { styled } from '@mui/material/styles';
24import { observer } from 'mobx-react-lite';
25import { useCallback } from 'react';
26
27import { useRootStore } from '../RootStoreProvider';
28import getLogger from '../utils/getLogger';
29
30import type GraphStore from './GraphStore';
31import SlideInPanel from './SlideInPanel';
32import exportDiagram from './exportDiagram';
33
34const log = getLogger('graph.ExportPanel');
35
36const SwitchButtonGroup = styled(ToggleButtonGroup, {
37 name: 'ExportPanel-SwitchButtonGroup',
38})(({ theme }) => ({
39 marginTop: theme.spacing(2),
40 marginInline: theme.spacing(2),
41 minWidth: '260px',
42 '.MuiToggleButton-root': {
43 width: '100%',
44 fontSize: '1rem',
45 lineHeight: '1.5',
46 },
47 '& svg': {
48 margin: '0 6px 0 0',
49 },
50}));
51
52function getLabel(value: number): string {
53 return `${value}%`;
54}
55
56const marks = [100, 200, 300, 400].map((value) => ({
57 value,
58 label: (
59 <Stack direction="column" alignItems="center">
60 <ImageIcon sx={{ width: `${11 + (value / 100) * 3}px` }} />
61 <Typography variant="caption">{getLabel(value)}</Typography>
62 </Stack>
63 ),
64}));
65
66function ExportPanel({
67 graph,
68 svgContainer,
69 dialog,
70}: {
71 graph: GraphStore;
72 svgContainer: HTMLElement | undefined;
73 dialog: boolean;
74}): JSX.Element {
75 const { exportSettingsStore } = useRootStore();
76
77 const icon = useCallback(
78 (show: boolean) =>
79 show && !dialog ? <ChevronRightIcon /> : <SaveAltIcon />,
80 [dialog],
81 );
82
83 const { format } = exportSettingsStore;
84 const emptyGraph = graph.semantics.nodes.length === 0;
85 const buttons = useCallback(
86 (close: () => void) => (
87 <>
88 <Button
89 color="inherit"
90 startIcon={<SaveAltIcon />}
91 disabled={emptyGraph}
92 onClick={() => {
93 exportDiagram(svgContainer, graph, exportSettingsStore, 'download')
94 .then(close)
95 .catch((error) => {
96 log.error('Failed to download diagram', error);
97 });
98 }}
99 >
100 Download
101 </Button>
102 {'write' in navigator.clipboard && format === 'png' && (
103 <Button
104 color="inherit"
105 startIcon={<ContentCopyIcon />}
106 disabled={emptyGraph}
107 onClick={() => {
108 exportDiagram(svgContainer, graph, exportSettingsStore, 'copy')
109 .then(close)
110 .catch((error) => {
111 log.error('Failed to copy diagram', error);
112 });
113 }}
114 >
115 Copy
116 </Button>
117 )}
118 </>
119 ),
120 [svgContainer, graph, exportSettingsStore, format, emptyGraph],
121 );
122
123 return (
124 <SlideInPanel
125 anchor="right"
126 dialog={dialog}
127 title="Export diagram"
128 icon={icon}
129 iconLabel="Show export panel"
130 buttons={buttons}
131 >
132 <SwitchButtonGroup size="small" className="rounded">
133 <ToggleButton
134 value="svg"
135 selected={exportSettingsStore.format === 'svg'}
136 onClick={() => exportSettingsStore.setFormat('svg')}
137 >
138 <ShapeLineIcon fontSize="small" /> SVG
139 </ToggleButton>
140 <ToggleButton
141 value="png"
142 selected={exportSettingsStore.format === 'png'}
143 onClick={() => exportSettingsStore.setFormat('png')}
144 >
145 <ImageIcon fontSize="small" /> PNG
146 </ToggleButton>
147 </SwitchButtonGroup>
148 <SwitchButtonGroup size="small" className="rounded">
149 <ToggleButton
150 value="svg"
151 selected={exportSettingsStore.theme === 'light'}
152 onClick={() => exportSettingsStore.setTheme('light')}
153 >
154 <LightModeIcon fontSize="small" /> Light
155 </ToggleButton>
156 <ToggleButton
157 value="png"
158 selected={exportSettingsStore.theme === 'dark'}
159 onClick={() => exportSettingsStore.setTheme('dark')}
160 >
161 <DarkModeIcon fontSize="small" /> Dark
162 </ToggleButton>
163 </SwitchButtonGroup>
164 <FormControlLabel
165 control={
166 <Switch
167 checked={exportSettingsStore.transparent}
168 onClick={() => exportSettingsStore.toggleTransparent()}
169 />
170 }
171 label="Transparent background"
172 />
173 {exportSettingsStore.format === 'svg' && (
174 <FormControlLabel
175 control={
176 <Switch
177 checked={exportSettingsStore.embedFonts}
178 onClick={() => exportSettingsStore.toggleEmbedFonts()}
179 />
180 }
181 label={
182 <Stack direction="column">
183 <Typography>Embed fonts</Typography>
184 <Typography variant="caption">
185 +75&thinsp;kB, only supported in browsers
186 </Typography>
187 </Stack>
188 }
189 />
190 )}
191 {exportSettingsStore.format === 'png' && (
192 <Box mx={4} mt={1} mb={2}>
193 <Slider
194 aria-label="Image scale"
195 value={exportSettingsStore.scale}
196 min={100}
197 max={400}
198 valueLabelFormat={getLabel}
199 getAriaValueText={getLabel}
200 step={50}
201 valueLabelDisplay="auto"
202 marks={marks}
203 onChange={(_, value) => {
204 if (typeof value === 'number') {
205 exportSettingsStore.setScale(value);
206 }
207 }}
208 />
209 </Box>
210 )}
211 </SlideInPanel>
212 );
213}
214
215export default observer(ExportPanel);
diff --git a/subprojects/frontend/src/graph/ExportSettingsStore.tsx b/subprojects/frontend/src/graph/ExportSettingsStore.tsx
new file mode 100644
index 00000000..8ee91b73
--- /dev/null
+++ b/subprojects/frontend/src/graph/ExportSettingsStore.tsx
@@ -0,0 +1,46 @@
1/*
2 * SPDX-FileCopyrightText: 2024 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { makeAutoObservable } from 'mobx';
8
9export type ExportFormat = 'svg' | 'png';
10export type ExportTheme = 'light' | 'dark';
11
12export default class ExportSettingsStore {
13 format: ExportFormat = 'svg';
14
15 theme: ExportTheme = 'light';
16
17 transparent = true;
18
19 embedFonts = false;
20
21 scale = 100;
22
23 constructor() {
24 makeAutoObservable(this);
25 }
26
27 setFormat(format: ExportFormat): void {
28 this.format = format;
29 }
30
31 setTheme(theme: ExportTheme): void {
32 this.theme = theme;
33 }
34
35 toggleTransparent(): void {
36 this.transparent = !this.transparent;
37 }
38
39 toggleEmbedFonts(): void {
40 this.embedFonts = !this.embedFonts;
41 }
42
43 setScale(scale: number): void {
44 this.scale = scale;
45 }
46}
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx
index 2bf40d1a..1416a259 100644
--- a/subprojects/frontend/src/graph/GraphArea.tsx
+++ b/subprojects/frontend/src/graph/GraphArea.tsx
@@ -11,7 +11,7 @@ import { useState } from 'react';
11import { useResizeDetector } from 'react-resize-detector'; 11import { useResizeDetector } from 'react-resize-detector';
12 12
13import DotGraphVisualizer from './DotGraphVisualizer'; 13import DotGraphVisualizer from './DotGraphVisualizer';
14import ExportButton from './ExportButton'; 14import ExportPanel from './ExportPanel';
15import type GraphStore from './GraphStore'; 15import type GraphStore from './GraphStore';
16import VisibilityPanel from './VisibilityPanel'; 16import VisibilityPanel from './VisibilityPanel';
17import ZoomCanvas from './ZoomCanvas'; 17import ZoomCanvas from './ZoomCanvas';
@@ -48,7 +48,7 @@ function GraphArea({ graph }: { graph: GraphStore }): JSX.Element {
48 )} 48 )}
49 </ZoomCanvas> 49 </ZoomCanvas>
50 <VisibilityPanel graph={graph} dialog={dialog} /> 50 <VisibilityPanel graph={graph} dialog={dialog} />
51 <ExportButton svgContainer={svgContainer} /> 51 <ExportPanel graph={graph} svgContainer={svgContainer} dialog={dialog} />
52 </Box> 52 </Box>
53 ); 53 );
54} 54}
diff --git a/subprojects/frontend/src/graph/SlideInDialog.tsx b/subprojects/frontend/src/graph/SlideInDialog.tsx
new file mode 100644
index 00000000..d9060fb0
--- /dev/null
+++ b/subprojects/frontend/src/graph/SlideInDialog.tsx
@@ -0,0 +1,109 @@
1/*
2 * SPDX-FileCopyrightText: 2023-2024 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import CloseIcon from '@mui/icons-material/Close';
8import Button from '@mui/material/Button';
9import IconButton from '@mui/material/IconButton';
10import Typography from '@mui/material/Typography';
11import { styled } from '@mui/material/styles';
12import React, { useId } from 'react';
13
14const SlideInDialogRoot = styled('div', {
15 name: 'SlideInDialog-Root',
16 shouldForwardProp: (propName) => propName !== 'dialog',
17})<{ dialog: boolean }>(({ theme, dialog }) => {
18 return {
19 maxHeight: '100%',
20 maxWidth: '100%',
21 overflow: 'hidden',
22 display: 'flex',
23 flexDirection: 'column',
24 '.SlideInDialog-title': {
25 display: 'flex',
26 flexDirection: 'row',
27 alignItems: 'center',
28 padding: theme.spacing(1),
29 paddingLeft: theme.spacing(2),
30 borderBottom: `1px solid ${theme.palette.divider}`,
31 '& h2': {
32 flexGrow: 1,
33 },
34 '.MuiIconButton-root': {
35 flexGrow: 0,
36 flexShrink: 0,
37 marginLeft: theme.spacing(2),
38 },
39 },
40 '.MuiFormControlLabel-root': {
41 marginLeft: 0,
42 paddingTop: theme.spacing(1),
43 paddingLeft: theme.spacing(1),
44 '& + .MuiFormControlLabel-root': {
45 paddingTop: 0,
46 },
47 },
48 '.SlideInDialog-buttons': {
49 padding: theme.spacing(1),
50 display: 'flex',
51 flexDirection: 'row',
52 justifyContent: 'flex-end',
53 ...(dialog
54 ? {
55 marginTop: theme.spacing(1),
56 borderTop: `1px solid ${theme.palette.divider}`,
57 }
58 : {}),
59 },
60 };
61});
62
63export default function SlideInDialog({
64 close,
65 dialog,
66 title,
67 buttons,
68 children,
69}: {
70 close: () => void;
71 dialog?: boolean;
72 title: string;
73 buttons: React.ReactNode | ((close: () => void) => React.ReactNode);
74 children?: React.ReactNode;
75}): JSX.Element {
76 const titleId = useId();
77
78 return (
79 <SlideInDialogRoot
80 dialog={dialog ?? SlideInDialog.defaultProps.dialog}
81 aria-labelledby={dialog ? titleId : undefined}
82 >
83 {dialog && (
84 <div className="SlideInDialog-title">
85 <Typography variant="h6" component="h2" id={titleId}>
86 {title}
87 </Typography>
88 <IconButton aria-label="Close" onClick={close}>
89 <CloseIcon />
90 </IconButton>
91 </div>
92 )}
93 {children}
94 <div className="SlideInDialog-buttons">
95 {typeof buttons === 'function' ? buttons(close) : buttons}
96 {!dialog && (
97 <Button color="inherit" onClick={close}>
98 Close
99 </Button>
100 )}
101 </div>
102 </SlideInDialogRoot>
103 );
104}
105
106SlideInDialog.defaultProps = {
107 dialog: false,
108 children: undefined,
109};
diff --git a/subprojects/frontend/src/graph/SlideInPanel.tsx b/subprojects/frontend/src/graph/SlideInPanel.tsx
new file mode 100644
index 00000000..2c189b5b
--- /dev/null
+++ b/subprojects/frontend/src/graph/SlideInPanel.tsx
@@ -0,0 +1,97 @@
1/*
2 * SPDX-FileCopyrightText: 2023-2024 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import Dialog from '@mui/material/Dialog';
8import IconButton from '@mui/material/IconButton';
9import Paper from '@mui/material/Paper';
10import Slide from '@mui/material/Slide';
11import { styled } from '@mui/material/styles';
12import React, { useCallback, useId, useState } from 'react';
13
14import SlideInDialog from './SlideInDialog';
15
16const SlideInPanelRoot = styled('div', {
17 name: 'SlideInPanel-Root',
18 shouldForwardProp: (propName) => propName !== 'anchor',
19})<{ anchor: 'left' | 'right' }>(({ theme, anchor }) => ({
20 position: 'absolute',
21 padding: theme.spacing(1),
22 top: 0,
23 [anchor]: 0,
24 maxHeight: '100%',
25 maxWidth: '100%',
26 overflow: 'hidden',
27 display: 'flex',
28 flexDirection: 'column',
29 alignItems: anchor === 'left' ? 'start' : 'end',
30 '.SlideInPanel-drawer': {
31 overflow: 'hidden',
32 display: 'flex',
33 maxWidth: '100%',
34 margin: theme.spacing(1),
35 },
36}));
37
38export default function SlideInPanel({
39 anchor,
40 dialog,
41 title,
42 icon,
43 iconLabel,
44 buttons,
45 children,
46}: {
47 anchor: 'left' | 'right';
48 dialog: boolean;
49 title: string;
50 icon: (show: boolean) => React.ReactNode;
51 iconLabel: string;
52 buttons: React.ReactNode | ((close: () => void) => React.ReactNode);
53 children?: React.ReactNode;
54}): JSX.Element {
55 const id = useId();
56 const [show, setShow] = useState(false);
57 const close = useCallback(() => setShow(false), []);
58
59 return (
60 <SlideInPanelRoot anchor={anchor}>
61 <IconButton
62 role="switch"
63 aria-checked={show}
64 aria-controls={dialog ? undefined : id}
65 aria-label={iconLabel}
66 onClick={() => setShow(!show)}
67 >
68 {icon(show)}
69 </IconButton>
70 {dialog ? (
71 <Dialog open={show} onClose={close} maxWidth="xl">
72 <SlideInDialog close={close} dialog title={title} buttons={buttons}>
73 {children}
74 </SlideInDialog>
75 </Dialog>
76 ) : (
77 <Slide
78 direction={anchor === 'left' ? 'right' : 'left'}
79 in={show}
80 id={id}
81 mountOnEnter
82 unmountOnExit
83 >
84 <Paper className="SlideInPanel-drawer" elevation={4}>
85 <SlideInDialog close={close} title={title} buttons={buttons}>
86 {children}
87 </SlideInDialog>
88 </Paper>
89 </Slide>
90 )}
91 </SlideInPanelRoot>
92 );
93}
94
95SlideInPanel.defaultProps = {
96 children: undefined,
97};
diff --git a/subprojects/frontend/src/graph/VisibilityDialog.tsx b/subprojects/frontend/src/graph/VisibilityDialog.tsx
deleted file mode 100644
index bfdcd59f..00000000
--- a/subprojects/frontend/src/graph/VisibilityDialog.tsx
+++ /dev/null
@@ -1,318 +0,0 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import CloseIcon from '@mui/icons-material/Close';
8import FilterListIcon from '@mui/icons-material/FilterList';
9import LabelIcon from '@mui/icons-material/Label';
10import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined';
11import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied';
12import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
13import Button from '@mui/material/Button';
14import Checkbox from '@mui/material/Checkbox';
15import FormControlLabel from '@mui/material/FormControlLabel';
16import IconButton from '@mui/material/IconButton';
17import Switch from '@mui/material/Switch';
18import Typography from '@mui/material/Typography';
19import { styled } from '@mui/material/styles';
20import { observer } from 'mobx-react-lite';
21import { useId } from 'react';
22
23import type GraphStore from './GraphStore';
24import { isVisibilityAllowed } from './GraphStore';
25import RelationName from './RelationName';
26
27const VisibilityDialogRoot = styled('div', {
28 name: 'VisibilityDialog-Root',
29 shouldForwardProp: (propName) => propName !== 'dialog',
30})<{ dialog: boolean }>(({ theme, dialog }) => {
31 const overlayOpacity = dialog ? 0.16 : 0.09;
32 return {
33 maxHeight: '100%',
34 maxWidth: '100%',
35 overflow: 'hidden',
36 display: 'flex',
37 flexDirection: 'column',
38 '.VisibilityDialog-title': {
39 display: 'flex',
40 flexDirection: 'row',
41 alignItems: 'center',
42 padding: theme.spacing(1),
43 paddingLeft: theme.spacing(2),
44 borderBottom: `1px solid ${theme.palette.divider}`,
45 '& h2': {
46 flexGrow: 1,
47 },
48 '.MuiIconButton-root': {
49 flexGrow: 0,
50 flexShrink: 0,
51 marginLeft: theme.spacing(2),
52 },
53 },
54 '.MuiFormControlLabel-root': {
55 marginLeft: 0,
56 paddingTop: theme.spacing(1),
57 paddingLeft: theme.spacing(1),
58 '& + .MuiFormControlLabel-root': {
59 paddingTop: 0,
60 },
61 },
62 '.VisibilityDialog-scroll': {
63 display: 'flex',
64 flexDirection: 'column',
65 height: 'auto',
66 overflowX: 'hidden',
67 overflowY: 'auto',
68 margin: `0 ${theme.spacing(2)}`,
69 '& table': {
70 // We use flexbox instead of `display: table` to get proper text-overflow
71 // behavior for overly long relation names.
72 display: 'flex',
73 flexDirection: 'column',
74 },
75 '& thead, & tbody': {
76 display: 'flex',
77 flexDirection: 'column',
78 },
79 '& thead': {
80 position: 'sticky',
81 top: 0,
82 zIndex: 999,
83 backgroundColor: theme.palette.background.paper,
84 ...(theme.palette.mode === 'dark'
85 ? {
86 // In dark mode, MUI Paper gets a lighter overlay.
87 backgroundImage: `linear-gradient(
88 rgba(255, 255, 255, ${overlayOpacity}),
89 rgba(255, 255, 255, ${overlayOpacity})
90 )`,
91 }
92 : {}),
93 '& tr': {
94 height: '44px',
95 },
96 },
97 '& tr': {
98 display: 'flex',
99 flexDirection: 'row',
100 maxWidth: '100%',
101 },
102 '& tbody tr': {
103 transition: theme.transitions.create('background', {
104 duration: theme.transitions.duration.shortest,
105 }),
106 '&:hover': {
107 background: theme.palette.action.hover,
108 '@media (hover: none)': {
109 background: 'transparent',
110 },
111 },
112 },
113 '& th, & td': {
114 display: 'flex',
115 flexDirection: 'row',
116 alignItems: 'center',
117 justifyContent: 'center',
118 // Set width in advance, since we can't rely on `display: table-cell`.
119 width: '44px',
120 },
121 '& th:nth-of-type(3), & td:nth-of-type(3)': {
122 justifyContent: 'start',
123 paddingLeft: theme.spacing(1),
124 paddingRight: theme.spacing(2),
125 // Only let the last column grow or shrink.
126 flexGrow: 1,
127 flexShrink: 1,
128 // Compute the maximum available space in advance to let the text overflow.
129 maxWidth: 'calc(100% - 88px)',
130 width: 'min-content',
131 },
132 '& td:nth-of-type(3)': {
133 cursor: 'pointer',
134 userSelect: 'none',
135 WebkitTapHighlightColor: 'transparent',
136 },
137
138 '& thead th, .VisibilityDialog-custom tr:last-child td': {
139 borderBottom: `1px solid ${theme.palette.divider}`,
140 },
141 },
142 // Hack to apply `text-overflow`.
143 '.VisibilityDialog-nowrap': {
144 maxWidth: '100%',
145 overflow: 'hidden',
146 wordWrap: 'nowrap',
147 textOverflow: 'ellipsis',
148 },
149 '.VisibilityDialog-buttons': {
150 padding: theme.spacing(1),
151 display: 'flex',
152 flexDirection: 'row',
153 justifyContent: 'flex-end',
154 ...(dialog
155 ? {
156 marginTop: theme.spacing(1),
157 borderTop: `1px solid ${theme.palette.divider}`,
158 }
159 : {}),
160 },
161 '.VisibilityDialog-empty': {
162 display: 'flex',
163 flexDirection: 'column',
164 alignItems: 'center',
165 color: theme.palette.text.secondary,
166 },
167 '.VisibilityDialog-emptyIcon': {
168 fontSize: '6rem',
169 marginBottom: theme.spacing(1),
170 },
171 };
172});
173
174function VisibilityDialog({
175 graph,
176 close,
177 dialog,
178}: {
179 graph: GraphStore;
180 close: () => void;
181 dialog?: boolean;
182}): JSX.Element {
183 const titleId = useId();
184
185 const builtinRows: JSX.Element[] = [];
186 const rows: JSX.Element[] = [];
187 graph.relationMetadata.forEach((metadata, name) => {
188 if (!isVisibilityAllowed(metadata, 'must')) {
189 return;
190 }
191 const visibility = graph.getVisibility(name);
192 const row = (
193 <tr key={metadata.name}>
194 <td>
195 <Checkbox
196 checked={visibility !== 'none'}
197 aria-label={`Show true and error values of ${metadata.simpleName}`}
198 onClick={() =>
199 graph.setVisibility(name, visibility === 'none' ? 'must' : 'none')
200 }
201 />
202 </td>
203 <td>
204 <Checkbox
205 checked={visibility === 'all'}
206 disabled={!isVisibilityAllowed(metadata, 'all')}
207 aria-label={`Show all values of ${metadata.simpleName}`}
208 onClick={() =>
209 graph.setVisibility(name, visibility === 'all' ? 'must' : 'all')
210 }
211 />
212 </td>
213 <td
214 onClick={() => graph.cycleVisibility(name)}
215 aria-label="Toggle visiblity"
216 >
217 <div className="VisibilityDialog-nowrap">
218 <RelationName metadata={metadata} abbreviate={graph.abbreviate} />
219 </div>
220 </td>
221 </tr>
222 );
223 if (name.startsWith('builtin::')) {
224 builtinRows.push(row);
225 } else {
226 rows.push(row);
227 }
228 });
229
230 const hasRows = rows.length > 0 || builtinRows.length > 0;
231
232 return (
233 <VisibilityDialogRoot
234 dialog={dialog ?? VisibilityDialog.defaultProps.dialog}
235 aria-labelledby={dialog ? titleId : undefined}
236 >
237 {dialog && (
238 <div className="VisibilityDialog-title">
239 <Typography variant="h6" component="h2" id={titleId}>
240 Customize view
241 </Typography>
242 <IconButton aria-label="Close" onClick={close}>
243 <CloseIcon />
244 </IconButton>
245 </div>
246 )}
247 <FormControlLabel
248 control={
249 <Switch
250 checked={!graph.abbreviate}
251 onClick={() => graph.toggleAbbrevaite()}
252 />
253 }
254 label="Fully qualified names"
255 />
256 <FormControlLabel
257 control={
258 <Switch checked={graph.scopes} onClick={() => graph.toggleScopes()} />
259 }
260 label="Object scopes"
261 />
262 <div className="VisibilityDialog-scroll">
263 {hasRows ? (
264 <table cellSpacing={0}>
265 <thead>
266 <tr>
267 <th aria-label="Show true and error values">
268 <LabelIcon />
269 </th>
270 <th aria-label="Show unknown values">
271 <LabelOutlinedIcon />
272 </th>
273 <th>Symbol</th>
274 </tr>
275 </thead>
276 <tbody className="VisibilityDialog-custom">{...rows}</tbody>
277 <tbody className="VisibilityDialog-builtin">{...builtinRows}</tbody>
278 </table>
279 ) : (
280 <div className="VisibilityDialog-empty">
281 <SentimentVeryDissatisfiedIcon
282 className="VisibilityDialog-emptyIcon"
283 fontSize="inherit"
284 />
285 <div>Partial model is empty</div>
286 </div>
287 )}
288 </div>
289 <div className="VisibilityDialog-buttons">
290 <Button
291 color="inherit"
292 onClick={() => graph.hideAll()}
293 startIcon={<VisibilityOffIcon />}
294 >
295 Hide all
296 </Button>
297 <Button
298 color="inherit"
299 onClick={() => graph.resetFilter()}
300 startIcon={<FilterListIcon />}
301 >
302 Reset filter
303 </Button>
304 {!dialog && (
305 <Button color="inherit" onClick={close}>
306 Close
307 </Button>
308 )}
309 </div>
310 </VisibilityDialogRoot>
311 );
312}
313
314VisibilityDialog.defaultProps = {
315 dialog: false,
316};
317
318export default observer(VisibilityDialog);
diff --git a/subprojects/frontend/src/graph/VisibilityPanel.tsx b/subprojects/frontend/src/graph/VisibilityPanel.tsx
index 20c4ffca..210ff5d5 100644
--- a/subprojects/frontend/src/graph/VisibilityPanel.tsx
+++ b/subprojects/frontend/src/graph/VisibilityPanel.tsx
@@ -1,43 +1,133 @@
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 ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; 7import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
8import FilterListIcon from '@mui/icons-material/FilterList';
9import LabelIcon from '@mui/icons-material/Label';
10import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined';
11import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied';
8import TuneIcon from '@mui/icons-material/Tune'; 12import TuneIcon from '@mui/icons-material/Tune';
13import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
9import Badge from '@mui/material/Badge'; 14import Badge from '@mui/material/Badge';
10import Dialog from '@mui/material/Dialog'; 15import Button from '@mui/material/Button';
11import IconButton from '@mui/material/IconButton'; 16import Checkbox from '@mui/material/Checkbox';
12import Paper from '@mui/material/Paper'; 17import FormControlLabel from '@mui/material/FormControlLabel';
13import Slide from '@mui/material/Slide'; 18import Switch from '@mui/material/Switch';
14import { styled } from '@mui/material/styles'; 19import { styled } from '@mui/material/styles';
15import { observer } from 'mobx-react-lite'; 20import { observer } from 'mobx-react-lite';
16import { useCallback, useId, useState } from 'react'; 21import { useCallback } from 'react';
17 22
18import type GraphStore from './GraphStore'; 23import type GraphStore from './GraphStore';
19import VisibilityDialog from './VisibilityDialog'; 24import { isVisibilityAllowed } from './GraphStore';
25import RelationName from './RelationName';
26import SlideInPanel from './SlideInPanel';
20 27
21const VisibilityPanelRoot = styled('div', { 28const VisibilityDialogScroll = styled('div', {
22 name: 'VisibilityPanel-Root', 29 name: 'VisibilityDialog-Scroll',
23})(({ theme }) => ({ 30 shouldForwardProp: (propName) => propName !== 'dialog',
24 position: 'absolute', 31})<{ dialog: boolean }>(({ theme, dialog }) => {
25 padding: theme.spacing(1), 32 const overlayOpacity = dialog ? 0.16 : 0.09;
26 top: 0, 33 return {
27 left: 0,
28 maxHeight: '100%',
29 maxWidth: '100%',
30 overflow: 'hidden',
31 display: 'flex',
32 flexDirection: 'column',
33 alignItems: 'start',
34 '.VisibilityPanel-drawer': {
35 overflow: 'hidden',
36 display: 'flex', 34 display: 'flex',
37 maxWidth: '100%', 35 flexDirection: 'column',
38 margin: theme.spacing(1), 36 height: 'auto',
39 }, 37 overflowX: 'hidden',
40})); 38 overflowY: 'auto',
39 margin: `0 ${theme.spacing(2)}`,
40 '& table': {
41 // We use flexbox instead of `display: table` to get proper text-overflow
42 // behavior for overly long relation names.
43 display: 'flex',
44 flexDirection: 'column',
45 },
46 '& thead, & tbody': {
47 display: 'flex',
48 flexDirection: 'column',
49 },
50 '& thead': {
51 position: 'sticky',
52 top: 0,
53 zIndex: 999,
54 backgroundColor: theme.palette.background.paper,
55 ...(theme.palette.mode === 'dark'
56 ? {
57 // In dark mode, MUI Paper gets a lighter overlay.
58 backgroundImage: `linear-gradient(
59 rgba(255, 255, 255, ${overlayOpacity}),
60 rgba(255, 255, 255, ${overlayOpacity})
61 )`,
62 }
63 : {}),
64 '& tr': {
65 height: '44px',
66 },
67 },
68 '& tr': {
69 display: 'flex',
70 flexDirection: 'row',
71 maxWidth: '100%',
72 },
73 '& tbody tr': {
74 transition: theme.transitions.create('background', {
75 duration: theme.transitions.duration.shortest,
76 }),
77 '&:hover': {
78 background: theme.palette.action.hover,
79 '@media (hover: none)': {
80 background: 'transparent',
81 },
82 },
83 },
84 '& th, & td': {
85 display: 'flex',
86 flexDirection: 'row',
87 alignItems: 'center',
88 justifyContent: 'center',
89 // Set width in advance, since we can't rely on `display: table-cell`.
90 width: '44px',
91 },
92 '& th:nth-of-type(3), & td:nth-of-type(3)': {
93 justifyContent: 'start',
94 paddingLeft: theme.spacing(1),
95 paddingRight: theme.spacing(2),
96 // Only let the last column grow or shrink.
97 flexGrow: 1,
98 flexShrink: 1,
99 // Compute the maximum available space in advance to let the text overflow.
100 maxWidth: 'calc(100% - 88px)',
101 width: 'min-content',
102 },
103 '& td:nth-of-type(3)': {
104 cursor: 'pointer',
105 userSelect: 'none',
106 WebkitTapHighlightColor: 'transparent',
107 },
108
109 '& thead th, .VisibilityDialog-custom tr:last-child td': {
110 borderBottom: `1px solid ${theme.palette.divider}`,
111 },
112 // Hack to apply `text-overflow`.
113 '.VisibilityDialog-nowrap': {
114 maxWidth: '100%',
115 overflow: 'hidden',
116 wordWrap: 'nowrap',
117 textOverflow: 'ellipsis',
118 },
119 '.VisibilityDialog-empty': {
120 display: 'flex',
121 flexDirection: 'column',
122 alignItems: 'center',
123 color: theme.palette.text.secondary,
124 },
125 '.VisibilityDialog-emptyIcon': {
126 fontSize: '6rem',
127 marginBottom: theme.spacing(1),
128 },
129 };
130});
41 131
42function VisibilityPanel({ 132function VisibilityPanel({
43 graph, 133 graph,
@@ -46,45 +136,132 @@ function VisibilityPanel({
46 graph: GraphStore; 136 graph: GraphStore;
47 dialog: boolean; 137 dialog: boolean;
48}): JSX.Element { 138}): JSX.Element {
49 const id = useId(); 139 const builtinRows: JSX.Element[] = [];
50 const [showFilter, setShowFilter] = useState(false); 140 const rows: JSX.Element[] = [];
51 const close = useCallback(() => setShowFilter(false), []); 141 graph.relationMetadata.forEach((metadata, name) => {
142 if (!isVisibilityAllowed(metadata, 'must')) {
143 return;
144 }
145 const visibility = graph.getVisibility(name);
146 const row = (
147 <tr key={metadata.name}>
148 <td>
149 <Checkbox
150 checked={visibility !== 'none'}
151 aria-label={`Show true and error values of ${metadata.simpleName}`}
152 onClick={() =>
153 graph.setVisibility(name, visibility === 'none' ? 'must' : 'none')
154 }
155 />
156 </td>
157 <td>
158 <Checkbox
159 checked={visibility === 'all'}
160 disabled={!isVisibilityAllowed(metadata, 'all')}
161 aria-label={`Show all values of ${metadata.simpleName}`}
162 onClick={() =>
163 graph.setVisibility(name, visibility === 'all' ? 'must' : 'all')
164 }
165 />
166 </td>
167 <td
168 onClick={() => graph.cycleVisibility(name)}
169 aria-label="Toggle visiblity"
170 >
171 <div className="VisibilityDialog-nowrap">
172 <RelationName metadata={metadata} abbreviate={graph.abbreviate} />
173 </div>
174 </td>
175 </tr>
176 );
177 if (name.startsWith('builtin::')) {
178 builtinRows.push(row);
179 } else {
180 rows.push(row);
181 }
182 });
183
184 const hasRows = rows.length > 0 || builtinRows.length > 0;
185
186 const hideBadge = graph.visibility.size === 0;
187 const icon = useCallback(
188 (show: boolean) => (
189 <Badge color="primary" variant="dot" invisible={hideBadge}>
190 {show && !dialog ? <ChevronLeftIcon /> : <TuneIcon />}
191 </Badge>
192 ),
193 [dialog, hideBadge],
194 );
52 195
53 return ( 196 return (
54 <VisibilityPanelRoot> 197 <SlideInPanel
55 <IconButton 198 anchor="left"
56 role="switch" 199 dialog={dialog}
57 aria-checked={showFilter} 200 title="Customize view"
58 aria-controls={dialog ? undefined : id} 201 icon={icon}
59 aria-label="Show filter panel" 202 iconLabel="Show filter panel"
60 onClick={() => setShowFilter(!showFilter)} 203 buttons={
61 > 204 <>
62 <Badge 205 <Button
63 color="primary" 206 color="inherit"
64 variant="dot" 207 onClick={() => graph.hideAll()}
65 invisible={graph.visibility.size === 0} 208 startIcon={<VisibilityOffIcon />}
66 > 209 >
67 {showFilter && !dialog ? <ChevronLeftIcon /> : <TuneIcon />} 210 Hide all
68 </Badge> 211 </Button>
69 </IconButton> 212 <Button
70 {dialog ? ( 213 color="inherit"
71 <Dialog open={showFilter} onClose={close} maxWidth="xl"> 214 onClick={() => graph.resetFilter()}
72 <VisibilityDialog graph={graph} close={close} dialog /> 215 startIcon={<FilterListIcon />}
73 </Dialog> 216 >
74 ) : ( 217 Reset filter
75 <Slide 218 </Button>
76 direction="right" 219 </>
77 in={showFilter} 220 }
78 id={id} 221 >
79 mountOnEnter 222 <FormControlLabel
80 unmountOnExit 223 control={
81 > 224 <Switch
82 <Paper className="VisibilityPanel-drawer" elevation={4}> 225 checked={!graph.abbreviate}
83 <VisibilityDialog graph={graph} close={close} /> 226 onClick={() => graph.toggleAbbrevaite()}
84 </Paper> 227 />
85 </Slide> 228 }
86 )} 229 label="Fully qualified names"
87 </VisibilityPanelRoot> 230 />
231 <FormControlLabel
232 control={
233 <Switch checked={graph.scopes} onClick={() => graph.toggleScopes()} />
234 }
235 label="Object scopes"
236 />
237 <VisibilityDialogScroll dialog={dialog}>
238 {hasRows ? (
239 <table cellSpacing={0}>
240 <thead>
241 <tr>
242 <th aria-label="Show true and error values">
243 <LabelIcon />
244 </th>
245 <th aria-label="Show unknown values">
246 <LabelOutlinedIcon />
247 </th>
248 <th>Symbol</th>
249 </tr>
250 </thead>
251 <tbody className="VisibilityDialog-custom">{...rows}</tbody>
252 <tbody className="VisibilityDialog-builtin">{...builtinRows}</tbody>
253 </table>
254 ) : (
255 <div className="VisibilityDialog-empty">
256 <SentimentVeryDissatisfiedIcon
257 className="VisibilityDialog-emptyIcon"
258 fontSize="inherit"
259 />
260 <div>Partial model is empty</div>
261 </div>
262 )}
263 </VisibilityDialogScroll>
264 </SlideInPanel>
88 ); 265 );
89} 266}
90 267
diff --git a/subprojects/frontend/src/graph/ExportButton.tsx b/subprojects/frontend/src/graph/exportDiagram.tsx
index 97444c6e..46c7f199 100644
--- a/subprojects/frontend/src/graph/ExportButton.tsx
+++ b/subprojects/frontend/src/graph/exportDiagram.tsx
@@ -15,38 +15,23 @@ import variableFontURL from '@fontsource-variable/open-sans/files/open-sans-lati
15import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; 15import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw';
16import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; 16import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw';
17import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; 17import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw';
18import SaveAltIcon from '@mui/icons-material/SaveAlt'; 18import type { Theme } from '@mui/material/styles';
19import IconButton from '@mui/material/IconButton';
20import { styled, useTheme, type Theme } from '@mui/material/styles';
21import { useCallback } from 'react';
22 19
23import getLogger from '../utils/getLogger'; 20import { darkTheme, lightTheme } from '../theme/ThemeProvider';
24 21
22import type ExportSettingsStore from './ExportSettingsStore';
23import type GraphStore from './GraphStore';
25import { createGraphTheme } from './GraphTheme'; 24import { createGraphTheme } from './GraphTheme';
26import { SVG_NS } from './postProcessSVG'; 25import { SVG_NS } from './postProcessSVG';
27 26
28const log = getLogger('graph.ExportButton');
29
30const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'; 27const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>';
31 28const SVG_CONTENT_TYPE = 'image/svg+xml';
32const ExportButtonRoot = styled('div', {
33 name: 'ExportButton-Root',
34})(({ theme }) => ({
35 position: 'absolute',
36 padding: theme.spacing(1),
37 top: 0,
38 right: 0,
39 overflow: 'hidden',
40 display: 'flex',
41 flexDirection: 'column',
42 alignItems: 'start',
43}));
44 29
45const ICONS: Map<string, Element> = new Map(); 30const ICONS: Map<string, Element> = new Map();
46 31
47function importSVG(svgSource: string, className: string): void { 32function importSVG(svgSource: string, className: string): void {
48 const parser = new DOMParser(); 33 const parser = new DOMParser();
49 const svgDocument = parser.parseFromString(svgSource, 'image/svg+xml'); 34 const svgDocument = parser.parseFromString(svgSource, SVG_CONTENT_TYPE);
50 const root = svgDocument.children[0]; 35 const root = svgDocument.children[0];
51 if (root === undefined) { 36 if (root === undefined) {
52 return; 37 return;
@@ -60,6 +45,21 @@ importSVG(labelSVG, 'icon-TRUE');
60importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); 45importSVG(labelOutlinedSVG, 'icon-UNKNOWN');
61importSVG(cancelSVG, 'icon-ERROR'); 46importSVG(cancelSVG, 'icon-ERROR');
62 47
48function addBackground(
49 svgDocument: XMLDocument,
50 svg: SVGSVGElement,
51 theme: Theme,
52): void {
53 const viewBox = svg.getAttribute('viewBox')?.split(' ');
54 const rect = svgDocument.createElementNS(SVG_NS, 'rect');
55 rect.setAttribute('x', viewBox?.[0] ?? '0');
56 rect.setAttribute('y', viewBox?.[1] ?? '0');
57 rect.setAttribute('width', viewBox?.[2] ?? '0');
58 rect.setAttribute('height', viewBox?.[3] ?? '0');
59 rect.setAttribute('fill', theme.palette.background.default);
60 svg.prepend(rect);
61}
62
63async function fetchAsFontURL(url: string): Promise<string> { 63async function fetchAsFontURL(url: string): Promise<string> {
64 const fetchResult = await fetch(url); 64 const fetchResult = await fetch(url);
65 const buffer = await fetchResult.arrayBuffer(); 65 const buffer = await fetchResult.arrayBuffer();
@@ -139,12 +139,13 @@ async function fetchVariableFontCSS(): Promise<string> {
139 return variableFontCSS; 139 return variableFontCSS;
140} 140}
141 141
142async function appendStyles( 142function appendStyles(
143 svgDocument: XMLDocument, 143 svgDocument: XMLDocument,
144 svg: SVGSVGElement, 144 svg: SVGSVGElement,
145 theme: Theme, 145 theme: Theme,
146 embedFonts?: 'woff2' | 'woff2-variations', 146 colorNodes: boolean,
147): Promise<void> { 147 fontsCSS: string,
148): void {
148 const cache = createCache({ 149 const cache = createCache({
149 key: 'refinery', 150 key: 'refinery',
150 container: svg, 151 container: svg,
@@ -154,15 +155,10 @@ async function appendStyles(
154 // `@emotion/serialize`, but they are compatible in practice. 155 // `@emotion/serialize`, but they are compatible in practice.
155 const styles = serializeStyles([createGraphTheme], cache.registered, { 156 const styles = serializeStyles([createGraphTheme], cache.registered, {
156 theme, 157 theme,
157 colorNodes: true, 158 colorNodes,
158 noEmbedIcons: true, 159 noEmbedIcons: true,
159 }); 160 });
160 const rules: string[] = []; 161 const rules: string[] = [fontsCSS];
161 if (embedFonts === 'woff2') {
162 rules.push(await fetchFontCSS());
163 } else if (embedFonts === 'woff2-variations') {
164 rules.push(await fetchVariableFontCSS());
165 }
166 const sheet = { 162 const sheet = {
167 insert(rule) { 163 insert(rule) {
168 rules.push(rule); 164 rules.push(rule);
@@ -209,24 +205,112 @@ function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void {
209 }); 205 });
210} 206}
211 207
212function downloadSVG(svgDocument: XMLDocument): void { 208function serializeSVG(svgDocument: XMLDocument): Blob {
213 const serializer = new XMLSerializer(); 209 const serializer = new XMLSerializer();
214 const svgText = `${PROLOG}\n${serializer.serializeToString(svgDocument)}`; 210 const svgText = `${PROLOG}\n${serializer.serializeToString(svgDocument)}`;
215 const blob = new Blob([svgText], { 211 return new Blob([svgText], {
216 type: 'image/svg+xml', 212 type: SVG_CONTENT_TYPE,
217 }); 213 });
214}
215
216function downloadBlob(blob: Blob, name: string): void {
218 const link = document.createElement('a'); 217 const link = document.createElement('a');
219 link.href = window.URL.createObjectURL(blob); 218 const url = window.URL.createObjectURL(blob);
220 link.download = 'graph.svg'; 219 try {
221 link.style.display = 'none'; 220 link.href = url;
222 document.body.appendChild(link); 221 link.download = name;
223 link.click(); 222 link.style.display = 'none';
224 document.body.removeChild(link); 223 document.body.appendChild(link);
224 link.click();
225 } finally {
226 window.URL.revokeObjectURL(url);
227 document.body.removeChild(link);
228 }
225} 229}
226 230
227async function exportSVG( 231async function copyBlob(blob: Blob): Promise<void> {
228 svgContainer: HTMLElement | undefined, 232 const { clipboard } = navigator;
233 if ('write' in clipboard) {
234 await clipboard.write([
235 new ClipboardItem({
236 [blob.type]: blob,
237 }),
238 ]);
239 }
240}
241
242async function serializePNG(
243 serializedSVG: Blob,
244 svg: SVGSVGElement,
245 settings: ExportSettingsStore,
229 theme: Theme, 246 theme: Theme,
247): Promise<Blob> {
248 const scale = settings.scale / 100;
249 const baseWidth = svg.width.baseVal.value;
250 const baseHeight = svg.height.baseVal.value;
251 const exactWidth = baseWidth * scale;
252 const exactHeight = baseHeight * scale;
253 const width = Math.round(exactWidth);
254 const height = Math.round(exactHeight);
255
256 const canvas = document.createElement('canvas');
257 canvas.width = width;
258 canvas.height = height;
259
260 const image = document.createElement('img');
261 const url = window.URL.createObjectURL(serializedSVG);
262 try {
263 await new Promise((resolve, reject) => {
264 image.addEventListener('load', () => resolve(undefined));
265 image.addEventListener('error', ({ error }) =>
266 reject(
267 error instanceof Error
268 ? error
269 : new Error(`Failed to load image: ${error}`),
270 ),
271 );
272 image.src = url;
273 });
274 } finally {
275 window.URL.revokeObjectURL(url);
276 }
277
278 const context = canvas.getContext('2d');
279 if (context === null) {
280 throw new Error('Failed to get canvas 2D context');
281 }
282 if (!settings.transparent) {
283 context.fillStyle = theme.palette.background.default;
284 context.fillRect(0, 0, width, height);
285 }
286 context.drawImage(
287 image,
288 0,
289 0,
290 baseWidth,
291 baseHeight,
292 0,
293 0,
294 exactWidth,
295 exactHeight,
296 );
297
298 return new Promise<Blob>((resolve, reject) => {
299 canvas.toBlob((exportedBlob) => {
300 if (exportedBlob === null) {
301 reject(new Error('Failed to export PNG blob'));
302 } else {
303 resolve(exportedBlob);
304 }
305 }, 'image/png');
306 });
307}
308
309export default async function exportDiagram(
310 svgContainer: HTMLElement | undefined,
311 graph: GraphStore,
312 settings: ExportSettingsStore,
313 mode: 'download' | 'copy',
230): Promise<void> { 314): Promise<void> {
231 const svg = svgContainer?.querySelector('svg'); 315 const svg = svgContainer?.querySelector('svg');
232 if (!svg) { 316 if (!svg) {
@@ -244,28 +328,36 @@ async function exportSVG(
244 } else { 328 } else {
245 svgDocument.replaceChild(copyOfSVG, originalRoot); 329 svgDocument.replaceChild(copyOfSVG, originalRoot);
246 } 330 }
331
332 const theme = settings.theme === 'light' ? lightTheme : darkTheme;
333 if (!settings.transparent) {
334 addBackground(svgDocument, copyOfSVG, theme);
335 }
336
247 fixForeignObjects(svgDocument, copyOfSVG); 337 fixForeignObjects(svgDocument, copyOfSVG);
248 await appendStyles(svgDocument, copyOfSVG, theme);
249 downloadSVG(svgDocument);
250}
251 338
252export default function ExportButton({ 339 const { colorNodes } = graph;
253 svgContainer, 340 let fontsCSS = '';
254}: { 341 if (settings.format === 'png') {
255 svgContainer: HTMLElement | undefined; 342 // If we are creating a PNG, font file size doesn't matter,
256}): JSX.Element { 343 // and we can reuse fonts the browser has already downloaded.
257 const theme = useTheme(); 344 fontsCSS = await fetchVariableFontCSS();
258 const saveCallback = useCallback(() => { 345 } else if (settings.embedFonts) {
259 exportSVG(svgContainer, theme).catch((error) => { 346 fontsCSS = await fetchFontCSS();
260 log.error('Failed to export SVG', error); 347 }
261 }); 348 appendStyles(svgDocument, copyOfSVG, theme, colorNodes, fontsCSS);
262 }, [svgContainer, theme]);
263 349
264 return ( 350 const serializedSVG = serializeSVG(svgDocument);
265 <ExportButtonRoot> 351 if (settings.format === 'png') {
266 <IconButton aria-label="Save SVG" onClick={saveCallback}> 352 const png = await serializePNG(serializedSVG, svg, settings, theme);
267 <SaveAltIcon /> 353 if (mode === 'copy') {
268 </IconButton> 354 await copyBlob(png);
269 </ExportButtonRoot> 355 } else {
270 ); 356 downloadBlob(png, 'graph.png');
357 }
358 } else if (mode === 'copy') {
359 await copyBlob(serializedSVG);
360 } else {
361 downloadBlob(serializedSVG, 'graph.svg');
362 }
271} 363}
diff --git a/subprojects/frontend/src/theme/ThemeProvider.tsx b/subprojects/frontend/src/theme/ThemeProvider.tsx
index a996cde8..6905fb4b 100644
--- a/subprojects/frontend/src/theme/ThemeProvider.tsx
+++ b/subprojects/frontend/src/theme/ThemeProvider.tsx
@@ -220,7 +220,7 @@ function createResponsiveTheme(
220 return responsiveFontSizes(themeWithOverrides); 220 return responsiveFontSizes(themeWithOverrides);
221} 221}
222 222
223const lightTheme = (() => { 223export const lightTheme = (() => {
224 const primaryText = '#19202b'; 224 const primaryText = '#19202b';
225 const disabledText = '#a0a1a7'; 225 const disabledText = '#a0a1a7';
226 const darkBackground = '#f5f5f5'; 226 const darkBackground = '#f5f5f5';
@@ -282,7 +282,7 @@ const lightTheme = (() => {
282 }); 282 });
283})(); 283})();
284 284
285const darkTheme = (() => { 285export const darkTheme = (() => {
286 const primaryText = '#ebebff'; 286 const primaryText = '#ebebff';
287 const secondaryText = '#abb2bf'; 287 const secondaryText = '#abb2bf';
288 const darkBackground = '#21252b'; 288 const darkBackground = '#21252b';