diff --git a/apps/studio/components/interfaces/Database/Schemas/DefaultEdge.tsx b/apps/studio/components/interfaces/Database/Schemas/DefaultEdge.tsx index dddf394a9d8..fee55849475 100644 --- a/apps/studio/components/interfaces/Database/Schemas/DefaultEdge.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/DefaultEdge.tsx @@ -8,9 +8,10 @@ import { useReactFlow, } from '@xyflow/react' import { ArrowLeft, ArrowRight } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { useState } from 'react' import { Badge, cn } from 'ui' +import { useSchemaGraphContext } from './SchemaGraphContext' import { EdgeData } from './Schemas.constants' import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState' import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent' @@ -35,6 +36,7 @@ export const DefaultEdge = ({ pathOptions, ...props }: EdgeProps>) => { + const { isDownloading } = useSchemaGraphContext() const [edgePath, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, @@ -46,13 +48,13 @@ export const DefaultEdge = ({ offset: pathOptions?.offset, stepPosition: pathOptions?.stepPosition, }) - return ( <> {data && selected ? ( diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 5da4bfb5bc2..0d470a0cfe9 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -47,6 +47,7 @@ import { SchemaGraphLegend } from './SchemaGraphLegend' import { EdgeData, TableNodeData } from './Schemas.constants' import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils' import { TableNode } from './SchemaTableNode' +import { useExportSchemaToImage } from './useExportSchemaToImage' import AlertError from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import SchemaSelector from '@/components/ui/SchemaSelector' @@ -70,6 +71,7 @@ export const SchemaGraph = () => { const { selectedSchema, setSelectedSchema } = useQuerySchemaState() const [selectedTable, setSelectedTable] = useState(null) const snap = useTableEditorStateSnapshot() + const { isDownloading, exportSchemaToImage } = useExportSchemaToImage() const [copied, setCopied] = useState(false) useEffect(() => { @@ -78,8 +80,6 @@ export const SchemaGraph = () => { } }, [copied]) - const [isDownloading, setIsDownloading] = useState(false) - const miniMapNodeColor = '#111318' const miniMapMaskColor = resolvedTheme?.includes('dark') ? 'rgb(17, 19, 24, .8)' @@ -181,66 +181,12 @@ export const SchemaGraph = () => { } ) - const downloadImage = (format: 'png' | 'svg') => { + const downloadImage = async (format: 'png' | 'svg') => { const reactflowViewport = document.querySelector('.react-flow__viewport') as HTMLElement if (!reactflowViewport) return - - setIsDownloading(true) - const width = reactflowViewport.clientWidth - const height = reactflowViewport.clientHeight + if (!ref) return const { x, y, zoom } = reactFlowInstance.getViewport() - - if (format === 'svg') { - toSvg(reactflowViewport, { - backgroundColor: 'white', - width, - height, - style: { - width: width.toString(), - height: height.toString(), - transform: `translate(${x}px, ${y}px) scale(${zoom})`, - }, - }) - .then((data) => { - const a = document.createElement('a') - a.setAttribute('download', `supabase-schema-${ref}.svg`) - a.setAttribute('href', data) - a.click() - toast.success('Successfully downloaded as SVG') - }) - .catch((error) => { - console.error('Failed to download:', error) - toast.error('Failed to download current view:', error.message) - }) - .finally(() => { - setIsDownloading(false) - }) - } else if (format === 'png') { - toPng(reactflowViewport, { - backgroundColor: 'white', - width, - height, - style: { - width: width.toString(), - height: height.toString(), - transform: `translate(${x}px, ${y}px) scale(${zoom})`, - }, - }) - .then((data) => { - const a = document.createElement('a') - a.setAttribute('download', `supabase-schema-${ref}.png`) - a.setAttribute('href', data) - a.click() - toast.success('Successfully downloaded as PNG') - }) - .catch((error) => { - console.error('Failed to download:', error) - toast.error('Failed to download current view:', error.message) - }) - .finally(() => { - setIsDownloading(false) - }) - } + exportSchemaToImage({ element: reactflowViewport, format, x, y, zoom, projectRef: ref }) } const isFirstLoad = useRef(true) @@ -268,7 +214,7 @@ export const SchemaGraph = () => { selectedSchema, ]) - const schemaGraphPanelEditorContext = useMemo( + const schemaGraphContext = useMemo( () => ({ selectedEdge, isDownloading, @@ -427,7 +373,7 @@ export const SchemaGraph = () => { ) : ( - +
, Edge> // FIXME: https://github.com/xyflow/xyflow/issues/4876 diff --git a/apps/studio/components/interfaces/Database/Schemas/useExportSchemaToImage.ts b/apps/studio/components/interfaces/Database/Schemas/useExportSchemaToImage.ts new file mode 100644 index 00000000000..abab66249cd --- /dev/null +++ b/apps/studio/components/interfaces/Database/Schemas/useExportSchemaToImage.ts @@ -0,0 +1,90 @@ +import { toPng, toSvg } from 'html-to-image' +import { useMemo, useState } from 'react' +import { toast } from 'sonner' + +import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent' + +export const useExportSchemaToImage = () => { + const [isDownloading, setIsDownloading] = useState(false) + // By doing this once and passing the result to html-to-image options, we avoid html-to-image calculating it for every node. + // This improves performance a lot. See https://github.com/bubkoo/html-to-image/issues/542#issuecomment-3249408793 + const allPropertyNames = useMemo(() => getAllPropertyNames(), []) + + const exportSchemaToImage = useStaticEffectEvent( + async ({ + element, + projectRef, + x, + y, + zoom, + format, + }: { + element: HTMLElement + projectRef: string + x: number + y: number + zoom: number + format: 'svg' | 'png' + }) => { + setIsDownloading(true) + const width = element.clientWidth + const height = element.clientHeight + + const options = { + includeStyleProperties: allPropertyNames, + backgroundColor: 'white', + width, + height, + style: { + width: width.toString(), + height: height.toString(), + transform: `translate(${x}px, ${y}px) scale(${zoom})`, + }, + } + try { + if (format === 'svg') { + const data = await toSvg(element, options) + const a = document.createElement('a') + a.setAttribute('download', `supabase-schema-${projectRef}.svg`) + a.setAttribute('href', data) + a.click() + toast.success('Successfully downloaded as SVG') + } else if (format === 'png') { + const data = await toPng(element, options) + const a = document.createElement('a') + a.setAttribute('download', `supabase-schema-${projectRef}.png`) + a.setAttribute('href', data) + a.click() + toast.success('Successfully downloaded as PNG') + } + } catch (error) { + console.error('Failed to download:', error) + toast.error(`Failed to download current view: ${(error as Error).message}`) + } finally { + setIsDownloading(false) + } + } + ) + + return useMemo( + () => ({ isDownloading, exportSchemaToImage }), + [isDownloading, exportSchemaToImage] + ) +} + +// Get all property names accessible through getComputedStyle(), excluding custom properties +export const getAllPropertyNames = () => { + if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') { + return [] + } + + const names = [] + const style = getComputedStyle(document.documentElement) + for (let i = 0; i < style.length; i++) { + const name = style[i] + if (!name.startsWith('--')) { + names.push(name) + } + } + return names +} diff --git a/apps/studio/package.json b/apps/studio/package.json index ba7532a05d6..f296005281b 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -92,7 +92,7 @@ "file-saver": "^2.0.5", "framer-motion": "^11.18.2", "generate-password-browser": "^1.1.0", - "html-to-image": "^1.10.8", + "html-to-image": "^1.11.13", "http-status": "^2.1.0", "icons": "workspace:*", "idb": "^8.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf3a8c26057..db54b835949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1040,8 +1040,8 @@ importers: specifier: ^1.1.0 version: 1.1.0 html-to-image: - specifier: ^1.10.8 - version: 1.11.11 + specifier: ^1.11.13 + version: 1.11.13 http-status: specifier: ^2.1.0 version: 2.1.0 @@ -12987,8 +12987,8 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - html-to-image@1.11.11: - resolution: {integrity: sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} html-url-attributes@3.0.0: resolution: {integrity: sha512-/sXbVCWayk6GDVg3ctOX6nxaVj7So40FcFAnWlWGNAB1LpYKcV5Cd10APjPjW80O7zYW2MsjBV4zZ7IZO5fVow==} @@ -31372,7 +31372,7 @@ snapshots: html-escaper@2.0.2: {} - html-to-image@1.11.11: {} + html-to-image@1.11.13: {} html-url-attributes@3.0.0: {}