fix(web): harden image export downloads (#3318)

* feat(web): export preview as image

* fix(web): harden image export downloads

* docs(skills): add PR feedback quality gate

* docs(skills): require critical review of Claude feedback

---------

Co-authored-by: 116405 <116405@ky-tech.com.cn>
This commit is contained in:
RyanCheng77 2026-05-30 12:44:00 +08:00 committed by GitHub
parent 9305bd1cff
commit 653a3fcc70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1353 additions and 49 deletions

View file

@ -402,13 +402,13 @@ export async function pickAndImportFolder(
});
async function postOnce(): Promise<Response | { ok: false; reason: string }> {
const token = mint(deps.desktopAuthSecret, deps.baseDir);
const headerValue = mint(deps.desktopAuthSecret, deps.baseDir);
try {
return await fetchImpl(importUrl, {
body: requestBody,
headers: {
"Content-Type": "application/json",
[DESKTOP_IMPORT_TOKEN_HEADER]: token,
[DESKTOP_IMPORT_TOKEN_HEADER]: headerValue,
},
method: "POST",
});
@ -501,13 +501,13 @@ export async function pickAndReplaceWorkingDir(
const requestBody = JSON.stringify({ baseDir: deps.baseDir });
async function postOnce(): Promise<Response | { ok: false; reason: string }> {
const token = mint(deps.desktopAuthSecret, deps.baseDir);
const headerValue = mint(deps.desktopAuthSecret, deps.baseDir);
try {
return await fetchImpl(workingDirUrl, {
body: requestBody,
headers: {
"Content-Type": "application/json",
[DESKTOP_IMPORT_TOKEN_HEADER]: token,
[DESKTOP_IMPORT_TOKEN_HEADER]: headerValue,
},
method: "POST",
});
@ -937,12 +937,13 @@ export function hideWindowExitingFullscreen(window: WindowFullscreenSurface): vo
window.hide();
}
// PPTX is rendered by the agent into the project folder and reaches the
// renderer through a normal `<a download>` link to /api/projects/:id/raw/*.
// Without this hook Electron writes the bytes straight to the OS Downloads
// folder, so the user never gets to pick a destination. setSaveDialogOptions
// makes Electron show the native Save As panel before the download starts.
const SAVE_AS_EXTENSIONS = new Set([".pptx"]);
// Some exports reach the renderer through a normal `<a download>` link
// (server-written PPTX, browser-generated image blobs). Without this hook
// Electron writes the bytes straight to the OS Downloads folder, so the user
// never gets to pick a destination. setSaveDialogOptions makes Electron show
// the native Save As panel before the download starts.
const IMAGE_SAVE_AS_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
const SAVE_AS_EXTENSIONS = new Set([".pptx", ...IMAGE_SAVE_AS_EXTENSIONS]);
function attachDownloadSaveAsDialog(window: BrowserWindow): void {
window.webContents.session.on("will-download", (_event, item) => {
@ -953,10 +954,15 @@ function attachDownloadSaveAsDialog(window: BrowserWindow): void {
item.setSaveDialogOptions({
title: "Save As",
defaultPath: filename,
filters: [
{ name: "PowerPoint Presentation", extensions: ["pptx"] },
{ name: "All Files", extensions: ["*"] },
],
filters: IMAGE_SAVE_AS_EXTENSIONS.has(ext)
? [
{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp"] },
{ name: "All Files", extensions: ["*"] },
]
: [
{ name: "PowerPoint Presentation", extensions: ["pptx"] },
{ name: "All Files", extensions: ["*"] },
],
});
});
}

View file

@ -53,8 +53,8 @@ import {
} from '../providers/registry';
import type { ProjectFilePreview } from '../providers/registry';
import {
downloadImageDataUrl,
exportAsHtml,
exportAsImage,
exportAsJsx,
exportAsMd,
exportAsPdf,
@ -62,8 +62,11 @@ import {
exportProjectAsZip,
exportReactComponentAsHtml,
exportReactComponentAsZip,
imageDataUrlToBlob,
openSandboxedPreviewInNewTab,
prepareImageExportTarget,
requestPreviewSnapshot,
type ImageExportFormat,
} from '../runtime/exports';
import { buildReactComponentSrcdoc } from '../runtime/react-component';
import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs';
@ -147,6 +150,15 @@ type PreviewViewportPreset = {
labelKey: keyof Dict;
titleKey: keyof Dict;
};
const IMAGE_EXPORT_FORMAT_OPTIONS: Array<{
value: ImageExportFormat;
label: string;
extension: string;
}> = [
{ value: 'png', label: 'PNG', extension: '.png' },
{ value: 'jpeg', label: 'JPEG', extension: '.jpg' },
{ value: 'webp', label: 'WebP', extension: '.webp' },
];
type DeployProviderOption = {
id: WebDeployProviderId;
labelKey: 'fileViewer.vercelProvider' | 'fileViewer.cloudflarePagesProvider';
@ -663,6 +675,21 @@ function setSlideStateCached(key: string, state: SlideState) {
}
}
function waitForIframeLoadOrTimeout(iframe: HTMLIFrameElement, timeout = 750): Promise<void> {
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
iframe.removeEventListener('load', finish);
window.clearTimeout(timer);
resolve();
};
const timer = window.setTimeout(finish, timeout);
iframe.addEventListener('load', finish, { once: true });
});
}
function previewViewportStateKey(projectId: string, file: Pick<ProjectFile, 'name' | 'path'>): string {
return `${projectId}:${file.path || file.name}`;
}
@ -4226,6 +4253,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const sourceFileKeyRef = useRef<string | null>(null);
const templateNameId = useId();
const templateDescriptionId = useId();
const imageExportTitleId = useId();
// Opt back into the legacy inline-asset srcDoc path via `?forceInline=1`
// on the host page. Lets users escape-hatch around the URL-load default
// for non-deck HTML that depends on the in-iframe localStorage shim.
@ -4266,6 +4294,15 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const [commentSavedToast, setCommentSavedToast] = useState<string | null>(null);
const [templateSavedToast, setTemplateSavedToast] = useState<string | null>(null);
const [deploySavedToast, setDeploySavedToast] = useState<{ message: string; details: string } | null>(null);
const [imageExportModalOpen, setImageExportModalOpen] = useState(false);
const [imageExportFormat, setImageExportFormat] = useState<ImageExportFormat>('png');
const [imageExportBusy, setImageExportBusy] = useState(false);
const [imageExportPreparing, setImageExportPreparing] = useState(false);
const [imageExportError, setImageExportError] = useState<string | null>(null);
const [imageExportSavedToast, setImageExportSavedToast] = useState<{ message: string; details: string } | null>(null);
const [imageExportPreparedBlob, setImageExportPreparedBlob] = useState<{ format: ImageExportFormat; blob: Blob } | null>(null);
const imageExportSnapshotDataUrlRef = useRef<string | null>(null);
const imageExportPrepareIdRef = useRef(0);
const [exportToast, setExportToast] = useState<string | null>(null);
const [selectedSideCommentIds, setSelectedSideCommentIds] = useState<Set<string>>(() => new Set());
const [commentSidePanelCollapsed, setCommentSidePanelCollapsed] = useState(false);
@ -4655,6 +4692,13 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
activatedSrcDocTransportHtmlRef.current = srcDoc;
return true;
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview]);
const activateSrcDocSnapshotTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => {
if (!srcDoc) return false;
const win = target?.contentWindow;
if (!win) return false;
win.postMessage({ type: 'od:srcdoc-transport-activate', html: srcDoc }, '*');
return true;
}, [srcDoc]);
useEffect(() => {
if (useUrlLoadPreview) {
activatedSrcDocTransportHtmlRef.current = null;
@ -6217,6 +6261,110 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
markExportReadyNudgeSeen(projectId, file.name);
setShareMenuOpen((v) => !v);
};
const captureExportImageSnapshot = useCallback(async () => {
if (!useUrlLoadPreview) {
const activeIframe = iframeRef.current;
return activeIframe ? requestPreviewSnapshot(activeIframe) : null;
}
const srcDocIframe = srcDocPreviewIframeRef.current;
if (!srcDocIframe) {
const activeIframe = iframeRef.current;
return activeIframe ? requestPreviewSnapshot(activeIframe) : null;
}
if (!srcDocShellReady) {
await waitForIframeLoadOrTimeout(srcDocIframe, 500);
}
const activated = activateSrcDocSnapshotTransport(srcDocIframe);
if (activated) {
await waitForIframeLoadOrTimeout(srcDocIframe);
}
return requestPreviewSnapshot(srcDocIframe);
}, [
activateSrcDocSnapshotTransport,
srcDocShellReady,
useUrlLoadPreview,
]);
const prepareImageExportBlob = useCallback(async (format: ImageExportFormat) => {
const prepareId = imageExportPrepareIdRef.current + 1;
imageExportPrepareIdRef.current = prepareId;
setImageExportPreparing(true);
setImageExportError(null);
setImageExportPreparedBlob(null);
try {
let dataUrl = imageExportSnapshotDataUrlRef.current;
if (!dataUrl) {
const snap = await captureExportImageSnapshot();
if (!snap) throw new Error('Snapshot capture returned null');
dataUrl = snap.dataUrl;
imageExportSnapshotDataUrlRef.current = dataUrl;
}
const blob = await imageDataUrlToBlob(dataUrl, format);
if (blob.size <= 0) throw new Error('Snapshot capture produced an empty image');
if (imageExportPrepareIdRef.current === prepareId) {
setImageExportPreparedBlob({ format, blob });
}
} catch (err) {
console.warn('[exportAsImage] failed to prepare snapshot:', err);
if (imageExportPrepareIdRef.current === prepareId) {
setImageExportError(t('fileViewer.exportImageFailed'));
}
} finally {
if (imageExportPrepareIdRef.current === prepareId) {
setImageExportPreparing(false);
}
}
}, [captureExportImageSnapshot, t]);
const openImageExportModal = () => {
setShareMenuOpen(false);
setImageExportError(null);
setImageExportPreparedBlob(null);
imageExportSnapshotDataUrlRef.current = null;
setImageExportModalOpen(true);
void prepareImageExportBlob(imageExportFormat);
};
const changeImageExportFormat = (format: ImageExportFormat) => {
setImageExportFormat(format);
void prepareImageExportBlob(format);
};
async function handleImageExportSave() {
const prepared = imageExportPreparedBlob;
if (!prepared || prepared.format !== imageExportFormat) {
setImageExportError(t('fileViewer.exportImageFailed'));
return;
}
setImageExportBusy(true);
setImageExportError(null);
try {
const target = await prepareImageExportTarget(exportTitle, imageExportFormat, { useNativePicker: false });
if (!target) return;
const preparedDataUrl = imageExportSnapshotDataUrlRef.current;
if (target.method === 'download' && imageExportFormat === 'png' && preparedDataUrl) {
downloadImageDataUrl(preparedDataUrl, target.filename);
} else {
await target.save(prepared.blob);
}
setImageExportModalOpen(false);
setImageExportSavedToast({
message: target.method === 'picker'
? t('fileViewer.exportImageSaved')
: t('fileViewer.exportImageDownloadStarted'),
details: target.method === 'picker'
? target.filename
: t('fileViewer.exportImageDownloadDetails', { filename: target.filename }),
});
} catch (err) {
console.warn('[exportAsImage] failed to save snapshot:', err);
setImageExportError(t('fileViewer.exportImageFailed'));
} finally {
setImageExportBusy(false);
}
}
const visibleSideComments = useMemo(
() => previewComments
.filter((comment) => comment.filePath === file.name && comment.status === 'open')
@ -6861,33 +7009,15 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
<span className="share-menu-icon"><RemixIcon name="file-ppt-line" size={15} /></span>
<span>{t('fileViewer.exportPptx') + '…'}</span>
</button>
{!useUrlLoadPreview ? (
<button
type="button"
className="share-menu-item share-menu-subitem"
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"><RemixIcon name="image-line" size={15} /></span>
<span>{t('fileViewer.exportImage')}</span>
</button>
) : null}
<button
type="button"
className="share-menu-item share-menu-subitem"
role="menuitem"
onClick={openImageExportModal}
>
<span className="share-menu-icon"><RemixIcon name="image-line" size={15} /></span>
<span>{t('fileViewer.exportImage')}</span>
</button>
<div className="share-menu-subsection-label" role="presentation">
{t('fileViewer.shareMenuSourceFiles')}
</div>
@ -7302,6 +7432,74 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
)}
</div>
) : null}
{imageExportModalOpen ? (
<div className="modal-backdrop" role="presentation">
<div
className="modal deploy-modal image-export-modal"
role="dialog"
aria-modal="true"
aria-labelledby={imageExportTitleId}
>
<div className="modal-head">
<div className="kicker">IMAGE</div>
<h2 id={imageExportTitleId}>{t('fileViewer.exportImage')}</h2>
<p className="subtitle">{t('fileViewer.exportImageModalSubtitle')}</p>
</div>
<div className="deploy-form image-export-form">
<fieldset className="image-export-format-field" disabled={imageExportBusy}>
<legend>{t('fileViewer.exportImageFormatLabel')}</legend>
<div className="image-export-format-options">
{IMAGE_EXPORT_FORMAT_OPTIONS.map((option) => (
<label
key={option.value}
className={`image-export-format-option${imageExportFormat === option.value ? ' active' : ''}`}
>
<input
type="radio"
name="image-export-format"
value={option.value}
aria-label={option.label}
checked={imageExportFormat === option.value}
onChange={() => changeImageExportFormat(option.value)}
/>
<span className="image-export-format-text">
<strong>{option.label}</strong>
<span aria-hidden="true">{option.extension}</span>
</span>
</label>
))}
</div>
</fieldset>
{imageExportError ? (
<p className="deploy-error" role="alert">{imageExportError}</p>
) : null}
</div>
<div className="modal-foot">
<button
type="button"
className="ghost-link button-like"
disabled={imageExportBusy}
onClick={() => {
setImageExportModalOpen(false);
setImageExportError(null);
}}
>
{t('common.cancel')}
</button>
<button
type="button"
className="viewer-action primary"
disabled={imageExportBusy || imageExportPreparing || !imageExportPreparedBlob}
onClick={() => {
void handleImageExportSave();
}}
>
{imageExportBusy || imageExportPreparing ? t('fileViewer.exportImageSaving') : t('common.save')}
</button>
</div>
</div>
</div>
) : null}
{templateModalOpen ? (
<div className="modal-backdrop" role="presentation">
<div className="modal deploy-modal" role="dialog" aria-modal="true">
@ -7631,6 +7829,16 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
onDismiss={() => setDeploySavedToast(null)}
/>
) : null}
{imageExportSavedToast ? (
<Toast
message={imageExportSavedToast.message}
details={imageExportSavedToast.details}
tone="success"
placement="top"
ttlMs={3600}
onDismiss={() => setImageExportSavedToast(null)}
/>
) : null}
</div>
);
}

