diff options
Diffstat (limited to 'subprojects/frontend/src')
3 files changed, 164 insertions, 38 deletions
diff --git a/subprojects/frontend/src/graph/export/ExportPanel.tsx b/subprojects/frontend/src/graph/export/ExportPanel.tsx index c93fa837..8d82b95c 100644 --- a/subprojects/frontend/src/graph/export/ExportPanel.tsx +++ b/subprojects/frontend/src/graph/export/ExportPanel.tsx | |||
@@ -6,6 +6,7 @@ | |||
6 | 6 | ||
7 | import ChevronRightIcon from '@mui/icons-material/ChevronRight'; | 7 | import ChevronRightIcon from '@mui/icons-material/ChevronRight'; |
8 | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | 8 | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; |
9 | import ContrastIcon from '@mui/icons-material/Contrast'; | ||
9 | import DarkModeIcon from '@mui/icons-material/DarkMode'; | 10 | import DarkModeIcon from '@mui/icons-material/DarkMode'; |
10 | import ImageIcon from '@mui/icons-material/Image'; | 11 | import ImageIcon from '@mui/icons-material/Image'; |
11 | import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; | 12 | import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; |
@@ -50,6 +51,13 @@ const SwitchButtonGroup = styled(ToggleButtonGroup, { | |||
50 | }, | 51 | }, |
51 | })); | 52 | })); |
52 | 53 | ||
54 | const AutoThemeMessage = styled(Typography, { | ||
55 | name: 'ExportPanel-AutoThemeMessage', | ||
56 | })(({ theme }) => ({ | ||
57 | width: '260px', | ||
58 | marginInline: theme.spacing(2), | ||
59 | })); | ||
60 | |||
53 | function getLabel(value: number): string { | 61 | function getLabel(value: number): string { |
54 | return `${value}%`; | 62 | return `${value}%`; |
55 | } | 63 | } |
@@ -155,29 +163,40 @@ function ExportPanel({ | |||
155 | </SwitchButtonGroup> | 163 | </SwitchButtonGroup> |
156 | <SwitchButtonGroup size="small" className="rounded"> | 164 | <SwitchButtonGroup size="small" className="rounded"> |
157 | <ToggleButton | 165 | <ToggleButton |
158 | value="svg" | 166 | value="light" |
159 | selected={exportSettingsStore.theme === 'light'} | 167 | selected={exportSettingsStore.theme === 'light'} |
160 | onClick={() => exportSettingsStore.setTheme('light')} | 168 | onClick={() => exportSettingsStore.setTheme('light')} |
161 | > | 169 | > |
162 | <LightModeIcon fontSize="small" /> Light | 170 | <LightModeIcon fontSize="small" /> Light |
163 | </ToggleButton> | 171 | </ToggleButton> |
164 | <ToggleButton | 172 | <ToggleButton |
165 | value="png" | 173 | value="dark" |
166 | selected={exportSettingsStore.theme === 'dark'} | 174 | selected={exportSettingsStore.theme === 'dark'} |
167 | onClick={() => exportSettingsStore.setTheme('dark')} | 175 | onClick={() => exportSettingsStore.setTheme('dark')} |
168 | > | 176 | > |
169 | <DarkModeIcon fontSize="small" /> Dark | 177 | <DarkModeIcon fontSize="small" /> Dark |
170 | </ToggleButton> | 178 | </ToggleButton> |
179 | {exportSettingsStore.canSetDynamicTheme && ( | ||
180 | <ToggleButton | ||
181 | value="dynamic" | ||
182 | selected={exportSettingsStore.theme === 'dynamic'} | ||
183 | onClick={() => exportSettingsStore.setTheme('dynamic')} | ||
184 | > | ||
185 | <ContrastIcon fontSize="small" /> Auto | ||
186 | </ToggleButton> | ||
187 | )} | ||
171 | </SwitchButtonGroup> | 188 | </SwitchButtonGroup> |
172 | <FormControlLabel | 189 | {exportSettingsStore.canChangeTransparency && ( |
173 | control={ | 190 | <FormControlLabel |
174 | <Switch | 191 | control={ |
175 | checked={exportSettingsStore.transparent} | 192 | <Switch |
176 | onClick={() => exportSettingsStore.toggleTransparent()} | 193 | checked={exportSettingsStore.transparent} |
177 | /> | 194 | onClick={() => exportSettingsStore.toggleTransparent()} |
178 | } | 195 | /> |
179 | label="Transparent background" | 196 | } |
180 | /> | 197 | label="Transparent background" |
198 | /> | ||
199 | )} | ||
181 | {exportSettingsStore.canEmbedFonts && ( | 200 | {exportSettingsStore.canEmbedFonts && ( |
182 | <FormControlLabel | 201 | <FormControlLabel |
183 | control={ | 202 | control={ |
@@ -200,6 +219,17 @@ function ExportPanel({ | |||
200 | } | 219 | } |
201 | /> | 220 | /> |
202 | )} | 221 | )} |
222 | {exportSettingsStore.theme === 'dynamic' && ( | ||
223 | <> | ||
224 | <AutoThemeMessage mt={2}> | ||
225 | For embedding into HTML directly | ||
226 | </AutoThemeMessage> | ||
227 | <AutoThemeMessage variant="caption" mt={1}> | ||
228 | Set <code>data-theme="dark"</code> on a containing element | ||
229 | to use a dark theme | ||
230 | </AutoThemeMessage> | ||
231 | </> | ||
232 | )} | ||
203 | {exportSettingsStore.canScale && ( | 233 | {exportSettingsStore.canScale && ( |
204 | <Box mx={4} mt={1} mb={2}> | 234 | <Box mx={4} mt={1} mb={2}> |
205 | <Slider | 235 | <Slider |
diff --git a/subprojects/frontend/src/graph/export/ExportSettingsStore.ts b/subprojects/frontend/src/graph/export/ExportSettingsStore.ts index 53a161ab..478227af 100644 --- a/subprojects/frontend/src/graph/export/ExportSettingsStore.ts +++ b/subprojects/frontend/src/graph/export/ExportSettingsStore.ts | |||
@@ -7,7 +7,7 @@ | |||
7 | import { makeAutoObservable } from 'mobx'; | 7 | import { makeAutoObservable } from 'mobx'; |
8 | 8 | ||
9 | export type ExportFormat = 'svg' | 'pdf' | 'png'; | 9 | export type ExportFormat = 'svg' | 'pdf' | 'png'; |
10 | export type ExportTheme = 'light' | 'dark'; | 10 | export type ExportTheme = 'light' | 'dark' | 'dynamic'; |
11 | 11 | ||
12 | export default class ExportSettingsStore { | 12 | export default class ExportSettingsStore { |
13 | format: ExportFormat = 'svg'; | 13 | format: ExportFormat = 'svg'; |
@@ -28,14 +28,24 @@ export default class ExportSettingsStore { | |||
28 | 28 | ||
29 | setFormat(format: ExportFormat): void { | 29 | setFormat(format: ExportFormat): void { |
30 | this.format = format; | 30 | this.format = format; |
31 | if (this.theme === 'dynamic' && this.format !== 'svg') { | ||
32 | this.theme = 'light'; | ||
33 | } | ||
31 | } | 34 | } |
32 | 35 | ||
33 | setTheme(theme: ExportTheme): void { | 36 | setTheme(theme: ExportTheme): void { |
34 | this.theme = theme; | 37 | this.theme = theme; |
38 | if (this.theme === 'dynamic') { | ||
39 | this.format = 'svg'; | ||
40 | this.transparent = true; | ||
41 | } | ||
35 | } | 42 | } |
36 | 43 | ||
37 | toggleTransparent(): void { | 44 | toggleTransparent(): void { |
38 | this.transparent = !this.transparent; | 45 | this.transparent = !this.transparent; |
46 | if (!this.transparent && this.theme === 'dynamic') { | ||
47 | this.theme = 'light'; | ||
48 | } | ||
39 | } | 49 | } |
40 | 50 | ||
41 | toggleEmbedFonts(): void { | 51 | toggleEmbedFonts(): void { |
@@ -55,10 +65,24 @@ export default class ExportSettingsStore { | |||
55 | this.embedPDFFonts = embedFonts; | 65 | this.embedPDFFonts = embedFonts; |
56 | } | 66 | } |
57 | this.embedSVGFonts = embedFonts; | 67 | this.embedSVGFonts = embedFonts; |
68 | if (this.embedSVGFonts && this.theme === 'dynamic') { | ||
69 | this.theme = 'light'; | ||
70 | } | ||
71 | } | ||
72 | |||
73 | get canSetDynamicTheme(): boolean { | ||
74 | return this.format === 'svg'; | ||
75 | } | ||
76 | |||
77 | get canChangeTransparency(): boolean { | ||
78 | return this.theme !== 'dynamic'; | ||
58 | } | 79 | } |
59 | 80 | ||
60 | get canEmbedFonts(): boolean { | 81 | get canEmbedFonts(): boolean { |
61 | return this.format === 'svg' || this.format === 'pdf'; | 82 | return ( |
83 | (this.format === 'svg' || this.format === 'pdf') && | ||
84 | this.theme !== 'dynamic' | ||
85 | ); | ||
62 | } | 86 | } |
63 | 87 | ||
64 | get canScale(): boolean { | 88 | get canScale(): boolean { |
diff --git a/subprojects/frontend/src/graph/export/exportDiagram.tsx b/subprojects/frontend/src/graph/export/exportDiagram.tsx index 6abbcfdf..a0c3460c 100644 --- a/subprojects/frontend/src/graph/export/exportDiagram.tsx +++ b/subprojects/frontend/src/graph/export/exportDiagram.tsx | |||
@@ -16,6 +16,7 @@ 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 type { Theme } from '@mui/material/styles'; | 18 | import type { Theme } from '@mui/material/styles'; |
19 | import { nanoid } from 'nanoid'; | ||
19 | 20 | ||
20 | import { darkTheme, lightTheme } from '../../theme/ThemeProvider'; | 21 | import { darkTheme, lightTheme } from '../../theme/ThemeProvider'; |
21 | import { copyBlob, saveBlob } from '../../utils/fileIO'; | 22 | import { copyBlob, saveBlob } from '../../utils/fileIO'; |
@@ -48,6 +49,36 @@ importSVG(labelSVG, 'icon-TRUE'); | |||
48 | importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); | 49 | importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); |
49 | importSVG(cancelSVG, 'icon-ERROR'); | 50 | importSVG(cancelSVG, 'icon-ERROR'); |
50 | 51 | ||
52 | function fixIDs(id: string, svgDocument: XMLDocument) { | ||
53 | const idMap = new Map<string, string>(); | ||
54 | let i = 0; | ||
55 | svgDocument.querySelectorAll('[id]').forEach((node) => { | ||
56 | const oldId = node.getAttribute('id'); | ||
57 | if (oldId === null) { | ||
58 | return; | ||
59 | } | ||
60 | if (oldId.endsWith(',clip')) { | ||
61 | const newId = `refinery-${id}-clip-${i}`; | ||
62 | i += 1; | ||
63 | idMap.set(`url(#${oldId})`, `url(#${newId})`); | ||
64 | node.setAttribute('id', newId); | ||
65 | } else { | ||
66 | node.setAttribute('id', ''); | ||
67 | } | ||
68 | }); | ||
69 | svgDocument.querySelectorAll('[clip-path]').forEach((node) => { | ||
70 | const oldPath = node.getAttribute('clip-path'); | ||
71 | if (oldPath === null) { | ||
72 | return; | ||
73 | } | ||
74 | const newPath = idMap.get(oldPath); | ||
75 | if (newPath === undefined) { | ||
76 | return; | ||
77 | } | ||
78 | node.setAttribute('clip-path', newPath); | ||
79 | }); | ||
80 | } | ||
81 | |||
51 | function addBackground( | 82 | function addBackground( |
52 | svgDocument: XMLDocument, | 83 | svgDocument: XMLDocument, |
53 | svg: SVGSVGElement, | 84 | svg: SVGSVGElement, |
@@ -142,40 +173,54 @@ async function fetchVariableFontCSS(): Promise<string> { | |||
142 | return variableFontCSS; | 173 | return variableFontCSS; |
143 | } | 174 | } |
144 | 175 | ||
176 | interface ThemeVariant { | ||
177 | selector: string; | ||
178 | theme: Theme; | ||
179 | } | ||
180 | |||
145 | function appendStyles( | 181 | function appendStyles( |
182 | id: string, | ||
146 | svgDocument: XMLDocument, | 183 | svgDocument: XMLDocument, |
147 | svg: SVGSVGElement, | 184 | svg: SVGSVGElement, |
148 | theme: Theme, | 185 | themes: ThemeVariant[], |
149 | colorNodes: boolean, | 186 | colorNodes: boolean, |
150 | hexTypeHashes: string[], | 187 | hexTypeHashes: string[], |
151 | fontsCSS: string, | 188 | fontsCSS: string, |
152 | ): void { | 189 | ): void { |
153 | const cache = createCache({ | 190 | const className = `refinery-${id}`; |
154 | key: 'refinery', | 191 | svg.classList.add(className); |
155 | container: svg, | ||
156 | prepend: true, | ||
157 | }); | ||
158 | // @ts-expect-error `CSSObject` types don't match up between `@mui/material` and | ||
159 | // `@emotion/serialize`, but they are compatible in practice. | ||
160 | const styles = serializeStyles([createGraphTheme], cache.registered, { | ||
161 | theme, | ||
162 | colorNodes, | ||
163 | hexTypeHashes, | ||
164 | noEmbedIcons: true, | ||
165 | }); | ||
166 | const rules: string[] = [fontsCSS]; | 192 | const rules: string[] = [fontsCSS]; |
167 | const sheet = { | 193 | themes.forEach(({ selector, theme }) => { |
168 | insert(rule) { | 194 | const cache = createCache({ |
169 | rules.push(rule); | 195 | key: 'refinery', |
170 | }, | 196 | container: svg, |
171 | } as StyleSheet; | 197 | prepend: true, |
172 | cache.insert('', styles, sheet, false); | 198 | }); |
199 | // @ts-expect-error `CSSObject` types don't match up between `@mui/material` and | ||
200 | // `@emotion/serialize`, but they are compatible in practice. | ||
201 | const styles = serializeStyles([createGraphTheme], cache.registered, { | ||
202 | theme, | ||
203 | colorNodes, | ||
204 | hexTypeHashes, | ||
205 | noEmbedIcons: true, | ||
206 | }); | ||
207 | const sheet = { | ||
208 | insert(rule) { | ||
209 | rules.push(rule); | ||
210 | }, | ||
211 | } as StyleSheet; | ||
212 | cache.insert(`${selector} .${className}`, styles, sheet, false); | ||
213 | }); | ||
173 | const styleElement = svgDocument.createElementNS(SVG_NS, 'style'); | 214 | const styleElement = svgDocument.createElementNS(SVG_NS, 'style'); |
174 | svg.prepend(styleElement); | 215 | svg.prepend(styleElement); |
175 | styleElement.innerHTML = rules.join(''); | 216 | styleElement.innerHTML = rules.join(''); |
176 | } | 217 | } |
177 | 218 | ||
178 | function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void { | 219 | function fixForeignObjects( |
220 | id: string, | ||
221 | svgDocument: XMLDocument, | ||
222 | svg: SVGSVGElement, | ||
223 | ): void { | ||
179 | const foreignObjects: SVGForeignObjectElement[] = []; | 224 | const foreignObjects: SVGForeignObjectElement[] = []; |
180 | svg | 225 | svg |
181 | .querySelectorAll('foreignObject') | 226 | .querySelectorAll('foreignObject') |
@@ -197,7 +242,7 @@ function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void { | |||
197 | object.children[0]?.classList?.forEach((className) => { | 242 | object.children[0]?.classList?.forEach((className) => { |
198 | useElement.classList.add(className); | 243 | useElement.classList.add(className); |
199 | if (ICONS.has(className)) { | 244 | if (ICONS.has(className)) { |
200 | useElement.setAttribute('href', `#${className}`); | 245 | useElement.setAttribute('href', `#refinery-${id}-${className}`); |
201 | } | 246 | } |
202 | }); | 247 | }); |
203 | object.replaceWith(useElement); | 248 | object.replaceWith(useElement); |
@@ -206,6 +251,7 @@ function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void { | |||
206 | svg.prepend(defs); | 251 | svg.prepend(defs); |
207 | ICONS.forEach((value) => { | 252 | ICONS.forEach((value) => { |
208 | const importedValue = svgDocument.importNode(value, true); | 253 | const importedValue = svgDocument.importNode(value, true); |
254 | importedValue.id = `refinery-${id}-${importedValue.id}`; | ||
209 | defs.appendChild(importedValue); | 255 | defs.appendChild(importedValue); |
210 | }); | 256 | }); |
211 | } | 257 | } |
@@ -322,12 +368,37 @@ export default async function exportDiagram( | |||
322 | svgDocument.replaceChild(copyOfSVG, originalRoot); | 368 | svgDocument.replaceChild(copyOfSVG, originalRoot); |
323 | } | 369 | } |
324 | 370 | ||
325 | const theme = settings.theme === 'light' ? lightTheme : darkTheme; | 371 | const id = nanoid(); |
372 | fixIDs(id, svgDocument); | ||
373 | |||
374 | let theme: Theme; | ||
375 | let themes: ThemeVariant[]; | ||
376 | if (settings.theme === 'dynamic') { | ||
377 | theme = lightTheme; | ||
378 | themes = [ | ||
379 | { | ||
380 | selector: '', | ||
381 | theme: lightTheme, | ||
382 | }, | ||
383 | { | ||
384 | selector: '[data-theme="dark"]', | ||
385 | theme: darkTheme, | ||
386 | }, | ||
387 | ]; | ||
388 | } else { | ||
389 | theme = settings.theme === 'light' ? lightTheme : darkTheme; | ||
390 | themes = [ | ||
391 | { | ||
392 | selector: '', | ||
393 | theme, | ||
394 | }, | ||
395 | ]; | ||
396 | } | ||
326 | if (!settings.transparent) { | 397 | if (!settings.transparent) { |
327 | addBackground(svgDocument, copyOfSVG, theme); | 398 | addBackground(svgDocument, copyOfSVG, theme); |
328 | } | 399 | } |
329 | 400 | ||
330 | fixForeignObjects(svgDocument, copyOfSVG); | 401 | fixForeignObjects(id, svgDocument, copyOfSVG); |
331 | 402 | ||
332 | const { colorNodes } = graph; | 403 | const { colorNodes } = graph; |
333 | let fontsCSS = ''; | 404 | let fontsCSS = ''; |
@@ -339,9 +410,10 @@ export default async function exportDiagram( | |||
339 | fontsCSS = await fetchFontCSS(); | 410 | fontsCSS = await fetchFontCSS(); |
340 | } | 411 | } |
341 | appendStyles( | 412 | appendStyles( |
413 | id, | ||
342 | svgDocument, | 414 | svgDocument, |
343 | copyOfSVG, | 415 | copyOfSVG, |
344 | theme, | 416 | themes, |
345 | colorNodes, | 417 | colorNodes, |
346 | graph.hexTypeHashes, | 418 | graph.hexTypeHashes, |
347 | fontsCSS, | 419 | fontsCSS, |