diff --git a/electron/webview-preload.js b/electron/webview-preload.js index ab4aa519..073a875c 100644 --- a/electron/webview-preload.js +++ b/electron/webview-preload.js @@ -137,39 +137,59 @@ try { }); // --------------------------------------------------------------------------- - // Canvas zoom passthrough (ctrl/meta + wheel) + // Horizontal scroll passthrough to canvas pan // - // A 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. + // 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. 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). // diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index be0a2436..6e05643e 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -664,6 +664,9 @@ const AgentChat: React.FC = ({ 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; diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index d6b53b13..b8f06675 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -303,7 +303,7 @@ const BrowserCard: React.FC = ({ // (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 = ({ 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, }, }), ); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 89302524..66446189 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -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 - // 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. diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts index e2294bdf..2bdd7ddb 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts @@ -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();