From 313feb8ceb158132d39118741905e6f37da62c1d Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 28 Jun 2026 23:52:28 +0200 Subject: [PATCH] feat: add SVG export option to canvas export modal Adds an SVG format choice below the Ultra quality option in the export modal. PNG quality options still drive raster export; the new SVG button exports a scalable vector via html-to-image toSvg. Closes #211 ha-relevant: yes --- .../src/components/modals/ExportModal.tsx | 28 +++++++++++--- .../modals/__tests__/ExportModal.test.tsx | 27 ++++++++++++- frontend/src/utils/__tests__/export.test.ts | 38 ++++++++++++++++++- frontend/src/utils/export.ts | 20 +++++++++- 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/modals/ExportModal.tsx b/frontend/src/components/modals/ExportModal.tsx index 4c6ad06..b1e3a9b 100644 --- a/frontend/src/components/modals/ExportModal.tsx +++ b/frontend/src/components/modals/ExportModal.tsx @@ -2,7 +2,7 @@ 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' +import { exportToPng, exportToSvg, EXPORT_QUALITY_OPTIONS, type ExportQuality, type ExportFormat } from '@/utils/export' interface ExportModalProps { open: boolean @@ -12,6 +12,7 @@ interface ExportModalProps { export function ExportModal({ open, onClose, getElement }: ExportModalProps) { const [quality, setQuality] = useState('high') + const [format, setFormat] = useState('png') const [exporting, setExporting] = useState(false) const handleExport = async () => { @@ -19,7 +20,11 @@ export function ExportModal({ open, onClose, getElement }: ExportModalProps) { if (!el) return setExporting(true) try { - await exportToPng(el, quality) + if (format === 'svg') { + await exportToSvg(el) + } else { + await exportToPng(el, quality) + } onClose() } finally { setExporting(false) @@ -30,7 +35,7 @@ export function ExportModal({ open, onClose, getElement }: ExportModalProps) { !v && onClose()}> - Export as PNG + Export Canvas
@@ -38,10 +43,10 @@ export function ExportModal({ open, onClose, getElement }: ExportModalProps) { ))} +
diff --git a/frontend/src/components/modals/__tests__/ExportModal.test.tsx b/frontend/src/components/modals/__tests__/ExportModal.test.tsx index 5baba23..5bd1cff 100644 --- a/frontend/src/components/modals/__tests__/ExportModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ExportModal.test.tsx @@ -3,8 +3,10 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { ExportModal } from '../ExportModal' const mockExportToPng = vi.fn() +const mockExportToSvg = vi.fn() vi.mock('@/utils/export', () => ({ exportToPng: (...args: unknown[]) => mockExportToPng(...args), + exportToSvg: (...args: unknown[]) => mockExportToSvg(...args), EXPORT_QUALITY_OPTIONS: [ { value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' }, { value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' }, @@ -20,6 +22,7 @@ describe('ExportModal', () => { beforeEach(() => { vi.clearAllMocks() mockExportToPng.mockResolvedValue(undefined) + mockExportToSvg.mockResolvedValue(undefined) }) it('renders all three quality options', () => { @@ -49,6 +52,28 @@ describe('ExportModal', () => { await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard')) }) + it('renders an SVG option under the quality options', () => { + render() + expect(screen.getByText('SVG')).toBeInTheDocument() + }) + + it('selects SVG and calls exportToSvg on Download click', async () => { + render() + fireEvent.click(screen.getByText('SVG').closest('button')!) + expect(screen.getByText('SVG').closest('button')!.className).toContain('border-[#00d4ff]') + fireEvent.click(screen.getByRole('button', { name: /download/i })) + await waitFor(() => expect(mockExportToSvg).toHaveBeenCalledWith(el)) + expect(mockExportToPng).not.toHaveBeenCalled() + }) + + it('switches back to PNG when a quality option is clicked after SVG', () => { + render() + fireEvent.click(screen.getByText('SVG').closest('button')!) + fireEvent.click(screen.getByText('Ultra').closest('button')!) + expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]') + expect(screen.getByText('SVG').closest('button')!.className).not.toContain('border-[#00d4ff]') + }) + it('closes after successful export', async () => { render() fireEvent.click(screen.getByRole('button', { name: /download/i })) @@ -69,6 +94,6 @@ describe('ExportModal', () => { it('does not render when closed', () => { render() - expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument() + expect(screen.queryByText('Export Canvas')).not.toBeInTheDocument() }) }) diff --git a/frontend/src/utils/__tests__/export.test.ts b/frontend/src/utils/__tests__/export.test.ts index 9d42221..e3051d1 100644 --- a/frontend/src/utils/__tests__/export.test.ts +++ b/frontend/src/utils/__tests__/export.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { exportToPng, EXPORT_QUALITY_OPTIONS } from '../export' +import { exportToPng, exportToSvg, EXPORT_QUALITY_OPTIONS } from '../export' const mockToPng = vi.fn() -vi.mock('html-to-image', () => ({ toPng: (...args: unknown[]) => mockToPng(...args) })) +const mockToSvg = vi.fn() +vi.mock('html-to-image', () => ({ + toPng: (...args: unknown[]) => mockToPng(...args), + toSvg: (...args: unknown[]) => mockToSvg(...args), +})) describe('exportToPng', () => { let el: HTMLElement @@ -18,6 +22,7 @@ describe('exportToPng', () => { ) appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((n) => n) mockToPng.mockResolvedValue('data:image/png;base64,abc') + mockToSvg.mockResolvedValue('data:image/svg+xml;base64,abc') }) afterEach(() => { @@ -56,6 +61,35 @@ describe('exportToPng', () => { }) }) +describe('exportToSvg', () => { + let el: HTMLElement + let clickSpy: 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 + ) + mockToSvg.mockResolvedValue('data:image/svg+xml;base64,abc') + }) + + afterEach(() => { + createSpy.mockRestore() + }) + + it('calls toSvg with dark background color', async () => { + await exportToSvg(el) + expect(mockToSvg).toHaveBeenCalledWith(el, expect.objectContaining({ backgroundColor: '#0d1117' })) + }) + + it('triggers a download', async () => { + await exportToSvg(el) + expect(clickSpy).toHaveBeenCalled() + }) +}) + describe('EXPORT_QUALITY_OPTIONS', () => { it('has exactly three options', () => { expect(EXPORT_QUALITY_OPTIONS).toHaveLength(3) diff --git a/frontend/src/utils/export.ts b/frontend/src/utils/export.ts index 83e53ab..14cea1c 100644 --- a/frontend/src/utils/export.ts +++ b/frontend/src/utils/export.ts @@ -1,6 +1,7 @@ -import { toPng } from 'html-to-image' +import { toPng, toSvg } from 'html-to-image' export type ExportQuality = 'standard' | 'high' | 'ultra' +export type ExportFormat = 'png' | 'svg' export const EXPORT_QUALITY_OPTIONS: { value: ExportQuality; label: string; pixelRatio: number; hint: string }[] = [ { value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' }, @@ -18,8 +19,23 @@ export async function exportToPng(element: HTMLElement, quality: ExportQuality = } as Partial, }) + triggerDownload(dataUrl, 'homelable-canvas.png') +} + +export async function exportToSvg(element: HTMLElement): Promise { + const dataUrl = await toSvg(element, { + backgroundColor: '#0d1117', + style: { + '--xy-controls-display': 'none', + } as Partial, + }) + + triggerDownload(dataUrl, 'homelable-canvas.svg') +} + +function triggerDownload(dataUrl: string, filename: string): void { const link = document.createElement('a') - link.download = 'homelable-canvas.png' + link.download = filename link.href = dataUrl link.click() }