mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
9305bd1cff
commit
653a3fcc70
31 changed files with 1353 additions and 49 deletions
|
|
@ -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: ["*"] },
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 l’aperçu actuel en image.',
|
||||
'fileViewer.exportImageFormatLabel': 'Format',
|
||||
'fileViewer.exportImageSaving': 'Enregistrement de l’image…',
|
||||
'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 n’est apparue.',
|
||||
'fileViewer.exportJsx': 'Exporter en JSX',
|
||||
'fileViewer.exportReactHtml': 'Exporter l\'aperçu en HTML',
|
||||
'fileViewer.exportStarted': 'Export started',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
210
apps/web/tests/components/file-viewer-image-export.test.tsx
Normal file
210
apps/web/tests/components/file-viewer-image-export.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
161
apps/web/tests/components/preview-modal-image-export.test.tsx
Normal file
161
apps/web/tests/components/preview-modal-image-export.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
67
skills/export-download-debugging/SKILL.md
Normal file
67
skills/export-download-debugging/SKILL.md
Normal 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.
|
||||
53
skills/pr-feedback-quality-gate/SKILL.md
Normal file
53
skills/pr-feedback-quality-gate/SKILL.md
Normal 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.
|
||||
Loading…
Reference in a new issue