diff --git a/src/__tests__/canvas/iframeFrameZoomGuard.test.tsx b/src/__tests__/canvas/iframeFrameZoomGuard.test.tsx new file mode 100644 index 00000000..5bc326ed --- /dev/null +++ b/src/__tests__/canvas/iframeFrameZoomGuard.test.tsx @@ -0,0 +1,101 @@ +/** + * Pinch / browser-zoom is blocked INSIDE the canvas breakpoint-frame iframes. + * + * Regression: `AdminZoomGuard` runs only on the parent admin document, but + * wheel / Safari `gesture*` events fire inside each frame's OWN iframe document + * and never cross the boundary. Without an in-frame guard, pinch-zooming over a + * breakpoint frame (e.g. while hovering its toolbar buttons) zoomed the whole + * admin page. `IframeFrameSurface` now installs the guard in every frame doc. + * + * The `gesturestart` / `gesturechange` / multi-touch assertions are the + * decisive ones: the design frame's pre-existing wheel forwarder already + * cancels every `wheel` event (to drive canvas pan), but it never touched the + * gesture/touch events — those are blocked solely by the new guard. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { cleanup, render, waitFor } from '@testing-library/react' +import { DndContext } from '@dnd-kit/core' +import { useEditorStore } from '@site/store/store' +import { CanvasRoot } from '@site/canvas/CanvasRoot' +import { makeNode, makePage, makeSite } from '../fixtures' +import '@modules/base' + +afterEach(cleanup) + +function dispatchCancelable( + target: EventTarget, + type: string, + props: Record = {}, +): Event { + const event = new Event(type, { cancelable: true, bubbles: true }) + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(event, key, { configurable: true, value }) + } + target.dispatchEvent(event) + return event +} + +function firstCanvasFrameDocument(): Document | null { + const iframe = Array.from(document.querySelectorAll('iframe')).find((i) => + i.title.startsWith('Canvas frame for '), + ) + return iframe?.contentDocument ?? null +} + +beforeEach(() => { + const page = makePage({ + id: 'p1', + slug: 'home', + title: 'Home', + rootNodeId: 'root', + nodes: { root: makeNode({ id: 'root', moduleId: 'base.body', children: [] }) }, + }) + const site = makeSite({ pages: [page] }) + useEditorStore.setState({ + site, + activePageId: 'p1', + activeDocument: null, + } as Parameters[0]) +}) + +describe('canvas breakpoint frame — in-frame browser-zoom guard', () => { + it('cancels pinch / ctrl-wheel and Safari gesture zoom inside the frame document', async () => { + render( + + + , + ) + + let frameDoc: Document | null = null + await waitFor(() => { + frameDoc = firstCanvasFrameDocument() + expect(frameDoc?.body).toBeTruthy() + }) + + // ctrl/meta wheel (trackpad pinch in Chrome) — zoom blocked. + expect(dispatchCancelable(frameDoc!, 'wheel', { ctrlKey: true }).defaultPrevented).toBe(true) + expect(dispatchCancelable(frameDoc!, 'wheel', { metaKey: true }).defaultPrevented).toBe(true) + // Safari pinch gestures + multi-touch pinch — only the new guard cancels these. + expect(dispatchCancelable(frameDoc!, 'gesturestart').defaultPrevented).toBe(true) + expect(dispatchCancelable(frameDoc!, 'gesturechange').defaultPrevented).toBe(true) + expect(dispatchCancelable(frameDoc!, 'touchmove', { touches: [{}, {}] }).defaultPrevented).toBe(true) + }) + + it('leaves single-finger touch scrolling inside the frame alone', async () => { + render( + + + , + ) + + let frameDoc: Document | null = null + await waitFor(() => { + frameDoc = firstCanvasFrameDocument() + expect(frameDoc?.body).toBeTruthy() + }) + + // A one-finger touchmove is a scroll, not a pinch — the guard must not cancel it. + expect(dispatchCancelable(frameDoc!, 'touchmove', { touches: [{}] }).defaultPrevented).toBe(false) + }) +}) diff --git a/src/admin/pages/site/canvas/IframeFrameSurface.tsx b/src/admin/pages/site/canvas/IframeFrameSurface.tsx index 947a129d..7dd8bc5f 100644 --- a/src/admin/pages/site/canvas/IframeFrameSurface.tsx +++ b/src/admin/pages/site/canvas/IframeFrameSurface.tsx @@ -84,6 +84,7 @@ import { CANVAS_VIEWPORT_HEIGHT, type CanvasViewport } from './resolveViewportUn import { useIframeFrameAutoHeight } from './useIframeFrameAutoHeight' import { applyIframeBodyReset, type IframeInteraction } from './iframeBodyReset' import { useEditorStore } from '@site/store/store' +import { installAdminZoomGuard } from '@admin/shared/AdminZoomGuard' import { closestReadonlyRegion, isElementLike } from './readonlyRegion' import styles from './IframeFrameSurface.module.css' @@ -318,6 +319,21 @@ export const IframeFrameSurface = forwardRef { + if (!iframeDoc) return + return installAdminZoomGuard(iframeDoc) + }, [iframeDoc]) + // ── Forward wheel events to the canvas gesture layer ───────────────── // Without this, scrolling the wheel while the cursor is over an iframe // does nothing — the iframe document doesn't propagate wheel events to