diff --git a/docs/features/canvas-iframe-per-frame.md b/docs/features/canvas-iframe-per-frame.md
index b45c77a6..b5a56f7d 100644
--- a/docs/features/canvas-iframe-per-frame.md
+++ b/docs/features/canvas-iframe-per-frame.md
@@ -49,7 +49,7 @@ IframeFrameSurface
| Mode | `interaction` value | Height | Scroll | Wheel | Pointer | Keyboard | Canvas chrome CSS |
|---|---|---|---|---|---|---|---|
-| Design canvas frame | `'canvas'` (default) | grows to content | none (frame scrolls with canvas pan) | forwarded to parent pan/zoom | forwarded for middle-click / space pan | forwarded to parent `document` (shortcuts); `Tab` blocked | applied (cursor, user-select, outline overrides) |
+| Design canvas frame | `'canvas'` (default) | grows to content | none (frame scrolls with canvas pan) | forwarded to parent pan/zoom (`Shift+wheel` pans sideways) | forwarded for space-left-drag pan | forwarded to parent `document` (shortcuts); `Tab` blocked | applied (cursor, user-select, outline overrides) |
| Live frame | `'live'` | 100% | native (iframe is the scroll viewport) | not forwarded | not forwarded | not forwarded | not applied (real cursors, text selection) |
### Viewport-unit feedback loop guard
@@ -143,7 +143,7 @@ React synthetic events bubble through the React fiber tree, not the DOM tree, so
Native events require explicit handling for four cases:
- **Wheel events (design mode):** `IframeFrameSurface` listens for `wheel` inside the iframe document and re-dispatches a new `WheelEvent` on the iframe element (parent document) so `useCanvas`'s pan/zoom handler picks it up.
-- **Pointer events (design mode):** Middle-click pan, space+left-click pan, and active reorder drags (`data-instatic-canvas-dragging` on ``) all need to cross the iframe boundary. `IframeFrameSurface` tracks `spaceHeld` and `panPointerId` state to identify when a pointerdown starts a pan, then forwards `pointerdown`/`pointermove`/`pointerup`/`pointercancel` to the parent document.
+- **Pointer events (design mode):** Space+left-click pan and active reorder drags (`data-instatic-canvas-dragging` on ``) need to cross the iframe boundary. Normal mouse scrolling is handled by wheel forwarding, with `Shift+wheel` mapped to horizontal canvas pan. `IframeFrameSurface` tracks `spaceHeld` and `panPointerId` state to identify when a pointerdown starts a pan, then forwards `pointerdown`/`pointermove`/`pointerup`/`pointercancel` to the parent document.
- **Keyboard shortcuts (design mode):** Clicking a node to select it focuses the iframe, so subsequent keystrokes are delivered to the iframe document. The editor's global / editor / panel shortcuts are native listeners on the parent `window` (spotlight `⌘K`, save `⌘S`) and parent `document` (panel toggles, undo/redo), which never see iframe events. `IframeFrameSurface` re-dispatches a cloned `keydown` on the **parent `document`** (not the iframe element) so it reaches those window/document listeners without re-entering React's root container — which would otherwise double-fire the canvas-root `onKeyDown` shortcuts that already receive the original via fiber bubbling. `Tab` is the exception: it is blocked (not forwarded) to keep the browser from tab-walking focusable nodes inside the design preview.
- **Portal overlay dismiss (all modes):** Portal-based overlays (context menus, dropdowns) attach their dismiss-on-outside-click listeners at the document level. A `mousedown` inside an iframe fires on the iframe's own document and never bubbles to the parent's listener, leaving the overlay stuck open. `ContextMenu` calls `collectSameOriginDocuments` (`src/ui/lib/sameOriginDocuments.ts`) to gather the parent document plus every reachable same-origin iframe document, then attaches dismiss listeners to all of them. Cross-origin iframes are skipped — their events are unreachable. The check for whether an event target is a valid DOM node uses `isNode` (also in `sameOriginDocuments.ts`), a structural check on `nodeType` that works across iframe realms where `instanceof Node` would fail.
diff --git a/docs/reference/canvas-dnd.md b/docs/reference/canvas-dnd.md
index 2e76ea1a..eb723dc1 100644
--- a/docs/reference/canvas-dnd.md
+++ b/docs/reference/canvas-dnd.md
@@ -202,7 +202,7 @@ The module inserter keeps its own pointer drag state, but target resolution and
### Drop an existing node
-The canvas's `NodeRenderer` registers each node as `useDraggable({ id: `node:${nodeId}` })`. The drag is initiated by clicking the node's drag handle (or by middle-click drag in some flows). The same drop-zone resolution applies.
+The canvas's `NodeRenderer` registers each node as `useDraggable({ id: `node:${nodeId}` })`. The drag is initiated by clicking the node's drag handle. The same drop-zone resolution applies.
### Drop INTO a container
diff --git a/src/__tests__/canvas/useCanvasWheelSync.test.tsx b/src/__tests__/canvas/useCanvasWheelSync.test.tsx
index 51bee57c..0108a8b3 100644
--- a/src/__tests__/canvas/useCanvasWheelSync.test.tsx
+++ b/src/__tests__/canvas/useCanvasWheelSync.test.tsx
@@ -3,6 +3,12 @@ import { useRef } from 'react'
import { act, cleanup as cleanupRender, fireEvent, render, screen } from '@testing-library/react'
import { useEditorStore } from '@site/store/store'
import { RESET_ZOOM } from '@site/canvas/math'
+import {
+ isCanvasSpacePanActive,
+ panDeltaFromWheel,
+ setCanvasSpacePanActive,
+ shouldStartCanvasPointerPan,
+} from '@site/canvas/canvasPanInput'
import { useCanvas } from '@site/hooks/useCanvas'
import { installAdminZoomGuard } from '@admin/shared/AdminZoomGuard'
@@ -50,6 +56,27 @@ afterEach(() => {
})
describe('useCanvas wheel pan sync', () => {
+ it('maps shift-wheel mouse scrolling to horizontal canvas pan', async () => {
+ render()
+
+ const root = screen.getByTestId('test-canvas-root')
+ const layer = screen.getByTestId('test-transform-layer')
+
+ dispatchWheel(root, {
+ shiftKey: true,
+ deltaX: 0,
+ deltaY: 120,
+ clientX: 10,
+ clientY: 10,
+ })
+
+ await act(async () => {
+ await nextAnimationFrame()
+ })
+
+ expect(layer.style.transform).toBe('translate(-120px, 0px) scale(1)')
+ })
+
it('does not snap back to stale store pan when hover changes before the debounced pan commit', async () => {
render()
@@ -98,3 +125,28 @@ describe('useCanvas wheel pan sync', () => {
expect(layer.style.transform).not.toBe('translate(0px, 0px) scale(1)')
})
})
+
+describe('canvas mouse pan input policy', () => {
+ it('tracks parent and iframe space-pan state independently for iframe pointer relays', () => {
+ setCanvasSpacePanActive(document, 'parentDocument', true)
+ expect(isCanvasSpacePanActive(document)).toBe(true)
+
+ setCanvasSpacePanActive(document, 'iframe', true)
+ setCanvasSpacePanActive(document, 'parentDocument', false)
+ expect(isCanvasSpacePanActive(document)).toBe(true)
+
+ setCanvasSpacePanActive(document, 'iframe', false)
+ expect(isCanvasSpacePanActive(document)).toBe(false)
+ })
+
+ it('uses shift-wheel for sideways mouse scrolling', () => {
+ expect(panDeltaFromWheel({ shiftKey: true, deltaX: 0, deltaY: 120 })).toEqual({ dx: -120, dy: 0 })
+ expect(panDeltaFromWheel({ shiftKey: false, deltaX: 0, deltaY: 120 })).toEqual({ dx: 0, dy: -120 })
+ })
+
+ it('does not use middle-button dragging as a canvas pan gesture', () => {
+ expect(shouldStartCanvasPointerPan({ button: 1 }, { spaceHeld: false })).toBe(false)
+ expect(shouldStartCanvasPointerPan({ button: 1 }, { spaceHeld: true })).toBe(false)
+ expect(shouldStartCanvasPointerPan({ button: 0 }, { spaceHeld: true })).toBe(true)
+ })
+})
diff --git a/src/admin/pages/site/canvas/IframeFrameSurface.module.css b/src/admin/pages/site/canvas/IframeFrameSurface.module.css
index d6c676db..f95d8064 100644
--- a/src/admin/pages/site/canvas/IframeFrameSurface.module.css
+++ b/src/admin/pages/site/canvas/IframeFrameSurface.module.css
@@ -34,6 +34,11 @@
display: block;
}
+:global(html[data-instatic-canvas-parent-space-pan='1']) .iframe:not(.iframeLive),
+:global(html[data-instatic-canvas-iframe-space-pan='1']) .iframe:not(.iframeLive) {
+ pointer-events: none;
+}
+
/* Live frame: a single real-size viewport that scrolls internally instead of
* growing to content. The live surface controls the wrapper's width; the
* iframe fills it and owns its own scrollbar (published height behaviour).
diff --git a/src/admin/pages/site/canvas/IframeFrameSurface.tsx b/src/admin/pages/site/canvas/IframeFrameSurface.tsx
index f690b8e0..34c1a5af 100644
--- a/src/admin/pages/site/canvas/IframeFrameSurface.tsx
+++ b/src/admin/pages/site/canvas/IframeFrameSurface.tsx
@@ -82,6 +82,11 @@ import { useCanvasFormControlSuppression } from './useCanvasFormControlSuppressi
import { CANVAS_VIEWPORT_HEIGHT, type CanvasViewport } from './resolveViewportUnits'
import { useIframeFrameAutoHeight } from './useIframeFrameAutoHeight'
import { applyIframeBodyReset, type IframeInteraction } from './iframeBodyReset'
+import {
+ isCanvasSpacePanActive,
+ setCanvasSpacePanActive,
+ shouldStartCanvasPointerPan,
+} from './canvasPanInput'
import { useEditorStore } from '@site/store/store'
import { closestReadonlyRegion, isElementLike } from './readonlyRegion'
import styles from './IframeFrameSurface.module.css'
@@ -372,14 +377,12 @@ export const IframeFrameSurface = forwardRef {
- if (e.code === 'Space') spaceHeld = false
+ if (e.code === 'Space') {
+ spaceHeld = false
+ setCanvasSpacePanActive(parentDocument, 'iframe', false)
+ }
}
iframeDoc.addEventListener('keydown', onKeyDown)
iframeDoc.addEventListener('keyup', onKeyUp)
@@ -510,25 +519,22 @@ export const IframeFrameSurface = forwardRef {
- // Middle button down — middle-click pan.
- if (e.button === 1) return true
- // Space + left button down — Figma-style pan.
- if (spaceHeld && e.button === 0) return true
- return false
+ return shouldStartCanvasPointerPan(e, {
+ spaceHeld: spaceHeld || isCanvasSpacePanActive(parentDocument),
+ })
}
const maybeForward = (e: PointerEvent) => {
- // (3) An external canvas drag is in progress — forward move/up/
+ // (2) An external canvas drag is in progress — forward move/up/
// cancel so the parent's `window` listeners keep ticking.
// pointerdown is excluded: the iframe never originates the drag,
// and forwarding the first iframe-internal pointerdown would
@@ -546,13 +552,10 @@ export const IframeFrameSurface = forwardRef {
+ setCanvasSpacePanActive(parentDocument, 'iframe', false)
iframeDoc.removeEventListener('keydown', onKeyDown)
iframeDoc.removeEventListener('keyup', onKeyUp)
iframeDoc.removeEventListener('pointerdown', maybeForward)
diff --git a/src/admin/pages/site/canvas/canvasPanInput.ts b/src/admin/pages/site/canvas/canvasPanInput.ts
new file mode 100644
index 00000000..cf2d6510
--- /dev/null
+++ b/src/admin/pages/site/canvas/canvasPanInput.ts
@@ -0,0 +1,58 @@
+interface WheelPanEvent {
+ deltaX: number
+ deltaY: number
+ shiftKey: boolean
+}
+
+interface PointerPanEvent {
+ button: number
+}
+
+interface PointerPanOptions {
+ spaceHeld: boolean
+}
+
+type CanvasSpacePanSource = 'parentDocument' | 'iframe'
+
+const CANVAS_SPACE_PAN_DATA_KEYS: Record = {
+ parentDocument: 'instaticCanvasParentSpacePan',
+ iframe: 'instaticCanvasIframeSpacePan',
+}
+
+export function panDeltaFromWheel(event: WheelPanEvent): { dx: number; dy: number } {
+ const wheelX = event.shiftKey && event.deltaX === 0 ? event.deltaY : event.deltaX
+ const wheelY = event.shiftKey ? 0 : event.deltaY
+ return { dx: invertWheelDelta(wheelX), dy: invertWheelDelta(wheelY) }
+}
+
+export function shouldStartCanvasPointerPan(
+ event: PointerPanEvent,
+ { spaceHeld }: PointerPanOptions,
+): boolean {
+ return spaceHeld && event.button === 0
+}
+
+export function setCanvasSpacePanActive(
+ doc: Document,
+ source: CanvasSpacePanSource,
+ active: boolean,
+): void {
+ const key = CANVAS_SPACE_PAN_DATA_KEYS[source]
+ if (active) {
+ doc.documentElement.dataset[key] = '1'
+ return
+ }
+ delete doc.documentElement.dataset[key]
+}
+
+export function isCanvasSpacePanActive(doc: Document): boolean {
+ const { dataset } = doc.documentElement
+ return (
+ dataset[CANVAS_SPACE_PAN_DATA_KEYS.parentDocument] === '1' ||
+ dataset[CANVAS_SPACE_PAN_DATA_KEYS.iframe] === '1'
+ )
+}
+
+function invertWheelDelta(delta: number): number {
+ return delta === 0 ? 0 : -delta
+}
diff --git a/src/admin/pages/site/hooks/useCanvas.ts b/src/admin/pages/site/hooks/useCanvas.ts
index 03ca4ce9..37fcab16 100644
--- a/src/admin/pages/site/hooks/useCanvas.ts
+++ b/src/admin/pages/site/hooks/useCanvas.ts
@@ -11,7 +11,6 @@
* Input support:
* - Ctrl/Cmd + wheel → zoom towards cursor
* - Plain wheel → pan vertically (and horizontally with shift)
- * - Middle mouse drag → pan
* - Space + left-drag → pan
* - Pinch (touch) → zoom+pan
* - +/- keys → zoom in/out (committed immediately)
@@ -31,6 +30,7 @@ import {
incrementalScaleFromPinchMovement,
} from '@site/canvas/math'
import { panToCenterBreakpointFrame } from '@site/canvas/canvasDomGeometry'
+import { panDeltaFromWheel, setCanvasSpacePanActive } from '@site/canvas/canvasPanInput'
interface Transform {
zoom: number
@@ -262,16 +262,19 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
) return
e.preventDefault()
spaceActiveRef.current = true
+ setCanvasSpacePanActive(document, 'parentDocument', true)
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.code === 'Space') {
spaceActiveRef.current = false
+ setCanvasSpacePanActive(document, 'parentDocument', false)
}
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
return () => {
+ setCanvasSpacePanActive(document, 'parentDocument', false)
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
}
@@ -414,9 +417,8 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
return
}
- const wheelX = event.shiftKey && event.deltaX === 0 ? event.deltaY : event.deltaX
- const wheelY = event.shiftKey ? 0 : event.deltaY
- const next = applyPan(t.panX, t.panY, -wheelX, -wheelY)
+ const { dx, dy } = panDeltaFromWheel(event)
+ const next = applyPan(t.panX, t.panY, dx, dy)
updateTransform({ zoom: t.zoom, ...next })
}
@@ -431,12 +433,11 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
const bind = useGesture(
{
onDrag: ({ delta: [dx, dy], buttons, first, last }) => {
- const isMiddleButton = (buttons & 4) !== 0
- const isSpacePan = spaceActiveRef.current
-
- if (!isMiddleButton && !isSpacePan) return
+ if (first) {
+ isDraggingRef.current = spaceActiveRef.current && (buttons & 1) !== 0
+ }
- if (first) isDraggingRef.current = true
+ if (!isDraggingRef.current) return
if (last) isDraggingRef.current = false
const t = transformRef.current
@@ -497,7 +498,7 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
handleKeyDown,
panBy,
centerOnBreakpointFrame,
- /** Whether a space-pan or middle-mouse drag is in progress */
+ /** Whether a space-pan drag is in progress */
isDragging: isDraggingRef,
}
}