From 42ef6be5714a55c5f0e0ba9ac9f3db7df2b87cf6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 19:31:13 -0700 Subject: [PATCH] [eric] canvas: right-drag marquee-selects the empty canvas again; the menu waits for a click-only release --- .../Dashboard/canvas/DashboardCanvas.tsx | 25 +++++++++++++------ .../Dashboard/canvas/useCanvasContextMenu.ts | 2 ++ .../interaction/useDashboardInteractions.ts | 6 +++-- .../hooks/state/useDashboardSelection.ts | 14 +++++------ 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 0f2fe60f..032e51b1 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -74,7 +74,8 @@ interface DashboardCanvasProps { getCanvasState: () => { panX: number; panY: number; zoom: number }; onViewportMouseDown: (e: React.MouseEvent) => void; onViewportMouseMove: (e: React.MouseEvent) => void; - onViewportMouseUp: (e: React.MouseEvent) => void; + /** Returns true when the release ended a marquee drag rather than a plain click. */ + onViewportMouseUp: (e: React.MouseEvent) => boolean; onViewportDoubleClick: (e: React.MouseEvent) => void; onCardSelect: (id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => void; onDragStart: (id: string, type: CardType) => void; @@ -173,12 +174,10 @@ const DashboardCanvas: React.FC = ({ const dispatch = useAppDispatch(); const fullscreenCardId = useAppSelector(selectFullscreenCardId); const minimizedCards = useAppSelector((s) => s.dashboardLayout.minimizedCards); - // The singleton app windows (Workflows, Settings) carry their own fullscreen flag, not a tiledCard; their fill also hides the dock. - const settingsFullscreen = useAppSelector((s) => !!s.dashboardLayout.settingsCard?.fullscreen); - const anyFullscreen = !!fullscreenCardId || !!workflowsHub?.fullscreen || settingsFullscreen; + const anyFullscreen = !!fullscreenCardId; const [headerRevealed, setHeaderRevealed] = React.useState(false); const [appsWindowOpen, setAppsWindowOpen] = React.useState(false); - const onCanvasContextMenu = useCanvasContextMenu({ + const openCanvasMenu = useCanvasContextMenu({ dispatch, dashboardId, expandedSessionIds, selection, canvasEmpty, viewportRef: canvas.viewportRef, getCamera: canvas.actions.getLiveState, onNewAgent, onAddBrowser, onApplications: () => setAppsWindowOpen(true), onTidy, onFitToView, @@ -254,7 +253,8 @@ const DashboardCanvas: React.FC = ({ // page->transparent fade here just read as a light-leak band over the themed canvas. }} > - + {/* Must follow the reveal: an always-auto child overrides the hidden overlay's pointer-events:none and swallowed the whole top strip, so a top/left-tiled window's traffic lights were unclickable. */} + = ({ data-canvas-viewport onMouseDown={onViewportMouseDown} onMouseMove={onViewportMouseMove} - onMouseUp={onViewportMouseUp} + onMouseUp={(e) => { + const marqueed = onViewportMouseUp(e); + // The right button belongs to the marquee, so the canvas menu waits for the release and + // only opens when nothing was rubber-banded. Opening on press stole the drag. + if (e.button === 2 && !marqueed) openCanvasMenu(e); + }} onDoubleClick={onViewportDoubleClick} - onContextMenu={onCanvasContextMenu} + onContextMenu={(e: React.MouseEvent) => { + // Bare canvas: kill the native menu (Inspect Element in dev) so the right-drag stays clean. + const t = e.target as HTMLElement; + if (!t.closest('[data-select-id]') && !t.closest('input, textarea, [contenteditable]')) e.preventDefault(); + }} sx={{ position: 'absolute', inset: 0, diff --git a/frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts b/frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts index 7cdb9794..bce49751 100644 --- a/frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts +++ b/frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts @@ -24,6 +24,8 @@ interface CanvasContextMenuArgs { export function useCanvasContextMenu(args: CanvasContextMenuArgs): (e: React.MouseEvent) => void { const { dispatch, dashboardId, expandedSessionIds, selection, canvasEmpty, viewportRef, getCamera } = args; const { onNewAgent, onAddBrowser, onApplications, onTidy, onFitToView } = args; + // Fired from the viewport's mouseUP (never contextmenu): the right button starts the marquee, so the + // menu may only appear once the release proves the gesture was a click. return useCallback((e: React.MouseEvent) => { // Bare canvas only; cards own their own menus and inputs/webviews keep the native one. const t = e.target as HTMLElement; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts index a4a7a99e..eecd4762 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts @@ -206,9 +206,11 @@ export function useDashboardInteractions({ selection.handleCanvasMouseMove(e.nativeEvent); }, [canvas.handlers, selection]); - const handleViewportMouseUp = useCallback((e: React.MouseEvent) => { + // Reports whether the release ended a marquee drag; the canvas uses that to tell a right-DRAG + // (rubber band) from a right-CLICK (context menu) without ever arbitrating on mousedown. + const handleViewportMouseUp = useCallback((e: React.MouseEvent): boolean => { canvas.handlers.onMouseUp(); - selection.handleCanvasMouseUp(e.nativeEvent); + return selection.handleCanvasMouseUp(e.nativeEvent); }, [canvas.handlers, selection]); // Double-click empty canvas → zoom OUT anchored at the cursor (Google Maps style). It must never diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts index d4ac7b09..5bda5ff6 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts @@ -244,22 +244,22 @@ export function useDashboardSelection( [screenToCanvas, computeMarqueeSelection], ); + // Returns true when the release ended a real marquee DRAG, so the caller can tell a rubber-band + // gesture from a plain click without duplicating the threshold bookkeeping. const handleCanvasMouseUp = useCallback( - (e: MouseEvent) => { + (e: MouseEvent): boolean => { const origin = marqueeOriginRef.current; - if (!origin) return; + if (!origin) return false; - if (!isDraggingMarqueeRef.current) { - if (!e.shiftKey) { - deselectAll(); - } - } + const dragged = isDraggingMarqueeRef.current; + if (!dragged && !e.shiftKey) deselectAll(); marqueeOriginRef.current = null; isDraggingMarqueeRef.current = false; setMarquee(null); document.body.style.userSelect = ''; document.body.classList.remove('dashboard-marquee-active'); + return dragged; }, [deselectAll], );