[eric] canvas: right-drag marquee-selects the empty canvas again; the menu waits for a click-only release

This commit is contained in:
ciregenz
2026-07-30 19:31:13 -07:00
parent 401120094e
commit 42ef6be571
4 changed files with 30 additions and 17 deletions
@@ -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<DashboardCanvasProps> = ({
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<DashboardCanvasProps> = ({
// page->transparent fade here just read as a light-leak band over the themed canvas.
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', pointerEvents: 'auto' }}>
{/* 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. */}
<Box sx={{ display: 'flex', alignItems: 'center', pointerEvents: headerRevealed ? 'auto' : 'none' }}>
<DashboardHeader
dashboardName={dashboardName}
sessions={sessions}
@@ -320,9 +320,18 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
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,
@@ -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;
@@ -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
@@ -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],
);