From 8d878db58984428d006c7b65985cb897366f7ade Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 17:35:32 -0700 Subject: [PATCH] [eric] canvas: make cmd/ctrl+wheel always zoom, and stop cards owning the plain wheel forever --- .../src/app/pages/AgentChat/AgentChat.tsx | 4 +++ .../hooks/interaction/useCanvasControls.ts | 29 +++++++------------ .../useOverlayScrollPassthrough.ts | 5 ++++ frontend/src/shared/cardScrollFocus.ts | 25 ++++++++++++++-- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 229b10d5..c2c57849 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -20,6 +20,7 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { friendlyStatusLabel } from '@/shared/statusLabel'; +import { getScrollFocusedCard } from '@/shared/cardScrollFocus'; import { openSettingsModal, dismissMcpSuggestion } from '@/shared/state/settingsSlice'; import { API_BASE, getAuthToken } from '@/shared/config'; import { @@ -694,6 +695,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (e.ctrlKey || e.metaKey) return; // Horizontal-dominant gestures must also reach the canvas so a sideways swipe pans the dashboard (chat has no horizontal scroll to absorb). if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; + // Google Maps model: a plain wheel over a chat you haven't clicked INTO belongs to the canvas, so let it through instead of swallowing it here (this is what made zoom look dead over any chat). + const cardId = el.closest('[data-select-id]')?.getAttribute('data-select-id') ?? null; + if (cardId && cardId !== getScrollFocusedCard()) return; const atTop = el.scrollTop <= 0; const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; const scrollingDown = e.deltaY > 0; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index b41d3f8a..390683e5 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -1,5 +1,6 @@ -import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react'; +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { store } from '@/shared/state/store'; +import { selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice'; import { setCanvasInteractionActive } from '@/shared/canvasInteractionState'; import { getLastInteractedBrowser } from '@/shared/browserFocus'; import { getScrollFocusedCard } from '@/shared/cardScrollFocus'; @@ -63,7 +64,6 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const stateRef = useRef(state); const liveDirtyRef = useRef(false); const spaceRef = useRef(false); - const cmdRef = useRef(false); const sensitivityRef = useRef(zoomSensitivity); sensitivityRef.current = zoomSensitivity; const contentBoundsRef = useRef(contentBounds); @@ -302,12 +302,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const onWheel = (e: WheelEvent) => { // Full size view owns the whole surface: any wheel that escapes the chat's scroll container // (side gutters, header) must NOT zoom/pan the hidden canvas underneath, that read as a - // glitchy zoom while scrolling the chat. Fullscreen has no canvas nav, period. - const tiledCards = store.getState().dashboardLayout.tiledCards; - for (const z of Object.values(tiledCards)) { - if (z === 'fullscreen') return; - } - // 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. + // glitchy zoom while scrolling the chat. Fullscreen has no canvas nav, period. The selector's + // existence check matters: a stale tile entry for a removed card would wedge the wheel forever. + if (selectFullscreenCardId(store.getState())) return; + // ctrl/cmd wheel is the zoom gesture on every surface: a physically held key or a trackpad pinch (which also sets ctrlKey). It bypasses scrollable children so zoom is always reachable, even over a chat you're typing in. 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. @@ -375,14 +373,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: inertiaFrameRef.current = null; } - 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. + if (isModifierWheel) { + // Pinch or held cmd/ctrl → 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; + pendingZoomDy += clamp(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP); pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top }; scheduleWheelFlush(); } else if (isTrackpadScroll) { @@ -413,7 +407,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: cancelAnimationFrame(inertiaFrameRef.current); inertiaFrameRef.current = null; } - pendingZoomDy += dy; + // Same per-event cap as a host-side notch, so one wheel click inside a guest page is a step, not a lurch. + pendingZoomDy += clamp(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP); pendingZoomCenter = { cx: (detail.clientX ?? 0) - rect.left, cy: (detail.clientY ?? 0) - rect.top, @@ -599,7 +594,6 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: setSpaceHeld(true); } if ((e.key === 'Meta' || e.key === 'Control') && !e.repeat) { - cmdRef.current = true; setCmdHeld(true); } if (e.ctrlKey || e.metaKey) { @@ -627,7 +621,6 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: setSpaceHeld(false); } if (e.key === 'Meta' || e.key === 'Control') { - cmdRef.current = false; setCmdHeld(false); } }; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts index 123aa5d2..d8675d28 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts @@ -1,4 +1,5 @@ import { useRef, useEffect } from 'react'; +import { getScrollFocusedCard } from '@/shared/cardScrollFocus'; /** Forwards wheel events through an overlay to the content beneath while keeping overlay click/drag; passes pinch-zoom. */ export function useOverlayScrollPassthrough(active: boolean) { @@ -10,6 +11,10 @@ export function useOverlayScrollPassthrough(active: boolean) { const handleWheel = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) return; + // Same Google Maps rule as the canvas wheel handler: only the card you clicked INTO gets the + // plain wheel. Selected-but-not-focused, the overlay must not swallow it, or zoom dies here. + const cardId = el.closest('[data-select-id]')?.getAttribute('data-select-id') ?? null; + if (cardId && cardId !== getScrollFocusedCard()) return; el.style.pointerEvents = 'none'; const underneath = document.elementFromPoint(e.clientX, e.clientY); diff --git a/frontend/src/shared/cardScrollFocus.ts b/frontend/src/shared/cardScrollFocus.ts index 21489fa8..6c523a09 100644 --- a/frontend/src/shared/cardScrollFocus.ts +++ b/frontend/src/shared/cardScrollFocus.ts @@ -1,11 +1,32 @@ // The card you've clicked INTO, so plain scroll reads its content (chat transcript, scheduled-task // list) while scroll everywhere else zooms the canvas (Google Maps model). Imperative + read on the -// wheel handler so no re-render; cleared when you click blank canvas. Browser/app cards aren't tracked -// here: their guest page owns its own scroll/zoom (Maps, Figma), so plain wheel always stays in them. +// wheel handler so no re-render. Browser/app cards aren't tracked here: their guest page owns its own +// scroll/zoom (Maps, Figma), so plain wheel always stays in them. let scrollFocusedCardId: string | null = null; +// Message bubbles carry their own data-select-id, so match the card by walking, not by closest(). +function insideFocusedCard(target: EventTarget | null): boolean { + let el = target as HTMLElement | null; + while (el) { + if (el.getAttribute?.('data-select-id') === scrollFocusedCardId) return true; + el = el.parentElement; + } + return false; +} + +// Focus follows the cursor: the moment it leaves the card, scroll belongs to the canvas again, so a +// chat you once clicked can't own the wheel forever and make zoom look broken. Listen on pointermove, +// not pointerover: boundary events also fire when a re-render swaps the element under a still cursor. +function releaseOnPointerLeave(e: PointerEvent): void { + if (insideFocusedCard(e.target)) return; + setScrollFocusedCard(null); +} + export function setScrollFocusedCard(id: string | null): void { + if (id === scrollFocusedCardId) return; scrollFocusedCardId = id; + if (id) document.addEventListener('pointermove', releaseOnPointerLeave, true); + else document.removeEventListener('pointermove', releaseOnPointerLeave, true); } export function getScrollFocusedCard(): string | null {