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, } }