mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
ff569fa50c
commit
852a005b32
23 changed files with 316 additions and 19 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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': 'حفظ كقالب...',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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': 'ذخیره به عنوان قالب…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'テンプレートとして保存…',
|
||||
|
|
|
|||
|
|
@ -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': '템플릿으로 저장…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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': 'Сохранить как шаблон…',
|
||||
|
|
|
|||
|
|
@ -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': 'จัดเก็บในหมวดเทมเพลต…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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': 'Зберегти як шаблон…',
|
||||
|
|
|
|||
|
|
@ -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': '保存为模板…',
|
||||
|
|
|
|||
|
|
@ -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': '儲存為範本…',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue