Fix URL preview image export capture

This commit is contained in:
yinjialu
2026-06-04 23:03:39 +08:00
parent 8970dfc6f4
commit 4659926be8
5 changed files with 281 additions and 29 deletions
+195 -5
View File
@@ -533,6 +533,184 @@ const URL_PREVIEW_SELECTION_BRIDGE = `<script data-od-url-selection-bridge>
})();
</script>`;
const URL_PREVIEW_SNAPSHOT_BRIDGE = `<script data-od-url-snapshot-bridge>
(function(){
if (window.__odUrlSnapshotBridge) return;
window.__odUrlSnapshotBridge = true;
var SNAPSHOT_STYLE_PROPS = [
'display','position','box-sizing','width','height','min-width','max-width','min-height','max-height',
'margin','margin-top','margin-right','margin-bottom','margin-left',
'padding','padding-top','padding-right','padding-bottom','padding-left',
'border','border-top','border-right','border-bottom','border-left','border-radius',
'font','font-family','font-size','font-weight','font-style','line-height','letter-spacing',
'color','background-color','opacity','transform','transform-origin','overflow','overflow-x','overflow-y',
'white-space','text-align','vertical-align','object-fit','object-position',
'flex','flex-direction','flex-wrap','flex-grow','flex-shrink','flex-basis',
'grid','grid-template-columns','grid-template-rows','grid-column','grid-row',
'gap','row-gap','column-gap','align-items','align-content','align-self',
'justify-items','justify-content','justify-self','inset','top','right','bottom','left',
'z-index','box-shadow','text-shadow'
];
function copyComputedStyle(source, target){
if (!source || !target || source.nodeType !== 1 || target.nodeType !== 1) return;
var computed = window.getComputedStyle(source);
var style = target.getAttribute('style') || '';
for (var i = 0; i < SNAPSHOT_STYLE_PROPS.length; i++){
var prop = SNAPSHOT_STYLE_PROPS[i];
var value = computed.getPropertyValue(prop);
if (value) style += prop + ':' + value + ';';
}
target.setAttribute('style', style);
}
function syncElementState(source, target){
var tag = source.tagName ? source.tagName.toLowerCase() : '';
if (tag === 'img' && source.currentSrc) target.setAttribute('src', source.currentSrc);
if (tag === 'input' || tag === 'textarea') target.setAttribute('value', source.value || '');
if (tag === 'canvas') {
try {
var img = document.createElement('img');
img.setAttribute('src', source.toDataURL('image/png'));
img.setAttribute('style', target.getAttribute('style') || '');
target.parentNode && target.parentNode.replaceChild(img, target);
} catch (_) {}
}
}
function inlineSnapshotStyles(originalRoot, cloneRoot){
copyComputedStyle(originalRoot, cloneRoot);
syncElementState(originalRoot, cloneRoot);
var originals = originalRoot.querySelectorAll('*');
var clones = cloneRoot.querySelectorAll('*');
var count = Math.min(originals.length, clones.length, 3500);
for (var i = 0; i < count; i++){
copyComputedStyle(originals[i], clones[i]);
syncElementState(originals[i], clones[i]);
}
var scripts = cloneRoot.querySelectorAll('script');
for (var s = scripts.length - 1; s >= 0; s--) scripts[s].remove();
var links = cloneRoot.querySelectorAll('link[rel~="stylesheet"], link[rel~="preload"], link[rel~="preconnect"]');
for (var l = links.length - 1; l >= 0; l--) links[l].remove();
var styles = cloneRoot.querySelectorAll('style');
for (var st = 0; st < styles.length; st++) {
styles[st].textContent = (styles[st].textContent || '')
.replace(/@import[^;]+;/gi, '')
.replace(/@font-face\\s*\\{[^}]*\\}/gi, '');
}
}
function pruneHiddenSnapshotNodes(originalRoot, cloneRoot){
var originals = originalRoot.querySelectorAll('*');
var clones = cloneRoot.querySelectorAll('*');
var count = Math.min(originals.length, clones.length);
var removals = [];
for (var i = 0; i < count; i++){
var original = originals[i];
var clone = clones[i];
if (!original || !clone || !clone.parentNode) continue;
var computed = window.getComputedStyle(original);
if (computed && (computed.display === 'none' || computed.visibility === 'hidden')) removals.push(clone);
}
for (var r = removals.length - 1; r >= 0; r--) {
if (removals[r].parentNode) removals[r].parentNode.removeChild(removals[r]);
}
}
function waitForImages(){
var imgs = Array.prototype.slice.call(document.images || []);
return Promise.all(imgs.map(function(img){
if (img.complete) return Promise.resolve();
return new Promise(function(resolve){
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
});
}));
}
function scrollOffset(){
var doc = document.documentElement;
var body = document.body;
return {
x: Math.max(window.scrollX || 0, doc ? doc.scrollLeft || 0 : 0, body ? body.scrollLeft || 0 : 0),
y: Math.max(window.scrollY || 0, doc ? doc.scrollTop || 0 : 0, body ? body.scrollTop || 0 : 0)
};
}
function escapeAttribute(value){
return String(value || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
function snapshotBackgroundColor(){
try {
var probe = window.getComputedStyle(document.body || document.documentElement);
var bg = probe && probe.backgroundColor || '';
if (!bg || bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)') return '#ffffff';
return bg;
} catch (_) { return '#ffffff'; }
}
function canvasLooksBlank(ctx, cw, ch){
try {
var data = ctx.getImageData(0, 0, cw, ch).data;
var step = Math.max(4, Math.floor((cw * ch) / 4096)) * 4;
var first = null, samples = 0;
for (var i = 0; i + 3 < data.length; i += step){
samples++;
if (!first){ first = [data[i], data[i+1], data[i+2], data[i+3]]; continue; }
if (Math.abs(data[i]-first[0]) > 6 || Math.abs(data[i+1]-first[1]) > 6 ||
Math.abs(data[i+2]-first[2]) > 6 || Math.abs(data[i+3]-first[3]) > 6) return false;
}
return samples > 8;
} catch (_) { return false; }
}
function renderSnapshot(id){
var w = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1);
var h = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1);
var dpr = window.devicePixelRatio || 1;
var bgColor = snapshotBackgroundColor();
var docW = Math.max(w, document.documentElement.scrollWidth || 0, document.body ? document.body.scrollWidth : 0);
var docH = Math.max(h, document.documentElement.scrollHeight || 0, document.body ? document.body.scrollHeight : 0);
var clone = document.documentElement.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
inlineSnapshotStyles(document.documentElement, clone);
pruneHiddenSnapshotNodes(document.documentElement, clone);
var scroll = scrollOffset();
var cloneBody = clone.querySelector('body');
var rootStyle = clone.getAttribute('style') || '';
var bodyStyle = cloneBody ? cloneBody.getAttribute('style') || '' : '';
var bodyContent = cloneBody ? cloneBody.innerHTML : clone.innerHTML;
var wrapperStyle = rootStyle + bodyStyle +
'margin:0;position:relative;left:' + (-scroll.x) + 'px;top:' + (-scroll.y) + 'px;' +
'width:' + docW + 'px;height:' + docH + 'px;overflow:visible;';
var html = '<div xmlns="http://www.w3.org/1999/xhtml" style="' + escapeAttribute(wrapperStyle) + '">' + bodyContent + '</div>';
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '">' +
'<foreignObject x="0" y="0" width="' + docW + '" height="' + docH + '">' + html + '</foreignObject></svg>';
var img = new Image();
img.onload = function(){
try {
var canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.floor(w * dpr));
canvas.height = Math.max(1, Math.floor(h * dpr));
var ctx = canvas.getContext('2d');
if (!ctx) throw new Error('no 2d context');
ctx.scale(dpr, dpr);
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
if (canvasLooksBlank(ctx, canvas.width, canvas.height)) {
window.parent.postMessage({ type: 'od:snapshot:result', id: id, error: 'empty-render' }, '*');
return;
}
window.parent.postMessage({ type: 'od:snapshot:result', id: id, dataUrl: canvas.toDataURL('image/png'), w: canvas.width, h: canvas.height }, '*');
} catch (err) {
window.parent.postMessage({ type: 'od:snapshot:result', id: id, error: String(err && err.message || err) }, '*');
}
};
img.onerror = function(){
window.parent.postMessage({ type: 'od:snapshot:result', id: id, error: 'snapshot image failed' }, '*');
};
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data || data.type !== 'od:snapshot' || !data.id) return;
waitForImages().then(function(){ renderSnapshot(String(data.id)); });
});
})();
</script>`;
function previewBridgeTokens(value: unknown): string[] {
if (Array.isArray(value)) return value.flatMap(previewBridgeTokens);
if (typeof value !== 'string') return [];
@@ -547,6 +725,10 @@ function wantsUrlPreviewSelectionBridge(value: unknown): boolean {
return previewBridgeTokens(value).some((token) => token === 'selection' || token === 'comment' || token === 'comments' || token === 'annotation');
}
function wantsUrlPreviewSnapshotBridge(value: unknown): boolean {
return previewBridgeTokens(value).some((token) => token === 'snapshot' || token === 'image' || token === 'capture');
}
function injectBeforeBodyClose(html: string, marker: string, injection: string): string {
if (html.includes(marker)) return html;
const bodyCloseIndex = html.search(/<\/body\s*>/i);
@@ -556,10 +738,14 @@ function injectBeforeBodyClose(html: string, marker: string, injection: string):
return `${html}${injection}`;
}
function injectUrlPreviewBridge(html: string, bridge: 'scroll' | 'selection'): string {
return bridge === 'scroll'
? injectBeforeBodyClose(html, 'data-od-url-scroll-bridge', URL_PREVIEW_SCROLL_BRIDGE)
: injectBeforeBodyClose(html, 'data-od-url-selection-bridge', URL_PREVIEW_SELECTION_BRIDGE);
function injectUrlPreviewBridge(html: string, bridge: 'scroll' | 'selection' | 'snapshot'): string {
if (bridge === 'scroll') {
return injectBeforeBodyClose(html, 'data-od-url-scroll-bridge', URL_PREVIEW_SCROLL_BRIDGE);
}
if (bridge === 'selection') {
return injectBeforeBodyClose(html, 'data-od-url-selection-bridge', URL_PREVIEW_SELECTION_BRIDGE);
}
return injectBeforeBodyClose(html, 'data-od-url-snapshot-bridge', URL_PREVIEW_SNAPSHOT_BRIDGE);
}
function normalizeChatSessionMode(value: unknown): ChatSessionMode {
@@ -2040,7 +2226,8 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
(file) => {
if (
(wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) ||
wantsUrlPreviewSelectionBridge(req.query.odPreviewBridge)) &&
wantsUrlPreviewSelectionBridge(req.query.odPreviewBridge) ||
wantsUrlPreviewSnapshotBridge(req.query.odPreviewBridge)) &&
/^text\/html(?:;|$)/i.test(file.mime)
) {
let html = file.buffer.toString('utf8');
@@ -2050,6 +2237,9 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
if (wantsUrlPreviewSelectionBridge(req.query.odPreviewBridge)) {
html = injectUrlPreviewBridge(html, 'selection');
}
if (wantsUrlPreviewSnapshotBridge(req.query.odPreviewBridge)) {
html = injectUrlPreviewBridge(html, 'snapshot');
}
return html;
}
return file.buffer;
+27 -1
View File
@@ -174,6 +174,10 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
path.join(dir, 'selection-bridged.html'),
Buffer.from('<html><body><script data-od-url-selection-bridge></script><main>Preview</main></body></html>'),
);
await writeFile(
path.join(dir, 'snapshot-bridged.html'),
Buffer.from('<html><body><script data-od-url-snapshot-bridge></script><main>Preview</main></body></html>'),
);
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
@@ -266,14 +270,29 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
expect(html).not.toContain('data-od-url-scroll-bridge');
});
it('injects the URL preview snapshot bridge only when requested', async () => {
const plain = await fetch(rawUrl('page.html'));
expect(await plain.text()).toBe('<html/>');
const bridged = await fetch(`${rawUrl('page.html')}?odPreviewBridge=snapshot`);
expect(bridged.status).toBe(200);
const html = await bridged.text();
expect(html).toContain('data-od-url-snapshot-bridge');
expect(html).toContain("type: 'od:snapshot:result'");
expect(html).not.toContain('data-od-url-scroll-bridge');
expect(html).not.toContain('data-od-url-selection-bridge');
});
it('injects scroll and selection URL preview bridges together', async () => {
const bridged = await fetch(`${rawUrl('body.html')}?odPreviewBridge=scroll&odPreviewBridge=selection`);
const bridged = await fetch(`${rawUrl('body.html')}?odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot`);
expect(bridged.status).toBe(200);
const html = await bridged.text();
expect(html).toContain('data-od-url-scroll-bridge');
expect(html).toContain('data-od-url-selection-bridge');
expect(html).toContain('data-od-url-snapshot-bridge');
expect(html.indexOf('data-od-url-scroll-bridge')).toBeLessThan(html.indexOf('</body>'));
expect(html.indexOf('data-od-url-selection-bridge')).toBeLessThan(html.indexOf('</body>'));
expect(html.indexOf('data-od-url-snapshot-bridge')).toBeLessThan(html.indexOf('</body>'));
});
it('does not inject the URL preview scroll bridge twice', async () => {
@@ -290,6 +309,13 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
expect(html.match(/data-od-url-selection-bridge/g)?.length).toBe(1);
});
it('does not inject the URL preview snapshot bridge twice', async () => {
const bridged = await fetch(`${rawUrl('snapshot-bridged.html')}?odPreviewBridge=snapshot`);
expect(bridged.status).toBe(200);
const html = await bridged.text();
expect(html.match(/data-od-url-snapshot-bridge/g)?.length).toBe(1);
});
it('returns 404 for a missing file', async () => {
const res = await fetch(rawUrl('missing.mp4'));
expect(res.status).toBe(404);
+9 -1
View File
@@ -5185,7 +5185,7 @@ function HtmlViewer({
needsFocusGuard,
}) && !manualEditRequiresSrcDoc;
const basePreviewSrcUrl = useMemo(
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewBridge=scroll&odPreviewBridge=selection`,
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot`,
[projectId, file.name, file.mtime, reloadKey],
);
const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl);
@@ -7152,6 +7152,14 @@ function HtmlViewer({
return requestPreviewSnapshotWithRetry(activeIframe);
}
const urlIframe = iframeRef.current ?? urlPreviewIframeRef.current;
if (urlIframe) {
await waitForIframeLoadOrTimeout(urlIframe, 250);
await waitForAnimationFrame();
const urlSnapshot = await requestPreviewSnapshotWithRetry(urlIframe);
if (urlSnapshot) return urlSnapshot;
}
const srcDocIframe = srcDocPreviewIframeRef.current;
if (!srcDocIframe) {
const activeIframe = iframeRef.current;
@@ -626,7 +626,7 @@ describe('FileViewer SVG artifacts', () => {
const { container } = render(<Shell />);
const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection');
expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot');
fireEvent.click(screen.getByRole('button', { name: 'Leave project' }));
@@ -634,7 +634,7 @@ describe('FileViewer SVG artifacts', () => {
expect(screen.getByTestId('home-view')).toBeTruthy();
const parkedFrame = container.querySelector<HTMLIFrameElement>('.iframe-keep-alive-pool iframe');
expect(parkedFrame).toBe(firstFrame);
expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection');
expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot');
fireEvent.click(screen.getByRole('button', { name: 'Return project' }));
@@ -781,7 +781,7 @@ describe('FileViewer SVG artifacts', () => {
expect(markup).toContain('data-od-render-mode="url-load"');
expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"');
expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="false"');
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&amp;r=0&amp;odPreviewBridge=scroll&amp;odPreviewBridge=selection"');
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&amp;r=0&amp;odPreviewBridge=scroll&amp;odPreviewBridge=selection&amp;odPreviewBridge=snapshot"');
expect(markup).toContain('sandbox="allow-scripts allow-downloads"');
});
@@ -811,13 +811,13 @@ describe('FileViewer SVG artifacts', () => {
);
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(frame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection');
expect(frame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot');
fireEvent.click(screen.getByRole('button', { name: /reload preview/i }));
const reloadedFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(reloadedFrame).toBe(frame);
expect(reloadedFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=1&odPreviewBridge=scroll&odPreviewBridge=selection');
expect(reloadedFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=1&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot');
});
it('remounts the srcDoc HTML preview when reload is requested', () => {
@@ -1167,7 +1167,7 @@ describe('FileViewer SVG artifacts', () => {
const { container } = render(<Switcher />);
const getFrame = () => container.querySelector<HTMLIFrameElement>('[data-testid="artifact-preview-frame"]');
const initialFrame = getFrame();
expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection');
expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot');
const observationsBeforeSwitch = observedCommittedSrcs.length;
fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));
@@ -1175,9 +1175,9 @@ describe('FileViewer SVG artifacts', () => {
const nextFrame = getFrame();
expect(nextFrame).toBeTruthy();
expect(observedCommittedSrcs[observationsBeforeSwitch]).toBe(
'/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection',
'/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot',
);
expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection');
expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot');
});
it('allows downloads in the in-tab HTML presentation iframe', async () => {
@@ -68,7 +68,7 @@ function renderHtmlPreview() {
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 };
return { ...view, activeFrame, srcDocFrame: srcDocFrame as HTMLIFrameElement };
}
function openImageExportDialog() {
@@ -86,7 +86,7 @@ async function waitForSaveButton() {
describe('FileViewer image export', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.resetAllMocks();
});
it('lets users choose an image format before saving URL-loaded HTML previews', async () => {
@@ -107,12 +107,12 @@ describe('FileViewer image export', () => {
save: saveImageBlobMock,
});
const { srcDocFrame } = renderHtmlPreview();
const { activeFrame } = renderHtmlPreview();
openImageExportDialog();
expect(screen.getByRole('radio', { name: 'PNG' })).toBeTruthy();
await waitFor(() => {
expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(srcDocFrame, 1500);
expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(activeFrame, 1500);
expect(imageDataUrlToBlobMock).toHaveBeenCalledWith('data:image/png;base64,ok', 'png');
});
await waitForSaveButton();
@@ -123,7 +123,7 @@ describe('FileViewer image export', () => {
});
fireEvent.click(await waitForSaveButton());
fireEvent.load(srcDocFrame as HTMLIFrameElement);
fireEvent.load(activeFrame as HTMLIFrameElement);
await waitFor(() => {
expect(prepareImageExportTargetMock).toHaveBeenCalledWith('workspace', 'jpeg', { useNativePicker: false });
@@ -135,24 +135,52 @@ describe('FileViewer image export', () => {
it('retries the srcDoc snapshot bridge before giving up on URL-loaded previews', async () => {
const pngBlob = new Blob(['png'], { type: 'image/png' });
requestPreviewSnapshotMock
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
let srcDocAttempts = 0;
requestPreviewSnapshotMock.mockImplementation(async (iframe: HTMLIFrameElement) => {
if (iframe.getAttribute('data-od-render-mode') === 'url-load') return null;
srcDocAttempts += 1;
if (srcDocAttempts === 1) return null;
return {
dataUrl: 'data:image/png;base64,recovered',
w: 800,
h: 600,
});
};
});
imageDataUrlToBlobMock.mockResolvedValueOnce(pngBlob);
const { srcDocFrame } = renderHtmlPreview();
openImageExportDialog();
await waitFor(() => {
expect(requestPreviewSnapshotMock).toHaveBeenCalledTimes(2);
expect(requestPreviewSnapshotMock).toHaveBeenNthCalledWith(1, srcDocFrame, 1500);
expect(requestPreviewSnapshotMock).toHaveBeenNthCalledWith(2, srcDocFrame, 3000);
expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(srcDocFrame, 1500);
expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(srcDocFrame, 3000);
expect(imageDataUrlToBlobMock).toHaveBeenCalledWith('data:image/png;base64,recovered', 'png');
}, { timeout: 4000 });
});
it('captures the visible URL-loaded preview before falling back to the hidden srcDoc transport', async () => {
const pngBlob = new Blob(['png'], { type: 'image/png' });
requestPreviewSnapshotMock.mockImplementation(async (iframe: HTMLIFrameElement) => {
if (iframe.getAttribute('data-od-render-mode') === 'url-load') {
return {
dataUrl: 'data:image/png;base64,visible',
w: 800,
h: 600,
};
}
return null;
});
imageDataUrlToBlobMock.mockResolvedValueOnce(pngBlob);
const { activeFrame, srcDocFrame } = renderHtmlPreview();
openImageExportDialog();
await waitFor(() => {
expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(activeFrame, 1500);
expect(imageDataUrlToBlobMock).toHaveBeenCalledWith('data:image/png;base64,visible', 'png');
});
expect(requestPreviewSnapshotMock).not.toHaveBeenCalledWith(srcDocFrame, 1500);
expect(screen.queryByRole('alert')).toBeNull();
});
it('uses the prepared PNG data URL for fallback downloads', async () => {
@@ -196,7 +224,7 @@ describe('FileViewer image export', () => {
expect(screen.getByRole('alert').textContent).toBe(
"Image capture failed. Please try again or use your browser's screenshot tool.",
);
});
}, { timeout: 4000 });
expect((screen.getByRole('button', { name: /^save$/i }) as HTMLButtonElement).disabled).toBe(true);
expect(prepareImageExportTargetMock).not.toHaveBeenCalled();
expect(imageDataUrlToBlobMock).not.toHaveBeenCalled();
@@ -223,7 +251,7 @@ describe('FileViewer image export', () => {
expect(screen.getByRole('alert').textContent).toBe(
"Image capture failed. Please try again or use your browser's screenshot tool.",
);
});
}, { timeout: 4000 });
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();