Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/features/canvas-iframe-per-frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<html>`) 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 `<html>`) 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.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/canvas-dnd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions src/__tests__/canvas/useCanvasWheelSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -50,6 +56,27 @@ afterEach(() => {
})

describe('useCanvas wheel pan sync', () => {
it('maps shift-wheel mouse scrolling to horizontal canvas pan', async () => {
render(<TestCanvas />)

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(<TestCanvas />)

Expand Down Expand Up @@ -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)
})
})
5 changes: 5 additions & 0 deletions src/admin/pages/site/canvas/IframeFrameSurface.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
62 changes: 33 additions & 29 deletions src/admin/pages/site/canvas/IframeFrameSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -372,14 +377,12 @@ export const IframeFrameSurface = forwardRef<IframeFrameSurfaceHandle, IframeFra
// ── Forward pointer events for canvas pan gestures + parent-doc canvas drags ────
// The canvas pan gesture (useCanvas via @use-gesture) and the canvas
// parent-doc canvas drag handlers both live in the parent document
// and rely on `window` pointer events. Three scenarios need to cross
// and rely on `window` pointer events. Two scenarios need to cross
// the iframe boundary from inside the iframe back to the parent:
//
// 1. Middle-click drag (`e.button === 1`) — always a pan, regardless
// of where the cursor is.
// 2. Space + left-click drag (Figma convention) — pan when the user
// 1. Space + left-click drag (Figma convention) — pan when the user
// is holding space, even with the cursor over a frame.
// 3. An active canvas drag started outside the iframe (selection
// 2. An active canvas drag started outside the iframe (selection
// toolbar handle, media panel asset, etc.). The
// pointer down fires in the parent, then as the cursor enters an
// iframe its pointermove/up events go to the iframe instead of
Expand Down Expand Up @@ -459,7 +462,10 @@ export const IframeFrameSurface = forwardRef<IframeFrameSurfaceHandle, IframeFra
// coalesced session) while the DOM keeps the text — store/DOM diverge.
// The element's own React onKeyDown still owns Escape/Enter.
if (useEditorStore.getState().activeInlineEdit) return
if (e.code === 'Space' && !e.repeat) spaceHeld = true
if (e.code === 'Space' && !e.repeat) {
spaceHeld = true
setCanvasSpacePanActive(parentDocument, 'iframe', true)
}
// Block Tab navigation inside the canvas iframe. The author is
// designing, not using, the page — letting Tab walk through
// link / button controls inside the iframe surface the browser's
Expand All @@ -474,7 +480,10 @@ export const IframeFrameSurface = forwardRef<IframeFrameSurfaceHandle, IframeFra
forwardKeyboard(e)
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceHeld = false
if (e.code === 'Space') {
spaceHeld = false
setCanvasSpacePanActive(parentDocument, 'iframe', false)
}
}
iframeDoc.addEventListener('keydown', onKeyDown)
iframeDoc.addEventListener('keyup', onKeyUp)
Expand Down Expand Up @@ -510,25 +519,22 @@ export const IframeFrameSurface = forwardRef<IframeFrameSurfaceHandle, IframeFra
return Number.isFinite(id) ? { pointerId: id } : { pointerId: 0 }
}
// True while a pan gesture started inside this iframe is still in
// flight (middle-click hold OR space+left-click hold). We start a
// pan on pointerdown when the conditions match and keep forwarding
// every subsequent pointermove / pointerup for the same pointerId
// until the button comes back up. This is the only way to know that
// a stray pointermove is "part of an active pan" — `e.buttons` is 0
// on the final pointerup, and using `e.button === 0` to detect "left
// is down during move" matches every casual mouse motion (because
// pointermove always reports `button` as 0). Tracking explicitly is
// the only correct option.
// flight (space+left-click hold). We start a pan on pointerdown when
// the conditions match and keep forwarding every subsequent pointermove
// / pointerup for the same pointerId until the button comes back up.
// This is the only way to know that a stray pointermove is "part of an
// active pan" — `e.buttons` is 0 on the final pointerup, and using
// `e.button === 0` to detect "left is down during move" matches every
// casual mouse motion (because pointermove always reports `button` as 0).
// Tracking explicitly is the only correct option.
let panPointerId: number | null = null
const isPanStartPointer = (e: PointerEvent): boolean => {
// 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
Expand All @@ -546,13 +552,10 @@ export const IframeFrameSurface = forwardRef<IframeFrameSurfaceHandle, IframeFra

if (e.type === 'pointerdown' && isPanStartPointer(e)) {
panPointerId = e.pointerId
// Space + left-click: swallow the original so the click doesn't
// also trigger module selection. Middle-click never selects
// anything so it doesn't need swallowing.
if (spaceHeld && e.button === 0) {
e.preventDefault()
e.stopPropagation()
}
// Swallow the original so the click doesn't also trigger module
// selection while the user is intentionally panning.
e.preventDefault()
e.stopPropagation()
forwardPointer(e)
return
}
Expand All @@ -578,6 +581,7 @@ export const IframeFrameSurface = forwardRef<IframeFrameSurfaceHandle, IframeFra
iframeDoc.addEventListener('pointerup', maybeForward)
iframeDoc.addEventListener('pointercancel', maybeForward)
return () => {
setCanvasSpacePanActive(parentDocument, 'iframe', false)
iframeDoc.removeEventListener('keydown', onKeyDown)
iframeDoc.removeEventListener('keyup', onKeyUp)
iframeDoc.removeEventListener('pointerdown', maybeForward)
Expand Down
58 changes: 58 additions & 0 deletions src/admin/pages/site/canvas/canvasPanInput.ts
Original file line number Diff line number Diff line change
@@ -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<CanvasSpacePanSource, string> = {
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
}
21 changes: 11 additions & 10 deletions src/admin/pages/site/hooks/useCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 })
}

Expand All @@ -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
Expand Down Expand Up @@ -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,
}
}