diff options
-rw-r--r-- | subprojects/frontend/src/RootStore.ts | 5 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/ExportPanel.tsx | 215 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/ExportSettingsStore.tsx | 46 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphArea.tsx | 4 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/SlideInDialog.tsx | 109 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/SlideInPanel.tsx | 97 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/VisibilityDialog.tsx | 318 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/VisibilityPanel.tsx | 303 | ||||
-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.tsx | 4 |
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 | ||
10 | import PWAStore from './PWAStore'; | 10 | import PWAStore from './PWAStore'; |
11 | import type EditorStore from './editor/EditorStore'; | 11 | import type EditorStore from './editor/EditorStore'; |
12 | import ExportSettingsScotre from './graph/ExportSettingsStore'; | ||
12 | import Compressor from './persistence/Compressor'; | 13 | import Compressor from './persistence/Compressor'; |
13 | import ThemeStore from './theme/ThemeStore'; | 14 | import 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 | |||
7 | import ChevronRightIcon from '@mui/icons-material/ChevronRight'; | ||
8 | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||
9 | import DarkModeIcon from '@mui/icons-material/DarkMode'; | ||
10 | import ImageIcon from '@mui/icons-material/Image'; | ||
11 | import LightModeIcon from '@mui/icons-material/LightMode'; | ||
12 | import SaveAltIcon from '@mui/icons-material/SaveAlt'; | ||
13 | import ShapeLineIcon from '@mui/icons-material/ShapeLine'; | ||
14 | import Box from '@mui/material/Box'; | ||
15 | import Button from '@mui/material/Button'; | ||
16 | import FormControlLabel from '@mui/material/FormControlLabel'; | ||
17 | import Slider from '@mui/material/Slider'; | ||
18 | import Stack from '@mui/material/Stack'; | ||
19 | import Switch from '@mui/material/Switch'; | ||
20 | import ToggleButton from '@mui/material/ToggleButton'; | ||
21 | import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; | ||
22 | import Typography from '@mui/material/Typography'; | ||
23 | import { styled } from '@mui/material/styles'; | ||
24 | import { observer } from 'mobx-react-lite'; | ||
25 | import { useCallback } from 'react'; | ||
26 | |||
27 | import { useRootStore } from '../RootStoreProvider'; | ||
28 | import getLogger from '../utils/getLogger'; | ||
29 | |||
30 | import type GraphStore from './GraphStore'; | ||
31 | import SlideInPanel from './SlideInPanel'; | ||
32 | import exportDiagram from './exportDiagram'; | ||
33 | |||
34 | const log = getLogger('graph.ExportPanel'); | ||
35 | |||
36 | const 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 | |||
52 | function getLabel(value: number): string { | ||
53 | return `${value}%`; | ||
54 | } | ||
55 | |||
56 | const 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 | |||
66 | function 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 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 | |||
215 | export 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 | |||
7 | import { makeAutoObservable } from 'mobx'; | ||
8 | |||
9 | export type ExportFormat = 'svg' | 'png'; | ||
10 | export type ExportTheme = 'light' | 'dark'; | ||
11 | |||
12 | export 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'; | |||
11 | import { useResizeDetector } from 'react-resize-detector'; | 11 | import { useResizeDetector } from 'react-resize-detector'; |
12 | 12 | ||
13 | import DotGraphVisualizer from './DotGraphVisualizer'; | 13 | import DotGraphVisualizer from './DotGraphVisualizer'; |
14 | import ExportButton from './ExportButton'; | 14 | import ExportPanel from './ExportPanel'; |
15 | import type GraphStore from './GraphStore'; | 15 | import type GraphStore from './GraphStore'; |
16 | import VisibilityPanel from './VisibilityPanel'; | 16 | import VisibilityPanel from './VisibilityPanel'; |
17 | import ZoomCanvas from './ZoomCanvas'; | 17 | import 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 | |||
7 | import CloseIcon from '@mui/icons-material/Close'; | ||
8 | import Button from '@mui/material/Button'; | ||
9 | import IconButton from '@mui/material/IconButton'; | ||
10 | import Typography from '@mui/material/Typography'; | ||
11 | import { styled } from '@mui/material/styles'; | ||
12 | import React, { useId } from 'react'; | ||
13 | |||
14 | const 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 | |||
63 | export 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 | |||
106 | SlideInDialog.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 | |||
7 | import Dialog from '@mui/material/Dialog'; | ||
8 | import IconButton from '@mui/material/IconButton'; | ||
9 | import Paper from '@mui/material/Paper'; | ||
10 | import Slide from '@mui/material/Slide'; | ||
11 | import { styled } from '@mui/material/styles'; | ||
12 | import React, { useCallback, useId, useState } from 'react'; | ||
13 | |||
14 | import SlideInDialog from './SlideInDialog'; | ||
15 | |||
16 | const 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 | |||
38 | export 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 | |||
95 | SlideInPanel.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 | |||
7 | import CloseIcon from '@mui/icons-material/Close'; | ||
8 | import FilterListIcon from '@mui/icons-material/FilterList'; | ||
9 | import LabelIcon from '@mui/icons-material/Label'; | ||
10 | import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined'; | ||
11 | import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied'; | ||
12 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; | ||
13 | import Button from '@mui/material/Button'; | ||
14 | import Checkbox from '@mui/material/Checkbox'; | ||
15 | import FormControlLabel from '@mui/material/FormControlLabel'; | ||
16 | import IconButton from '@mui/material/IconButton'; | ||
17 | import Switch from '@mui/material/Switch'; | ||
18 | import Typography from '@mui/material/Typography'; | ||
19 | import { styled } from '@mui/material/styles'; | ||
20 | import { observer } from 'mobx-react-lite'; | ||
21 | import { useId } from 'react'; | ||
22 | |||
23 | import type GraphStore from './GraphStore'; | ||
24 | import { isVisibilityAllowed } from './GraphStore'; | ||
25 | import RelationName from './RelationName'; | ||
26 | |||
27 | const 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 | |||
174 | function 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 | |||
314 | VisibilityDialog.defaultProps = { | ||
315 | dialog: false, | ||
316 | }; | ||
317 | |||
318 | export 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 | ||
7 | import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; | 7 | import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; |
8 | import FilterListIcon from '@mui/icons-material/FilterList'; | ||
9 | import LabelIcon from '@mui/icons-material/Label'; | ||
10 | import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined'; | ||
11 | import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied'; | ||
8 | import TuneIcon from '@mui/icons-material/Tune'; | 12 | import TuneIcon from '@mui/icons-material/Tune'; |
13 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; | ||
9 | import Badge from '@mui/material/Badge'; | 14 | import Badge from '@mui/material/Badge'; |
10 | import Dialog from '@mui/material/Dialog'; | 15 | import Button from '@mui/material/Button'; |
11 | import IconButton from '@mui/material/IconButton'; | 16 | import Checkbox from '@mui/material/Checkbox'; |
12 | import Paper from '@mui/material/Paper'; | 17 | import FormControlLabel from '@mui/material/FormControlLabel'; |
13 | import Slide from '@mui/material/Slide'; | 18 | import Switch from '@mui/material/Switch'; |
14 | import { styled } from '@mui/material/styles'; | 19 | import { styled } from '@mui/material/styles'; |
15 | import { observer } from 'mobx-react-lite'; | 20 | import { observer } from 'mobx-react-lite'; |
16 | import { useCallback, useId, useState } from 'react'; | 21 | import { useCallback } from 'react'; |
17 | 22 | ||
18 | import type GraphStore from './GraphStore'; | 23 | import type GraphStore from './GraphStore'; |
19 | import VisibilityDialog from './VisibilityDialog'; | 24 | import { isVisibilityAllowed } from './GraphStore'; |
25 | import RelationName from './RelationName'; | ||
26 | import SlideInPanel from './SlideInPanel'; | ||
20 | 27 | ||
21 | const VisibilityPanelRoot = styled('div', { | 28 | const 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 | ||
42 | function VisibilityPanel({ | 132 | function 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 | |||
15 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; | 15 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; |
16 | import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; | 16 | import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; |
17 | import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; | 17 | import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; |
18 | import SaveAltIcon from '@mui/icons-material/SaveAlt'; | 18 | import type { Theme } from '@mui/material/styles'; |
19 | import IconButton from '@mui/material/IconButton'; | ||
20 | import { styled, useTheme, type Theme } from '@mui/material/styles'; | ||
21 | import { useCallback } from 'react'; | ||
22 | 19 | ||
23 | import getLogger from '../utils/getLogger'; | 20 | import { darkTheme, lightTheme } from '../theme/ThemeProvider'; |
24 | 21 | ||
22 | import type ExportSettingsStore from './ExportSettingsStore'; | ||
23 | import type GraphStore from './GraphStore'; | ||
25 | import { createGraphTheme } from './GraphTheme'; | 24 | import { createGraphTheme } from './GraphTheme'; |
26 | import { SVG_NS } from './postProcessSVG'; | 25 | import { SVG_NS } from './postProcessSVG'; |
27 | 26 | ||
28 | const log = getLogger('graph.ExportButton'); | ||
29 | |||
30 | const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'; | 27 | const PROLOG = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'; |
31 | 28 | const SVG_CONTENT_TYPE = 'image/svg+xml'; | |
32 | const 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 | ||
45 | const ICONS: Map<string, Element> = new Map(); | 30 | const ICONS: Map<string, Element> = new Map(); |
46 | 31 | ||
47 | function importSVG(svgSource: string, className: string): void { | 32 | function 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'); | |||
60 | importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); | 45 | importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); |
61 | importSVG(cancelSVG, 'icon-ERROR'); | 46 | importSVG(cancelSVG, 'icon-ERROR'); |
62 | 47 | ||
48 | function 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 | |||
63 | async function fetchAsFontURL(url: string): Promise<string> { | 63 | async 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 | ||
142 | async function appendStyles( | 142 | function 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 | ||
212 | function downloadSVG(svgDocument: XMLDocument): void { | 208 | function 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 | |||
216 | function 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 | ||
227 | async function exportSVG( | 231 | async 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 | |||
242 | async 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 | |||
309 | export 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 | ||
252 | export 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 | ||
223 | const lightTheme = (() => { | 223 | export 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 | ||
285 | const darkTheme = (() => { | 285 | export 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'; |