mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Prevent preview wheel gestures from escaping into zoom
Trackpad pinch-like wheel events arrive with ctrl/meta modifiers on some platforms, which can make a normal vertical scroll feel like the preview zoomed. The preview now consumes those modified wheel events inside the host preview shell and in injected srcdoc previews, then maps the delta back to scroll where a scroll target exists. Constraint: URL-loaded sandbox iframes cannot always be inspected by the host, so srcdoc previews need their own in-frame guard. Rejected: Add allow-same-origin to preview iframes | weakens the sandbox boundary for generated artifacts. Confidence: medium Scope-risk: narrow Directive: Do not broaden iframe sandbox permissions to fix gesture handling without a security review. Tested: pnpm guard Tested: pnpm --filter @open-design/web typecheck Tested: pnpm --filter @open-design/web exec vitest run tests/components/FileViewer.test.tsx tests/runtime/srcdoc.test.ts Tested: playwright-cli verified ctrl-wheel in preview keeps app zoom at 100% and prevents default in the iframe context
This commit is contained in:
parent
880723a4aa
commit
976407ab4c
4 changed files with 175 additions and 0 deletions
|
|
@ -478,6 +478,56 @@ export function effectivePreviewScale(
|
|||
return Math.min(previewScale, fitScale);
|
||||
}
|
||||
|
||||
function previewWheelDeltaToPixels(delta: number, deltaMode: number): number {
|
||||
const WHEEL_DELTA_LINE = 1;
|
||||
const WHEEL_DELTA_PAGE = 2;
|
||||
|
||||
if (deltaMode === WHEEL_DELTA_LINE) return delta * 16;
|
||||
if (deltaMode === WHEEL_DELTA_PAGE) return delta * 160;
|
||||
return delta;
|
||||
}
|
||||
|
||||
export function handlePreviewWheelZoomGesture(
|
||||
event: Pick<globalThis.WheelEvent, 'ctrlKey' | 'metaKey' | 'deltaMode' | 'deltaX' | 'deltaY' | 'preventDefault'>,
|
||||
scrollTarget?: Pick<HTMLElement, 'scrollLeft' | 'scrollTop'> | null,
|
||||
) {
|
||||
if (!event.ctrlKey && !event.metaKey) return false;
|
||||
|
||||
event.preventDefault();
|
||||
if (scrollTarget) {
|
||||
scrollTarget.scrollLeft += previewWheelDeltaToPixels(event.deltaX, event.deltaMode);
|
||||
scrollTarget.scrollTop += previewWheelDeltaToPixels(event.deltaY, event.deltaMode);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isScrollablePreviewElement(value: HTMLElement): boolean {
|
||||
return value.scrollHeight > value.clientHeight || value.scrollWidth > value.clientWidth;
|
||||
}
|
||||
|
||||
function previewWheelScrollTarget(event: globalThis.WheelEvent, fallback: HTMLElement | null): HTMLElement | null {
|
||||
let node = event.target instanceof HTMLElement ? event.target : null;
|
||||
while (node) {
|
||||
if (isScrollablePreviewElement(node)) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function installPreviewWheelZoomGuard(
|
||||
target: EventTarget | null | undefined,
|
||||
scrollTargetForEvent: (event: globalThis.WheelEvent) => HTMLElement | null,
|
||||
) {
|
||||
if (!target) return () => {};
|
||||
const onWheel = (event: Event) => {
|
||||
if (event instanceof WheelEvent) {
|
||||
handlePreviewWheelZoomGesture(event, scrollTargetForEvent(event));
|
||||
}
|
||||
};
|
||||
target.addEventListener('wheel', onWheel, { capture: true, passive: false });
|
||||
return () => target.removeEventListener('wheel', onWheel, { capture: true });
|
||||
}
|
||||
|
||||
function previewScaleShellStyle(
|
||||
viewport: PreviewViewportId,
|
||||
previewScale: number,
|
||||
|
|
@ -712,6 +762,7 @@ export function LiveArtifactViewer({
|
|||
const [previewViewport, setPreviewViewport] = useState<PreviewViewportId>('desktop');
|
||||
const [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>();
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const iframeWheelZoomGuardCleanupRef = useRef<(() => void) | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshError, setRefreshError] = useState<string | null>(null);
|
||||
const [refreshSuccess, setRefreshSuccess] = useState<string | null>(null);
|
||||
|
|
@ -725,6 +776,29 @@ export function LiveArtifactViewer({
|
|||
if (typeof document === 'undefined') return;
|
||||
setChromeActionsHost(document.getElementById(APP_CHROME_FILE_ACTIONS_ID));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const host = previewBodyRef.current;
|
||||
return installPreviewWheelZoomGuard(host, (event) => previewWheelScrollTarget(event, host));
|
||||
}, []);
|
||||
const installIframeWheelZoomGuard = useCallback(() => {
|
||||
iframeWheelZoomGuardCleanupRef.current?.();
|
||||
iframeWheelZoomGuardCleanupRef.current = null;
|
||||
try {
|
||||
const frameDocument = iframeRef.current?.contentWindow?.document;
|
||||
if (!frameDocument) return;
|
||||
const fallback =
|
||||
frameDocument.querySelector<HTMLElement>('.design-canvas') ??
|
||||
(frameDocument.scrollingElement instanceof HTMLElement ? frameDocument.scrollingElement : null);
|
||||
iframeWheelZoomGuardCleanupRef.current = installPreviewWheelZoomGuard(
|
||||
frameDocument,
|
||||
(event) => previewWheelScrollTarget(event, fallback),
|
||||
);
|
||||
} catch {}
|
||||
}, []);
|
||||
useEffect(() => () => {
|
||||
iframeWheelZoomGuardCleanupRef.current?.();
|
||||
iframeWheelZoomGuardCleanupRef.current = null;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!presentMenuOpen) return;
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
|
|
@ -1111,6 +1185,7 @@ export function LiveArtifactViewer({
|
|||
title={liveArtifact.title}
|
||||
sandbox="allow-scripts allow-popups"
|
||||
src={previewUrl}
|
||||
onLoad={installIframeWheelZoomGuard}
|
||||
/>
|
||||
</PreviewDrawOverlay>
|
||||
</div>
|
||||
|
|
@ -3653,6 +3728,7 @@ function HtmlViewer({
|
|||
const [manualEditViewportWidth, setManualEditViewportWidth] = useState<number | null>(null);
|
||||
const [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>();
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const iframeWheelZoomGuardCleanupRef = useRef<(() => void) | null>(null);
|
||||
const previewScrollRestoreRef = useRef<{
|
||||
hostLeft: number;
|
||||
hostTop: number;
|
||||
|
|
@ -3685,6 +3761,29 @@ function HtmlViewer({
|
|||
return value;
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const host = previewBodyRef.current;
|
||||
return installPreviewWheelZoomGuard(host, (event) => previewWheelScrollTarget(event, host));
|
||||
}, []);
|
||||
const installIframeWheelZoomGuard = useCallback(() => {
|
||||
iframeWheelZoomGuardCleanupRef.current?.();
|
||||
iframeWheelZoomGuardCleanupRef.current = null;
|
||||
try {
|
||||
const frameDocument = iframeRef.current?.contentWindow?.document;
|
||||
if (!frameDocument) return;
|
||||
const fallback =
|
||||
frameDocument.querySelector<HTMLElement>('.design-canvas') ??
|
||||
(frameDocument.scrollingElement instanceof HTMLElement ? frameDocument.scrollingElement : null);
|
||||
iframeWheelZoomGuardCleanupRef.current = installPreviewWheelZoomGuard(
|
||||
frameDocument,
|
||||
(event) => previewWheelScrollTarget(event, fallback),
|
||||
);
|
||||
} catch {}
|
||||
}, []);
|
||||
useEffect(() => () => {
|
||||
iframeWheelZoomGuardCleanupRef.current?.();
|
||||
iframeWheelZoomGuardCleanupRef.current = null;
|
||||
}, []);
|
||||
const capturePreviewScrollPosition = useCallback(() => {
|
||||
const host = previewBodyRef.current;
|
||||
let frameLeft = 0;
|
||||
|
|
@ -5975,6 +6074,7 @@ function HtmlViewer({
|
|||
sandbox="allow-scripts"
|
||||
src={previewSrcUrl}
|
||||
onLoad={() => {
|
||||
installIframeWheelZoomGuard();
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
iframeRef.current?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
|
|
@ -5994,6 +6094,7 @@ function HtmlViewer({
|
|||
sandbox="allow-scripts"
|
||||
srcDoc={srcDoc}
|
||||
onLoad={() => {
|
||||
installIframeWheelZoomGuard();
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
iframeRef.current?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
|
|
|
|||
|
|
@ -433,6 +433,30 @@ function injectSandboxShim(doc: string): string {
|
|||
}
|
||||
tryShim('localStorage');
|
||||
tryShim('sessionStorage');
|
||||
function wheelDeltaToPixels(delta, mode){
|
||||
if (mode === 1) return delta * 16;
|
||||
if (mode === 2) return delta * 160;
|
||||
return delta;
|
||||
}
|
||||
function isScrollable(el){
|
||||
return !!el && (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth);
|
||||
}
|
||||
function wheelScrollTarget(event){
|
||||
var node = event.target && event.target.nodeType === 1 ? event.target : null;
|
||||
while (node && node !== document.documentElement) {
|
||||
if (isScrollable(node)) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return document.querySelector('.design-canvas') || document.scrollingElement || document.documentElement;
|
||||
}
|
||||
document.addEventListener('wheel', function(event){
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
event.preventDefault();
|
||||
var target = wheelScrollTarget(event);
|
||||
if (!target) return;
|
||||
target.scrollLeft += wheelDeltaToPixels(event.deltaX || 0, event.deltaMode || 0);
|
||||
target.scrollTop += wheelDeltaToPixels(event.deltaY || 0, event.deltaMode || 0);
|
||||
}, { capture: true, passive: false });
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target || !(e.target instanceof Element)) return;
|
||||
var link = e.target.closest('a[href]');
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
SvgViewer,
|
||||
applyInspectOverridesToSource,
|
||||
effectivePreviewScale,
|
||||
handlePreviewWheelZoomGesture,
|
||||
parseInspectOverridesFromSource,
|
||||
serializeInspectOverrides,
|
||||
updateInspectOverride,
|
||||
|
|
@ -77,6 +78,46 @@ describe('FileViewer preview scale', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('FileViewer preview wheel gestures', () => {
|
||||
it('guards ctrl-wheel trackpad gestures from becoming browser zoom', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const scrollTarget = { scrollLeft: 4, scrollTop: 10 } as HTMLElement;
|
||||
|
||||
const handled = handlePreviewWheelZoomGesture({
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 3,
|
||||
deltaY: 42,
|
||||
preventDefault,
|
||||
}, scrollTarget);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(scrollTarget.scrollLeft).toBe(7);
|
||||
expect(scrollTarget.scrollTop).toBe(52);
|
||||
});
|
||||
|
||||
it('leaves ordinary two-finger scroll gestures native', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const scrollTarget = { scrollLeft: 4, scrollTop: 10 } as HTMLElement;
|
||||
|
||||
const handled = handlePreviewWheelZoomGesture({
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 3,
|
||||
deltaY: 42,
|
||||
preventDefault,
|
||||
}, scrollTarget);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
expect(scrollTarget.scrollLeft).toBe(4);
|
||||
expect(scrollTarget.scrollTop).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileViewer JSON artifacts', () => {
|
||||
it('pretty-prints valid JSON in the text viewer', async () => {
|
||||
const file = baseFile({
|
||||
|
|
|
|||
|
|
@ -37,6 +37,15 @@ describe('buildSrcdoc', () => {
|
|||
expect(srcdoc).toContain('foreignObject');
|
||||
});
|
||||
|
||||
it('guards sandboxed previews from ctrl-wheel browser zoom gestures', () => {
|
||||
const srcdoc = buildSrcdoc('<main style="height:200vh">Hero</main>');
|
||||
|
||||
expect(srcdoc).toContain("document.addEventListener('wheel'");
|
||||
expect(srcdoc).toContain('if (!event.ctrlKey && !event.metaKey) return;');
|
||||
expect(srcdoc).toContain('event.preventDefault();');
|
||||
expect(srcdoc).toContain("document.querySelector('.design-canvas')");
|
||||
});
|
||||
|
||||
it('only uses directly mutable slide conventions for setActive support', () => {
|
||||
const srcdoc = buildSrcdoc(
|
||||
'<section class="slide">One</section><section class="slide">Two</section>',
|
||||
|
|
|
|||
Loading…
Reference in a new issue