Skip to content

Commit cb5fd3b

Browse files
authored
fix(web): auto-fit wide html previews (#5751)
* fix(web): auto-fit wide html previews Generated-By: looper 0.0.0-dev (runner=worker, agent=codex) * test(e2e): allow auto-fit preview zoom Generated-By: looper 0.0.0-dev (runner=worker, agent=codex) * fix(web): gate preview auto-fit on content width Generated-By: looper 0.0.0-dev (runner=worker, agent=codex) * fix: measure preview width through iframe bridge * fix: bridge powered preview sizing
1 parent 023fc6e commit cb5fd3b

7 files changed

Lines changed: 464 additions & 49 deletions

File tree

apps/daemon/src/routes/project/index.ts

Lines changed: 98 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,44 @@ const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
111111
if (window.__odUrlScrollBridge) return;
112112
window.__odUrlScrollBridge = true;
113113
var pending = false;
114+
var contentSizePending = false;
114115
function scrollElement(){
115116
return document.querySelector('.design-canvas') || document.scrollingElement || document.documentElement;
116117
}
117118
function num(value){
118119
var next = Number(value || 0);
119120
return Number.isFinite(next) ? next : 0;
120121
}
122+
function measureContentWidth(){
123+
var root = document.documentElement;
124+
var body = document.body || root;
125+
if (!root) return null;
126+
var values = [
127+
root.scrollWidth,
128+
body && body.scrollWidth,
129+
root.offsetWidth,
130+
body && body.offsetWidth,
131+
root.clientWidth,
132+
body && body.clientWidth
133+
];
134+
var width = 0;
135+
for (var i = 0; i < values.length; i += 1) {
136+
var next = num(values[i]);
137+
if (next > width) width = next;
138+
}
139+
return width > 0 ? Math.ceil(width) : null;
140+
}
141+
function postContentSize(){
142+
window.parent.postMessage({ type: 'od:preview-content-size', width: measureContentWidth() }, '*');
143+
}
144+
function scheduleContentSize(){
145+
if (contentSizePending) return;
146+
contentSizePending = true;
147+
window.requestAnimationFrame(function(){
148+
contentSizePending = false;
149+
postContentSize();
150+
});
151+
}
121152
function post(){
122153
var el = scrollElement();
123154
if (!el) return;
@@ -172,22 +203,44 @@ const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
172203
if (data.type === 'od:preview-scroll-by') {
173204
scrollBy(scrollElement(), data.left, data.top);
174205
schedule();
206+
scheduleContentSize();
207+
return;
208+
}
209+
if (data.type === 'od:preview-content-size-request') {
210+
scheduleContentSize();
175211
}
176212
});
177213
window.addEventListener('scroll', schedule, true);
178214
document.addEventListener('scroll', schedule, true);
179-
window.addEventListener('resize', schedule);
215+
window.addEventListener('resize', function(){
216+
schedule();
217+
scheduleContentSize();
218+
});
219+
if (typeof ResizeObserver !== 'undefined') {
220+
try {
221+
var observer = new ResizeObserver(scheduleContentSize);
222+
observer.observe(document.documentElement);
223+
if (document.body) observer.observe(document.body);
224+
} catch (_) {}
225+
}
180226
if (document.readyState === 'loading') {
181227
document.addEventListener('DOMContentLoaded', function(){
182228
requestRestore();
183229
schedule();
230+
scheduleContentSize();
184231
});
185232
} else {
186233
setTimeout(function(){
187234
requestRestore();
188235
schedule();
236+
scheduleContentSize();
189237
}, 0);
190238
}
239+
setTimeout(scheduleContentSize, 80);
240+
setTimeout(scheduleContentSize, 260);
241+
if (document.fonts && document.fonts.ready) {
242+
document.fonts.ready.then(scheduleContentSize).catch(function(){});
243+
}
191244
})();
192245
</script>`;
193246

@@ -809,6 +862,39 @@ function injectUrlPreviewBridge(html: string, bridge: 'scroll' | 'selection' | '
809862
return injectBeforeBodyClose(html, 'data-od-url-snapshot-bridge', URL_PREVIEW_SNAPSHOT_BRIDGE);
810863
}
811864

865+
function applyUrlPreviewBridgesToHtml(
866+
transformed: string | Buffer,
867+
mime: string,
868+
requestedBridge: unknown,
869+
): string | Buffer {
870+
if (
871+
!(
872+
wantsUrlPreviewScrollBridge(requestedBridge) ||
873+
wantsUrlPreviewSelectionBridge(requestedBridge) ||
874+
wantsUrlPreviewSnapshotBridge(requestedBridge)
875+
) ||
876+
!/^text\/html(?:;|$)/i.test(mime)
877+
) {
878+
return transformed;
879+
}
880+
881+
let html = Buffer.isBuffer(transformed) ? transformed.toString('utf8') : transformed;
882+
// Sanitize the <title> so Cmd+P -> "Save as PDF" produces a Teams-safe
883+
// filename. URL-load iframes cannot rely on the host rewriting the document
884+
// title after load, and powered previews are intentionally cross-origin.
885+
html = daemonSanitizeTitleInDoc(html);
886+
if (wantsUrlPreviewScrollBridge(requestedBridge)) {
887+
html = injectUrlPreviewBridge(html, 'scroll');
888+
}
889+
if (wantsUrlPreviewSelectionBridge(requestedBridge)) {
890+
html = injectUrlPreviewBridge(html, 'selection');
891+
}
892+
if (wantsUrlPreviewSnapshotBridge(requestedBridge)) {
893+
html = injectUrlPreviewBridge(html, 'snapshot');
894+
}
895+
return html;
896+
}
897+
812898
// ---------------------------------------------------------------------------
813899
// Teams-safe title sanitization for the URL-load preview path (issue #3918).
814900
//
@@ -3222,39 +3308,15 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
32223308
project?.metadata,
32233309
undefined,
32243310
skipHtmlPreviewBridge ? undefined : async (file) => {
3225-
let transformed = await maybeResolveVitePreviewHtml({
3311+
const transformed = await maybeResolveVitePreviewHtml({
32263312
file,
32273313
projectId,
32283314
relPath,
32293315
metadata: project?.metadata,
32303316
projectsRoot: PROJECTS_DIR,
32313317
readProjectFile,
32323318
});
3233-
if (
3234-
(wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) ||
3235-
wantsUrlPreviewSelectionBridge(req.query.odPreviewBridge) ||
3236-
wantsUrlPreviewSnapshotBridge(req.query.odPreviewBridge)) &&
3237-
/^text\/html(?:;|$)/i.test(file.mime)
3238-
) {
3239-
let html = Buffer.isBuffer(transformed) ? transformed.toString('utf8') : transformed;
3240-
// Sanitize the <title> so Cmd+P → "Save as PDF" produces a
3241-
// Teams-safe filename. The URL-load iframe uses sandbox without
3242-
// allow-same-origin, so the host cannot rewrite contentDocument.title
3243-
// after load — we must do it here in the response. The srcDoc path
3244-
// has its own sanitization in buildSrcdoc (apps/web/src/runtime/srcdoc.ts).
3245-
html = daemonSanitizeTitleInDoc(html);
3246-
if (wantsUrlPreviewScrollBridge(req.query.odPreviewBridge)) {
3247-
html = injectUrlPreviewBridge(html, 'scroll');
3248-
}
3249-
if (wantsUrlPreviewSelectionBridge(req.query.odPreviewBridge)) {
3250-
html = injectUrlPreviewBridge(html, 'selection');
3251-
}
3252-
if (wantsUrlPreviewSnapshotBridge(req.query.odPreviewBridge)) {
3253-
html = injectUrlPreviewBridge(html, 'snapshot');
3254-
}
3255-
transformed = html;
3256-
}
3257-
return transformed;
3319+
return applyUrlPreviewBridgesToHtml(transformed, file.mime, req.query.odPreviewBridge);
32583320
},
32593321
true, // revalidate: emit ETag/Last-Modified so covers/preview/export reuse cached assets
32603322
);
@@ -3304,15 +3366,17 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
33043366
relPath,
33053367
project?.metadata,
33063368
() => setPoweredPreviewHeaders(res),
3307-
skipPoweredTransform ? undefined : async (file) =>
3308-
maybeResolveVitePreviewHtml({
3369+
skipPoweredTransform ? undefined : async (file) => {
3370+
const transformed = await maybeResolveVitePreviewHtml({
33093371
file,
33103372
projectId,
3311-
relPath,
3312-
metadata: project?.metadata,
3313-
projectsRoot: PROJECTS_DIR,
3314-
readProjectFile,
3315-
}),
3373+
relPath,
3374+
metadata: project?.metadata,
3375+
projectsRoot: PROJECTS_DIR,
3376+
readProjectFile,
3377+
});
3378+
return applyUrlPreviewBridgesToHtml(transformed, file.mime, req.query.odPreviewBridge);
3379+
},
33163380
);
33173381
} catch (err: any) {
33183382
const status = err && err.code === 'ENOENT' ? 404 : 400;

apps/daemon/tests/project-file-range.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,8 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
349349
const html = await bridged.text();
350350
expect(html).toContain('data-od-url-scroll-bridge');
351351
expect(html).toContain("type: 'od:preview-scroll'");
352+
expect(html).toContain("type: 'od:preview-content-size'");
353+
expect(html).toContain('od:preview-content-size-request');
352354
});
353355

354356
it('injects the URL preview scroll bridge before the closing body tag', async () => {
@@ -427,6 +429,16 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
427429
expect(preflight.headers.get('access-control-allow-origin')).toBeNull();
428430
});
429431

432+
it('injects the URL preview scroll bridge for powered previews when requested', async () => {
433+
const bridged = await fetch(`${poweredUrl('page.html')}?odPreviewBridge=scroll`);
434+
expect(bridged.status).toBe(200);
435+
expect(bridged.headers.get('document-isolation-policy')).toBe('isolate-and-credentialless');
436+
const html = await bridged.text();
437+
expect(html).toContain('data-od-url-scroll-bridge');
438+
expect(html).toContain("type: 'od:preview-content-size'");
439+
expect(html).toContain('od:preview-content-size-request');
440+
});
441+
430442
it('does not let the powered preview origin call normal daemon APIs', async () => {
431443
const origin = poweredOrigin();
432444
const poweredReferer = `${origin}/api/projects/${projectId}/powered/page.html`;

0 commit comments

Comments
 (0)