diff options
author | Kristóf Marussy <kristof@marussy.com> | 2024-04-27 23:40:42 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2024-04-27 23:40:42 +0200 |
commit | 85a3ee2bcaf9a77b9d3751c5a18bdb6f6d20ad49 (patch) | |
tree | 19c0850d14ae6afae85515847163810ea25be711 /subprojects/frontend/src | |
parent | rfactor(frontend): scroll to top on initialization (diff) | |
download | refinery-85a3ee2bcaf9a77b9d3751c5a18bdb6f6d20ad49.tar.gz refinery-85a3ee2bcaf9a77b9d3751c5a18bdb6f6d20ad49.tar.zst refinery-85a3ee2bcaf9a77b9d3751c5a18bdb6f6d20ad49.zip |
refactor(frontend): fix icon placement in Safari
Also affected WebKitGTK
Diffstat (limited to 'subprojects/frontend/src')
-rw-r--r-- | subprojects/frontend/src/graph/GraphArea.tsx | 2 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphTheme.tsx | 58 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/SVGIcons.tsx | 41 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/export/exportDiagram.tsx | 65 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/icons.tsx | 29 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/postProcessSVG.ts | 20 |
6 files changed, 111 insertions, 104 deletions
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx index b5d93aef..70ae3bc5 100644 --- a/subprojects/frontend/src/graph/GraphArea.tsx +++ b/subprojects/frontend/src/graph/GraphArea.tsx | |||
@@ -12,6 +12,7 @@ import { useResizeDetector } from 'react-resize-detector'; | |||
12 | 12 | ||
13 | import DotGraphVisualizer from './DotGraphVisualizer'; | 13 | import DotGraphVisualizer from './DotGraphVisualizer'; |
14 | import type GraphStore from './GraphStore'; | 14 | import type GraphStore from './GraphStore'; |
15 | import SVGIcons from './SVGIcons'; | ||
15 | import VisibilityPanel from './VisibilityPanel'; | 16 | import VisibilityPanel from './VisibilityPanel'; |
16 | import ZoomCanvas from './ZoomCanvas'; | 17 | import ZoomCanvas from './ZoomCanvas'; |
17 | import ExportPanel from './export/ExportPanel'; | 18 | import ExportPanel from './export/ExportPanel'; |
@@ -38,6 +39,7 @@ function GraphArea({ graph }: { graph: GraphStore }): JSX.Element { | |||
38 | position="relative" | 39 | position="relative" |
39 | ref={ref} | 40 | ref={ref} |
40 | > | 41 | > |
42 | <SVGIcons /> | ||
41 | <ZoomCanvas> | 43 | <ZoomCanvas> |
42 | {(fitZoom) => ( | 44 | {(fitZoom) => ( |
43 | <DotGraphVisualizer | 45 | <DotGraphVisualizer |
diff --git a/subprojects/frontend/src/graph/GraphTheme.tsx b/subprojects/frontend/src/graph/GraphTheme.tsx index 50a003e0..bdc01b78 100644 --- a/subprojects/frontend/src/graph/GraphTheme.tsx +++ b/subprojects/frontend/src/graph/GraphTheme.tsx | |||
@@ -4,9 +4,6 @@ | |||
4 | * SPDX-License-Identifier: EPL-2.0 | 4 | * SPDX-License-Identifier: EPL-2.0 |
5 | */ | 5 | */ |
6 | 6 | ||
7 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; | ||
8 | import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; | ||
9 | import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; | ||
10 | import { | 7 | import { |
11 | alpha, | 8 | alpha, |
12 | styled, | 9 | styled, |
@@ -16,8 +13,6 @@ import { | |||
16 | import { lch } from 'd3-color'; | 13 | import { lch } from 'd3-color'; |
17 | import { range } from 'lodash-es'; | 14 | import { range } from 'lodash-es'; |
18 | 15 | ||
19 | import svgURL from '../utils/svgURL'; | ||
20 | |||
21 | import obfuscateColor from './obfuscateColor'; | 16 | import obfuscateColor from './obfuscateColor'; |
22 | 17 | ||
23 | function createEdgeColor( | 18 | function createEdgeColor( |
@@ -69,32 +64,16 @@ function createTypeHashStyles( | |||
69 | return result; | 64 | return result; |
70 | } | 65 | } |
71 | 66 | ||
72 | function iconStyle( | ||
73 | svg: string, | ||
74 | color: string, | ||
75 | noEmbedIcons?: boolean, | ||
76 | ): CSSObject { | ||
77 | if (noEmbedIcons) { | ||
78 | return { | ||
79 | fill: color, | ||
80 | }; | ||
81 | } | ||
82 | return { | ||
83 | maskImage: svgURL(svg), | ||
84 | background: color, | ||
85 | }; | ||
86 | } | ||
87 | |||
88 | export function createGraphTheme({ | 67 | export function createGraphTheme({ |
89 | theme, | 68 | theme, |
90 | colorNodes, | 69 | colorNodes, |
91 | hexTypeHashes, | 70 | hexTypeHashes, |
92 | noEmbedIcons, | 71 | useOpacity, |
93 | }: { | 72 | }: { |
94 | theme: Theme; | 73 | theme: Theme; |
95 | colorNodes: boolean; | 74 | colorNodes: boolean; |
96 | hexTypeHashes: string[]; | 75 | hexTypeHashes: string[]; |
97 | noEmbedIcons?: boolean; | 76 | useOpacity?: boolean; |
98 | }): CSSObject { | 77 | }): CSSObject { |
99 | const shadowAlapha = theme.palette.mode === 'dark' ? 0.32 : 0.24; | 78 | const shadowAlapha = theme.palette.mode === 'dark' ? 0.32 : 0.24; |
100 | 79 | ||
@@ -120,13 +99,15 @@ export function createGraphTheme({ | |||
120 | '.node-INDIVIDUAL .node-outline': { | 99 | '.node-INDIVIDUAL .node-outline': { |
121 | strokeWidth: 2, | 100 | strokeWidth: 2, |
122 | }, | 101 | }, |
123 | '.node-shadow.node-bg': noEmbedIcons | 102 | '.node-shadow.node-bg': useOpacity |
124 | ? { | 103 | ? { |
125 | // Inkscape can't handle opacity in exported SVG. | 104 | // Inkscape can't handle RGBA in exported SVG. |
126 | fill: theme.palette.text.primary, | 105 | fill: theme.palette.text.primary, |
127 | opacity: shadowAlapha, | 106 | opacity: shadowAlapha, |
128 | } | 107 | } |
129 | : { | 108 | : { |
109 | // But using `opacity` with the transition animation leads to flashing shadows, | ||
110 | // so we still use RGBA whenever possible. | ||
130 | fill: alpha(theme.palette.text.primary, shadowAlapha), | 111 | fill: alpha(theme.palette.text.primary, shadowAlapha), |
131 | }, | 112 | }, |
132 | '.node-exists-UNKNOWN .node-outline': { | 113 | '.node-exists-UNKNOWN .node-outline': { |
@@ -147,24 +128,15 @@ export function createGraphTheme({ | |||
147 | }, | 128 | }, |
148 | ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'), | 129 | ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'), |
149 | ...createEdgeColor('ERROR', theme.palette.error.main), | 130 | ...createEdgeColor('ERROR', theme.palette.error.main), |
150 | ...(noEmbedIcons | 131 | '.icon-TRUE': { |
151 | ? {} | 132 | fill: theme.palette.text.primary, |
152 | : { | 133 | }, |
153 | '.icon': { | 134 | '.icon-UNKNOWN': { |
154 | maskSize: '12px 12px', | 135 | fill: theme.palette.text.secondary, |
155 | maskPosition: '50% 50%', | 136 | }, |
156 | maskRepeat: 'no-repeat', | 137 | '.icon-ERROR': { |
157 | width: '100%', | 138 | fill: theme.palette.error.main, |
158 | height: '100%', | 139 | }, |
159 | }, | ||
160 | }), | ||
161 | '.icon-TRUE': iconStyle(labelSVG, theme.palette.text.primary, noEmbedIcons), | ||
162 | '.icon-UNKNOWN': iconStyle( | ||
163 | labelOutlinedSVG, | ||
164 | theme.palette.text.secondary, | ||
165 | noEmbedIcons, | ||
166 | ), | ||
167 | '.icon-ERROR': iconStyle(cancelSVG, theme.palette.error.main, noEmbedIcons), | ||
168 | 'text.label-UNKNOWN': { | 140 | 'text.label-UNKNOWN': { |
169 | fill: theme.palette.text.secondary, | 141 | fill: theme.palette.text.secondary, |
170 | }, | 142 | }, |
diff --git a/subprojects/frontend/src/graph/SVGIcons.tsx b/subprojects/frontend/src/graph/SVGIcons.tsx new file mode 100644 index 00000000..fa3484b1 --- /dev/null +++ b/subprojects/frontend/src/graph/SVGIcons.tsx | |||
@@ -0,0 +1,41 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2024 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import { styled } from '@mui/material/styles'; | ||
8 | import { useCallback } from 'react'; | ||
9 | |||
10 | import icons from './icons'; | ||
11 | |||
12 | export const SVG_NS = 'http://www.w3.org/2000/svg'; | ||
13 | |||
14 | const SVGIconsHolder = styled('div', { | ||
15 | name: 'SVGIcons-Holder', | ||
16 | })({ | ||
17 | position: 'absolute', | ||
18 | top: 0, | ||
19 | left: 0, | ||
20 | width: 0, | ||
21 | height: 0, | ||
22 | visibility: 'hidden', | ||
23 | }); | ||
24 | |||
25 | export default function SVGIcons(): JSX.Element { | ||
26 | const addNodes = useCallback((element: HTMLDivElement | null) => { | ||
27 | if (element === null) { | ||
28 | return; | ||
29 | } | ||
30 | const svgElement = document.createElementNS(SVG_NS, 'svg'); | ||
31 | const defs = document.createElementNS(SVG_NS, 'defs'); | ||
32 | svgElement.appendChild(defs); | ||
33 | icons.forEach((value) => { | ||
34 | const importedValue = document.importNode(value, true); | ||
35 | importedValue.id = `refinery-${importedValue.id}`; | ||
36 | defs.appendChild(importedValue); | ||
37 | }); | ||
38 | element.replaceChildren(svgElement); | ||
39 | }, []); | ||
40 | return <SVGIconsHolder ref={addNodes} />; | ||
41 | } | ||
diff --git a/subprojects/frontend/src/graph/export/exportDiagram.tsx b/subprojects/frontend/src/graph/export/exportDiagram.tsx index 52d19aa0..73b40fea 100644 --- a/subprojects/frontend/src/graph/export/exportDiagram.tsx +++ b/subprojects/frontend/src/graph/export/exportDiagram.tsx | |||
@@ -12,9 +12,6 @@ import normalFontURL from '@fontsource/open-sans/files/open-sans-latin-400-norma | |||
12 | import boldFontURL from '@fontsource/open-sans/files/open-sans-latin-700-normal.woff2?url'; | 12 | import boldFontURL from '@fontsource/open-sans/files/open-sans-latin-700-normal.woff2?url'; |
13 | import variableItalicFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-italic.woff2?url'; | 13 | import variableItalicFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-italic.woff2?url'; |
14 | import variableFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2?url'; | 14 | import variableFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2?url'; |
15 | import cancelSVG from '@material-icons/svg/svg/cancel/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'; | ||
18 | import type { Theme } from '@mui/material/styles'; | 15 | import type { Theme } from '@mui/material/styles'; |
19 | import { nanoid } from 'nanoid'; | 16 | import { nanoid } from 'nanoid'; |
20 | 17 | ||
@@ -22,6 +19,7 @@ import { darkTheme, lightTheme } from '../../theme/ThemeProvider'; | |||
22 | import { copyBlob, saveBlob } from '../../utils/fileIO'; | 19 | import { copyBlob, saveBlob } from '../../utils/fileIO'; |
23 | import type GraphStore from '../GraphStore'; | 20 | import type GraphStore from '../GraphStore'; |
24 | import { createGraphTheme } from '../GraphTheme'; | 21 | import { createGraphTheme } from '../GraphTheme'; |
22 | import icons from '../icons'; | ||
25 | import { SVG_NS } from '../postProcessSVG'; | 23 | import { SVG_NS } from '../postProcessSVG'; |
26 | 24 | ||
27 | import type ExportSettingsStore from './ExportSettingsStore'; | 25 | import type ExportSettingsStore from './ExportSettingsStore'; |
@@ -31,24 +29,6 @@ const PNG_CONTENT_TYPE = 'image/png'; | |||
31 | const SVG_CONTENT_TYPE = 'image/svg+xml'; | 29 | const SVG_CONTENT_TYPE = 'image/svg+xml'; |
32 | const EXPORT_ID = 'export-image'; | 30 | const EXPORT_ID = 'export-image'; |
33 | 31 | ||
34 | const ICONS: Map<string, Element> = new Map(); | ||
35 | |||
36 | function importSVG(svgSource: string, className: string): void { | ||
37 | const parser = new DOMParser(); | ||
38 | const svgDocument = parser.parseFromString(svgSource, SVG_CONTENT_TYPE); | ||
39 | const root = svgDocument.children[0]; | ||
40 | if (root === undefined) { | ||
41 | return; | ||
42 | } | ||
43 | root.id = className; | ||
44 | root.classList.add(className); | ||
45 | ICONS.set(className, root); | ||
46 | } | ||
47 | |||
48 | importSVG(labelSVG, 'icon-TRUE'); | ||
49 | importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); | ||
50 | importSVG(cancelSVG, 'icon-ERROR'); | ||
51 | |||
52 | function fixIDs(id: string, svgDocument: XMLDocument) { | 32 | function fixIDs(id: string, svgDocument: XMLDocument) { |
53 | const idMap = new Map<string, string>(); | 33 | const idMap = new Map<string, string>(); |
54 | let i = 0; | 34 | let i = 0; |
@@ -202,7 +182,7 @@ function appendStyles( | |||
202 | theme, | 182 | theme, |
203 | colorNodes, | 183 | colorNodes, |
204 | hexTypeHashes, | 184 | hexTypeHashes, |
205 | noEmbedIcons: true, | 185 | useOpacity: true, |
206 | }); | 186 | }); |
207 | const sheet = { | 187 | const sheet = { |
208 | insert(rule) { | 188 | insert(rule) { |
@@ -216,42 +196,25 @@ function appendStyles( | |||
216 | styleElement.innerHTML = rules.join(''); | 196 | styleElement.innerHTML = rules.join(''); |
217 | } | 197 | } |
218 | 198 | ||
219 | function fixForeignObjects( | 199 | function fixIcons( |
220 | id: string, | 200 | id: string, |
221 | svgDocument: XMLDocument, | 201 | svgDocument: XMLDocument, |
222 | svg: SVGSVGElement, | 202 | svg: SVGSVGElement, |
223 | ): void { | 203 | ): void { |
224 | const foreignObjects: SVGForeignObjectElement[] = []; | 204 | const prefix = `refinery-${id}-`; |
225 | svg | 205 | const hrefPrefix = `#${prefix}`; |
226 | .querySelectorAll('foreignObject') | 206 | svg.querySelectorAll('use').forEach((use) => { |
227 | .forEach((object) => foreignObjects.push(object)); | 207 | const href = use.getAttribute('href'); |
228 | foreignObjects.forEach((object) => { | 208 | if (href === null) { |
229 | const useElement = svgDocument.createElementNS(SVG_NS, 'use'); | 209 | return; |
230 | let x = Number(object.getAttribute('x') ?? '0'); | 210 | } |
231 | let y = Number(object.getAttribute('y') ?? '0'); | 211 | use.setAttribute('href', href.replace(/^#refinery-/, hrefPrefix)); |
232 | const width = Number(object.getAttribute('width') ?? '0'); | ||
233 | const height = Number(object.getAttribute('height') ?? '0'); | ||
234 | const size = Math.min(width, height); | ||
235 | x += (width - size) / 2; | ||
236 | y += (height - size) / 2; | ||
237 | useElement.setAttribute('x', String(x)); | ||
238 | useElement.setAttribute('y', String(y)); | ||
239 | useElement.setAttribute('width', String(size)); | ||
240 | useElement.setAttribute('height', String(size)); | ||
241 | useElement.id = object.id; | ||
242 | object.children[0]?.classList?.forEach((className) => { | ||
243 | useElement.classList.add(className); | ||
244 | if (ICONS.has(className)) { | ||
245 | useElement.setAttribute('href', `#refinery-${id}-${className}`); | ||
246 | } | ||
247 | }); | ||
248 | object.replaceWith(useElement); | ||
249 | }); | 212 | }); |
250 | const defs = svgDocument.createElementNS(SVG_NS, 'defs'); | 213 | const defs = svgDocument.createElementNS(SVG_NS, 'defs'); |
251 | svg.prepend(defs); | 214 | svg.prepend(defs); |
252 | ICONS.forEach((value) => { | 215 | icons.forEach((value) => { |
253 | const importedValue = svgDocument.importNode(value, true); | 216 | const importedValue = svgDocument.importNode(value, true); |
254 | importedValue.id = `refinery-${id}-${importedValue.id}`; | 217 | importedValue.id = `${prefix}${importedValue.id}`; |
255 | defs.appendChild(importedValue); | 218 | defs.appendChild(importedValue); |
256 | }); | 219 | }); |
257 | } | 220 | } |
@@ -398,7 +361,7 @@ export default async function exportDiagram( | |||
398 | addBackground(svgDocument, copyOfSVG, theme); | 361 | addBackground(svgDocument, copyOfSVG, theme); |
399 | } | 362 | } |
400 | 363 | ||
401 | fixForeignObjects(id, svgDocument, copyOfSVG); | 364 | fixIcons(id, svgDocument, copyOfSVG); |
402 | 365 | ||
403 | const { colorNodes } = graph; | 366 | const { colorNodes } = graph; |
404 | let fontsCSS = ''; | 367 | let fontsCSS = ''; |
diff --git a/subprojects/frontend/src/graph/icons.tsx b/subprojects/frontend/src/graph/icons.tsx new file mode 100644 index 00000000..4f4407a0 --- /dev/null +++ b/subprojects/frontend/src/graph/icons.tsx | |||
@@ -0,0 +1,29 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2024 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; | ||
8 | import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; | ||
9 | import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; | ||
10 | |||
11 | const icons: Map<string, Element> = new Map(); | ||
12 | |||
13 | export default icons; | ||
14 | |||
15 | function importSVG(svgSource: string, className: string): void { | ||
16 | const parser = new DOMParser(); | ||
17 | const svgDocument = parser.parseFromString(svgSource, 'image/svg+xml'); | ||
18 | const root = svgDocument.children[0]; | ||
19 | if (root === undefined) { | ||
20 | return; | ||
21 | } | ||
22 | root.id = className; | ||
23 | root.classList.add(className); | ||
24 | icons.set(className, root); | ||
25 | } | ||
26 | |||
27 | importSVG(labelSVG, 'icon-TRUE'); | ||
28 | importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); | ||
29 | importSVG(cancelSVG, 'icon-ERROR'); | ||
diff --git a/subprojects/frontend/src/graph/postProcessSVG.ts b/subprojects/frontend/src/graph/postProcessSVG.ts index bf990f3a..97130012 100644 --- a/subprojects/frontend/src/graph/postProcessSVG.ts +++ b/subprojects/frontend/src/graph/postProcessSVG.ts | |||
@@ -144,14 +144,14 @@ function replaceImages(node: SVGGElement) { | |||
144 | } | 144 | } |
145 | const width = image.getAttribute('width')?.replace('px', '') ?? ''; | 145 | const width = image.getAttribute('width')?.replace('px', '') ?? ''; |
146 | const height = image.getAttribute('height')?.replace('px', '') ?? ''; | 146 | const height = image.getAttribute('height')?.replace('px', '') ?? ''; |
147 | const foreign = document.createElementNS(SVG_NS, 'foreignObject'); | 147 | const use = document.createElementNS(SVG_NS, 'use'); |
148 | foreign.setAttribute('x', image.getAttribute('x') ?? ''); | 148 | use.setAttribute('x', image.getAttribute('x') ?? ''); |
149 | foreign.setAttribute('y', image.getAttribute('y') ?? ''); | 149 | use.setAttribute('y', image.getAttribute('y') ?? ''); |
150 | foreign.setAttribute('width', width); | 150 | use.setAttribute('width', width); |
151 | foreign.setAttribute('height', height); | 151 | use.setAttribute('height', height); |
152 | const div = document.createElement('div'); | 152 | const iconName = `icon-${href.replace('#', '')}`; |
153 | div.classList.add('icon', `icon-${href.replace('#', '')}`); | 153 | use.setAttribute('href', `#refinery-${iconName}`); |
154 | foreign.appendChild(div); | 154 | use.classList.add('icon', iconName); |
155 | const sibling = image.nextElementSibling; | 155 | const sibling = image.nextElementSibling; |
156 | // Since dot doesn't respect the `id` attribute on table cells with a single image, | 156 | // Since dot doesn't respect the `id` attribute on table cells with a single image, |
157 | // compute the ID based on the ID of the next element (the label). | 157 | // compute the ID based on the ID of the next element (the label). |
@@ -160,9 +160,9 @@ function replaceImages(node: SVGGElement) { | |||
160 | sibling.tagName.toLowerCase() === 'g' && | 160 | sibling.tagName.toLowerCase() === 'g' && |
161 | sibling.id !== '' | 161 | sibling.id !== '' |
162 | ) { | 162 | ) { |
163 | foreign.id = `${sibling.id},icon`; | 163 | use.id = `${sibling.id},icon`; |
164 | } | 164 | } |
165 | image.parentNode?.replaceChild(foreign, image); | 165 | image.parentNode?.replaceChild(use, image); |
166 | }); | 166 | }); |
167 | } | 167 | } |
168 | 168 | ||