diff --git a/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx b/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx index 13ff4845..ab633bd2 100644 --- a/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx @@ -89,6 +89,12 @@ function CardContextMenu(): React.ReactElement | null { return () => window.removeEventListener(CARD_MENU_EVENT, onOpen); }, []); + // Body stamp so hover-dismiss surfaces (the Spaces strip) can hold still while a menu is up. + useEffect(() => { + document.body.classList.toggle('osw-card-menu-open', !!menu); + return () => document.body.classList.remove('osw-card-menu-open'); + }, [menu]); + // Measured, never guessed: rows render ~30px, so a hardcoded row height over-corrects long menus. useLayoutEffect(() => { if (!menu || !rootRef.current) return; diff --git a/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx b/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx index 260f67a5..77c4e30c 100644 --- a/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx @@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { createDashboard, deleteDashboard, duplicateDashboard, renameDashboard } from '@/shared/state/dashboardsSlice'; +import { openCardContextMenu } from './openCardContextMenu'; import { ACTIVE_TILE_ATTR, useStripScroll } from './useStripScroll'; // macOS Spaces, one for one: rest the cursor on the top edge and the spaces bar slides down, @@ -18,13 +19,6 @@ const TILE_H = 104; const DISMISS_BELOW_PX = 72; const EDGE_W = 76; -interface TileMenu { - id: string; - name: string; - x: number; - y: number; -} - interface StripEdgeProps { side: 'left' | 'right'; visible: boolean; @@ -66,7 +60,6 @@ const SpacesStrip: React.FC = () => { const location = useLocation(); const dashboards = useAppSelector((s) => s.dashboards.items); const [open, setOpen] = React.useState(false); - const [menu, setMenu] = React.useState(null); const [renamingId, setRenamingId] = React.useState(null); const [renameValue, setRenameValue] = React.useState(''); const barRef = React.useRef(null); @@ -87,24 +80,16 @@ const SpacesStrip: React.FC = () => { // moved a comfortable distance BELOW the bar; hovering within or near the bar never flickers. // The watcher pauses while a context menu, rename or drag-pan is live, so those can wander below the bar. React.useEffect(() => { - if (!open || menu || renamingId || strip.dragging) return undefined; + if (!open || renamingId || strip.dragging) return undefined; const onMove = (e: MouseEvent): void => { + // The shared menu stamps this class while up; the strip must not slide away under an open menu. + if (document.body.classList.contains('osw-card-menu-open')) return; const barBottom = barRef.current?.getBoundingClientRect().bottom ?? 0; if (e.clientY > barBottom + DISMISS_BELOW_PX) setOpen(false); }; window.addEventListener('mousemove', onMove); return () => window.removeEventListener('mousemove', onMove); - }, [open, menu, renamingId, strip.dragging]); - - // The context menu closes on any outside press or Esc, like a native menu. - React.useEffect(() => { - if (!menu) return undefined; - const onDown = (): void => setMenu(null); - const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') setMenu(null); }; - window.addEventListener('mousedown', onDown); - window.addEventListener('keydown', onKey); - return () => { window.removeEventListener('mousedown', onDown); window.removeEventListener('keydown', onKey); }; - }, [menu]); + }, [open, renamingId, strip.dragging]); const addSpace = (): void => { void dispatch(createDashboard('Untitled Dashboard')).then((result) => { @@ -138,14 +123,6 @@ const SpacesStrip: React.FC = () => { } }; - const menuItemSx = { - display: 'flex', alignItems: 'center', width: '100%', px: 1.5, py: 0.75, - border: 'none', background: 'transparent', borderRadius: '7px', - color: 'rgba(255,255,255,0.9)', fontFamily: 'inherit', fontSize: '0.8125rem', - cursor: 'pointer', textAlign: 'left' as const, - '&:hover': { background: 'rgba(255,255,255,0.1)' }, - }; - return ( <> setOpen(true)} sx={{ position: 'fixed', top: 0, left: 0, right: 0, height: HOT_ZONE_PX, zIndex: 99998 }} /> @@ -187,7 +164,14 @@ const SpacesStrip: React.FC = () => { component="button" {...{ [ACTIVE_TILE_ATTR]: active ? 'true' : 'false' }} onClick={() => { if (!renaming) { navigate(`/dashboard/${d.id}`); setOpen(false); } }} - onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setMenu({ id: d.id, name: d.name || 'Untitled', x: e.clientX, y: e.clientY }); }} + onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { + items: [ + { label: 'Rename', onClick: () => { setRenameValue(d.name || 'Untitled'); setRenamingId(d.id); } }, + { label: 'Duplicate', onClick: () => duplicateSpace(d.id) }, + { kind: 'separator' }, + { label: 'Delete', danger: true, disabled: list.length <= 1, onClick: () => deleteSpace(d.id) }, + ], + })} sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.75, flexShrink: 0, p: 0, border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit', @@ -257,34 +241,6 @@ const SpacesStrip: React.FC = () => { strip.scrollByPage(-1)} /> strip.scrollByPage(1)} /> - {menu && ( - e.stopPropagation()} - sx={{ - position: 'fixed', top: menu.y + 4, left: Math.min(menu.x, window.innerWidth - 180), zIndex: 100001, - width: 168, p: 0.5, borderRadius: '10px', - background: 'rgba(28,25,33,0.96)', - backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)', - border: '1px solid rgba(255,255,255,0.12)', - boxShadow: '0 18px 44px rgba(0,0,0,0.5)', - }} - > - { setRenameValue(menu.name); setRenamingId(menu.id); setMenu(null); }}> - Rename - - { duplicateSpace(menu.id); setMenu(null); }}> - Duplicate - - { if (list.length > 1) { deleteSpace(menu.id); setMenu(null); } }} - > - Delete - - - )} ); };