dashboard: pan canvas via middle-drag and horizontal scroll over browser/chat

- Middle-mouse drag inside a browser webview is forwarded as canvas pan
  (previously eaten silently by the guest compositor).
- Horizontal-dominant wheel inside a browser webview pans the canvas
  when no ancestor in the guest page can absorb horizontal scroll;
  otherwise the page handles it.
- Horizontal-dominant wheel over a chat panel pans the canvas (chat has
  no horizontal scroller). Vertical scroll is unchanged.

Plain vertical scroll and ctrl/meta+wheel zoom over a browser stay with
chromium's defaults.
This commit is contained in:
abccodes
2026-06-13 19:38:20 -07:00
parent cee7a94559
commit 335e1a47e4
5 changed files with 125 additions and 49 deletions
+87 -25
View File
@@ -137,39 +137,59 @@ try {
});
// ---------------------------------------------------------------------------
// Canvas zoom passthrough (ctrl/meta + wheel)
// Horizontal scroll passthrough to canvas pan
//
// A <webview> is an out-of-process Chromium guest; wheel events that
// originate inside it never bubble to the embedding renderer. Without
// intercepting here, ctrl+wheel over a browser card just zooms the
// embedded page (Chromium's default) and the dashboard canvas never
// sees the gesture — issue #27.
//
// Capture-phase + passive:false so we run before the page's own listeners
// and can preventDefault to suppress the in-page page-zoom. We then
// forward the gesture (deltaY + guest-local cursor coords) to the host
// via sendToHost; BrowserCard's ipc-message handler turns it back into a
// synthetic WheelEvent dispatched from the webview element, which bubbles
// naturally to useCanvasControls' wheel listener.
// <webview> is an out-of-process guest; wheel events inside it never bubble
// to the embedding renderer. Vertical scroll and ctrl/meta+wheel zoom stay
// with the page (chromium default). A horizontal-dominant gesture, however,
// should pan the dashboard canvas if the guest page has nothing horizontal
// to scroll, to match the behavior over chat panels (which never have a
// horizontal scroller and always pan the canvas).
const pageCanScrollX = (node, dx) => {
let t = node;
while (t) {
const sw = t.scrollWidth || 0;
const cw = t.clientWidth || 0;
if (sw > cw) {
let style;
try { style = getComputedStyle(t); } catch (_) {}
const ox = style ? style.overflowX : 'visible';
if (ox === 'auto' || ox === 'scroll') {
const atRight = t.scrollLeft + cw >= sw - 1;
const atLeft = t.scrollLeft <= 1;
const atBoundary = (dx > 0 && atRight) || (dx < 0 && atLeft);
if (!atBoundary) return true;
}
}
t = t.parentElement;
}
const docEl = document.scrollingElement || document.documentElement;
if (docEl && docEl.scrollWidth > docEl.clientWidth) {
const atRight = docEl.scrollLeft + docEl.clientWidth >= docEl.scrollWidth - 1;
const atLeft = docEl.scrollLeft <= 1;
const atBoundary = (dx > 0 && atRight) || (dx < 0 && atLeft);
if (!atBoundary) return true;
}
return false;
};
const onWheelCapture = (e) => {
if (!(e.ctrlKey || e.metaKey)) return;
// Pinch / ctrl+wheel stays with the page (chromium's in-page zoom).
if (e.ctrlKey || e.metaKey) 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;
e.preventDefault();
e.stopPropagation();
try {
console.warn('[openswarm:webview-preload] ctrl+wheel intercept → sendToHost', {
deltaY: e.deltaY,
clientX: e.clientX,
clientY: e.clientY,
});
ipcRenderer.sendToHost('canvas-wheel-zoom', {
ipcRenderer.sendToHost('canvas-wheel-pan', {
deltaX: e.deltaX,
deltaY: e.deltaY,
deltaMode: e.deltaMode,
clientX: e.clientX,
clientY: e.clientY,
});
} catch (err) {
console.warn('[openswarm:webview-preload] sendToHost failed', err);
}
} catch (_) {}
};
// Listen on both window and document in capture phase so we run before any
// page-level handler that might swallow the event. passive:false is required
@@ -177,6 +197,48 @@ try {
window.addEventListener('wheel', onWheelCapture, { capture: true, passive: false });
document.addEventListener('wheel', onWheelCapture, { capture: true, passive: false });
// ---------------------------------------------------------------------------
// Middle-mouse-button drag → canvas pan
//
// Empty canvas and agent cards already get middle-button pan because the
// event bubbles to the dashboard's mousedown handler. <webview> is a
// separate compositor layer that eats mouse events, so middle-drag over a
// browser silently did nothing. Intercept here and forward the per-event
// movement as a pan delta through the existing canvas-wheel-pan channel
// (negated, since drag pans panX += dx while wheel pans panX -= dx).
// Always pans regardless of capture state — middle-drag is unambiguously
// a canvas gesture.
let middleDragging = false;
const onMouseDownMiddle = (e) => {
if (e.button !== 1) return;
e.preventDefault();
e.stopPropagation();
middleDragging = true;
};
const onMouseMoveMiddle = (e) => {
if (!middleDragging) return;
e.preventDefault();
e.stopPropagation();
const dx = e.movementX || 0;
const dy = e.movementY || 0;
if (dx === 0 && dy === 0) return;
try {
ipcRenderer.sendToHost('canvas-wheel-pan', { deltaX: -dx, deltaY: -dy, deltaMode: 0 });
} catch (_) {}
};
const onMouseUpMiddle = (e) => {
if (e.button !== 1) return;
middleDragging = false;
};
// Chromium starts auxiliary-scroll on middle-click; auxclick prevents that.
const onAuxClickSuppress = (e) => {
if (e.button === 1) { e.preventDefault(); e.stopPropagation(); }
};
window.addEventListener('mousedown', onMouseDownMiddle, { capture: true });
window.addEventListener('mousemove', onMouseMoveMiddle, { capture: true });
window.addEventListener('mouseup', onMouseUpMiddle, { capture: true });
window.addEventListener('auxclick', onAuxClickSuppress, { capture: true });
// ---------------------------------------------------------------------------
// Double-click to fit the browser card (parity with agent-chat dblclick).
//
@@ -664,6 +664,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
// Without this early-out the unconditional stopPropagation below kills
// ctrl+wheel and the canvas listener never fires.
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;
const atTop = el.scrollTop <= 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
const scrollingDown = e.deltaY > 0;
@@ -303,7 +303,7 @@ const BrowserCard: React.FC<Props> = ({
// (the historical Windows mount segfault). Clear the crash-safety marker.
if (isWindows) markWindowsWebviewSurvived();
wv.loadURL(targetUrl).catch(() => {});
// Lock guest zoom at 1.0 so ctrl+wheel never triggers Chromium's in-page zoom; canvas zoom takes over (issue #27).
// Disable in-guest pinch zoom; page zoom (cmd+= / cmd+-) still works via chromium.
try {
(wv as any).setVisualZoomLevelLimits?.(1, 1);
(wv as any).setZoomFactor?.(1);
@@ -328,19 +328,16 @@ const BrowserCard: React.FC<Props> = ({
setPasskeyDialogOpen(true);
} else if (e?.channel === 'browser-dblclick') {
onDoubleClickRef.current?.(browserId, 'browser');
} else if (e?.channel === 'canvas-wheel-zoom') {
// Convert guest coords to doc coords and dispatch a CustomEvent; synthetic WheelEvent bubble was unreliable through GuestView.
} else if (e?.channel === 'canvas-wheel-pan') {
// Plain wheel inside an unselected webview never bubbles out; the
// preload forwards it here so the dashboard canvas can pan.
const payload = e.args?.[0] || {};
const wvRect = wv.getBoundingClientRect();
const docX = wvRect.left + (payload.clientX ?? 0);
const docY = wvRect.top + (payload.clientY ?? 0);
window.dispatchEvent(
new CustomEvent('openswarm:canvas-wheel-zoom', {
new CustomEvent('openswarm:canvas-wheel-pan', {
detail: {
deltaX: payload.deltaX ?? 0,
deltaY: payload.deltaY ?? 0,
deltaMode: payload.deltaMode ?? 0,
clientX: docX,
clientY: docY,
},
}),
);
@@ -261,6 +261,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
// Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic.
const canScrollY = target.scrollHeight > target.clientHeight;
const canScrollX = target.scrollWidth > target.clientWidth;
// Horizontal-dominant gestures over a container that only scrolls
// vertically (e.g., chat) should pan the canvas instead of being
// silently absorbed by the child's no-op horizontal handling.
if (Math.abs(dx) > Math.abs(dy) && !canScrollX) {
target = target.parentElement;
continue;
}
const atYBoundary = !canScrollY ||
(dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) ||
(dy < 0 && target.scrollTop <= 1);
@@ -302,31 +311,26 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
el.addEventListener('wheel', onWheel, { passive: false });
// ctrl/meta+wheel events that originate inside an Electron <webview>
// never bubble out of the guest into the host DOM, so the wheel
// listener above can't see them. BrowserCard's preload-bridge
// forwards those gestures via this CustomEvent (issue #27); we run
// the same zoom-around-cursor math the wheel handler uses.
const onForwardedZoom = (e: Event) => {
// Plain wheel inside a webview can't bubble out either; the preload
// forwards horizontal-dominant scrolls as a pan when the guest page
// has nothing to scroll horizontally, plus middle-mouse drag deltas.
const onForwardedPan = (e: Event) => {
const detail = (e as CustomEvent).detail || {};
const dy = detail.deltaMode === 1 ? detail.deltaY * 40 : detail.deltaY;
const rect = el.getBoundingClientRect();
const dy = detail.deltaMode === 1 ? (detail.deltaY ?? 0) * 40 : (detail.deltaY ?? 0);
const dx = detail.deltaMode === 1 ? (detail.deltaX ?? 0) * 40 : (detail.deltaX ?? 0);
if (inertiaFrameRef.current) {
cancelAnimationFrame(inertiaFrameRef.current);
inertiaFrameRef.current = null;
}
pendingZoomDy += dy;
pendingZoomCenter = {
cx: (detail.clientX ?? 0) - rect.left,
cy: (detail.clientY ?? 0) - rect.top,
};
pendingPanDx += dx;
pendingPanDy += dy;
scheduleWheelFlush();
};
window.addEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom);
window.addEventListener('openswarm:canvas-wheel-pan', onForwardedPan);
return () => {
el.removeEventListener('wheel', onWheel);
window.removeEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom);
window.removeEventListener('openswarm:canvas-wheel-pan', onForwardedPan);
if (wheelRafId != null) cancelAnimationFrame(wheelRafId);
if (wheelIdleTimer != null) clearTimeout(wheelIdleTimer);
// Don't leave the flag stuck on if the canvas unmounts mid-gesture.
@@ -22,6 +22,8 @@ export function useOverlayScrollPassthrough(active: boolean) {
dy *= 20;
}
const horizontalDominant = Math.abs(dx) > Math.abs(dy);
let node = underneath as HTMLElement | null;
while (node) {
if (node.tagName === 'WEBVIEW') {
@@ -52,6 +54,14 @@ export function useOverlayScrollPassthrough(active: boolean) {
node.scrollWidth > node.clientWidth &&
(cs.overflowX === 'auto' || cs.overflowX === 'scroll');
// Horizontal-dominant gesture over a vertically-only scrollable
// container: don't absorb it (scrollBy with dx would be a no-op).
// Let it bubble to the canvas wheel handler so the canvas pans.
if (horizontalDominant && !canScrollX) {
node = node.parentElement;
continue;
}
if (canScrollY || canScrollX) {
e.stopPropagation();
e.preventDefault();