mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[eric] canvas: make cmd/ctrl+wheel always zoom, and stop cards owning the plain wheel forever
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user