From 15aafc815d5fdf33936dc1eb996a35b85e590436 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 21:40:11 +0800 Subject: [PATCH] fix(web): make draw scroll cross-origin safe Proxy preview scroll wheel deltas through the iframe bridge so Draw can keep scrolling URL-loaded previews without cross-origin access failures. Agent-Model: gpt-5 Agent-Family: openai Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197 Agent-Step: 0.0.4 --- .../web/src/components/PreviewDrawOverlay.tsx | 42 ++++++++++-- apps/web/src/runtime/srcdoc.ts | 28 ++++++++ .../components/PreviewDrawOverlay.test.tsx | 64 +++++++++++++++++++ apps/web/tests/runtime/srcdoc.test.ts | 2 + 4 files changed, 132 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/PreviewDrawOverlay.tsx b/apps/web/src/components/PreviewDrawOverlay.tsx index eb1d28a14..0da884bb8 100644 --- a/apps/web/src/components/PreviewDrawOverlay.tsx +++ b/apps/web/src/components/PreviewDrawOverlay.tsx @@ -184,6 +184,40 @@ export function PreviewDrawOverlay({ ); } + function canTryDirectFrameScroll(iframe: HTMLIFrameElement): boolean { + const sandbox = iframe.getAttribute('sandbox'); + return sandbox === null || /\ballow-same-origin\b/.test(sandbox); + } + + function postFrameScrollBy(win: Window, left: number, top: number): boolean { + try { + win.postMessage({ type: 'od:preview-scroll-by', left, top }, '*'); + return true; + } catch { + return false; + } + } + + function scrollPreviewIframeBy(iframe: HTMLIFrameElement, left: number, top: number): boolean { + const win = iframe.contentWindow; + if (!win) return false; + + if (canTryDirectFrameScroll(iframe)) { + try { + const scrollBy = win.scrollBy; + if (typeof scrollBy === 'function') { + win.scrollBy({ left, top, behavior: 'auto' }); + return true; + } + } catch { + // Sandboxed / cross-origin frames throw on Window property reads. + // Fall through to the postMessage bridge injected into srcDoc previews. + } + } + + return postFrameScrollBy(win, left, top); + } + function onPointerDown(e: PointerEvent) { if (!active || sending) return; (e.target as Element).setPointerCapture?.(e.pointerId); @@ -233,10 +267,10 @@ export function PreviewDrawOverlay({ function onCanvasWheel(e: WheelEvent) { if (!active || sending) return; const iframe = activePreviewIframe(); - const win = iframe?.contentWindow; - if (!win || typeof win.scrollBy !== 'function') return; - e.preventDefault(); - win.scrollBy({ left: e.deltaX, top: e.deltaY, behavior: 'auto' }); + if (!iframe) return; + if (scrollPreviewIframeBy(iframe, e.deltaX, e.deltaY)) { + e.preventDefault(); + } } function clearInk() { diff --git a/apps/web/src/runtime/srcdoc.ts b/apps/web/src/runtime/srcdoc.ts index b1cfccde1..e8c75219b 100644 --- a/apps/web/src/runtime/srcdoc.ts +++ b/apps/web/src/runtime/srcdoc.ts @@ -1125,6 +1125,29 @@ function meaningfulDomFallbackTarget(el) { function previewScrollElement(){ return document.querySelector('.design-canvas') || document.scrollingElement || document.documentElement; } + function previewScrollBy(left, top){ + var dx = Number(left || 0); + var dy = Number(top || 0); + if (!Number.isFinite(dx)) dx = 0; + if (!Number.isFinite(dy)) dy = 0; + if (!dx && !dy) return; + var el = previewScrollElement(); + if (!el) return; + try { + if (typeof el.scrollBy === 'function') el.scrollBy({ left: dx, top: dy, behavior: 'auto' }); + else { + el.scrollLeft = (el.scrollLeft || 0) + dx; + el.scrollTop = (el.scrollTop || 0) + dy; + } + } catch (_) { + try { + el.scrollLeft = (el.scrollLeft || 0) + dx; + el.scrollTop = (el.scrollTop || 0) + dy; + } catch (__) {} + } + schedulePostTargets(); + schedulePostPreviewScroll(); + } function postPreviewScroll(){ var el = previewScrollElement(); if (!el) return; @@ -1322,6 +1345,11 @@ function meaningfulDomFallbackTarget(el) { schedulePostActiveCommentTarget(); return; } + if (data.type === 'od:preview-scroll-by') { + previewScrollBy(data.left, data.top); + return; + } + if (data.type === 'od:inspect-mode') { inspectEnabled = !!data.enabled; document.documentElement.toggleAttribute('data-od-inspect-mode', inspectEnabled); diff --git a/apps/web/tests/components/PreviewDrawOverlay.test.tsx b/apps/web/tests/components/PreviewDrawOverlay.test.tsx index 839abd762..6d7ec080c 100644 --- a/apps/web/tests/components/PreviewDrawOverlay.test.tsx +++ b/apps/web/tests/components/PreviewDrawOverlay.test.tsx @@ -158,6 +158,70 @@ describe('PreviewDrawOverlay', () => { expect(scrollBy).toHaveBeenCalledWith({ left: 12, top: 180, behavior: 'auto' }); }); + it('uses the postMessage scroll bridge for sandboxed preview iframes', () => { + const { container } = render( + +