feat(web): add export as image screenshot to share menu (#1569)

Add an option to export the current preview viewport as a PNG image.

- Add requestPreviewSnapshot() utility in exports.ts (reuses the existing
  srcdoc snapshot bridge via postMessage)
- Add exportAsImage() and dataUrlToBlob() helpers for Blob download
- Add Export as image menu item in the HTML viewer share menu, gated
  behind srcdoc mode (bridge only present in srcdoc, not URL-load mode)
- Refactor PreviewDrawOverlay to delegate to the shared
  requestPreviewSnapshot() instead of duplicating the snapshot logic
- Add fileViewer.exportImage i18n key across all 19 locale files
- Add 7 unit tests covering snapshot request, timeout, error handling,
  and download filename sanitization

Fixes #1500
This commit is contained in:
sukumarp2022 2026-05-14 08:37:28 +05:30 committed by GitHub
parent ff569fa50c
commit 852a005b32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 316 additions and 19 deletions

View file

@ -46,6 +46,7 @@ import {
import type { ProjectFilePreview } from '../providers/registry';
import {
exportAsHtml,
exportAsImage,
exportAsJsx,
exportAsMd,
exportAsPdf,
@ -54,6 +55,7 @@ import {
exportReactComponentAsHtml,
exportReactComponentAsZip,
openSandboxedPreviewInNewTab,
requestPreviewSnapshot,
} from '../runtime/exports';
import { buildReactComponentSrcdoc } from '../runtime/react-component';
import { buildSrcdoc } from '../runtime/srcdoc';
@ -5485,6 +5487,33 @@ function HtmlViewer({
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
<span>{t('fileViewer.exportMd')}</span>
</button>
{!useUrlLoadPreview ? (
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={async () => {
setShareMenuOpen(false);
const iframe = iframeRef.current;
if (!iframe) return;
const snap = await requestPreviewSnapshot(iframe);
try {
if (snap) {
exportAsImage(snap.dataUrl, exportTitle);
} else {
console.warn('[exportAsImage] snapshot capture returned null');
alert(t('fileViewer.exportImageFailed'));
}
} catch (err) {
console.warn('[exportAsImage] failed to convert snapshot:', err);
alert(t('fileViewer.exportImageFailed'));
}
}}
>
<span className="share-menu-icon"><Icon name="image" size={14} /></span>
<span>{t('fileViewer.exportImage')}</span>
</button>
) : null}
<div className="share-menu-divider" />
<button
type="button"

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties, type Poin
import { Icon } from './Icon';
import type { PreviewVisualMarkKind } from '../types';
import { requestPreviewSnapshot } from '../runtime/exports';
export type PreviewDrawMode = 'click' | 'draw';
@ -205,25 +206,9 @@ export function PreviewDrawOverlay({
}
async function requestSnapshot(): Promise<{ dataUrl: string; w: number; h: number } | null> {
const iframe = wrapRef.current?.querySelector('iframe');
const win = iframe?.contentWindow;
if (!iframe || !win) return null;
const id = `snap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return new Promise((resolve) => {
let done = false;
function onMsg(ev: MessageEvent) {
const d = ev.data as { type?: string; id?: string; dataUrl?: string; w?: number; h?: number; error?: string } | null;
if (!d || d.type !== 'od:snapshot:result' || d.id !== id) return;
if (done) return;
done = true;
window.removeEventListener('message', onMsg);
if (d.dataUrl && d.w && d.h) resolve({ dataUrl: d.dataUrl, w: d.w, h: d.h });
else resolve(null);
}
window.addEventListener('message', onMsg);
try { win.postMessage({ type: 'od:snapshot', id }, '*'); } catch { /* sandboxed */ }
setTimeout(() => { if (!done) { done = true; window.removeEventListener('message', onMsg); resolve(null); } }, 2500);
});
const iframe = wrapRef.current?.querySelector('iframe') as HTMLIFrameElement | null;
if (!iframe) return null;
return requestPreviewSnapshot(iframe);
}
function drawCaptureTarget(

View file

@ -844,6 +844,8 @@ export const ar: Dict = {
'fileViewer.exportZip': 'تحميل كـ zip.',
'fileViewer.exportHtml': 'تصدير كـ HTML مستقل',
'fileViewer.exportMd': 'تصدير كـ Markdown',
'fileViewer.exportImage': 'تصدير كصورة',
'fileViewer.exportImageFailed': 'فشل التقاط الصورة. يرجى المحاولة مرة أخرى أو استخدام أداة لقطة الشاشة في المتصفح.',
'fileViewer.exportJsx': 'تصدير كـ JSX',
'fileViewer.exportReactHtml': 'تصدير المعاينة كـ HTML',
'fileViewer.saveAsTemplate': 'حفظ كقالب...',

View file

@ -732,6 +732,8 @@ export const de: Dict = {
'fileViewer.exportZip': 'Als .zip herunterladen',
'fileViewer.exportHtml': 'Als eigenständiges HTML exportieren',
'fileViewer.exportMd': 'Als Markdown exportieren',
'fileViewer.exportImage': 'Als Bild exportieren',
'fileViewer.exportImageFailed': 'Bildaufnahme fehlgeschlagen. Bitte versuchen Sie es erneut oder verwenden Sie das Screenshot-Tool Ihres Browsers.',
'fileViewer.exportJsx': 'Als JSX exportieren',
'fileViewer.exportReactHtml': 'Vorschau als HTML exportieren',
'fileViewer.saveAsTemplate': 'Als Template speichern…',

View file

@ -940,6 +940,8 @@ export const en: Dict = {
'fileViewer.exportZip': 'Download as .zip',
'fileViewer.exportHtml': 'Export as standalone HTML',
'fileViewer.exportMd': 'Export as Markdown',
'fileViewer.exportImage': 'Export as image',
'fileViewer.exportImageFailed': 'Image capture failed. Please try again or use your browser\'s screenshot tool.',
'fileViewer.exportJsx': 'Export as JSX',
'fileViewer.exportReactHtml': 'Export preview as HTML',
'fileViewer.saveAsTemplate': 'Save as template…',

View file

@ -733,6 +733,8 @@ export const esES: Dict = {
'fileViewer.exportZip': 'Descargar como .zip',
'fileViewer.exportHtml': 'Exportar como HTML independiente',
'fileViewer.exportMd': 'Exportar como Markdown',
'fileViewer.exportImage': 'Exportar como imagen',
'fileViewer.exportImageFailed': 'Error al capturar la imagen. Inténtalo de nuevo o usa la herramienta de captura de pantalla de tu navegador.',
'fileViewer.exportJsx': 'Exportar como JSX',
'fileViewer.exportReactHtml': 'Exportar vista previa como HTML',
'fileViewer.saveAsTemplate': 'Guardar como plantilla…',

View file

@ -868,6 +868,8 @@ export const fa: Dict = {
'fileViewer.exportZip': 'دانلود به صورت .zip',
'fileViewer.exportHtml': 'صادرکردن به HTML مستقل',
'fileViewer.exportMd': 'صادرکردن به صورت Markdown',
'fileViewer.exportImage': 'صادرکردن به صورت تصویر',
'fileViewer.exportImageFailed': 'گرفتن تصویر ناموفق بود. لطفاً دوباره تلاش کنید یا از ابزار اسکرین‌شات مرورگرتان استفاده کنید.',
'fileViewer.exportJsx': 'صادرکردن به JSX',
'fileViewer.exportReactHtml': 'صادرکردن پیش‌نمایش به HTML',
'fileViewer.saveAsTemplate': 'ذخیره به عنوان قالب…',

View file

@ -844,6 +844,8 @@ export const fr: Dict = {
'fileViewer.exportZip': 'Télécharger en .zip',
'fileViewer.exportHtml': 'Exporter en HTML autonome',
'fileViewer.exportMd': 'Exporter en Markdown',
'fileViewer.exportImage': 'Exporter en image',
'fileViewer.exportImageFailed': 'La capture d\'image a échoué. Veuillez réessayer ou utiliser l\'outil de capture d\'écran de votre navigateur.',
'fileViewer.exportJsx': 'Exporter en JSX',
'fileViewer.exportReactHtml': 'Exporter l\'aperçu en HTML',
'fileViewer.saveAsTemplate': 'Enregistrer comme modèle…',

View file

@ -844,6 +844,8 @@ export const hu: Dict = {
'fileViewer.exportZip': 'Letöltés .zip-ként',
'fileViewer.exportHtml': 'Exportálás önálló HTML-ként',
'fileViewer.exportMd': 'Exportálás Markdown-ként',
'fileViewer.exportImage': 'Exportálás képként',
'fileViewer.exportImageFailed': 'A képrögzítés sikertelen. Kérjük, próbálja újra, vagy használja a böngészője képernyőkép eszközét.',
'fileViewer.exportJsx': 'Exportálás JSX-ként',
'fileViewer.exportReactHtml': 'Előnézet exportálása HTML-ként',
'fileViewer.saveAsTemplate': 'Mentés sablonként…',

View file

@ -963,6 +963,8 @@ export const id: Dict = {
'fileViewer.exportZip': 'Ekspor ZIP',
'fileViewer.exportHtml': 'Ekspor HTML',
'fileViewer.exportMd': 'Ekspor Markdown',
'fileViewer.exportImage': 'Ekspor gambar',
'fileViewer.exportImageFailed': 'Gagal menangkap gambar. Silakan coba lagi atau gunakan alat tangkapan layar browser Anda.',
'fileViewer.exportJsx': 'Ekspor JSX',
'fileViewer.exportReactHtml': 'Ekspor React HTML',
'fileViewer.saveAsTemplate': 'Simpan sebagai templat',

View file

@ -731,6 +731,8 @@ export const ja: Dict = {
'fileViewer.exportZip': '.zip としてダウンロード',
'fileViewer.exportHtml': 'スタンドアロン HTML としてエクスポート',
'fileViewer.exportMd': 'Markdown としてエクスポート',
'fileViewer.exportImage': '画像としてエクスポート',
'fileViewer.exportImageFailed': '画像のキャプチャに失敗しました。再試行するか、ブラウザのスクリーンショット機能をご利用ください。',
'fileViewer.exportJsx': 'JSX としてエクスポート',
'fileViewer.exportReactHtml': 'プレビューを HTML としてエクスポート',
'fileViewer.saveAsTemplate': 'テンプレートとして保存…',

View file

@ -844,6 +844,8 @@ export const ko: Dict = {
'fileViewer.exportZip': '.zip으로 다운로드',
'fileViewer.exportHtml': '독립 실행형 HTML로 내보내기',
'fileViewer.exportMd': 'Markdown으로 내보내기',
'fileViewer.exportImage': '이미지로 내보내기',
'fileViewer.exportImageFailed': '이미지 캡처에 실패했습니다. 다시 시도하거나 브라우저의 스크린샷 도구를 사용하세요.',
'fileViewer.exportJsx': 'JSX로 내보내기',
'fileViewer.exportReactHtml': '미리보기를 HTML로 내보내기',
'fileViewer.saveAsTemplate': '템플릿으로 저장…',

View file

@ -844,6 +844,8 @@ export const pl: Dict = {
'fileViewer.exportZip': 'Pobierz jako .zip',
'fileViewer.exportHtml': 'Eksportuj jako samodzielny HTML',
'fileViewer.exportMd': 'Eksportuj jako Markdown',
'fileViewer.exportImage': 'Eksportuj jako obraz',
'fileViewer.exportImageFailed': 'Przechwytywanie obrazu nie powiodło się. Spróbuj ponownie lub użyj narzędzia do zrzutów ekranu w przeglądarce.',
'fileViewer.exportJsx': 'Eksportuj jako JSX',
'fileViewer.exportReactHtml': 'Eksportuj podgląd jako HTML',
'fileViewer.saveAsTemplate': 'Zapisz jako szablon…',

View file

@ -867,6 +867,8 @@ export const ptBR: Dict = {
'fileViewer.exportZip': 'Baixar como .zip',
'fileViewer.exportHtml': 'Exportar como HTML independente',
'fileViewer.exportMd': 'Exportar como Markdown',
'fileViewer.exportImage': 'Exportar como imagem',
'fileViewer.exportImageFailed': 'Falha ao capturar a imagem. Tente novamente ou use a ferramenta de captura de tela do seu navegador.',
'fileViewer.exportJsx': 'Exportar como JSX',
'fileViewer.exportReactHtml': 'Exportar prévia como HTML',
'fileViewer.saveAsTemplate': 'Salvar como template…',

View file

@ -867,6 +867,8 @@ export const ru: Dict = {
'fileViewer.exportZip': 'Скачать как .zip',
'fileViewer.exportHtml': 'Экспорт как HTML',
'fileViewer.exportMd': 'Экспорт в Markdown',
'fileViewer.exportImage': 'Экспорт как изображение',
'fileViewer.exportImageFailed': 'Не удалось сделать снимок. Попробуйте ещё раз или воспользуйтесь инструментом скриншотов вашего браузера.',
'fileViewer.exportJsx': 'Экспорт как JSX',
'fileViewer.exportReactHtml': 'Экспорт предпросмотра как HTML',
'fileViewer.saveAsTemplate': 'Сохранить как шаблон…',

View file

@ -805,6 +805,8 @@ export const th: Dict = {
'fileViewer.exportZip': 'สูบทั้งหมดมาในรูป .zip',
'fileViewer.exportHtml': 'เอาไปแค่รูปไฟล์ HTML',
'fileViewer.exportMd': 'แปลงข้อความแบบฉบับเป็น Markdown',
'fileViewer.exportImage': 'ส่งออกเป็นรูปภาพ',
'fileViewer.exportImageFailed': 'การจับภาพล้มเหลว กรุณาลองอีกครั้งหรือใช้เครื่องมือจับภาพหน้าจอของเบราว์เซอร์',
'fileViewer.exportJsx': 'นำโค้ดในรูปแบบ React JSX ออก',
'fileViewer.exportReactHtml': 'แยกโหลดมาแค่โครง HTML เท่านั้น',
'fileViewer.saveAsTemplate': 'จัดเก็บในหมวดเทมเพลต…',

View file

@ -831,6 +831,8 @@ export const tr: Dict = {
'fileViewer.exportZip': 'ZIP olarak indir',
'fileViewer.exportHtml': 'Tekil HTML olarak dışa aktar',
'fileViewer.exportMd': 'Markdown olarak dışa aktar',
'fileViewer.exportImage': 'Görsel olarak dışa aktar',
'fileViewer.exportImageFailed': 'Görsel yakalama başarısız oldu. Lütfen tekrar deneyin veya tarayıcınızın ekran görüntüsü aracını kullanın.',
'fileViewer.exportJsx': 'JSX olarak dışa aktar',
'fileViewer.exportReactHtml': 'Önizlemeyi HTML olarak dışa aktar',
'fileViewer.saveAsTemplate': 'Şablon olarak kaydet…',

View file

@ -886,6 +886,8 @@ export const uk: Dict = {
'fileViewer.exportZip': 'Завантажити як .zip',
'fileViewer.exportHtml': 'Експортувати як самостійний HTML',
'fileViewer.exportMd': 'Експортувати як Markdown',
'fileViewer.exportImage': 'Експортувати як зображення',
'fileViewer.exportImageFailed': 'Не вдалося захопити зображення. Спробуйте ще раз або скористайтеся інструментом знімків екрана вашого браузера.',
'fileViewer.exportJsx': 'Експортувати як JSX',
'fileViewer.exportReactHtml': 'Експортувати попередній перегляд як HTML',
'fileViewer.saveAsTemplate': 'Зберегти як шаблон…',

View file

@ -927,6 +927,8 @@ export const zhCN: Dict = {
'fileViewer.exportZip': '下载为 .zip',
'fileViewer.exportHtml': '导出为独立 HTML',
'fileViewer.exportMd': '导出为 Markdown',
'fileViewer.exportImage': '导出为图片',
'fileViewer.exportImageFailed': '图片捕获失败,请重试或使用浏览器的截图工具。',
'fileViewer.exportJsx': '导出为 JSX',
'fileViewer.exportReactHtml': '导出预览 HTML',
'fileViewer.saveAsTemplate': '保存为模板…',

View file

@ -918,6 +918,8 @@ export const zhTW: Dict = {
'fileViewer.exportZip': '下載為 .zip',
'fileViewer.exportHtml': '匯出為獨立 HTML',
'fileViewer.exportMd': '匯出為 Markdown',
'fileViewer.exportImage': '匯出為圖片',
'fileViewer.exportImageFailed': '圖片擷取失敗,請重試或使用瀏覽器的截圖工具。',
'fileViewer.exportJsx': '匯出為 JSX',
'fileViewer.exportReactHtml': '匯出預覽 HTML',
'fileViewer.saveAsTemplate': '儲存為範本…',

View file

@ -1197,6 +1197,8 @@ export interface Dict {
'fileViewer.exportZip': string;
'fileViewer.exportHtml': string;
'fileViewer.exportMd': string;
'fileViewer.exportImage': string;
'fileViewer.exportImageFailed': string;
'fileViewer.exportJsx': string;
'fileViewer.exportReactHtml': string;
'fileViewer.saveAsTemplate': string;

View file

@ -317,6 +317,82 @@ export function exportAsMd(source: string, title: string): void {
triggerDownload(blob, `${safeFilename(title, 'artifact')}.md`);
}
// ---------------------------------------------------------------------------
// Image screenshot export
// ---------------------------------------------------------------------------
/**
* Request a PNG screenshot of the current viewport from the snapshot bridge
* injected into a srcdoc preview iframe. Returns null if the bridge is not
* present (e.g. URL-load mode) or the capture times out.
*/
export function requestPreviewSnapshot(
iframe: HTMLIFrameElement,
timeout = 2500,
): Promise<{ dataUrl: string; w: number; h: number } | null> {
const win = iframe.contentWindow;
if (!win) return Promise.resolve(null);
const id = `snap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return new Promise((resolve) => {
let done = false;
function onMsg(ev: MessageEvent) {
if (ev.source !== win) return;
const d = ev.data as {
type?: string;
id?: string;
dataUrl?: string;
w?: number;
h?: number;
error?: string;
} | null;
if (!d || d.type !== 'od:snapshot:result' || d.id !== id) return;
if (done) return;
done = true;
window.removeEventListener('message', onMsg);
if (d.dataUrl && d.w && d.h) resolve({ dataUrl: d.dataUrl, w: d.w, h: d.h });
else resolve(null);
}
window.addEventListener('message', onMsg);
try {
win.postMessage({ type: 'od:snapshot', id }, '*');
} catch {
/* sandboxed */
}
setTimeout(() => {
if (!done) {
done = true;
window.removeEventListener('message', onMsg);
resolve(null);
}
}, timeout);
});
}
/** Convert a data-URL to a Blob without re-encoding through canvas. */
function dataUrlToBlob(dataUrl: string): Blob {
if (!dataUrl.startsWith('data:')) {
throw new Error('Invalid data URL');
}
const [header, base64] = dataUrl.split(',');
const mime = header?.match(/:(.*?);/)?.[1] ?? 'image/png';
const bytes = atob(base64 ?? '');
const arr = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
return new Blob([arr], { type: mime });
}
/** Download a snapshot data-URL as a PNG file. */
export function exportAsImage(dataUrl: string, title: string): void {
try {
const blob = dataUrlToBlob(dataUrl);
triggerDownload(blob, `${safeFilename(title, 'artifact')}.png`);
} catch (err) {
console.warn('[exportAsImage] failed to convert snapshot:', err);
// Re-throw the error to allow the caller to handle UI feedback
throw err;
}
}
export type ProjectPdfExportResult = 'desktop' | 'fallback';
export async function exportProjectAsPdf(opts: {

View file

@ -5,10 +5,12 @@ import {
buildDesignHandoffContent,
buildDesignManifestContent,
buildSandboxedPreviewDocument,
exportAsImage,
exportAsMd,
exportAsPdf,
exportProjectAsPdf,
openSandboxedPreviewInNewTab,
requestPreviewSnapshot,
} from '../../src/runtime/exports';
function mockResponse(headers: Record<string, string>): Response {
@ -524,3 +526,170 @@ describe('sandboxed preview Blob exports', () => {
expect(htmlArg).not.toContain('window.print()');
});
});
// ---------------------------------------------------------------------------
// Image screenshot export
// ---------------------------------------------------------------------------
describe('requestPreviewSnapshot', () => {
let listeners: Map<string, Set<(ev: unknown) => void>>;
beforeEach(() => {
listeners = new Map();
vi.stubGlobal('window', {
addEventListener: (type: string, fn: (ev: unknown) => void) => {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type)!.add(fn);
},
removeEventListener: (type: string, fn: (ev: unknown) => void) => {
listeners.get(type)?.delete(fn);
},
dispatchEvent: (ev: { type: string }) => {
for (const fn of listeners.get(ev.type) ?? []) fn(ev);
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('returns null when the iframe has no contentWindow', async () => {
const iframe = { contentWindow: null } as unknown as HTMLIFrameElement;
const result = await requestPreviewSnapshot(iframe);
expect(result).toBeNull();
});
it('resolves with snapshot data when the bridge responds', async () => {
const postMessageMock = vi.fn();
const contentWindow = { postMessage: postMessageMock };
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
const promise = requestPreviewSnapshot(iframe);
expect(postMessageMock).toHaveBeenCalledOnce();
const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };
// Simulate the bridge responding — source must match iframe.contentWindow
window.dispatchEvent(
{ type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id, dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
);
const result = await promise;
expect(result).toEqual({ dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 });
});
it('resolves null when the bridge responds with an error', async () => {
const postMessageMock = vi.fn();
const contentWindow = { postMessage: postMessageMock };
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
const promise = requestPreviewSnapshot(iframe);
const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };
window.dispatchEvent(
{ type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id, error: 'snapshot image failed' } } as unknown as Event,
);
const result = await promise;
expect(result).toBeNull();
});
it('resolves null on timeout', async () => {
vi.useFakeTimers();
const iframe = {
contentWindow: { postMessage: vi.fn() },
} as unknown as HTMLIFrameElement;
const promise = requestPreviewSnapshot(iframe, 100);
vi.advanceTimersByTime(150);
const result = await promise;
expect(result).toBeNull();
vi.useRealTimers();
});
it('ignores messages with a mismatched id', async () => {
vi.useFakeTimers();
const postMessageMock = vi.fn();
const contentWindow = { postMessage: postMessageMock };
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
const promise = requestPreviewSnapshot(iframe, 100);
// Correct source but wrong id — should be ignored
window.dispatchEvent(
{ type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id: 'wrong-id', dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
);
vi.advanceTimersByTime(150);
const result = await promise;
expect(result).toBeNull();
vi.useRealTimers();
});
it('ignores messages from a different source window', async () => {
vi.useFakeTimers();
const postMessageMock = vi.fn();
const contentWindow = { postMessage: postMessageMock };
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
const promise = requestPreviewSnapshot(iframe, 100);
const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };
// Correct id but wrong source — should be ignored
window.dispatchEvent(
{ type: 'message', source: { other: true }, data: { type: 'od:snapshot:result', id, dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
);
vi.advanceTimersByTime(150);
const result = await promise;
expect(result).toBeNull();
vi.useRealTimers();
});
});
describe('exportAsImage', () => {
let clickMock: ReturnType<typeof vi.fn>;
let anchors: Array<{ href: string; download: string; click: ReturnType<typeof vi.fn> }>;
beforeEach(() => {
clickMock = vi.fn();
anchors = [];
vi.stubGlobal('URL', { createObjectURL: () => 'blob:mock-url', revokeObjectURL: vi.fn() });
vi.stubGlobal('document', {
createElement: () => {
const el = { href: '', download: '', click: clickMock };
anchors.push(el);
return el;
},
body: {
appendChild: vi.fn(),
removeChild: vi.fn(),
},
});
// triggerDownload calls setTimeout for deferred revoke
vi.stubGlobal('setTimeout', (fn: () => void) => fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('triggers a download with a .png filename', () => {
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
exportAsImage(dataUrl, 'My Design');
expect(clickMock).toHaveBeenCalledOnce();
expect(anchors).toHaveLength(1);
expect(anchors[0]!.download).toBe('My-Design.png');
});
it('sanitizes the title into a safe filename', () => {
exportAsImage('data:image/png;base64,AA==', 'Hello <World> / Test!');
expect(anchors[0]!.download).toBe('Hello-World-Test.png');
});
});