mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Fix URL preview image export capture
This commit is contained in:
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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&r=0&odPreviewBridge=scroll&odPreviewBridge=selection"');
|
||||
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&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();
|
||||
|
||||
Reference in New Issue
Block a user