View file

@ -1,7 +1,14 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useT } from '../i18n';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import { exportAsHtml, exportAsPdf, exportAsZip, openSandboxedPreviewInNewTab } from '../runtime/exports';
import {
exportAsHtml,
exportAsImage,
exportAsPdf,
exportAsZip,
openSandboxedPreviewInNewTab,
requestPreviewSnapshot,
} from '../runtime/exports';
import { buildSrcdoc } from '../runtime/srcdoc';
import { Icon } from './Icon';
@ -197,10 +204,10 @@ interface Props {
onShareClick?: () => void;
onSidebarToggleClick?: (open: boolean) => void;
// Fires when the user picks a share-menu item ("pdf" / "zip" / "html"
// / "open_in_new_tab"). Used by callers that want to track popover-
// / "image" / "open_in_new_tab"). Used by callers that want to track popover-
// level clicks separately from the share trigger.
onSharePopoverItemClick?: (
item: 'pdf' | 'zip' | 'html' | 'open_in_new_tab',
item: 'pdf' | 'zip' | 'html' | 'image' | 'open_in_new_tab',
) => void;
}
@ -243,6 +250,7 @@ export function PreviewModal({
const templateShareRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const stageFrameRef = useRef<HTMLDivElement | null>(null);
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
const [stageSize, setStageSize] = useState<{ w: number; h: number }>({
w: 0,
h: 0,
@ -736,6 +744,34 @@ export function PreviewModal({
</span>
<span>{t('common.exportHtml')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={async () => {
onSharePopoverItemClick?.('image');
setTemplateShareOpen(false);
const iframe = previewIframeRef.current;
if (!iframe) return;
const snap = await requestPreviewSnapshot(iframe);
try {
if (snap) {
exportAsImage(snap.dataUrl, exportTitle);
} else {
console.warn('[PreviewModal] snapshot capture returned null');
alert(t('common.exportImageFailed'));
}
} catch (err) {
console.warn('[PreviewModal] failed to convert snapshot:', err);
alert(t('common.exportImageFailed'));
}
}}
>
<span className="share-menu-icon">
<Icon name="image" size={14} />
</span>
<span>{t('common.exportImage')}</span>
</button>
<button
type="button"
className="share-menu-item"
@ -848,6 +884,7 @@ export function PreviewModal({
<div className="ds-modal-stage-iframe-scaler" style={scalerStyle}>
<iframe
key={activeView?.id ?? 'view'}
ref={previewIframeRef}
title={`${title} ${activeView?.label ?? ''}`}
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
srcDoc={srcDoc}

View file

@ -79,6 +79,8 @@ export const ar: Dict = {
'common.exportPdf': 'تصدير كـ PDF',
'common.exportZip': 'تحميل كـ zip.',
'common.exportHtml': 'تصدير كـ HTML مستقل',
'common.exportImage': 'تصدير كصورة',
'common.exportImageFailed': 'فشل التقاط الصورة. يرجى المحاولة مرة أخرى أو استخدام أداة لقطة الشاشة في المتصفح.',
'common.justNow': 'الآن',
'common.minutesAgo': 'منذ {n} دقيقة',
'common.hoursAgo': 'منذ {n} ساعة',
@ -1172,6 +1174,12 @@ export const ar: Dict = {
'fileViewer.exportMd': 'تصدير كـ Markdown',
'fileViewer.exportImage': 'تصدير كصورة',
'fileViewer.exportImageFailed': 'فشل التقاط الصورة. يرجى المحاولة مرة أخرى أو استخدام أداة لقطة الشاشة في المتصفح.',
'fileViewer.exportImageModalSubtitle': 'اختر تنسيقًا، ثم نزّل المعاينة الحالية كصورة.',
'fileViewer.exportImageFormatLabel': 'التنسيق',
'fileViewer.exportImageSaving': 'جارٍ حفظ الصورة…',
'fileViewer.exportImageSaved': 'تم حفظ الصورة',
'fileViewer.exportImageDownloadStarted': 'بدأ التنزيل',
'fileViewer.exportImageDownloadDetails': 'إذا لم تظهر نافذة حفظ باسم، فابحث عن {filename} في مجلد تنزيلات المتصفح.',
'fileViewer.exportJsx': 'تصدير كـ JSX',
'fileViewer.exportReactHtml': 'تصدير المعاينة كـ HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const de: Dict = {
'common.exportPdf': 'Als PDF exportieren',
'common.exportZip': 'Als .zip herunterladen',
'common.exportHtml': 'Als eigenständiges HTML exportieren',
'common.exportImage': 'Als Bild exportieren',
'common.exportImageFailed': 'Bildaufnahme fehlgeschlagen. Bitte versuchen Sie es erneut oder verwenden Sie das Screenshot-Tool Ihres Browsers.',
'common.justNow': 'gerade eben',
'common.minutesAgo': 'vor {n} Min.',
'common.hoursAgo': 'vor {n} Std.',
@ -1060,6 +1062,12 @@ export const de: Dict = {
'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.exportImageModalSubtitle': 'Wählen Sie ein Format und laden Sie dann die aktuelle Vorschau als Bild herunter.',
'fileViewer.exportImageFormatLabel': 'Format',
'fileViewer.exportImageSaving': 'Bild wird gespeichert…',
'fileViewer.exportImageSaved': 'Bild gespeichert',
'fileViewer.exportImageDownloadStarted': 'Download gestartet',
'fileViewer.exportImageDownloadDetails': '{filename} befindet sich in den Browser-Downloads, falls kein Speichern-unter-Dialog erschienen ist.',
'fileViewer.exportJsx': 'Als JSX exportieren',
'fileViewer.exportReactHtml': 'Vorschau als HTML exportieren',
'fileViewer.exportStarted': 'Export started',

View file

@ -40,6 +40,8 @@ export const en: Dict = {
'common.exportPdf': 'Export as PDF',
'common.exportZip': 'Download as .zip',
'common.exportHtml': 'Export as standalone HTML',
'common.exportImage': 'Export as image',
'common.exportImageFailed': "Image capture failed. Please try again or use your browser's screenshot tool.",
'common.justNow': 'just now',
'common.minutesAgo': '{n}m ago',
'common.hoursAgo': '{n}h ago',
@ -1807,6 +1809,12 @@ export const en: Dict = {
'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.exportImageModalSubtitle': 'Choose a format, then download the current preview as an image.',
'fileViewer.exportImageFormatLabel': 'Format',
'fileViewer.exportImageSaving': 'Saving image…',
'fileViewer.exportImageSaved': 'Image saved',
'fileViewer.exportImageDownloadStarted': 'Download started',
'fileViewer.exportImageDownloadDetails': '{filename} is in your browser downloads if no Save As dialog appeared.',
'fileViewer.exportJsx': 'Export as JSX',
'fileViewer.exportReactHtml': 'Export preview as HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const esES: Dict = {
'common.exportPdf': 'Exportar como PDF',
'common.exportZip': 'Descargar como .zip',
'common.exportHtml': 'Exportar como HTML independiente',
'common.exportImage': 'Exportar como imagen',
'common.exportImageFailed': 'Error al capturar la imagen. Inténtalo de nuevo o usa la herramienta de captura de pantalla de tu navegador.',
'common.justNow': 'justo ahora',
'common.minutesAgo': 'hace {n} min',
'common.hoursAgo': 'hace {n} h',
@ -1061,6 +1063,12 @@ export const esES: Dict = {
'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.exportImageModalSubtitle': 'Elige un formato y descarga la vista previa actual como imagen.',
'fileViewer.exportImageFormatLabel': 'Formato',
'fileViewer.exportImageSaving': 'Guardando imagen…',
'fileViewer.exportImageSaved': 'Imagen guardada',
'fileViewer.exportImageDownloadStarted': 'Descarga iniciada',
'fileViewer.exportImageDownloadDetails': '{filename} está en las descargas del navegador si no apareció un cuadro de diálogo Guardar como.',
'fileViewer.exportJsx': 'Exportar como JSX',
'fileViewer.exportReactHtml': 'Exportar vista previa como HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const fa: Dict = {
'common.exportPdf': 'صادرکردن به PDF',
'common.exportZip': 'دانلود به صورت .zip',
'common.exportHtml': 'صادرکردن به HTML مستقل',
'common.exportImage': 'صادرکردن به صورت تصویر',
'common.exportImageFailed': 'عکس‌برداری ناموفق بود. لطفاً دوباره تلاش کنید یا از ابزار عکس‌برداری مرورگر خود استفاده کنید.',
'common.justNow': 'همین الان',
'common.minutesAgo': '{n} دقیقه پیش',
'common.hoursAgo': '{n} ساعت پیش',
@ -1196,6 +1198,12 @@ export const fa: Dict = {
'fileViewer.exportMd': 'صادرکردن به صورت Markdown',
'fileViewer.exportImage': 'صادرکردن به صورت تصویر',
'fileViewer.exportImageFailed': 'گرفتن تصویر ناموفق بود. لطفاً دوباره تلاش کنید یا از ابزار اسکرین‌شات مرورگرتان استفاده کنید.',
'fileViewer.exportImageModalSubtitle': 'یک قالب انتخاب کنید، سپس پیش‌نمایش فعلی را به‌صورت تصویر دانلود کنید.',
'fileViewer.exportImageFormatLabel': 'قالب',
'fileViewer.exportImageSaving': 'در حال ذخیره تصویر…',
'fileViewer.exportImageSaved': 'تصویر ذخیره شد',
'fileViewer.exportImageDownloadStarted': 'دانلود شروع شد',
'fileViewer.exportImageDownloadDetails': 'اگر پنجره «ذخیره به‌عنوان» ظاهر نشد، {filename} را در پوشه دانلودهای مرورگر بررسی کنید.',
'fileViewer.exportJsx': 'صادرکردن به JSX',
'fileViewer.exportReactHtml': 'صادرکردن پیش‌نمایش به HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -49,6 +49,8 @@ export const fr: Dict = {
'common.exportPdf': 'Exporter en PDF',
'common.exportZip': 'Télécharger en .zip',
'common.exportHtml': 'Exporter en HTML autonome',
'common.exportImage': 'Exporter en image',
'common.exportImageFailed': 'Échec de la capture d\'image. Veuillez réessayer ou utiliser l\'outil de capture d\'écran de votre navigateur.',
'common.justNow': 'à l\'instant',
'common.minutesAgo': 'il y a {n} min',
'common.hoursAgo': 'il y a {n} h',
@ -1711,6 +1713,12 @@ export const fr: Dict = {
'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.exportImageModalSubtitle': 'Choisissez un format, puis téléchargez laperçu actuel en image.',
'fileViewer.exportImageFormatLabel': 'Format',
'fileViewer.exportImageSaving': 'Enregistrement de limage…',
'fileViewer.exportImageSaved': 'Image enregistrée',
'fileViewer.exportImageDownloadStarted': 'Téléchargement démarré',
'fileViewer.exportImageDownloadDetails': '{filename} se trouve dans les téléchargements du navigateur si aucune fenêtre Enregistrer sous nest apparue.',
'fileViewer.exportJsx': 'Exporter en JSX',
'fileViewer.exportReactHtml': 'Exporter l\'aperçu en HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const hu: Dict = {
'common.exportPdf': 'Exportálás PDF-ként',
'common.exportZip': 'Letöltés .zip-ként',
'common.exportHtml': 'Exportálás önálló HTML-ként',
'common.exportImage': 'Exportálás képként',
'common.exportImageFailed': 'A kép rögzítése sikertelen. Próbálja újra, vagy használja a böngésző képernyőkép eszközét.',
'common.justNow': 'az imént',
'common.minutesAgo': '{n} perce',
'common.hoursAgo': '{n} órája',
@ -1172,6 +1174,12 @@ export const hu: Dict = {
'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.exportImageModalSubtitle': 'Válasszon formátumot, majd töltse le az aktuális előnézetet képként.',
'fileViewer.exportImageFormatLabel': 'Formátum',
'fileViewer.exportImageSaving': 'Kép mentése…',
'fileViewer.exportImageSaved': 'Kép mentve',
'fileViewer.exportImageDownloadStarted': 'Letöltés elindítva',
'fileViewer.exportImageDownloadDetails': 'Ha nem jelent meg Mentés másként párbeszédablak, a {filename} a böngésző letöltései között található.',
'fileViewer.exportJsx': 'Exportálás JSX-ként',
'fileViewer.exportReactHtml': 'Előnézet exportálása HTML-ként',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const id: Dict = {
'common.exportPdf': 'Ekspor sebagai PDF',
'common.exportZip': 'Unduh sebagai .zip',
'common.exportHtml': 'Ekspor sebagai HTML mandiri',
'common.exportImage': 'Ekspor sebagai gambar',
'common.exportImageFailed': 'Gagal menangkap gambar. Silakan coba lagi atau gunakan alat tangkapan layar browser Anda.',
'common.justNow': 'baru saja',
'common.minutesAgo': '{n}m lalu',
'common.hoursAgo': '{n}j lalu',
@ -1289,6 +1291,12 @@ export const id: Dict = {
'fileViewer.exportMd': 'Ekspor Markdown',
'fileViewer.exportImage': 'Ekspor gambar',
'fileViewer.exportImageFailed': 'Gagal menangkap gambar. Silakan coba lagi atau gunakan alat tangkapan layar browser Anda.',
'fileViewer.exportImageModalSubtitle': 'Pilih format, lalu unduh pratinjau saat ini sebagai gambar.',
'fileViewer.exportImageFormatLabel': 'Format',
'fileViewer.exportImageSaving': 'Menyimpan gambar…',
'fileViewer.exportImageSaved': 'Gambar tersimpan',
'fileViewer.exportImageDownloadStarted': 'Unduhan dimulai',
'fileViewer.exportImageDownloadDetails': '{filename} ada di unduhan browser jika dialog Simpan Sebagai tidak muncul.',
'fileViewer.exportJsx': 'Ekspor JSX',
'fileViewer.exportReactHtml': 'Ekspor React HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const it: Dict = {
'common.exportPdf': 'Esporta in PDF',
'common.exportZip': 'Scarica come .zip',
'common.exportHtml': 'Esporta in HTML autonomo',
'common.exportImage': 'Esporta come immagine',
'common.exportImageFailed': 'Acquisizione immagine fallita. Riprova o usa lo strumento di screenshot del browser.',
'common.justNow': 'proprio ora',
'common.minutesAgo': '{n} min fa',
'common.hoursAgo': '{n} h fa',
@ -1078,6 +1080,14 @@ export const it: Dict = {
'fileViewer.exportZip': 'Scarica come .zip',
'fileViewer.exportHtml': 'Esporta in HTML autonomo',
'fileViewer.exportMd': 'Esporta in Markdown',
'fileViewer.exportImage': 'Esporta come immagine',
'fileViewer.exportImageFailed': 'Acquisizione immagine non riuscita. Riprova o usa lo strumento screenshot del browser.',
'fileViewer.exportImageModalSubtitle': 'Scegli un formato, poi scarica l\'anteprima corrente come immagine.',
'fileViewer.exportImageFormatLabel': 'Formato',
'fileViewer.exportImageSaving': 'Salvataggio immagine…',
'fileViewer.exportImageSaved': 'Immagine salvata',
'fileViewer.exportImageDownloadStarted': 'Download avviato',
'fileViewer.exportImageDownloadDetails': '{filename} si trova nei download del browser se non è apparsa la finestra Salva con nome.',
'fileViewer.exportJsx': 'Esporta in JSX',
'fileViewer.exportReactHtml': 'Esporta anteprima in HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const ja: Dict = {
'common.exportPdf': 'PDFとしてエクスポート',
'common.exportZip': '.zipとしてダウンロード',
'common.exportHtml': 'スタンドアロンHTMLとしてエクスポート',
'common.exportImage': '画像としてエクスポート',
'common.exportImageFailed': '画像のキャプチャに失敗しました。再試行するか、ブラウザのスクリーンショットツールを使用してください。',
'common.justNow': 'たった今',
'common.minutesAgo': '{n}分前',
'common.hoursAgo': '{n}時間前',
@ -1059,6 +1061,12 @@ export const ja: Dict = {
'fileViewer.exportMd': 'Markdown としてエクスポート',
'fileViewer.exportImage': '画像としてエクスポート',
'fileViewer.exportImageFailed': '画像のキャプチャに失敗しました。再試行するか、ブラウザのスクリーンショット機能をご利用ください。',
'fileViewer.exportImageModalSubtitle': '形式を選択して、現在のプレビューを画像としてダウンロードします。',
'fileViewer.exportImageFormatLabel': '形式',
'fileViewer.exportImageSaving': '画像を保存中…',
'fileViewer.exportImageSaved': '画像を保存しました',
'fileViewer.exportImageDownloadStarted': 'ダウンロードを開始しました',
'fileViewer.exportImageDownloadDetails': '保存ダイアログが表示されなかった場合は、ブラウザのダウンロードフォルダで {filename} を確認してください。',
'fileViewer.exportJsx': 'JSX としてエクスポート',
'fileViewer.exportReactHtml': 'プレビューを HTML としてエクスポート',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const ko: Dict = {
'common.exportPdf': 'PDF로 내보내기',
'common.exportZip': '.zip으로 다운로드',
'common.exportHtml': '독립 실행형 HTML로 내보내기',
'common.exportImage': '이미지로 내보내기',
'common.exportImageFailed': '이미지 캡처에 실패했습니다. 다시 시도하거나 브라우저의 스크린샷 도구를 사용하세요.',
'common.justNow': '방금 전',
'common.minutesAgo': '{n}분 전',
'common.hoursAgo': '{n}시간 전',
@ -1172,6 +1174,12 @@ export const ko: Dict = {
'fileViewer.exportMd': 'Markdown으로 내보내기',
'fileViewer.exportImage': '이미지로 내보내기',
'fileViewer.exportImageFailed': '이미지 캡처에 실패했습니다. 다시 시도하거나 브라우저의 스크린샷 도구를 사용하세요.',
'fileViewer.exportImageModalSubtitle': '형식을 선택한 다음 현재 미리보기를 이미지로 다운로드합니다.',
'fileViewer.exportImageFormatLabel': '형식',
'fileViewer.exportImageSaving': '이미지 저장 중…',
'fileViewer.exportImageSaved': '이미지가 저장되었습니다',
'fileViewer.exportImageDownloadStarted': '다운로드가 시작되었습니다',
'fileViewer.exportImageDownloadDetails': '다른 이름으로 저장 창이 나타나지 않았다면 브라우저 다운로드 폴더에서 {filename}을 확인하세요.',
'fileViewer.exportJsx': 'JSX로 내보내기',
'fileViewer.exportReactHtml': '미리보기를 HTML로 내보내기',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const pl: Dict = {
'common.exportPdf': 'Eksportuj jako PDF',
'common.exportZip': 'Pobierz jako .zip',
'common.exportHtml': 'Eksportuj jako samodzielny HTML',
'common.exportImage': 'Eksportuj jako obraz',
'common.exportImageFailed': 'Przechwytywanie obrazu nie powiodło się. Spróbuj ponownie lub użyj narzędzia do zrzutów ekranu przeglądarki.',
'common.justNow': 'przed chwilą',
'common.minutesAgo': '{n} min temu',
'common.hoursAgo': '{n} godz. temu',
@ -1172,6 +1174,12 @@ export const pl: Dict = {
'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.exportImageModalSubtitle': 'Wybierz format, a następnie pobierz bieżący podgląd jako obraz.',
'fileViewer.exportImageFormatLabel': 'Format',
'fileViewer.exportImageSaving': 'Zapisywanie obrazu…',
'fileViewer.exportImageSaved': 'Obraz zapisany',
'fileViewer.exportImageDownloadStarted': 'Pobieranie rozpoczęte',
'fileViewer.exportImageDownloadDetails': '{filename} znajduje się w pobranych plikach przeglądarki, jeśli nie pojawiło się okno Zapisz jako.',
'fileViewer.exportJsx': 'Eksportuj jako JSX',
'fileViewer.exportReactHtml': 'Eksportuj podgląd jako HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const ptBR: Dict = {
'common.exportPdf': 'Exportar como PDF',
'common.exportZip': 'Baixar como .zip',
'common.exportHtml': 'Exportar como HTML independente',
'common.exportImage': 'Exportar como imagem',
'common.exportImageFailed': 'Falha ao capturar imagem. Tente novamente ou use a ferramenta de captura de tela do seu navegador.',
'common.justNow': 'agora mesmo',
'common.minutesAgo': 'há {n} min',
'common.hoursAgo': 'há {n} h',
@ -1195,6 +1197,12 @@ export const ptBR: Dict = {
'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.exportImageModalSubtitle': 'Escolha um formato e baixe a prévia atual como imagem.',
'fileViewer.exportImageFormatLabel': 'Formato',
'fileViewer.exportImageSaving': 'Salvando imagem…',
'fileViewer.exportImageSaved': 'Imagem salva',
'fileViewer.exportImageDownloadStarted': 'Download iniciado',
'fileViewer.exportImageDownloadDetails': '{filename} está nos downloads do navegador se a janela Salvar como não apareceu.',
'fileViewer.exportJsx': 'Exportar como JSX',
'fileViewer.exportReactHtml': 'Exportar prévia como HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const ru: Dict = {
'common.exportPdf': 'Экспорт в PDF',
'common.exportZip': 'Скачать как .zip',
'common.exportHtml': 'Экспорт как HTML',
'common.exportImage': 'Экспорт как изображение',
'common.exportImageFailed': 'Не удалось захватить изображение. Попробуйте еще раз или воспользуйтесь инструментом создания снимков экрана в браузере.',
'common.justNow': 'только что',
'common.minutesAgo': '{n} мин. назад',
'common.hoursAgo': '{n} ч. назад',
@ -1195,6 +1197,12 @@ export const ru: Dict = {
'fileViewer.exportMd': 'Экспорт в Markdown',
'fileViewer.exportImage': 'Экспорт как изображение',
'fileViewer.exportImageFailed': 'Не удалось сделать снимок. Попробуйте ещё раз или воспользуйтесь инструментом скриншотов вашего браузера.',
'fileViewer.exportImageModalSubtitle': 'Выберите формат, затем скачайте текущий предпросмотр как изображение.',
'fileViewer.exportImageFormatLabel': 'Формат',
'fileViewer.exportImageSaving': 'Сохранение изображения…',
'fileViewer.exportImageSaved': 'Изображение сохранено',
'fileViewer.exportImageDownloadStarted': 'Загрузка началась',
'fileViewer.exportImageDownloadDetails': '{filename} находится в загрузках браузера, если окно «Сохранить как» не появилось.',
'fileViewer.exportJsx': 'Экспорт как JSX',
'fileViewer.exportReactHtml': 'Экспорт предпросмотра как HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const th: Dict = {
'common.exportPdf': 'ส่งออกเป็น PDF',
'common.exportZip': 'ดาวน์โหลดเป็น .zip',
'common.exportHtml': 'ส่งออกเป็น HTML',
'common.exportImage': 'ส่งออกเป็นรูปภาพ',
'common.exportImageFailed': 'การจับภาพล้มเหลว โปรดลองอีกครั้งหรือใช้เครื่องมือจับภาพหน้าจอของเบราว์เซอร์',
'common.justNow': 'เมื่อครู่นี้',
'common.minutesAgo': '{n} นาทีที่แล้ว',
'common.hoursAgo': '{n} ชั่วโมงที่แล้ว',
@ -1101,6 +1103,12 @@ export const th: Dict = {
'fileViewer.exportMd': 'แปลงข้อความแบบฉบับเป็น Markdown',
'fileViewer.exportImage': 'ส่งออกเป็นรูปภาพ',
'fileViewer.exportImageFailed': 'การจับภาพล้มเหลว กรุณาลองอีกครั้งหรือใช้เครื่องมือจับภาพหน้าจอของเบราว์เซอร์',
'fileViewer.exportImageModalSubtitle': 'เลือกรูปแบบ แล้วดาวน์โหลดตัวอย่างปัจจุบันเป็นรูปภาพ',
'fileViewer.exportImageFormatLabel': 'รูปแบบ',
'fileViewer.exportImageSaving': 'กำลังบันทึกรูปภาพ…',
'fileViewer.exportImageSaved': 'บันทึกรูปภาพแล้ว',
'fileViewer.exportImageDownloadStarted': 'เริ่มดาวน์โหลดแล้ว',
'fileViewer.exportImageDownloadDetails': 'หากไม่มีหน้าต่างบันทึกเป็นปรากฏขึ้น ให้ตรวจสอบ {filename} ในโฟลเดอร์ดาวน์โหลดของเบราว์เซอร์',
'fileViewer.exportJsx': 'นำโค้ดในรูปแบบ React JSX ออก',
'fileViewer.exportReactHtml': 'แยกโหลดมาแค่โครง HTML เท่านั้น',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const tr: Dict = {
'common.exportPdf': 'PDF olarak dışa aktar',
'common.exportZip': 'ZIP olarak indir',
'common.exportHtml': 'Tekil HTML olarak dışa aktar',
'common.exportImage': 'Görüntü olarak dışa aktar',
'common.exportImageFailed': 'Görüntü 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.',
'common.justNow': 'şimdi',
'common.minutesAgo': '{n} dakika önce',
'common.hoursAgo': '{n} saat önce',
@ -1159,6 +1161,12 @@ export const tr: Dict = {
'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.exportImageModalSubtitle': 'Bir biçim seçin, ardından geçerli önizlemeyi resim olarak indirin.',
'fileViewer.exportImageFormatLabel': 'Biçim',
'fileViewer.exportImageSaving': 'Görsel kaydediliyor…',
'fileViewer.exportImageSaved': 'Görsel kaydedildi',
'fileViewer.exportImageDownloadStarted': 'İndirme başladı',
'fileViewer.exportImageDownloadDetails': 'Farklı Kaydet penceresi görünmediyse {filename} tarayıcı indirmelerinde.',
'fileViewer.exportJsx': 'JSX olarak dışa aktar',
'fileViewer.exportReactHtml': 'Önizlemeyi HTML olarak dışa aktar',
'fileViewer.exportStarted': 'Export started',

View file

@ -79,6 +79,8 @@ export const uk: Dict = {
'common.exportPdf': 'Експортувати як PDF',
'common.exportZip': 'Завантажити як .zip',
'common.exportHtml': 'Експортувати як HTML автономно',
'common.exportImage': 'Експортувати як зображення',
'common.exportImageFailed': 'Не вдалося захопити зображення. Спробуйте ще раз або скористайтеся інструментом знімків екрана браузера.',
'common.justNow': 'щойно',
'common.minutesAgo': '{n}хв тому',
'common.hoursAgo': '{n}г тому',
@ -1214,6 +1216,12 @@ export const uk: Dict = {
'fileViewer.exportMd': 'Експортувати як Markdown',
'fileViewer.exportImage': 'Експортувати як зображення',
'fileViewer.exportImageFailed': 'Не вдалося захопити зображення. Спробуйте ще раз або скористайтеся інструментом знімків екрана вашого браузера.',
'fileViewer.exportImageModalSubtitle': 'Виберіть формат, а потім завантажте поточний попередній перегляд як зображення.',
'fileViewer.exportImageFormatLabel': 'Формат',
'fileViewer.exportImageSaving': 'Збереження зображення…',
'fileViewer.exportImageSaved': 'Зображення збережено',
'fileViewer.exportImageDownloadStarted': 'Завантаження розпочато',
'fileViewer.exportImageDownloadDetails': '{filename} знаходиться у завантаженнях браузера, якщо вікно «Зберегти як» не з’явилося.',
'fileViewer.exportJsx': 'Експортувати як JSX',
'fileViewer.exportReactHtml': 'Експортувати попередній перегляд як HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -40,6 +40,8 @@ export const zhCN: Dict = {
'common.exportPdf': '导出为 PDF',
'common.exportZip': '下载为 .zip',
'common.exportHtml': '导出为独立 HTML',
'common.exportImage': '导出为图片',
'common.exportImageFailed': '图片截图失败,请重试或使用浏览器的截图工具。',
'common.justNow': '刚刚',
'common.minutesAgo': '{n} 分钟前',
'common.hoursAgo': '{n} 小时前',
@ -1795,6 +1797,12 @@ export const zhCN: Dict = {
'fileViewer.exportMd': '导出为 Markdown',
'fileViewer.exportImage': '导出为图片',
'fileViewer.exportImageFailed': '图片捕获失败,请重试或使用浏览器的截图工具。',
'fileViewer.exportImageModalSubtitle': '选择图片格式后,将当前预览下载为图片。',
'fileViewer.exportImageFormatLabel': '格式',
'fileViewer.exportImageSaving': '正在保存图片…',
'fileViewer.exportImageSaved': '图片已保存',
'fileViewer.exportImageDownloadStarted': '已开始下载',
'fileViewer.exportImageDownloadDetails': '如果没有弹出保存窗口,请在浏览器下载目录中查看 {filename}。',
'fileViewer.exportJsx': '导出为 JSX',
'fileViewer.exportReactHtml': '导出预览 HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -82,6 +82,8 @@ export const zhTW: Dict = {
'common.exportPdf': '匯出為 PDF',
'common.exportZip': '下載為 .zip',
'common.exportHtml': '匯出為獨立 HTML',
'common.exportImage': '匯出為圖片',
'common.exportImageFailed': '圖片截圖失敗,請重試或使用瀏覽器的截圖工具。',
'common.justNow': '剛剛',
'common.minutesAgo': '{n} 分鐘前',
'common.hoursAgo': '{n} 小時前',
@ -1385,6 +1387,12 @@ export const zhTW: Dict = {
'fileViewer.exportMd': '匯出為 Markdown',
'fileViewer.exportImage': '匯出為圖片',
'fileViewer.exportImageFailed': '圖片擷取失敗,請重試或使用瀏覽器的截圖工具。',
'fileViewer.exportImageModalSubtitle': '選擇圖片格式後,將目前預覽下載為圖片。',
'fileViewer.exportImageFormatLabel': '格式',
'fileViewer.exportImageSaving': '正在儲存圖片…',
'fileViewer.exportImageSaved': '圖片已儲存',
'fileViewer.exportImageDownloadStarted': '已開始下載',
'fileViewer.exportImageDownloadDetails': '如果沒有跳出儲存視窗,請在瀏覽器下載資料夾中查看 {filename}。',
'fileViewer.exportJsx': '匯出為 JSX',
'fileViewer.exportReactHtml': '匯出預覽 HTML',
'fileViewer.exportStarted': 'Export started',

View file

@ -58,6 +58,8 @@ export interface Dict {
'common.exportPdf': string;
'common.exportZip': string;
'common.exportHtml': string;
'common.exportImage': string;
'common.exportImageFailed': string;
'common.justNow': string;
'common.minutesAgo': string;
'common.hoursAgo': string;
@ -2127,6 +2129,12 @@ export interface Dict {
'fileViewer.exportMd': string;
'fileViewer.exportImage': string;
'fileViewer.exportImageFailed': string;
'fileViewer.exportImageModalSubtitle': string;
'fileViewer.exportImageFormatLabel': string;
'fileViewer.exportImageSaving': string;
'fileViewer.exportImageSaved': string;
'fileViewer.exportImageDownloadStarted': string;
'fileViewer.exportImageDownloadDetails': string;
'fileViewer.exportJsx': string;
'fileViewer.exportReactHtml': string;
'fileViewer.exportStarted': string;

View file

@ -32,14 +32,18 @@ function safeFilename(name: string, fallback: string): string {
return slug || fallback;
}
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
function triggerHrefDownload(href: string, filename: string): void {
const a = document.createElement('a');
a.href = url;
a.href = href;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
triggerHrefDownload(url, filename);
// Revoke later — Safari sometimes hasn't finished reading the blob yet
// when the click handler returns.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
@ -397,11 +401,200 @@ function dataUrlToBlob(dataUrl: string): Blob {
const [header, base64] = dataUrl.split(',');
const mime = header?.match(/:(.*?);/)?.[1] ?? 'image/png';
const bytes = atob(base64 ?? '');
if (bytes.length <= 0) {
throw new Error('Image snapshot is empty');
}
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 });
}
export type ImageExportFormat = 'png' | 'jpeg' | 'webp';
type ImageExportSpec = {
extension: string;
mime: `image/${string}`;
pickerLabel: string;
};
const IMAGE_EXPORT_SPECS: Record<ImageExportFormat, ImageExportSpec> = {
png: {
extension: 'png',
mime: 'image/png',
pickerLabel: 'PNG image',
},
jpeg: {
extension: 'jpg',
mime: 'image/jpeg',
pickerLabel: 'JPEG image',
},
webp: {
extension: 'webp',
mime: 'image/webp',
pickerLabel: 'WebP image',
},
};
type FileSystemWritableFileStreamLike = {
write(data: Blob): Promise<void>;
close(): Promise<void>;
};
type FileSystemFileHandleLike = {
createWritable(): Promise<FileSystemWritableFileStreamLike>;
};
type SaveFilePickerOptionsLike = {
suggestedName?: string;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
};
type WindowWithSaveFilePicker = Window & {
showSaveFilePicker?: (options?: SaveFilePickerOptionsLike) => Promise<FileSystemFileHandleLike>;
};
export type ImageExportTarget = {
filename: string;
method: 'download' | 'picker';
save: (blob: Blob) => Promise<void> | void;
};
type ImageExportTargetOptions = {
useNativePicker?: boolean;
};
function imageExportFilename(title: string, format: ImageExportFormat): string {
const spec = IMAGE_EXPORT_SPECS[format];
return `${safeFilename(title, 'artifact')}.${spec.extension}`;
}
function downloadImageExportTarget(filename: string): ImageExportTarget {
return {
filename,
method: 'download',
save: (blob) => {
triggerDownload(blob, filename);
},
};
}
export function downloadImageDataUrl(dataUrl: string, filename: string): void {
// Validate the snapshot without converting the actual download path to a blob URL.
dataUrlToBlob(dataUrl);
triggerHrefDownload(dataUrl, filename);
}
function isDomExceptionNamed(err: unknown, names: ReadonlySet<string>): boolean {
if (typeof DOMException !== 'undefined' && err instanceof DOMException) {
return names.has(err.name);
}
if (!err || typeof err !== 'object' || !('name' in err)) return false;
return typeof err.name === 'string' && names.has(err.name);
}
function loadImageFromDataUrl(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not decode image snapshot'));
img.src = dataUrl;
});
}
function canvasToBlob(canvas: HTMLCanvasElement, mime: string, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error(`Could not encode snapshot as ${mime}`));
return;
}
if (blob.type && blob.type !== mime) {
reject(new Error(`Browser encoded ${blob.type} instead of ${mime}`));
return;
}
resolve(blob);
}, mime, quality);
});
}
export async function imageDataUrlToBlob(
dataUrl: string,
format: ImageExportFormat,
): Promise<Blob> {
const spec = IMAGE_EXPORT_SPECS[format];
if (format === 'png') {
const blob = dataUrlToBlob(dataUrl);
if (blob.type === spec.mime) return blob;
}
const img = await loadImageFromDataUrl(dataUrl);
const width = img.naturalWidth || img.width;
const height = img.naturalHeight || img.height;
if (width <= 0 || height <= 0) {
throw new Error('Image snapshot is empty');
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas is not available');
if (format === 'jpeg') {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(img, 0, 0, width, height);
return canvasToBlob(canvas, spec.mime, format === 'jpeg' ? 0.92 : undefined);
}
export async function prepareImageExportTarget(
title: string,
format: ImageExportFormat,
options: ImageExportTargetOptions = {},
): Promise<ImageExportTarget | null> {
const spec = IMAGE_EXPORT_SPECS[format];
const filename = imageExportFilename(title, format);
const picker = (window as WindowWithSaveFilePicker).showSaveFilePicker;
if (options.useNativePicker !== false && typeof picker === 'function') {
try {
const handle = await picker.call(window, {
suggestedName: filename,
types: [
{
description: spec.pickerLabel,
accept: {
[spec.mime]: [`.${spec.extension}`],
},
},
],
});
return {
filename,
method: 'picker',
save: async (blob) => {
const writable = await handle.createWritable();
try {
await writable.write(blob);
} finally {
await writable.close();
}
},
};
} catch (err) {
if (isDomExceptionNamed(err, new Set(['AbortError']))) return null;
if (isDomExceptionNamed(err, new Set(['NotAllowedError', 'SecurityError']))) {
return downloadImageExportTarget(filename);
}
throw err;
}
}
return downloadImageExportTarget(filename);
}
/** Download a snapshot data-URL as a PNG file. */
export function exportAsImage(dataUrl: string, title: string): void {
try {

View file

@ -929,6 +929,109 @@
max-height: calc(100vh - 32px);
overflow: auto;
}
.image-export-modal.modal {
width: min(430px, calc(100vw - 32px));
gap: 0;
padding: 0;
}
.image-export-modal .modal-head {
padding: 22px 24px 12px;
}
.image-export-modal .modal-head .kicker {
display: none;
}
.image-export-modal .modal-head h2 {
font-size: 20px;
letter-spacing: 0;
}
.image-export-modal .modal-head .subtitle {
max-width: none;
margin-top: 6px;
line-height: 1.5;
text-wrap: pretty;
}
.image-export-form {
padding: 0 24px 18px;
}
.image-export-format-field {
margin: 0;
padding: 0;
border: 0;
}
.image-export-format-field legend {
margin-bottom: 10px;
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
}
.image-export-format-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
gap: 2px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-subtle);
}
.image-export-format-option {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 38px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
cursor: pointer;
transition:
border-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
background 140ms cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.image-export-format-option:hover {
background: color-mix(in srgb, var(--bg-panel) 70%, transparent);
}
.image-export-format-option.active {
border-color: color-mix(in srgb, var(--border-strong) 72%, transparent);
background: var(--bg-panel);
box-shadow: 0 1px 1px color-mix(in srgb, var(--text) 7%, transparent);
}
.image-export-format-option input[type="radio"] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
opacity: 0;
cursor: pointer;
}
.image-export-format-option:has(input[type="radio"]:focus-visible) {
outline: 2px solid color-mix(in srgb, var(--accent) 42%, transparent);
outline-offset: 2px;
}
.image-export-format-text {
display: flex;
align-items: baseline;
justify-content: center;
min-width: 0;
gap: 0;
}
.image-export-format-text strong {
color: var(--text);
font-size: 13px;
font-weight: 600;
}
.image-export-format-text span {
display: none;
}
.image-export-modal .modal-foot {
padding: 14px 24px 20px;
border-top: 1px solid var(--border);
}
/* Add a few extra pixels above the deploy dialog's primary action so
it does not crowd the divider. The shared .modal-foot uses 12px
vertical padding which is fine for shorter dialogs but reads as

View file

@ -600,6 +600,38 @@ describe('FileViewer SVG artifacts', () => {
expect(markup).toContain('sandbox="allow-scripts allow-downloads"');
});
it('offers image export for URL-loaded HTML previews', () => {
const file = baseFile({
name: 'workspace.html',
path: 'workspace.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Workspace',
entry: 'workspace.html',
renderer: 'html',
exports: ['html'],
},
});
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml="<html><body><main>Workspace</main></body></html>"
/>,
);
expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).getAttribute('data-od-render-mode')).toBe('url-load');
fireEvent.click(screen.getByRole('button', { name: /share/i }));
expect(screen.getByRole('menuitem', { name: /export as image/i })).toBeTruthy();
});
it('keeps inactive HTML preview transports mounted without booting the artifact', async () => {
const file = baseFile({
name: 'page.html',

View file

@ -0,0 +1,210 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ProjectFile } from '../../src/types';
const {
downloadImageDataUrlMock,
imageDataUrlToBlobMock,
prepareImageExportTargetMock,
requestPreviewSnapshotMock,
saveImageBlobMock,
} = vi.hoisted(() => ({
downloadImageDataUrlMock: vi.fn(),
imageDataUrlToBlobMock: vi.fn(),
prepareImageExportTargetMock: vi.fn(),
requestPreviewSnapshotMock: vi.fn(),
saveImageBlobMock: vi.fn(),
}));
vi.mock('../../src/runtime/exports', async () => {
const actual = await vi.importActual<typeof import('../../src/runtime/exports')>(
'../../src/runtime/exports',
);
return {
...actual,
downloadImageDataUrl: downloadImageDataUrlMock,
imageDataUrlToBlob: imageDataUrlToBlobMock,
prepareImageExportTarget: prepareImageExportTargetMock,
requestPreviewSnapshot: requestPreviewSnapshotMock,
};
});
import { FileViewer } from '../../src/components/FileViewer';
function htmlFile(): ProjectFile {
return {
name: 'workspace.html',
path: 'workspace.html',
type: 'file',
size: 1024,
mtime: 1710000000,
kind: 'html',
mime: 'text/html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Workspace',
entry: 'workspace.html',
renderer: 'html',
exports: ['html'],
},
};
}
function renderHtmlPreview() {
const view = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlFile()}
liveHtml="<html><body><main>Workspace</main></body></html>"
/>,
);
const { container } = view;
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('url-load');
const srcDocFrame = container.querySelector<HTMLIFrameElement>('iframe[data-od-render-mode="srcdoc"]');
expect(srcDocFrame).toBeTruthy();
fireEvent.load(srcDocFrame as HTMLIFrameElement);
return { ...view, srcDocFrame: srcDocFrame as HTMLIFrameElement };
}
function openImageExportDialog() {
fireEvent.click(screen.getByRole('button', { name: /share/i }));
fireEvent.click(screen.getByRole('menuitem', { name: /export as image/i }));
expect(screen.getByRole('dialog', { name: /export as image/i })).toBeTruthy();
}
async function waitForSaveButton() {
const button = await screen.findByRole('button', { name: /^save$/i });
expect((button as HTMLButtonElement).disabled).toBe(false);
return button;
}
describe('FileViewer image export', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('lets users choose an image format before saving URL-loaded HTML previews', async () => {
const pngBlob = new Blob(['png'], { type: 'image/png' });
const imageBlob = new Blob(['jpeg'], { type: 'image/jpeg' });
requestPreviewSnapshotMock.mockResolvedValueOnce({
dataUrl: 'data:image/png;base64,ok',
w: 800,
h: 600,
});
imageDataUrlToBlobMock.mockImplementation(async (_dataUrl: string, format: 'png' | 'jpeg' | 'webp') => {
if (format === 'jpeg') return imageBlob;
return pngBlob;
});
prepareImageExportTargetMock.mockResolvedValueOnce({
filename: 'workspace.jpg',
method: 'picker',
save: saveImageBlobMock,
});
const { srcDocFrame } = renderHtmlPreview();
openImageExportDialog();
expect(screen.getByRole('radio', { name: 'PNG' })).toBeTruthy();
await waitFor(() => {
expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(srcDocFrame);
expect(imageDataUrlToBlobMock).toHaveBeenCalledWith('data:image/png;base64,ok', 'png');
});
await waitForSaveButton();
fireEvent.click(screen.getByRole('radio', { name: 'JPEG' }));
await waitFor(() => {
expect(imageDataUrlToBlobMock).toHaveBeenCalledWith('data:image/png;base64,ok', 'jpeg');
});
fireEvent.click(await waitForSaveButton());
fireEvent.load(srcDocFrame as HTMLIFrameElement);
await waitFor(() => {
expect(prepareImageExportTargetMock).toHaveBeenCalledWith('workspace', 'jpeg', { useNativePicker: false });
});
expect(requestPreviewSnapshotMock).toHaveBeenCalledTimes(1);
expect(saveImageBlobMock).toHaveBeenCalledWith(imageBlob);
expect(screen.getByText('workspace.jpg')).toBeTruthy();
});
it('uses the prepared PNG data URL for fallback downloads', async () => {
const imageBlob = new Blob(['png'], { type: 'image/png' });
requestPreviewSnapshotMock.mockResolvedValueOnce({
dataUrl: 'data:image/png;base64,ok',
w: 800,
h: 600,
});
imageDataUrlToBlobMock.mockResolvedValueOnce(imageBlob);
prepareImageExportTargetMock.mockResolvedValueOnce({
filename: 'workspace.png',
method: 'download',
save: saveImageBlobMock,
});
renderHtmlPreview();
openImageExportDialog();
fireEvent.click(await waitForSaveButton());
await waitFor(() => {
expect(prepareImageExportTargetMock).toHaveBeenCalledWith('workspace', 'png', { useNativePicker: false });
expect(downloadImageDataUrlMock).toHaveBeenCalledWith('data:image/png;base64,ok', 'workspace.png');
});
expect(saveImageBlobMock).not.toHaveBeenCalled();
expect(screen.getByText(/workspace\.png/)).toBeTruthy();
});
it('does not create a save target when snapshot capture fails', async () => {
requestPreviewSnapshotMock.mockResolvedValueOnce(null);
prepareImageExportTargetMock.mockResolvedValueOnce({
filename: 'workspace.png',
method: 'picker',
save: saveImageBlobMock,
});
renderHtmlPreview();
openImageExportDialog();
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe(
"Image capture failed. Please try again or use your browser's screenshot tool.",
);
});
expect((screen.getByRole('button', { name: /^save$/i }) as HTMLButtonElement).disabled).toBe(true);
expect(prepareImageExportTargetMock).not.toHaveBeenCalled();
expect(imageDataUrlToBlobMock).not.toHaveBeenCalled();
expect(saveImageBlobMock).not.toHaveBeenCalled();
});
it('does not write the save target when the captured image is empty', async () => {
requestPreviewSnapshotMock.mockResolvedValueOnce({
dataUrl: 'data:image/png;base64,ok',
w: 800,
h: 600,
});
imageDataUrlToBlobMock.mockResolvedValueOnce(new Blob([]));
prepareImageExportTargetMock.mockResolvedValueOnce({
filename: 'workspace.png',
method: 'picker',
save: saveImageBlobMock,
});
renderHtmlPreview();
openImageExportDialog();
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe(
"Image capture failed. Please try again or use your browser's screenshot tool.",
);
});
expect((screen.getByRole('button', { name: /^save$/i }) as HTMLButtonElement).disabled).toBe(true);
expect(imageDataUrlToBlobMock).toHaveBeenCalledWith('data:image/png;base64,ok', 'png');
expect(prepareImageExportTargetMock).not.toHaveBeenCalled();
expect(saveImageBlobMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,161 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PreviewModal } from '../../src/components/PreviewModal';
// Regression coverage for image export: the PreviewModal share menu must
// include an "Export as image" button that snapshots the srcdoc iframe and
// downloads a PNG. The snapshot bridges (requestPreviewSnapshot) and the
// Blob download (exportAsImage) come from the shared exports module —
// mock them so the test can exercise the full button flow without a real
// iframe or DOM snapshot.
const { exportAsImageMock, requestPreviewSnapshotMock } = vi.hoisted(() => ({
exportAsImageMock: vi.fn(),
requestPreviewSnapshotMock: vi.fn(),
}));
vi.mock('../../src/runtime/exports', () => ({
exportAsHtml: vi.fn(),
exportAsImage: exportAsImageMock,
exportAsPdf: vi.fn(),
exportAsZip: vi.fn(),
openSandboxedPreviewInNewTab: vi.fn(),
requestPreviewSnapshot: requestPreviewSnapshotMock,
}));
const baseProps = {
title: 'Sample',
views: [{ id: 'main', label: 'Main', html: '<p>hello</p>' }],
exportTitleFor: (id: string) => id,
};
function openShareMenu() {
const share = screen.getByRole('button', { name: /share/i });
fireEvent.click(share);
}
describe('PreviewModal image export', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('renders the Export as image button in the share menu', () => {
render(
<PreviewModal {...baseProps} onClose={() => {}} />,
);
openShareMenu();
expect(
screen.getByRole('menuitem', { name: /export as image/i }),
).toBeTruthy();
});
it('hides the share menu (including image export) when the view is custom (no iframe)', () => {
render(
<PreviewModal
{...baseProps}
views={[{ id: 'custom', label: 'Custom', custom: <div>media</div> }]}
onClose={() => {}}
/>,
);
// The share button itself must not render for custom views.
expect(screen.queryByRole('button', { name: /share/i })).toBeNull();
});
it('calls requestPreviewSnapshot and exportAsImage on success', async () => {
const fakeDataUrl = 'data:image/png;base64,iVBORw0KGgo=';
requestPreviewSnapshotMock.mockResolvedValueOnce({
dataUrl: fakeDataUrl,
w: 800,
h: 600,
});
render(
<PreviewModal {...baseProps} onClose={() => {}} />,
);
openShareMenu();
fireEvent.click(screen.getByRole('menuitem', { name: /export as image/i }));
await waitFor(() => {
expect(requestPreviewSnapshotMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(exportAsImageMock).toHaveBeenCalledWith(fakeDataUrl, 'main');
});
});
it('alerts when snapshot capture returns null', async () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
requestPreviewSnapshotMock.mockResolvedValueOnce(null);
render(
<PreviewModal {...baseProps} onClose={() => {}} />,
);
openShareMenu();
fireEvent.click(screen.getByRole('menuitem', { name: /export as image/i }));
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledTimes(1);
});
// exportAsImage must not be called when snapshot is null.
expect(exportAsImageMock).not.toHaveBeenCalled();
alertSpy.mockRestore();
});
it('alerts when exportAsImage throws', async () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
requestPreviewSnapshotMock.mockResolvedValueOnce({
dataUrl: 'data:image/png;base64,ok',
w: 800,
h: 600,
});
exportAsImageMock.mockImplementationOnce(() => {
throw new Error('blob conversion failed');
});
render(
<PreviewModal {...baseProps} onClose={() => {}} />,
);
openShareMenu();
fireEvent.click(screen.getByRole('menuitem', { name: /export as image/i }));
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledTimes(1);
});
alertSpy.mockRestore();
});
it('fires onSharePopoverItemClick with "image"', () => {
const onItemClick = vi.fn();
requestPreviewSnapshotMock.mockResolvedValueOnce({
dataUrl: 'data:image/png;base64,ok',
w: 800,
h: 600,
});
render(
<PreviewModal
{...baseProps}
onClose={() => {}}
onSharePopoverItemClick={onItemClick}
/>,
);
openShareMenu();
fireEvent.click(screen.getByRole('menuitem', { name: /export as image/i }));
expect(onItemClick).toHaveBeenCalledWith('image');
});
});

View file

@ -5,12 +5,14 @@ import {
archiveRootFromFilePath,
buildDesignHandoffContent,
buildDesignManifestContent,
downloadImageDataUrl,
buildSandboxedPreviewDocument,
exportAsImage,
exportAsMd,
exportAsPdf,
exportProjectAsPdf,
openSandboxedPreviewInNewTab,
prepareImageExportTarget,
requestPreviewSnapshot,
} from '../../src/runtime/exports';
@ -672,12 +674,14 @@ describe('requestPreviewSnapshot', () => {
describe('exportAsImage', () => {
let clickMock: ReturnType<typeof vi.fn>;
let createObjectURLMock: ReturnType<typeof vi.fn>;
let anchors: Array<{ href: string; download: string; click: ReturnType<typeof vi.fn> }>;
beforeEach(() => {
clickMock = vi.fn();
createObjectURLMock = vi.fn(() => 'blob:mock-url');
anchors = [];
vi.stubGlobal('URL', { createObjectURL: () => 'blob:mock-url', revokeObjectURL: vi.fn() });
vi.stubGlobal('URL', { createObjectURL: createObjectURLMock, revokeObjectURL: vi.fn() });
vi.stubGlobal('document', {
createElement: () => {
const el = { href: '', download: '', click: clickMock };
@ -712,4 +716,72 @@ describe('exportAsImage', () => {
expect(anchors[0]!.download).toBe('Hello-World-Test.png');
});
it('does not download an empty image snapshot', () => {
expect(() => exportAsImage('data:image/png;base64,', 'Empty')).toThrow('Image snapshot is empty');
expect(clickMock).not.toHaveBeenCalled();
expect(anchors).toHaveLength(0);
});
it('downloads a validated image data URL without creating a blob URL', () => {
const dataUrl = 'data:image/png;base64,AA==';
downloadImageDataUrl(dataUrl, 'workspace.png');
expect(clickMock).toHaveBeenCalledOnce();
expect(createObjectURLMock).not.toHaveBeenCalled();
expect(anchors[0]!.href).toBe(dataUrl);
expect(anchors[0]!.download).toBe('workspace.png');
});
it('does not download an empty image data URL', () => {
expect(() => downloadImageDataUrl('data:image/png;base64,', 'workspace.png')).toThrow('Image snapshot is empty');
expect(clickMock).not.toHaveBeenCalled();
expect(anchors).toHaveLength(0);
});
it('falls back to download when the native save picker is blocked', async () => {
const showSaveFilePicker = vi.fn().mockRejectedValue(
new DOMException('Must be handling a user gesture to show a file picker.', 'SecurityError'),
);
vi.stubGlobal('window', { showSaveFilePicker });
const target = await prepareImageExportTarget('My Design', 'jpeg');
expect(showSaveFilePicker).toHaveBeenCalledOnce();
expect(target?.method).toBe('download');
expect(target?.filename).toBe('My-Design.jpg');
await target?.save(new Blob(['jpeg'], { type: 'image/jpeg' }));
expect(clickMock).toHaveBeenCalledOnce();
expect(anchors[0]!.download).toBe('My-Design.jpg');
});
it('falls back to download when the native save picker reports a cross-realm SecurityError', async () => {
const securityError = Object.assign(new Error('Must be handling a user gesture to show a file picker.'), {
name: 'SecurityError',
});
const showSaveFilePicker = vi.fn().mockRejectedValue(securityError);
vi.stubGlobal('window', { showSaveFilePicker });
const target = await prepareImageExportTarget('My Design', 'webp');
expect(showSaveFilePicker).toHaveBeenCalledOnce();
expect(target?.method).toBe('download');
expect(target?.filename).toBe('My-Design.webp');
});
it('can skip the native save picker to avoid pre-creating empty files', async () => {
const showSaveFilePicker = vi.fn();
vi.stubGlobal('window', { showSaveFilePicker });
const target = await prepareImageExportTarget('My Design', 'png', { useNativePicker: false });
expect(showSaveFilePicker).not.toHaveBeenCalled();
expect(target?.method).toBe('download');
expect(target?.filename).toBe('My-Design.png');
});
});

View file

@ -0,0 +1,67 @@
---
name: export-download-debugging
description: |
Diagnose and fix browser, preview, or Electron export/download failures, especially image export issues involving Save As, Blob/Data URLs, the File System Access API, createWritable failures, and 0 KB files.
triggers:
- "export image failed"
- "image export"
- "download is 0kb"
- "0 KB file"
- "showSaveFilePicker"
- "createWritable"
- "blob URL"
- "Electron download"
od:
mode: utility
category: web-artifacts
---
# Export Download Debugging
Use this when an export or download appears to run but produces an empty,
missing, or unusable file. It is tuned for browser previews, iframe capture,
and Electron shells.
## Core rule
Separate capture from save. Prove the export payload has non-zero bytes before
debugging the destination path. A 0 KB file often means the native picker or
host created the target file, then the write failed or was blocked.
## Evidence
- Record the payload type, MIME type, byte length, object URL or data URL path,
and thrown error name/message.
- Check browser console output and app logs around `showSaveFilePicker`,
`createWritable`, `<a download>`, `URL.createObjectURL`, and URL revocation.
- In Electron, inspect the `will-download` path: filename, MIME, file extension,
save dialog filters, cancellation, and final file size.
- Confirm the actual downloaded file size on disk and whether it opens in a
normal image viewer.
## Fix order
1. Prepare and validate the export payload first; disable Save until bytes are
available.
2. Prefer the simplest stable save channel for the host. If
`showSaveFilePicker().createWritable()` fails, is sandboxed, or creates empty
files, route to `<a download>` or Electron's download manager instead.
3. Avoid opening a native picker before the payload is ready. This prevents the
host from creating an empty destination file on later write failure.
4. For PNG fallback, prefer a verified non-empty data URL when blob URLs,
revocation timing, CSP, or iframe sandboxing are suspicious.
5. In Electron, add explicit Save As filters for exported image extensions
(`.png`, `.jpg`, `.jpeg`, `.webp`) so users can choose a real image target.
6. Keep the UI restrained: format selection, filename/path hint, clear pending
and error states, and one primary save action.
## Validation
- Add or update tests for non-empty payload preparation, fallback save behavior,
file extension/MIME mapping, and UI disabled/error states.
- Run focused export tests, then `pnpm --filter @open-design/web typecheck`.
- If Electron download handling changed, also run
`pnpm --filter @open-design/desktop typecheck`.
- Before landing, run `pnpm guard`, inspect the staged diff for unrelated files,
generated artifacts, and accidental secrets, then use the repository's PR
quality-gate workflow if the fix is being prepared for review.

View file

@ -0,0 +1,53 @@
---
name: pr-feedback-quality-gate
description: |
Safely track pull request feedback, resolve review comments or merge conflicts, validate fixes, and use a read-only cross-review before committing or pushing follow-up changes.
triggers:
- "PR feedback"
- "review comments"
- "merge conflicts"
- "cross-review"
- "Claude CLI review"
- "monitor PR"
od:
mode: utility
---
# PR Feedback Quality Gate
Use this when a PR has review feedback, merge conflicts, pending checks, or
needs a monitored follow-up after a fix.
## Workflow
1. Inspect PR state first: comments, reviews, mergeability, checks, branch, and
local worktree status. Keep unrelated local changes out of the PR.
2. Use an isolated worktree for review fixes or conflict resolution when the
main checkout is dirty, behind remote, or being used by another agent.
3. Make the smallest safe fix. Preserve the original bug invariant and any
newer upstream structure introduced by `main`.
4. Run the narrow validation first, then the repository-required gates. For
this repo, include `pnpm guard`; add package typechecks/builds/tests when
touched files require them.
5. Before commit or push, run a read-only cross-review of the staged or proposed
diff. Forbid file edits and git write or coordination commands.
6. Treat cross-review as evidence, not authority. Accept only findings grounded
in the diff, repository rules, user goal, or validation results. Downgrade or
reject style preferences, broad scope expansion, and suggestions that conflict
with safety or ownership boundaries; record the reason briefly.
7. If accepted blockers remain, fix them, rerun validation, and repeat the
review. Commit and push only after validation passes and there are no
accepted blockers.
## Monitoring cadence
- Active review or failing checks: check often enough to unblock quickly.
- Clean or approved PR waiting for merge: check about every 12 hours.
- Merged PR: reduce to daily lightweight observation for CI, release, or
regression signals, and stop making code changes unless asked.
## Report
Always report PR state, actions taken, cross-review verdict, accepted or
rejected findings, validation run, commits pushed, skipped checks with reasons,
remaining risks, and next step.