From 0704ccb59965790af0ad8bf3b164372fff8fb275 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 30 Jun 2026 23:51:22 +0200 Subject: [PATCH] fix(editor): make canvas mouse scroll use shift-wheel sideways --- docs/features/canvas-iframe-per-frame.md | 4 +- docs/reference/canvas-dnd.md | 2 +- .../canvas/useCanvasWheelSync.test.tsx | 35 +++++++++++++++ .../pages/site/canvas/IframeFrameSurface.tsx | 45 ++++++++----------- src/admin/pages/site/canvas/canvasPanInput.ts | 30 +++++++++++++ src/admin/pages/site/hooks/useCanvas.ts | 18 ++++---- 6 files changed, 94 insertions(+), 40 deletions(-) create mode 100644 src/admin/pages/site/canvas/canvasPanInput.ts 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 4ccffb01..ffaacd8a 100644 --- a/docs/reference/canvas-dnd.md +++ b/docs/reference/canvas-dnd.md @@ -197,7 +197,7 @@ The `id` prefix tells `onDragEnd` it's a new-module insert. ### 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..40678526 100644 --- a/src/__tests__/canvas/useCanvasWheelSync.test.tsx +++ b/src/__tests__/canvas/useCanvasWheelSync.test.tsx @@ -3,6 +3,7 @@ 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 { panDeltaFromWheel, shouldStartCanvasPointerPan } from '@site/canvas/canvasPanInput' import { useCanvas } from '@site/hooks/useCanvas' import { installAdminZoomGuard } from '@admin/shared/AdminZoomGuard' @@ -50,6 +51,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 +120,16 @@ describe('useCanvas wheel pan sync', () => { expect(layer.style.transform).not.toBe('translate(0px, 0px) scale(1)') }) }) + +describe('canvas mouse pan input policy', () => { + 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.tsx b/src/admin/pages/site/canvas/IframeFrameSurface.tsx index 62c2d1b6..86933e59 100644 --- a/src/admin/pages/site/canvas/IframeFrameSurface.tsx +++ b/src/admin/pages/site/canvas/IframeFrameSurface.tsx @@ -83,6 +83,7 @@ import { useCanvasFormControlSuppression } from './useCanvasFormControlSuppressi import { CANVAS_VIEWPORT_HEIGHT, type CanvasViewport } from './resolveViewportUnits' import { useIframeFrameAutoHeight } from './useIframeFrameAutoHeight' import { applyIframeBodyReset, type IframeInteraction } from './iframeBodyReset' +import { shouldStartCanvasPointerPan } from './canvasPanInput' import { useEditorStore } from '@site/store/store' import { closestReadonlyRegion, isElementLike } from './readonlyRegion' import styles from './IframeFrameSurface.module.css' @@ -373,14 +374,12 @@ 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 }) } const maybeForward = (e: PointerEvent) => { - // (3) An external reorder drag is in progress — forward move/up/ + // (2) An external reorder 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 @@ -547,13 +541,10 @@ export const IframeFrameSurface = forwardRef { - 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 +495,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, } }