From 4707439d6c451cb318cb39e17c39e4b6ea822a5d Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:00:28 -0700 Subject: [PATCH 1/3] pierre/scroll-to-zoom: plain wheel zooms at viewport center Plain vertical wheel/two-finger scroll over the canvas now zooms (anchored at the viewport center, reusing the zoomIn/zoomOut anchor) instead of panning the y-axis. Horizontal-dominant scroll still pans X, gated on the dominant axis so a sideways swipe's vertical jitter can't also zoom. cmd/ctrl+wheel and trackpad pinch still zoom at the cursor. flushWheel now folds a same-frame pan into the zoom branch, since a vertical zoom and a horizontal pan can now be accumulated in one RAF tick; dropping the pan would swallow the gesture. --- .../hooks/interaction/useCanvasControls.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 1b1071f8..78561168 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -173,7 +173,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: animateToRef.current = animateTo; - // Wheel zoom centered on cursor + // Plain wheel zooms at the viewport center; cmd/ctrl+wheel and trackpad pinch zoom at the cursor. useEffect(() => { const el = viewportRef.current; if (!el || !enabled) return; // Skip wheel listener when canvas is hidden @@ -199,9 +199,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const factor = Math.pow(2, -zDy * sensitivityToMultiplier(sensitivityRef.current)); const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM); const ratio = newZoom / prev.zoom; + // Apply any pan accumulated in the same frame too: a zoom and a pan can now land together (vertical zoom + horizontal pan across a RAF boundary, or a forwarded pan), and dropping it would swallow the gesture. return { - panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio, - panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio, + panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio - dx, + panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio - dy, zoom: newZoom, }; }); @@ -290,15 +291,20 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: } if (isPinchZoom) { - // Pinch gesture → accumulate zoom deltas + last cursor position. factor = 2^(-Σdy·s) which equals the product of per-event factors, so accumulating dy is mathematically identical to applying each event one at a time. + // Pinch / cmd+wheel → accumulate zoom deltas + last cursor position. factor = 2^(-Σdy·s) which equals the product of per-event factors, so accumulating dy is mathematically identical to applying each event one at a time. const rect = el.getBoundingClientRect(); pendingZoomDy += dy; pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top }; scheduleWheelFlush(); - } else { - // Two-finger scroll → accumulate pan deltas. + } else if (Math.abs(dx) > Math.abs(dy)) { + // Horizontal-dominant scroll → pan X; it's the only horizontal-pan gesture. Dominant-axis, so the vertical jitter in a sideways swipe doesn't also zoom. pendingPanDx += dx; - pendingPanDy += dy; + scheduleWheelFlush(); + } else { + // Plain vertical scroll → zoom, anchored at the viewport center (same anchor as zoomIn/zoomOut), not the cursor. + const rect = el.getBoundingClientRect(); + pendingZoomDy += dy; + pendingZoomCenter = { cx: rect.width / 2, cy: rect.height / 2 }; scheduleWheelFlush(); } }; From c4704ff27a0704f31e7db7e68792fd02f4b1aa03 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:03:02 -0700 Subject: [PATCH 2/3] pierre/clamp-mouse-wheel-zoom: tame discrete wheel notches A mouse-wheel notch arrives as deltaY 100 where a trackpad sends ~1-10; piped through the zoom curve at default sensitivity that's a ~24% jump per notch, and macOS wheel acceleration stacks them. Clamp the per-event zoom delta to +/-24 so each notch is a small predictable step. No-op for trackpads (their deltas are already under the cap), so the continuous pinch/scroll curve is unchanged. --- .../pages/Dashboard/hooks/interaction/useCanvasControls.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 78561168..3ba5757f 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -9,6 +9,8 @@ const MAX_ZOOM = 3.0; const ZOOM_IN_FACTOR = 1.1; const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR; const FIT_PADDING = 200; +// A mouse notch lands as deltaY 100 where a trackpad sends ~1-10, so cap the per-event zoom delta: uncapped, one notch is a ~24% jump and macOS wheel acceleration stacks them. No-op for trackpads. +const WHEEL_ZOOM_DELTA_CAP = 24; // Maps the 1 to 100 user setting to an internal multiplier (50 default = 0.004). function sensitivityToMultiplier(setting: number): number { @@ -301,9 +303,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: pendingPanDx += dx; scheduleWheelFlush(); } else { - // Plain vertical scroll → zoom, anchored at the viewport center (same anchor as zoomIn/zoomOut), not the cursor. + // Plain vertical scroll → zoom, anchored at the viewport center (same anchor as zoomIn/zoomOut), not the cursor. Clamp the per-event delta so a discrete mouse notch is a small step, not a lurch. const rect = el.getBoundingClientRect(); - pendingZoomDy += dy; + pendingZoomDy += clamp(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP); pendingZoomCenter = { cx: rect.width / 2, cy: rect.height / 2 }; scheduleWheelFlush(); } From b8cd6fe2b3b454ce157eb1bda327ff72ff4bd8c1 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:11:02 -0700 Subject: [PATCH 3/3] pierre/cmd-scroll-vertical-pan: cmd/ctrl+scroll pans vertically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/ctrl + scroll now pans the canvas up/down instead of zooming at the cursor. The catch: a trackpad pinch reports as a wheel with ctrlKey set, indistinguishable at the event level from a real Ctrl+wheel. Gate on the physically-held key (cmdRef, tracked via keydown): a real modifier down pans; a pinch has ctrlKey without any keydown, so it still zooms at the cursor. Plain wheel continues to zoom at the viewport center. The webview-forwarded cmd/ctrl+wheel path (canvas-wheel-zoom) stays cursor-zoom — that channel can't tell a pinch from a held key, and keeping it zoom preserves pinch-to-zoom while hovering a browser card. --- .../hooks/interaction/useCanvasControls.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 3ba5757f..a524ed9e 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -175,7 +175,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: animateToRef.current = animateTo; - // Plain wheel zooms at the viewport center; cmd/ctrl+wheel and trackpad pinch zoom at the cursor. + // Plain wheel zooms at the viewport center; cmd/ctrl+wheel pans vertically; trackpad pinch zooms at the cursor. useEffect(() => { const el = viewportRef.current; if (!el || !enabled) return; // Skip wheel listener when canvas is hidden @@ -233,8 +233,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const scrollableCache: WeakMap = new WeakMap(); const onWheel = (e: WheelEvent) => { - // Pinch-to-zoom on trackpads sets ctrlKey; plain scroll does not - const isPinchZoom = e.ctrlKey || e.metaKey; + // ctrl/cmd wheel is a modifier gesture: a real held key (cmd/ctrl + scroll → vertical pan) or a trackpad pinch, which also sets ctrlKey (→ zoom at cursor). Either way it bypasses scrollable children and acts on the canvas. + const isModifierWheel = e.ctrlKey || e.metaKey; // Let scrollable children handle the event when appropriate, but fall through to canvas pan if the child is at its scroll boundary. const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY; @@ -259,7 +259,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: scrollableCache.set(target, cls); } - if (cls === 'scrollable' && !isPinchZoom) { + if (cls === 'scrollable' && !isModifierWheel) { // Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic. const canScrollY = target.scrollHeight > target.clientHeight; const canScrollX = target.scrollWidth > target.clientWidth; @@ -292,8 +292,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: inertiaFrameRef.current = null; } - if (isPinchZoom) { - // Pinch / cmd+wheel → accumulate zoom deltas + last cursor position. factor = 2^(-Σdy·s) which equals the product of per-event factors, so accumulating dy is mathematically identical to applying each event one at a time. + if (isModifierWheel && cmdRef.current) { + // Real cmd/ctrl physically held + scroll → vertical pan. cmdRef is set from a keydown; a trackpad pinch sets ctrlKey with no keydown, so it falls through to the zoom branch below and pinch-to-zoom survives. + pendingPanDy += dy; + scheduleWheelFlush(); + } else if (isModifierWheel) { + // Trackpad pinch → accumulate zoom deltas + last cursor position. factor = 2^(-Σdy·s) which equals the product of per-event factors, so accumulating dy is mathematically identical to applying each event one at a time. const rect = el.getBoundingClientRect(); pendingZoomDy += dy; pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top };