diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 7833aaf2..431fce3d 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -823,7 +823,11 @@ const BrowserCard: React.FC = ({ data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })} // Marks a kept-alive card parked off-screen (it belongs to another dashboard); fit-to-view must skip it or it pans the canvas to chase it and the card bleeds onto the dashboard you're viewing. data-keepalive-hidden={keepAliveHidden ? '1' : undefined} - onPointerDownCapture={() => onBringToFront?.(browserId, 'browser')} + onPointerDownCapture={(e: React.PointerEvent) => { + onBringToFront?.(browserId, 'browser'); + // Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path. + if (e.button === 0 && !e.shiftKey) onCardSelect?.(browserId, 'browser', false); + }} onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; onCardSelect?.(browserId, 'browser', e.shiftKey); diff --git a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx index 7728eb3b..d6a48e19 100644 --- a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx @@ -255,7 +255,11 @@ const NoteCard: React.FC = ({ data-select-type="note-card" data-select-id={noteId} data-select-meta={JSON.stringify({ name: 'Note', content: content.slice(0, 60) })} - onPointerDownCapture={() => onBringToFront?.(noteId, 'note')} + onPointerDownCapture={(e: React.PointerEvent) => { + onBringToFront?.(noteId, 'note'); + // Capture-phase so a click the textarea swallows still selects the note; shift keeps the bubbled toggle path. + if (e.button === 0 && !e.shiftKey) onCardSelect?.(noteId, 'note', false); + }} onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; onCardSelect?.(noteId, 'note', e.shiftKey); diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts index 6b117039..8e1ed192 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts @@ -96,9 +96,13 @@ export function useDashboardSelection( } return next; } - // Plain click selects the clicked card (collapsing any multi-select to it) so spawn-beside-selection actually fires; deselect = empty-canvas click or Esc. - if (prev.size === 1 && prev.has(id)) { - return prev; + // Plain click/press selects the clicked card so spawn-beside-selection actually fires; deselect = empty-canvas click or Esc. An already-selected member keeps the whole selection (a press also starts multi-drag; collapsing would break it) but moves to last so the clicked card is the spawn anchor. + if (prev.has(id)) { + if (Array.from(prev.keys()).pop() === id) return prev; + const next = new Map(prev); + next.delete(id); + next.set(id, type); + return next; } return new Map([[id, type]]); }); @@ -212,6 +216,8 @@ export function useDashboardSelection( const handleCanvasMouseDown = useCallback( (e: MouseEvent) => { if (e.button !== 0 && e.button !== 2) return; + // A press starting on a card is a card interaction (select/drag), not a marquee; arming here would make the mouseup deselect the card that was just clicked. + if ((e.target as HTMLElement)?.closest?.('[data-select-id]')) return; marqueeOriginRef.current = { screenX: e.clientX, screenY: e.clientY }; isDraggingMarqueeRef.current = false;