[eric] canvas: make cmd/ctrl+wheel always zoom, and stop cards owning the plain wheel forever

This commit is contained in:
ciregenz
2026-07-30 17:35:32 -07:00
parent ce7939b94d
commit 8d878db589
4 changed files with 43 additions and 20 deletions
@@ -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<AgentChatProps> = ({ 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;
@@ -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);
}
};
@@ -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);
+23 -2
View File
@@ -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 {