From cf07f20847642aec1eafe6234c2ce35264bbaabb Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Fri, 23 Feb 2024 19:03:03 +0100 Subject: feat(frontend): graph export formal selector --- subprojects/frontend/src/RootStore.ts | 5 + subprojects/frontend/src/graph/ExportButton.tsx | 271 --------------- subprojects/frontend/src/graph/ExportPanel.tsx | 215 ++++++++++++ .../frontend/src/graph/ExportSettingsStore.tsx | 46 +++ subprojects/frontend/src/graph/GraphArea.tsx | 4 +- subprojects/frontend/src/graph/SlideInDialog.tsx | 109 +++++++ subprojects/frontend/src/graph/SlideInPanel.tsx | 97 ++++++ .../frontend/src/graph/VisibilityDialog.tsx | 318 ------------------ subprojects/frontend/src/graph/VisibilityPanel.tsx | 303 +++++++++++++---- subprojects/frontend/src/graph/exportDiagram.tsx | 363 +++++++++++++++++++++ subprojects/frontend/src/theme/ThemeProvider.tsx | 4 +- 11 files changed, 1079 insertions(+), 656 deletions(-) delete mode 100644 subprojects/frontend/src/graph/ExportButton.tsx create mode 100644 subprojects/frontend/src/graph/ExportPanel.tsx create mode 100644 subprojects/frontend/src/graph/ExportSettingsStore.tsx create mode 100644 subprojects/frontend/src/graph/SlideInDialog.tsx create mode 100644 subprojects/frontend/src/graph/SlideInPanel.tsx delete mode 100644 subprojects/frontend/src/graph/VisibilityDialog.tsx create mode 100644 subprojects/frontend/src/graph/exportDiagram.tsx (limited to 'subprojects/frontend/src') 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'; import PWAStore from './PWAStore'; import type EditorStore from './editor/EditorStore'; +import ExportSettingsScotre from './graph/ExportSettingsStore'; import Compressor from './persistence/Compressor'; import ThemeStore from './theme/ThemeStore'; @@ -29,16 +30,20 @@ export default class RootStore { readonly themeStore: ThemeStore; + readonly exportSettingsStore: ExportSettingsScotre; + disposed = false; constructor() { this.pwaStore = new PWAStore(); this.themeStore = new ThemeStore(); + this.exportSettingsStore = new ExportSettingsScotre(); makeAutoObservable(this, { compressor: false, editorStoreClass: false, pwaStore: false, themeStore: false, + exportSettingsStore: false, }); (async () => { const { default: EditorStore } = await import('./editor/EditorStore'); diff --git a/subprojects/frontend/src/graph/ExportButton.tsx b/subprojects/frontend/src/graph/ExportButton.tsx deleted file mode 100644 index 97444c6e..00000000 --- a/subprojects/frontend/src/graph/ExportButton.tsx +++ /dev/null @@ -1,271 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 The Refinery Authors - * - * SPDX-License-Identifier: EPL-2.0 - */ - -import createCache from '@emotion/cache'; -import { serializeStyles } from '@emotion/serialize'; -import type { StyleSheet } from '@emotion/utils'; -import italicFontURL from '@fontsource/open-sans/files/open-sans-latin-400-italic.woff2?url'; -import normalFontURL from '@fontsource/open-sans/files/open-sans-latin-400-normal.woff2?url'; -import boldFontURL from '@fontsource/open-sans/files/open-sans-latin-700-normal.woff2?url'; -import variableItalicFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-italic.woff2?url'; -import variableFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2?url'; -import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; -import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; -import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; -import SaveAltIcon from '@mui/icons-material/SaveAlt'; -import IconButton from '@mui/material/IconButton'; -import { styled, useTheme, type Theme } from '@mui/material/styles'; -import { useCallback } from 'react'; - -import getLogger from '../utils/getLogger'; - -import { createGraphTheme } from './GraphTheme'; -import { SVG_NS } from './postProcessSVG'; - -const log = getLogger('graph.ExportButton'); - -const PROLOG = ''; - -const ExportButtonRoot = styled('div', { - name: 'ExportButton-Root', -})(({ theme }) => ({ - position: 'absolute', - padding: theme.spacing(1), - top: 0, - right: 0, - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', - alignItems: 'start', -})); - -const ICONS: Map = new Map(); - -function importSVG(svgSource: string, className: string): void { - const parser = new DOMParser(); - const svgDocument = parser.parseFromString(svgSource, 'image/svg+xml'); - const root = svgDocument.children[0]; - if (root === undefined) { - return; - } - root.id = className; - root.classList.add(className); - ICONS.set(className, root); -} - -importSVG(labelSVG, 'icon-TRUE'); -importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); -importSVG(cancelSVG, 'icon-ERROR'); - -async function fetchAsFontURL(url: string): Promise { - const fetchResult = await fetch(url); - const buffer = await fetchResult.arrayBuffer(); - const blob = new Blob([buffer], { type: 'font/woff2' }); - return new Promise((resolve, reject) => { - const fileReader = new FileReader(); - fileReader.addEventListener('load', () => { - resolve(fileReader.result as string); - }); - fileReader.addEventListener('error', () => { - reject(fileReader.error); - }); - fileReader.readAsDataURL(blob); - }); -} - -let fontCSS: string | undefined; -let variableFontCSS: string | undefined; - -async function fetchFontCSS(): Promise { - if (fontCSS !== undefined) { - return fontCSS; - } - const [normalDataURL, boldDataURL, italicDataURL] = await Promise.all([ - fetchAsFontURL(normalFontURL), - fetchAsFontURL(boldFontURL), - fetchAsFontURL(italicFontURL), - ]); - fontCSS = ` -@font-face { - font-family: 'Open Sans'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(${normalDataURL}) format('woff2'); -} -@font-face { - font-family: 'Open Sans'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(${boldDataURL}) format('woff2'); -} -@font-face { - font-family: 'Open Sans'; - font-style: italic; - font-display: swap; - font-weight: 400; - src: url(${italicDataURL}) format('woff2'); -}`; - return fontCSS; -} - -async function fetchVariableFontCSS(): Promise { - if (variableFontCSS !== undefined) { - return variableFontCSS; - } - const [variableDataURL, variableItalicDataURL] = await Promise.all([ - fetchAsFontURL(variableFontURL), - fetchAsFontURL(variableItalicFontURL), - ]); - variableFontCSS = ` -@font-face { - font-family: 'Open Sans Variable'; - font-style: normal; - font-display: swap; - font-weight: 300 800; - src: url(${variableDataURL}) format('woff2-variations'); -} -@font-face { - font-family: 'Open Sans Variable'; - font-style: normal; - font-display: swap; - font-weight: 300 800; - src: url(${variableItalicDataURL}) format('woff2-variations'); -}`; - return variableFontCSS; -} - -async function appendStyles( - svgDocument: XMLDocument, - svg: SVGSVGElement, - theme: Theme, - embedFonts?: 'woff2' | 'woff2-variations', -): Promise { - const cache = createCache({ - key: 'refinery', - container: svg, - prepend: true, - }); - // @ts-expect-error `CSSObject` types don't match up between `@mui/material` and - // `@emotion/serialize`, but they are compatible in practice. - const styles = serializeStyles([createGraphTheme], cache.registered, { - theme, - colorNodes: true, - noEmbedIcons: true, - }); - const rules: string[] = []; - if (embedFonts === 'woff2') { - rules.push(await fetchFontCSS()); - } else if (embedFonts === 'woff2-variations') { - rules.push(await fetchVariableFontCSS()); - } - const sheet = { - insert(rule) { - rules.push(rule); - }, - } as StyleSheet; - cache.insert('', styles, sheet, false); - const styleElement = svgDocument.createElementNS(SVG_NS, 'style'); - svg.prepend(styleElement); - styleElement.innerHTML = rules.join(''); -} - -function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void { - const foreignObjects: SVGForeignObjectElement[] = []; - svg - .querySelectorAll('foreignObject') - .forEach((object) => foreignObjects.push(object)); - foreignObjects.forEach((object) => { - const useElement = svgDocument.createElementNS(SVG_NS, 'use'); - let x = Number(object.getAttribute('x') ?? '0'); - let y = Number(object.getAttribute('y') ?? '0'); - const width = Number(object.getAttribute('width') ?? '0'); - const height = Number(object.getAttribute('height') ?? '0'); - const size = Math.min(width, height); - x += (width - size) / 2; - y += (height - size) / 2; - useElement.setAttribute('x', String(x)); - useElement.setAttribute('y', String(y)); - useElement.setAttribute('width', String(size)); - useElement.setAttribute('height', String(size)); - useElement.id = object.id; - object.children[0]?.classList?.forEach((className) => { - useElement.classList.add(className); - if (ICONS.has(className)) { - useElement.setAttribute('href', `#${className}`); - } - }); - object.replaceWith(useElement); - }); - const defs = svgDocument.createElementNS(SVG_NS, 'defs'); - svg.prepend(defs); - ICONS.forEach((value) => { - const importedValue = svgDocument.importNode(value, true); - defs.appendChild(importedValue); - }); -} - -function downloadSVG(svgDocument: XMLDocument): void { - const serializer = new XMLSerializer(); - const svgText = `${PROLOG}\n${serializer.serializeToString(svgDocument)}`; - const blob = new Blob([svgText], { - type: 'image/svg+xml', - }); - const link = document.createElement('a'); - link.href = window.URL.createObjectURL(blob); - link.download = 'graph.svg'; - link.style.display = 'none'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -} - -async function exportSVG( - svgContainer: HTMLElement | undefined, - theme: Theme, -): Promise { - const svg = svgContainer?.querySelector('svg'); - if (!svg) { - return; - } - const svgDocument = document.implementation.createDocument( - SVG_NS, - 'svg', - null, - ); - const copyOfSVG = svgDocument.importNode(svg, true); - const originalRoot = svgDocument.childNodes[0]; - if (originalRoot === undefined) { - svgDocument.appendChild(copyOfSVG); - } else { - svgDocument.replaceChild(copyOfSVG, originalRoot); - } - fixForeignObjects(svgDocument, copyOfSVG); - await appendStyles(svgDocument, copyOfSVG, theme); - downloadSVG(svgDocument); -} - -export default function ExportButton({ - svgContainer, -}: { - svgContainer: HTMLElement | undefined; -}): JSX.Element { - const theme = useTheme(); - const saveCallback = useCallback(() => { - exportSVG(svgContainer, theme).catch((error) => { - log.error('Failed to export SVG', error); - }); - }, [svgContainer, theme]); - - return ( - - - - - - ); -} 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 @@ +/* + * SPDX-FileCopyrightText: 2024 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DarkModeIcon from '@mui/icons-material/DarkMode'; +import ImageIcon from '@mui/icons-material/Image'; +import LightModeIcon from '@mui/icons-material/LightMode'; +import SaveAltIcon from '@mui/icons-material/SaveAlt'; +import ShapeLineIcon from '@mui/icons-material/ShapeLine'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Slider from '@mui/material/Slider'; +import Stack from '@mui/material/Stack'; +import Switch from '@mui/material/Switch'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Typography from '@mui/material/Typography'; +import { styled } from '@mui/material/styles'; +import { observer } from 'mobx-react-lite'; +import { useCallback } from 'react'; + +import { useRootStore } from '../RootStoreProvider'; +import getLogger from '../utils/getLogger'; + +import type GraphStore from './GraphStore'; +import SlideInPanel from './SlideInPanel'; +import exportDiagram from './exportDiagram'; + +const log = getLogger('graph.ExportPanel'); + +const SwitchButtonGroup = styled(ToggleButtonGroup, { + name: 'ExportPanel-SwitchButtonGroup', +})(({ theme }) => ({ + marginTop: theme.spacing(2), + marginInline: theme.spacing(2), + minWidth: '260px', + '.MuiToggleButton-root': { + width: '100%', + fontSize: '1rem', + lineHeight: '1.5', + }, + '& svg': { + margin: '0 6px 0 0', + }, +})); + +function getLabel(value: number): string { + return `${value}%`; +} + +const marks = [100, 200, 300, 400].map((value) => ({ + value, + label: ( + + + {getLabel(value)} + + ), +})); + +function ExportPanel({ + graph, + svgContainer, + dialog, +}: { + graph: GraphStore; + svgContainer: HTMLElement | undefined; + dialog: boolean; +}): JSX.Element { + const { exportSettingsStore } = useRootStore(); + + const icon = useCallback( + (show: boolean) => + show && !dialog ? : , + [dialog], + ); + + const { format } = exportSettingsStore; + const emptyGraph = graph.semantics.nodes.length === 0; + const buttons = useCallback( + (close: () => void) => ( + <> + + {'write' in navigator.clipboard && format === 'png' && ( + + )} + + ), + [svgContainer, graph, exportSettingsStore, format, emptyGraph], + ); + + return ( + + + exportSettingsStore.setFormat('svg')} + > + SVG + + exportSettingsStore.setFormat('png')} + > + PNG + + + + exportSettingsStore.setTheme('light')} + > + Light + + exportSettingsStore.setTheme('dark')} + > + Dark + + + exportSettingsStore.toggleTransparent()} + /> + } + label="Transparent background" + /> + {exportSettingsStore.format === 'svg' && ( + exportSettingsStore.toggleEmbedFonts()} + /> + } + label={ + + Embed fonts + + +75 kB, only supported in browsers + + + } + /> + )} + {exportSettingsStore.format === 'png' && ( + + { + if (typeof value === 'number') { + exportSettingsStore.setScale(value); + } + }} + /> + + )} + + ); +} + +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 @@ +/* + * SPDX-FileCopyrightText: 2024 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { makeAutoObservable } from 'mobx'; + +export type ExportFormat = 'svg' | 'png'; +export type ExportTheme = 'light' | 'dark'; + +export default class ExportSettingsStore { + format: ExportFormat = 'svg'; + + theme: ExportTheme = 'light'; + + transparent = true; + + embedFonts = false; + + scale = 100; + + constructor() { + makeAutoObservable(this); + } + + setFormat(format: ExportFormat): void { + this.format = format; + } + + setTheme(theme: ExportTheme): void { + this.theme = theme; + } + + toggleTransparent(): void { + this.transparent = !this.transparent; + } + + toggleEmbedFonts(): void { + this.embedFonts = !this.embedFonts; + } + + setScale(scale: number): void { + this.scale = scale; + } +} 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'; import { useResizeDetector } from 'react-resize-detector'; import DotGraphVisualizer from './DotGraphVisualizer'; -import ExportButton from './ExportButton'; +import ExportPanel from './ExportPanel'; import type GraphStore from './GraphStore'; import VisibilityPanel from './VisibilityPanel'; import ZoomCanvas from './ZoomCanvas'; @@ -48,7 +48,7 @@ function GraphArea({ graph }: { graph: GraphStore }): JSX.Element { )} - + ); } 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 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import CloseIcon from '@mui/icons-material/Close'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import { styled } from '@mui/material/styles'; +import React, { useId } from 'react'; + +const SlideInDialogRoot = styled('div', { + name: 'SlideInDialog-Root', + shouldForwardProp: (propName) => propName !== 'dialog', +})<{ dialog: boolean }>(({ theme, dialog }) => { + return { + maxHeight: '100%', + maxWidth: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + '.SlideInDialog-title': { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + padding: theme.spacing(1), + paddingLeft: theme.spacing(2), + borderBottom: `1px solid ${theme.palette.divider}`, + '& h2': { + flexGrow: 1, + }, + '.MuiIconButton-root': { + flexGrow: 0, + flexShrink: 0, + marginLeft: theme.spacing(2), + }, + }, + '.MuiFormControlLabel-root': { + marginLeft: 0, + paddingTop: theme.spacing(1), + paddingLeft: theme.spacing(1), + '& + .MuiFormControlLabel-root': { + paddingTop: 0, + }, + }, + '.SlideInDialog-buttons': { + padding: theme.spacing(1), + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + ...(dialog + ? { + marginTop: theme.spacing(1), + borderTop: `1px solid ${theme.palette.divider}`, + } + : {}), + }, + }; +}); + +export default function SlideInDialog({ + close, + dialog, + title, + buttons, + children, +}: { + close: () => void; + dialog?: boolean; + title: string; + buttons: React.ReactNode | ((close: () => void) => React.ReactNode); + children?: React.ReactNode; +}): JSX.Element { + const titleId = useId(); + + return ( + + {dialog && ( +
+ + {title} + + + + +
+ )} + {children} +
+ {typeof buttons === 'function' ? buttons(close) : buttons} + {!dialog && ( + + )} +
+
+ ); +} + +SlideInDialog.defaultProps = { + dialog: false, + children: undefined, +}; 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 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import Dialog from '@mui/material/Dialog'; +import IconButton from '@mui/material/IconButton'; +import Paper from '@mui/material/Paper'; +import Slide from '@mui/material/Slide'; +import { styled } from '@mui/material/styles'; +import React, { useCallback, useId, useState } from 'react'; + +import SlideInDialog from './SlideInDialog'; + +const SlideInPanelRoot = styled('div', { + name: 'SlideInPanel-Root', + shouldForwardProp: (propName) => propName !== 'anchor', +})<{ anchor: 'left' | 'right' }>(({ theme, anchor }) => ({ + position: 'absolute', + padding: theme.spacing(1), + top: 0, + [anchor]: 0, + maxHeight: '100%', + maxWidth: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + alignItems: anchor === 'left' ? 'start' : 'end', + '.SlideInPanel-drawer': { + overflow: 'hidden', + display: 'flex', + maxWidth: '100%', + margin: theme.spacing(1), + }, +})); + +export default function SlideInPanel({ + anchor, + dialog, + title, + icon, + iconLabel, + buttons, + children, +}: { + anchor: 'left' | 'right'; + dialog: boolean; + title: string; + icon: (show: boolean) => React.ReactNode; + iconLabel: string; + buttons: React.ReactNode | ((close: () => void) => React.ReactNode); + children?: React.ReactNode; +}): JSX.Element { + const id = useId(); + const [show, setShow] = useState(false); + const close = useCallback(() => setShow(false), []); + + return ( + + setShow(!show)} + > + {icon(show)} + + {dialog ? ( + + + {children} + + + ) : ( + + + + {children} + + + + )} + + ); +} + +SlideInPanel.defaultProps = { + children: undefined, +}; 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 @@ -/* - * SPDX-FileCopyrightText: 2023 The Refinery Authors - * - * SPDX-License-Identifier: EPL-2.0 - */ - -import CloseIcon from '@mui/icons-material/Close'; -import FilterListIcon from '@mui/icons-material/FilterList'; -import LabelIcon from '@mui/icons-material/Label'; -import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined'; -import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied'; -import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; -import Button from '@mui/material/Button'; -import Checkbox from '@mui/material/Checkbox'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import IconButton from '@mui/material/IconButton'; -import Switch from '@mui/material/Switch'; -import Typography from '@mui/material/Typography'; -import { styled } from '@mui/material/styles'; -import { observer } from 'mobx-react-lite'; -import { useId } from 'react'; - -import type GraphStore from './GraphStore'; -import { isVisibilityAllowed } from './GraphStore'; -import RelationName from './RelationName'; - -const VisibilityDialogRoot = styled('div', { - name: 'VisibilityDialog-Root', - shouldForwardProp: (propName) => propName !== 'dialog', -})<{ dialog: boolean }>(({ theme, dialog }) => { - const overlayOpacity = dialog ? 0.16 : 0.09; - return { - maxHeight: '100%', - maxWidth: '100%', - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', - '.VisibilityDialog-title': { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - padding: theme.spacing(1), - paddingLeft: theme.spacing(2), - borderBottom: `1px solid ${theme.palette.divider}`, - '& h2': { - flexGrow: 1, - }, - '.MuiIconButton-root': { - flexGrow: 0, - flexShrink: 0, - marginLeft: theme.spacing(2), - }, - }, - '.MuiFormControlLabel-root': { - marginLeft: 0, - paddingTop: theme.spacing(1), - paddingLeft: theme.spacing(1), - '& + .MuiFormControlLabel-root': { - paddingTop: 0, - }, - }, - '.VisibilityDialog-scroll': { - display: 'flex', - flexDirection: 'column', - height: 'auto', - overflowX: 'hidden', - overflowY: 'auto', - margin: `0 ${theme.spacing(2)}`, - '& table': { - // We use flexbox instead of `display: table` to get proper text-overflow - // behavior for overly long relation names. - display: 'flex', - flexDirection: 'column', - }, - '& thead, & tbody': { - display: 'flex', - flexDirection: 'column', - }, - '& thead': { - position: 'sticky', - top: 0, - zIndex: 999, - backgroundColor: theme.palette.background.paper, - ...(theme.palette.mode === 'dark' - ? { - // In dark mode, MUI Paper gets a lighter overlay. - backgroundImage: `linear-gradient( - rgba(255, 255, 255, ${overlayOpacity}), - rgba(255, 255, 255, ${overlayOpacity}) - )`, - } - : {}), - '& tr': { - height: '44px', - }, - }, - '& tr': { - display: 'flex', - flexDirection: 'row', - maxWidth: '100%', - }, - '& tbody tr': { - transition: theme.transitions.create('background', { - duration: theme.transitions.duration.shortest, - }), - '&:hover': { - background: theme.palette.action.hover, - '@media (hover: none)': { - background: 'transparent', - }, - }, - }, - '& th, & td': { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - // Set width in advance, since we can't rely on `display: table-cell`. - width: '44px', - }, - '& th:nth-of-type(3), & td:nth-of-type(3)': { - justifyContent: 'start', - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(2), - // Only let the last column grow or shrink. - flexGrow: 1, - flexShrink: 1, - // Compute the maximum available space in advance to let the text overflow. - maxWidth: 'calc(100% - 88px)', - width: 'min-content', - }, - '& td:nth-of-type(3)': { - cursor: 'pointer', - userSelect: 'none', - WebkitTapHighlightColor: 'transparent', - }, - - '& thead th, .VisibilityDialog-custom tr:last-child td': { - borderBottom: `1px solid ${theme.palette.divider}`, - }, - }, - // Hack to apply `text-overflow`. - '.VisibilityDialog-nowrap': { - maxWidth: '100%', - overflow: 'hidden', - wordWrap: 'nowrap', - textOverflow: 'ellipsis', - }, - '.VisibilityDialog-buttons': { - padding: theme.spacing(1), - display: 'flex', - flexDirection: 'row', - justifyContent: 'flex-end', - ...(dialog - ? { - marginTop: theme.spacing(1), - borderTop: `1px solid ${theme.palette.divider}`, - } - : {}), - }, - '.VisibilityDialog-empty': { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - color: theme.palette.text.secondary, - }, - '.VisibilityDialog-emptyIcon': { - fontSize: '6rem', - marginBottom: theme.spacing(1), - }, - }; -}); - -function VisibilityDialog({ - graph, - close, - dialog, -}: { - graph: GraphStore; - close: () => void; - dialog?: boolean; -}): JSX.Element { - const titleId = useId(); - - const builtinRows: JSX.Element[] = []; - const rows: JSX.Element[] = []; - graph.relationMetadata.forEach((metadata, name) => { - if (!isVisibilityAllowed(metadata, 'must')) { - return; - } - const visibility = graph.getVisibility(name); - const row = ( - - - - graph.setVisibility(name, visibility === 'none' ? 'must' : 'none') - } - /> - - - - graph.setVisibility(name, visibility === 'all' ? 'must' : 'all') - } - /> - - graph.cycleVisibility(name)} - aria-label="Toggle visiblity" - > -
- -
- - - ); - if (name.startsWith('builtin::')) { - builtinRows.push(row); - } else { - rows.push(row); - } - }); - - const hasRows = rows.length > 0 || builtinRows.length > 0; - - return ( - - {dialog && ( -
- - Customize view - - - - -
- )} - graph.toggleAbbrevaite()} - /> - } - label="Fully qualified names" - /> - graph.toggleScopes()} /> - } - label="Object scopes" - /> -
- {hasRows ? ( - - - - - - - - - {...rows} - {...builtinRows} -
- - - - Symbol
- ) : ( -
- -
Partial model is empty
-
- )} -
-
- - - {!dialog && ( - - )} -
-
- ); -} - -VisibilityDialog.defaultProps = { - dialog: false, -}; - -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 @@ /* - * SPDX-FileCopyrightText: 2023 The Refinery Authors + * SPDX-FileCopyrightText: 2023-2024 The Refinery Authors * * SPDX-License-Identifier: EPL-2.0 */ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import LabelIcon from '@mui/icons-material/Label'; +import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined'; +import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied'; import TuneIcon from '@mui/icons-material/Tune'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import Badge from '@mui/material/Badge'; -import Dialog from '@mui/material/Dialog'; -import IconButton from '@mui/material/IconButton'; -import Paper from '@mui/material/Paper'; -import Slide from '@mui/material/Slide'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; import { styled } from '@mui/material/styles'; import { observer } from 'mobx-react-lite'; -import { useCallback, useId, useState } from 'react'; +import { useCallback } from 'react'; import type GraphStore from './GraphStore'; -import VisibilityDialog from './VisibilityDialog'; +import { isVisibilityAllowed } from './GraphStore'; +import RelationName from './RelationName'; +import SlideInPanel from './SlideInPanel'; -const VisibilityPanelRoot = styled('div', { - name: 'VisibilityPanel-Root', -})(({ theme }) => ({ - position: 'absolute', - padding: theme.spacing(1), - top: 0, - left: 0, - maxHeight: '100%', - maxWidth: '100%', - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', - alignItems: 'start', - '.VisibilityPanel-drawer': { - overflow: 'hidden', +const VisibilityDialogScroll = styled('div', { + name: 'VisibilityDialog-Scroll', + shouldForwardProp: (propName) => propName !== 'dialog', +})<{ dialog: boolean }>(({ theme, dialog }) => { + const overlayOpacity = dialog ? 0.16 : 0.09; + return { display: 'flex', - maxWidth: '100%', - margin: theme.spacing(1), - }, -})); + flexDirection: 'column', + height: 'auto', + overflowX: 'hidden', + overflowY: 'auto', + margin: `0 ${theme.spacing(2)}`, + '& table': { + // We use flexbox instead of `display: table` to get proper text-overflow + // behavior for overly long relation names. + display: 'flex', + flexDirection: 'column', + }, + '& thead, & tbody': { + display: 'flex', + flexDirection: 'column', + }, + '& thead': { + position: 'sticky', + top: 0, + zIndex: 999, + backgroundColor: theme.palette.background.paper, + ...(theme.palette.mode === 'dark' + ? { + // In dark mode, MUI Paper gets a lighter overlay. + backgroundImage: `linear-gradient( + rgba(255, 255, 255, ${overlayOpacity}), + rgba(255, 255, 255, ${overlayOpacity}) + )`, + } + : {}), + '& tr': { + height: '44px', + }, + }, + '& tr': { + display: 'flex', + flexDirection: 'row', + maxWidth: '100%', + }, + '& tbody tr': { + transition: theme.transitions.create('background', { + duration: theme.transitions.duration.shortest, + }), + '&:hover': { + background: theme.palette.action.hover, + '@media (hover: none)': { + background: 'transparent', + }, + }, + }, + '& th, & td': { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + // Set width in advance, since we can't rely on `display: table-cell`. + width: '44px', + }, + '& th:nth-of-type(3), & td:nth-of-type(3)': { + justifyContent: 'start', + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(2), + // Only let the last column grow or shrink. + flexGrow: 1, + flexShrink: 1, + // Compute the maximum available space in advance to let the text overflow. + maxWidth: 'calc(100% - 88px)', + width: 'min-content', + }, + '& td:nth-of-type(3)': { + cursor: 'pointer', + userSelect: 'none', + WebkitTapHighlightColor: 'transparent', + }, + + '& thead th, .VisibilityDialog-custom tr:last-child td': { + borderBottom: `1px solid ${theme.palette.divider}`, + }, + // Hack to apply `text-overflow`. + '.VisibilityDialog-nowrap': { + maxWidth: '100%', + overflow: 'hidden', + wordWrap: 'nowrap', + textOverflow: 'ellipsis', + }, + '.VisibilityDialog-empty': { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + color: theme.palette.text.secondary, + }, + '.VisibilityDialog-emptyIcon': { + fontSize: '6rem', + marginBottom: theme.spacing(1), + }, + }; +}); function VisibilityPanel({ graph, @@ -46,45 +136,132 @@ function VisibilityPanel({ graph: GraphStore; dialog: boolean; }): JSX.Element { - const id = useId(); - const [showFilter, setShowFilter] = useState(false); - const close = useCallback(() => setShowFilter(false), []); + const builtinRows: JSX.Element[] = []; + const rows: JSX.Element[] = []; + graph.relationMetadata.forEach((metadata, name) => { + if (!isVisibilityAllowed(metadata, 'must')) { + return; + } + const visibility = graph.getVisibility(name); + const row = ( + + + + graph.setVisibility(name, visibility === 'none' ? 'must' : 'none') + } + /> + + + + graph.setVisibility(name, visibility === 'all' ? 'must' : 'all') + } + /> + + graph.cycleVisibility(name)} + aria-label="Toggle visiblity" + > +
+ +
+ + + ); + if (name.startsWith('builtin::')) { + builtinRows.push(row); + } else { + rows.push(row); + } + }); + + const hasRows = rows.length > 0 || builtinRows.length > 0; + + const hideBadge = graph.visibility.size === 0; + const icon = useCallback( + (show: boolean) => ( + + {show && !dialog ? : } + + ), + [dialog, hideBadge], + ); return ( - - setShowFilter(!showFilter)} - > - - {showFilter && !dialog ? : } - - - {dialog ? ( - - - - ) : ( - - - - - - )} - + + + + + } + > + graph.toggleAbbrevaite()} + /> + } + label="Fully qualified names" + /> + graph.toggleScopes()} /> + } + label="Object scopes" + /> + + {hasRows ? ( + + + + + + + + + {...rows} + {...builtinRows} +
+ + + + Symbol
+ ) : ( +
+ +
Partial model is empty
+
+ )} +
+
); } diff --git a/subprojects/frontend/src/graph/exportDiagram.tsx b/subprojects/frontend/src/graph/exportDiagram.tsx new file mode 100644 index 00000000..46c7f199 --- /dev/null +++ b/subprojects/frontend/src/graph/exportDiagram.tsx @@ -0,0 +1,363 @@ +/* + * SPDX-FileCopyrightText: 2024 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import createCache from '@emotion/cache'; +import { serializeStyles } from '@emotion/serialize'; +import type { StyleSheet } from '@emotion/utils'; +import italicFontURL from '@fontsource/open-sans/files/open-sans-latin-400-italic.woff2?url'; +import normalFontURL from '@fontsource/open-sans/files/open-sans-latin-400-normal.woff2?url'; +import boldFontURL from '@fontsource/open-sans/files/open-sans-latin-700-normal.woff2?url'; +import variableItalicFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-italic.woff2?url'; +import variableFontURL from '@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2?url'; +import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; +import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; +import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; +import type { Theme } from '@mui/material/styles'; + +import { darkTheme, lightTheme } from '../theme/ThemeProvider'; + +import type ExportSettingsStore from './ExportSettingsStore'; +import type GraphStore from './GraphStore'; +import { createGraphTheme } from './GraphTheme'; +import { SVG_NS } from './postProcessSVG'; + +const PROLOG = ''; +const SVG_CONTENT_TYPE = 'image/svg+xml'; + +const ICONS: Map = new Map(); + +function importSVG(svgSource: string, className: string): void { + const parser = new DOMParser(); + const svgDocument = parser.parseFromString(svgSource, SVG_CONTENT_TYPE); + const root = svgDocument.children[0]; + if (root === undefined) { + return; + } + root.id = className; + root.classList.add(className); + ICONS.set(className, root); +} + +importSVG(labelSVG, 'icon-TRUE'); +importSVG(labelOutlinedSVG, 'icon-UNKNOWN'); +importSVG(cancelSVG, 'icon-ERROR'); + +function addBackground( + svgDocument: XMLDocument, + svg: SVGSVGElement, + theme: Theme, +): void { + const viewBox = svg.getAttribute('viewBox')?.split(' '); + const rect = svgDocument.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('x', viewBox?.[0] ?? '0'); + rect.setAttribute('y', viewBox?.[1] ?? '0'); + rect.setAttribute('width', viewBox?.[2] ?? '0'); + rect.setAttribute('height', viewBox?.[3] ?? '0'); + rect.setAttribute('fill', theme.palette.background.default); + svg.prepend(rect); +} + +async function fetchAsFontURL(url: string): Promise { + const fetchResult = await fetch(url); + const buffer = await fetchResult.arrayBuffer(); + const blob = new Blob([buffer], { type: 'font/woff2' }); + return new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.addEventListener('load', () => { + resolve(fileReader.result as string); + }); + fileReader.addEventListener('error', () => { + reject(fileReader.error); + }); + fileReader.readAsDataURL(blob); + }); +} + +let fontCSS: string | undefined; +let variableFontCSS: string | undefined; + +async function fetchFontCSS(): Promise { + if (fontCSS !== undefined) { + return fontCSS; + } + const [normalDataURL, boldDataURL, italicDataURL] = await Promise.all([ + fetchAsFontURL(normalFontURL), + fetchAsFontURL(boldFontURL), + fetchAsFontURL(italicFontURL), + ]); + fontCSS = ` +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(${normalDataURL}) format('woff2'); +} +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(${boldDataURL}) format('woff2'); +} +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-display: swap; + font-weight: 400; + src: url(${italicDataURL}) format('woff2'); +}`; + return fontCSS; +} + +async function fetchVariableFontCSS(): Promise { + if (variableFontCSS !== undefined) { + return variableFontCSS; + } + const [variableDataURL, variableItalicDataURL] = await Promise.all([ + fetchAsFontURL(variableFontURL), + fetchAsFontURL(variableItalicFontURL), + ]); + variableFontCSS = ` +@font-face { + font-family: 'Open Sans Variable'; + font-style: normal; + font-display: swap; + font-weight: 300 800; + src: url(${variableDataURL}) format('woff2-variations'); +} +@font-face { + font-family: 'Open Sans Variable'; + font-style: normal; + font-display: swap; + font-weight: 300 800; + src: url(${variableItalicDataURL}) format('woff2-variations'); +}`; + return variableFontCSS; +} + +function appendStyles( + svgDocument: XMLDocument, + svg: SVGSVGElement, + theme: Theme, + colorNodes: boolean, + fontsCSS: string, +): void { + const cache = createCache({ + key: 'refinery', + container: svg, + prepend: true, + }); + // @ts-expect-error `CSSObject` types don't match up between `@mui/material` and + // `@emotion/serialize`, but they are compatible in practice. + const styles = serializeStyles([createGraphTheme], cache.registered, { + theme, + colorNodes, + noEmbedIcons: true, + }); + const rules: string[] = [fontsCSS]; + const sheet = { + insert(rule) { + rules.push(rule); + }, + } as StyleSheet; + cache.insert('', styles, sheet, false); + const styleElement = svgDocument.createElementNS(SVG_NS, 'style'); + svg.prepend(styleElement); + styleElement.innerHTML = rules.join(''); +} + +function fixForeignObjects(svgDocument: XMLDocument, svg: SVGSVGElement): void { + const foreignObjects: SVGForeignObjectElement[] = []; + svg + .querySelectorAll('foreignObject') + .forEach((object) => foreignObjects.push(object)); + foreignObjects.forEach((object) => { + const useElement = svgDocument.createElementNS(SVG_NS, 'use'); + let x = Number(object.getAttribute('x') ?? '0'); + let y = Number(object.getAttribute('y') ?? '0'); + const width = Number(object.getAttribute('width') ?? '0'); + const height = Number(object.getAttribute('height') ?? '0'); + const size = Math.min(width, height); + x += (width - size) / 2; + y += (height - size) / 2; + useElement.setAttribute('x', String(x)); + useElement.setAttribute('y', String(y)); + useElement.setAttribute('width', String(size)); + useElement.setAttribute('height', String(size)); + useElement.id = object.id; + object.children[0]?.classList?.forEach((className) => { + useElement.classList.add(className); + if (ICONS.has(className)) { + useElement.setAttribute('href', `#${className}`); + } + }); + object.replaceWith(useElement); + }); + const defs = svgDocument.createElementNS(SVG_NS, 'defs'); + svg.prepend(defs); + ICONS.forEach((value) => { + const importedValue = svgDocument.importNode(value, true); + defs.appendChild(importedValue); + }); +} + +function serializeSVG(svgDocument: XMLDocument): Blob { + const serializer = new XMLSerializer(); + const svgText = `${PROLOG}\n${serializer.serializeToString(svgDocument)}`; + return new Blob([svgText], { + type: SVG_CONTENT_TYPE, + }); +} + +function downloadBlob(blob: Blob, name: string): void { + const link = document.createElement('a'); + const url = window.URL.createObjectURL(blob); + try { + link.href = url; + link.download = name; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + } finally { + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } +} + +async function copyBlob(blob: Blob): Promise { + const { clipboard } = navigator; + if ('write' in clipboard) { + await clipboard.write([ + new ClipboardItem({ + [blob.type]: blob, + }), + ]); + } +} + +async function serializePNG( + serializedSVG: Blob, + svg: SVGSVGElement, + settings: ExportSettingsStore, + theme: Theme, +): Promise { + const scale = settings.scale / 100; + const baseWidth = svg.width.baseVal.value; + const baseHeight = svg.height.baseVal.value; + const exactWidth = baseWidth * scale; + const exactHeight = baseHeight * scale; + const width = Math.round(exactWidth); + const height = Math.round(exactHeight); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const image = document.createElement('img'); + const url = window.URL.createObjectURL(serializedSVG); + try { + await new Promise((resolve, reject) => { + image.addEventListener('load', () => resolve(undefined)); + image.addEventListener('error', ({ error }) => + reject( + error instanceof Error + ? error + : new Error(`Failed to load image: ${error}`), + ), + ); + image.src = url; + }); + } finally { + window.URL.revokeObjectURL(url); + } + + const context = canvas.getContext('2d'); + if (context === null) { + throw new Error('Failed to get canvas 2D context'); + } + if (!settings.transparent) { + context.fillStyle = theme.palette.background.default; + context.fillRect(0, 0, width, height); + } + context.drawImage( + image, + 0, + 0, + baseWidth, + baseHeight, + 0, + 0, + exactWidth, + exactHeight, + ); + + return new Promise((resolve, reject) => { + canvas.toBlob((exportedBlob) => { + if (exportedBlob === null) { + reject(new Error('Failed to export PNG blob')); + } else { + resolve(exportedBlob); + } + }, 'image/png'); + }); +} + +export default async function exportDiagram( + svgContainer: HTMLElement | undefined, + graph: GraphStore, + settings: ExportSettingsStore, + mode: 'download' | 'copy', +): Promise { + const svg = svgContainer?.querySelector('svg'); + if (!svg) { + return; + } + const svgDocument = document.implementation.createDocument( + SVG_NS, + 'svg', + null, + ); + const copyOfSVG = svgDocument.importNode(svg, true); + const originalRoot = svgDocument.childNodes[0]; + if (originalRoot === undefined) { + svgDocument.appendChild(copyOfSVG); + } else { + svgDocument.replaceChild(copyOfSVG, originalRoot); + } + + const theme = settings.theme === 'light' ? lightTheme : darkTheme; + if (!settings.transparent) { + addBackground(svgDocument, copyOfSVG, theme); + } + + fixForeignObjects(svgDocument, copyOfSVG); + + const { colorNodes } = graph; + let fontsCSS = ''; + if (settings.format === 'png') { + // If we are creating a PNG, font file size doesn't matter, + // and we can reuse fonts the browser has already downloaded. + fontsCSS = await fetchVariableFontCSS(); + } else if (settings.embedFonts) { + fontsCSS = await fetchFontCSS(); + } + appendStyles(svgDocument, copyOfSVG, theme, colorNodes, fontsCSS); + + const serializedSVG = serializeSVG(svgDocument); + if (settings.format === 'png') { + const png = await serializePNG(serializedSVG, svg, settings, theme); + if (mode === 'copy') { + await copyBlob(png); + } else { + downloadBlob(png, 'graph.png'); + } + } else if (mode === 'copy') { + await copyBlob(serializedSVG); + } else { + downloadBlob(serializedSVG, 'graph.svg'); + } +} 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( return responsiveFontSizes(themeWithOverrides); } -const lightTheme = (() => { +export const lightTheme = (() => { const primaryText = '#19202b'; const disabledText = '#a0a1a7'; const darkBackground = '#f5f5f5'; @@ -282,7 +282,7 @@ const lightTheme = (() => { }); })(); -const darkTheme = (() => { +export const darkTheme = (() => { const primaryText = '#ebebff'; const secondaryText = '#abb2bf'; const darkBackground = '#21252b'; -- cgit v1.2.3-54-g00ecf