mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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
This commit is contained in:
parent
6f532ca35c
commit
15aafc815d
4 changed files with 132 additions and 4 deletions
|
|
@ -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<HTMLCanvasElement>) {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<PreviewDrawOverlay active>
|
||||
<iframe title="preview" sandbox="allow-scripts allow-downloads" />
|
||||
</PreviewDrawOverlay>,
|
||||
);
|
||||
|
||||
const canvas = container.querySelector('canvas');
|
||||
const iframe = container.querySelector('iframe');
|
||||
expect(canvas).toBeTruthy();
|
||||
expect(iframe?.contentWindow).toBeTruthy();
|
||||
|
||||
const postMessage = vi.fn();
|
||||
Object.defineProperty(iframe!.contentWindow!, 'postMessage', {
|
||||
value: postMessage,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
fireEvent.wheel(canvas!, {
|
||||
deltaX: 8,
|
||||
deltaY: 96,
|
||||
});
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
{ type: 'od:preview-scroll-by', left: 8, top: 96 },
|
||||
'*',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the scroll bridge when direct frame scroll is cross-origin blocked', () => {
|
||||
const { container } = render(
|
||||
<PreviewDrawOverlay active>
|
||||
<iframe title="preview" />
|
||||
</PreviewDrawOverlay>,
|
||||
);
|
||||
|
||||
const canvas = container.querySelector('canvas');
|
||||
const iframe = container.querySelector('iframe');
|
||||
expect(canvas).toBeTruthy();
|
||||
expect(iframe?.contentWindow).toBeTruthy();
|
||||
|
||||
const postMessage = vi.fn();
|
||||
Object.defineProperty(iframe!.contentWindow!, 'postMessage', {
|
||||
value: postMessage,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(iframe!.contentWindow!, 'scrollBy', {
|
||||
get() {
|
||||
throw new DOMException('Blocked a frame from accessing a cross-origin frame.', 'SecurityError');
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
fireEvent.wheel(canvas!, {
|
||||
deltaX: 4,
|
||||
deltaY: 72,
|
||||
});
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
{ type: 'od:preview-scroll-by', left: 4, top: 72 },
|
||||
'*',
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the draw toolbar from an explicit close button', async () => {
|
||||
const onActiveChange = vi.fn();
|
||||
const { getByRole } = render(
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ describe('buildSrcdoc', () => {
|
|||
expect(srcdoc).toContain('schedulePostPreviewScroll');
|
||||
expect(srcdoc).toContain("type: 'od:preview-scroll'");
|
||||
expect(srcdoc).toContain("type: 'od:preview-scroll-request'");
|
||||
expect(srcdoc).toContain("data.type === 'od:preview-scroll-by'");
|
||||
expect(srcdoc).toContain('previewScrollBy(data.left, data.top)');
|
||||
expect(srcdoc).toContain('data-od-selection-bridge-style');
|
||||
expect(srcdoc).toContain('html[data-od-comment-mode] body iframe');
|
||||
expect(srcdoc).toContain('html[data-od-inspect-mode] body iframe');
|
||||
|
|
|
|||
Loading…
Reference in a new issue