aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend')
-rw-r--r--subprojects/frontend/src/graph/export/ExportPanel.tsx52
-rw-r--r--subprojects/frontend/src/graph/export/ExportSettingsStore.ts28
-rw-r--r--subprojects/frontend/src/graph/export/exportDiagram.tsx122
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
7import ChevronRightIcon from '@mui/icons-material/ChevronRight'; 7import ChevronRightIcon from '@mui/icons-material/ChevronRight';
8import ContentCopyIcon from '@mui/icons-material/ContentCopy'; 8import ContentCopyIcon from '@mui/icons-material/ContentCopy';
9import ContrastIcon from '@mui/icons-material/Contrast';
9import DarkModeIcon from '@mui/icons-material/DarkMode'; 10import DarkModeIcon from '@mui/icons-material/DarkMode';
10import ImageIcon from '@mui/icons-material/Image'; 11import ImageIcon from '@mui/icons-material/Image';
11import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; 12import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
@@ -50,6 +51,13 @@ const SwitchButtonGroup = styled(ToggleButtonGroup, {
50 }, 51 },
51})); 52}));
52 53
54const AutoThemeMessage = styled(Typography, {
55 name: 'ExportPanel-AutoThemeMessage',
56})(({ theme }) => ({
57 width: '260px',
58 marginInline: theme.spacing(2),
59}));
60
53function getLabel(value: number): string { 61function 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=&quot;dark&quot;</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 @@
7import { makeAutoObservable } from 'mobx'; 7import { makeAutoObservable } from 'mobx';
8 8
9export type ExportFormat = 'svg' | 'pdf' | 'png'; 9export type ExportFormat = 'svg' | 'pdf' | 'png';
10export type ExportTheme = 'light' | 'dark'; 10export type ExportTheme = 'light' | 'dark' | 'dynamic';
11 11
12export default class ExportSettingsStore { 12export 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';
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 type { Theme } from '@mui/material/styles'; 18import type { Theme } from '@mui/material/styles';
19import { nanoid } from 'nanoid';
19 20
20import { darkTheme, lightTheme } from '../../theme/ThemeProvider'; 21import { darkTheme, lightTheme } from '../../theme/ThemeProvider';
21import { copyBlob, saveBlob } from '../../utils/fileIO'; 22import { copyBlob, saveBlob } from '../../utils/fileIO';
@@ -48,6 +49,36 @@ importSVG(labelSVG, 'icon-TRUE');
48importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); 49importSVG(labelOutlinedSVG, 'icon-UNKNOWN');
49importSVG(cancelSVG, 'icon-ERROR'); 50importSVG(cancelSVG, 'icon-ERROR');
50 51
52function 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
51function addBackground( 82function 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
176interface ThemeVariant {
177 selector: string;
178 theme: Theme;
179}
180
145function appendStyles( 181function 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
178function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void { 219function 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,