[eric] dashboard: scroll zooms the canvas over any card (Google Maps); click into a card to scroll its content

This commit is contained in:
ciregenz
2026-07-15 19:17:08 -07:00
parent d405c221ed
commit dd4605c7b3
6 changed files with 96 additions and 11 deletions
+43 -11
View File
@@ -163,6 +163,15 @@ try {
});
} catch (_) {}
// Scroll-focus (Google Maps model): plain wheel zooms the canvas over this card UNLESS the user
// has clicked INTO it, in which case plain wheel scrolls the page/app content. Host pushes the flag.
let scrollFocused = false;
try {
ipcRenderer.on('openswarm:set-scroll-focus', (_event, payload) => {
scrollFocused = !!(payload && payload.focused);
});
} catch (_) {}
// First in-guest mousedown tells the host to activate interact mode. Never
// preventDefault so the click still reaches the app (Minecraft etc).
const onMouseDownNotify = (e) => {
@@ -225,20 +234,43 @@ try {
} catch (_) {}
return;
}
if (isInteractive) return;
// Vertical-dominant scroll stays with the page.
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
// Horizontal-dominant: defer to the page if anything inside can absorb
// it; otherwise forward to the host as a canvas pan.
if (pageCanScrollX(e.target, e.deltaX)) return;
// Focused (clicked-into) card, or an interactive app: wheel stays with the page/app content.
if (isInteractive || scrollFocused) {
// Vertical-dominant scroll stays with the page.
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
// Horizontal-dominant: defer to the page if anything inside can absorb it; otherwise forward as a canvas pan.
if (pageCanScrollX(e.target, e.deltaX)) return;
e.preventDefault();
e.stopPropagation();
try {
ipcRenderer.sendToHost('canvas-wheel-pan', {
deltaX: e.deltaX,
deltaY: e.deltaY,
deltaMode: e.deltaMode,
});
} catch (_) {}
return;
}
// Not focused: navigate the CANVAS while hovering the card (Google Maps). Horizontal-dominant swipe pans; vertical scroll zooms at the cursor.
e.preventDefault();
e.stopPropagation();
try {
ipcRenderer.sendToHost('canvas-wheel-pan', {
deltaX: e.deltaX,
deltaY: e.deltaY,
deltaMode: e.deltaMode,
});
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
ipcRenderer.sendToHost('canvas-wheel-pan', {
deltaX: e.deltaX,
deltaY: e.deltaY,
deltaMode: e.deltaMode,
});
} else {
const iw2 = window.innerWidth || 1;
const ih2 = window.innerHeight || 1;
ipcRenderer.sendToHost('canvas-wheel-zoom', {
deltaY: e.deltaY,
deltaMode: e.deltaMode,
fracX: Math.max(0, Math.min(1, e.clientX / iw2)),
fracY: Math.max(0, Math.min(1, e.clientY / ih2)),
});
}
} catch (_) {}
};
// Listen on both window and document in capture phase so we run before any
@@ -51,6 +51,7 @@ import {
type BrowserWebview,
} from '@/shared/browserRegistry';
import { setLastInteractedBrowser } from '@/shared/browserFocus';
import { onScrollFocusChange, getScrollFocusedCard } from '@/shared/cardScrollFocus';
import { registerCapsuleForRestore } from '@/shared/browserStateCapsule';
import BrowserFindBar from './BrowserFindBar';
import { useBrowserActivity } from '@/shared/useBrowserActivity';
@@ -271,6 +272,19 @@ const BrowserCard: React.FC<Props> = ({
setRegistryActiveTab(browserId, activeTabId);
}, [browserId, activeTabId]);
// Tell the (out-of-process) guest whether plain wheel should scroll the page or zoom the canvas.
// Focused (clicked-into) = scroll the page; otherwise scroll-to-zoom works while hovering it (Google Maps).
useEffect(() => {
const push = (focusedId: string | null) => {
const focused = focusedId === browserId;
for (const wv of webviewMap.current.values()) {
try { wv.send?.('openswarm:set-scroll-focus', { focused }); } catch (_e) { /* guest not ready */ }
}
};
push(getScrollFocusedCard());
return onScrollFocusChange(push);
}, [browserId, activeTabId]);
// Open the find bar when AppShell routes a Ctrl/Cmd+F to this browser; re-trigger re-focuses the input.
useEffect(() => {
const onFind = (e: Event) => {
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react';
import { setCanvasInteractionActive } from '@/shared/canvasInteractionState';
import { getLastInteractedBrowser } from '@/shared/browserFocus';
import { getScrollFocusedCard } from '@/shared/cardScrollFocus';
import { getWebview } from '@/shared/browserRegistry';
import { applyBrowserZoom } from '@/shared/browserZoom';
@@ -264,6 +265,13 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
}
if (cls === 'scrollable' && !isModifierWheel) {
// Google Maps model: plain scroll zooms the canvas over ANY card (chat, app, scheduled task) UNLESS you've clicked INTO that card to read it. So a scrollable child only eats the wheel when its card is the scroll-focused one; otherwise fall through to canvas zoom.
const cardEl = target.closest('[data-select-id]');
const cardId = cardEl?.getAttribute('data-select-id') ?? null;
if (!cardId || cardId !== getScrollFocusedCard()) {
target = target.parentElement;
continue;
}
// Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic.
const canScrollY = target.scrollHeight > target.clientHeight;
const canScrollX = target.scrollWidth > target.clientWidth;
@@ -4,6 +4,7 @@ import { useAppDispatch } from '@/shared/hooks';
import { store } from '@/shared/state/store';
import { collapseSession, expandSession } from '@/shared/state/agentsSlice';
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
import { setScrollFocusedCard } from '@/shared/cardScrollFocus';
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
import type { useCanvasControls } from './useCanvasControls';
@@ -52,6 +53,8 @@ export function useDashboardInteractions({
selection.selectCard(id, type, false);
dispatch(bringToFront({ id, type }));
// Clicking INTO a card focuses it for scrolling: plain wheel now reads its content instead of zooming the canvas (Google Maps model). Clicking blank canvas clears it (below).
setScrollFocusedCard(id);
// The Workflows window is an app you click around inside, not a card you re-center every tap. Single-click only raises + selects it; double-click still zoom-to-fits (handleCardDoubleClick). Without this, clicking any button inside it yanked the canvas into a re-zoom.
if (type === 'workflows-hub' || type === 'workflows-monitor') return;
@@ -109,6 +112,8 @@ export function useDashboardInteractions({
if (agentDriven) return;
selection.selectCard(browserId, 'browser', false);
dispatch(bringToFront({ id: browserId, type: 'browser' }));
// Clicking inside a browser's page focuses it: plain wheel now scrolls the page instead of zooming the canvas.
setScrollFocusedCard(browserId);
};
window.addEventListener('openswarm:browser-guest-select', onGuestSelect);
return () => window.removeEventListener('openswarm:browser-guest-select', onGuestSelect);
@@ -130,6 +135,9 @@ export function useDashboardInteractions({
if (e.button !== 0) return;
if (isCardTarget(e.target, e.currentTarget)) return;
// Clicking blank canvas leaves every card: plain scroll zooms the canvas again (Google Maps model).
setScrollFocusedCard(null);
// Canvas click, drop any lingering input focus so arrow-key nav works immediately without the user having to press Escape first.
const active = document.activeElement as HTMLElement | null;
const activeTag = active?.tagName;
+1
View File
@@ -25,6 +25,7 @@ export interface BrowserWebview extends HTMLElement {
isLoading: () => boolean;
// Optional: present on real Electron webviews; the iframe fallback lacks it, callers must ?.() it.
isCurrentlyAudible?: () => boolean;
send?: (channel: string, ...args: any[]) => void;
capturePage: (rect?: { x: number; y: number; width: number; height: number }) => Promise<ElectronNativeImage>;
executeJavaScript: (code: string) => Promise<any>;
sendInputEvent: (event: any) => void;
+22
View File
@@ -0,0 +1,22 @@
// The card you've clicked INTO, so plain scroll reads its content (chat transcript, web page)
// 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.
let scrollFocusedCardId: string | null = null;
type Listener = (id: string | null) => void;
const listeners = new Set<Listener>();
export function setScrollFocusedCard(id: string | null): void {
if (scrollFocusedCardId === id) return;
scrollFocusedCardId = id;
for (const l of listeners) l(id);
}
export function getScrollFocusedCard(): string | null {
return scrollFocusedCardId;
}
// Browser cards subscribe so they can tell their (out-of-process) guest whether plain wheel should scroll the page or zoom the canvas.
export function onScrollFocusChange(l: Listener): () => void {
listeners.add(l);
return () => { listeners.delete(l); };
}