From 7e08a85f733e30a303187a3a8fe9fcc6c9733283 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 20 Apr 2026 11:40:16 +0200 Subject: [PATCH] feat: add quality selector to PNG export (standard / high / ultra) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking Export PNG now opens a modal with three quality presets: - Standard (1× pixel ratio) — small file - High (2×, default) — recommended for sharing - Ultra (4×) — print quality Adds ExportModal component, updates exportToPng() to accept a quality param, and wires the modal into App.tsx replacing the direct export call. --- frontend/src/App.tsx | 18 +++-- .../src/components/modals/ExportModal.tsx | 71 ++++++++++++++++++ .../modals/__tests__/ExportModal.test.tsx | 74 +++++++++++++++++++ frontend/src/utils/__tests__/export.test.ts | 71 ++++++++++++++++++ frontend/src/utils/export.ts | 17 +++-- 5 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/modals/ExportModal.tsx create mode 100644 frontend/src/components/modals/__tests__/ExportModal.test.tsx create mode 100644 frontend/src/utils/__tests__/export.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fdd1b1c..a3073ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { applyDagreLayout } from '@/utils/layout' import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { generateUUID } from '@/utils/uuid' import { generateMarkdownTable } from '@/utils/exportMarkdown' -import { exportToPng } from '@/utils/export' +import { ExportModal } from '@/components/modals/ExportModal' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { parseYamlToCanvas } from '@/utils/importYaml' import { TooltipProvider } from '@/components/ui/tooltip' @@ -53,6 +53,7 @@ export default function App() { const [pendingConnection, setPendingConnection] = useState(null) const [editEdgeId, setEditEdgeId] = useState(null) const [scanConfigOpen, setScanConfigOpen] = useState(false) + const [exportModalOpen, setExportModalOpen] = useState(false) // Declare handleSave before the Ctrl+S effect so it is in scope const handleSave = useCallback(async () => { @@ -305,15 +306,10 @@ export default function App() { } }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) - const handleExport = useCallback(async () => { + const handleExport = useCallback(() => { const el = canvasRef.current?.querySelector('.react-flow') if (!el) { toast.error('Canvas not ready'); return } - try { - await exportToPng(el) - toast.success('Exported as PNG') - } catch { - toast.error('Export failed') - } + setExportModalOpen(true) }, []) const handleEdgeConnect = useCallback((connection: Connection) => { @@ -531,6 +527,12 @@ export default function App() { /> setShortcutsOpen(false)} /> + setExportModalOpen(false)} + getElement={() => canvasRef.current?.querySelector('.react-flow') ?? null} + /> + diff --git a/frontend/src/components/modals/ExportModal.tsx b/frontend/src/components/modals/ExportModal.tsx new file mode 100644 index 0000000..4c6ad06 --- /dev/null +++ b/frontend/src/components/modals/ExportModal.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { Download, Loader2 } from 'lucide-react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { exportToPng, EXPORT_QUALITY_OPTIONS, type ExportQuality } from '@/utils/export' + +interface ExportModalProps { + open: boolean + onClose: () => void + getElement: () => HTMLElement | null +} + +export function ExportModal({ open, onClose, getElement }: ExportModalProps) { + const [quality, setQuality] = useState('high') + const [exporting, setExporting] = useState(false) + + const handleExport = async () => { + const el = getElement() + if (!el) return + setExporting(true) + try { + await exportToPng(el, quality) + onClose() + } finally { + setExporting(false) + } + } + + return ( + !v && onClose()}> + + + Export as PNG + + +
+ {EXPORT_QUALITY_OPTIONS.map((opt) => ( + + ))} +
+ + + + + +
+
+ ) +} diff --git a/frontend/src/components/modals/__tests__/ExportModal.test.tsx b/frontend/src/components/modals/__tests__/ExportModal.test.tsx new file mode 100644 index 0000000..5baba23 --- /dev/null +++ b/frontend/src/components/modals/__tests__/ExportModal.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ExportModal } from '../ExportModal' + +const mockExportToPng = vi.fn() +vi.mock('@/utils/export', () => ({ + exportToPng: (...args: unknown[]) => mockExportToPng(...args), + EXPORT_QUALITY_OPTIONS: [ + { value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' }, + { value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' }, + { value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' }, + ], +})) + +const el = document.createElement('div') +const getElement = () => el +const onClose = vi.fn() + +describe('ExportModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExportToPng.mockResolvedValue(undefined) + }) + + it('renders all three quality options', () => { + render() + expect(screen.getByText('Standard')).toBeInTheDocument() + expect(screen.getByText('High')).toBeInTheDocument() + expect(screen.getByText('Ultra')).toBeInTheDocument() + }) + + it('selects High by default', () => { + render() + const highBtn = screen.getByText('High').closest('button')! + expect(highBtn.className).toContain('border-[#00d4ff]') + }) + + it('changes selection when another option is clicked', () => { + render() + fireEvent.click(screen.getByText('Ultra').closest('button')!) + expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]') + expect(screen.getByText('High').closest('button')!.className).not.toContain('border-[#00d4ff]') + }) + + it('calls exportToPng with selected quality on Download click', async () => { + render() + fireEvent.click(screen.getByText('Standard').closest('button')!) + fireEvent.click(screen.getByRole('button', { name: /download/i })) + await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard')) + }) + + it('closes after successful export', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: /download/i })) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + }) + + it('calls onClose when Cancel is clicked', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + expect(onClose).toHaveBeenCalled() + }) + + it('does not call exportToPng when getElement returns null', async () => { + render( null} />) + fireEvent.click(screen.getByRole('button', { name: /download/i })) + await waitFor(() => expect(mockExportToPng).not.toHaveBeenCalled()) + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/utils/__tests__/export.test.ts b/frontend/src/utils/__tests__/export.test.ts new file mode 100644 index 0000000..9d42221 --- /dev/null +++ b/frontend/src/utils/__tests__/export.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { exportToPng, EXPORT_QUALITY_OPTIONS } from '../export' + +const mockToPng = vi.fn() +vi.mock('html-to-image', () => ({ toPng: (...args: unknown[]) => mockToPng(...args) })) + +describe('exportToPng', () => { + let el: HTMLElement + let clickSpy: ReturnType + let appendSpy: ReturnType + let createSpy: ReturnType + + beforeEach(() => { + el = document.createElement('div') + clickSpy = vi.fn() + createSpy = vi.spyOn(document, 'createElement').mockReturnValue( + Object.assign(document.createElement('a'), { click: clickSpy }) as HTMLAnchorElement + ) + appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((n) => n) + mockToPng.mockResolvedValue('data:image/png;base64,abc') + }) + + afterEach(() => { + createSpy.mockRestore() + appendSpy.mockRestore() + }) + + it('calls toPng with pixelRatio 1 for standard quality', async () => { + await exportToPng(el, 'standard') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 1 })) + }) + + it('calls toPng with pixelRatio 2 for high quality', async () => { + await exportToPng(el, 'high') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 })) + }) + + it('calls toPng with pixelRatio 4 for ultra quality', async () => { + await exportToPng(el, 'ultra') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 4 })) + }) + + it('defaults to high quality when no quality arg given', async () => { + await exportToPng(el) + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 })) + }) + + it('triggers a download with the correct filename', async () => { + await exportToPng(el, 'high') + expect(clickSpy).toHaveBeenCalled() + }) + + it('passes dark background color', async () => { + await exportToPng(el, 'standard') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ backgroundColor: '#0d1117' })) + }) +}) + +describe('EXPORT_QUALITY_OPTIONS', () => { + it('has exactly three options', () => { + expect(EXPORT_QUALITY_OPTIONS).toHaveLength(3) + }) + + it('options are standard, high, ultra in order', () => { + expect(EXPORT_QUALITY_OPTIONS.map((o) => o.value)).toEqual(['standard', 'high', 'ultra']) + }) + + it('pixel ratios are 1, 2, 4', () => { + expect(EXPORT_QUALITY_OPTIONS.map((o) => o.pixelRatio)).toEqual([1, 2, 4]) + }) +}) diff --git a/frontend/src/utils/export.ts b/frontend/src/utils/export.ts index 9705e69..83e53ab 100644 --- a/frontend/src/utils/export.ts +++ b/frontend/src/utils/export.ts @@ -1,14 +1,19 @@ import { toPng } from 'html-to-image' -/** - * Export the React Flow canvas as a PNG and trigger a browser download. - * Pass the `.react-flow` wrapper element. - */ -export async function exportToPng(element: HTMLElement): Promise { +export type ExportQuality = 'standard' | 'high' | 'ultra' + +export const EXPORT_QUALITY_OPTIONS: { value: ExportQuality; label: string; pixelRatio: number; hint: string }[] = [ + { value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' }, + { value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' }, + { value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' }, +] + +export async function exportToPng(element: HTMLElement, quality: ExportQuality = 'high'): Promise { + const option = EXPORT_QUALITY_OPTIONS.find((o) => o.value === quality) ?? EXPORT_QUALITY_OPTIONS[1] const dataUrl = await toPng(element, { backgroundColor: '#0d1117', + pixelRatio: option.pixelRatio, style: { - // Exclude controls from the export '--xy-controls-display': 'none', } as Partial, })