diff --git a/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx b/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx index 8c4c9c69..73c814d6 100644 --- a/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx @@ -1,10 +1,11 @@ import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import { Plus } from 'lucide-react'; +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 { ACTIVE_TILE_ATTR, useStripScroll } from './useStripScroll'; // macOS Spaces, one for one: rest the cursor on the top edge and the spaces bar slides down, // full-size thumbnail tiles with the name beneath, + at the right end to add a space. It stays @@ -15,6 +16,7 @@ const HOT_ZONE_PX = 3; const TILE_W = 176; const TILE_H = 104; const DISMISS_BELOW_PX = 72; +const EDGE_W = 76; interface TileMenu { id: string; @@ -23,6 +25,41 @@ interface TileMenu { y: number; } +interface StripEdgeProps { + side: 'left' | 'right'; + visible: boolean; + onNudge: () => void; +} + +// The fade says "there is more this way"; the chevron makes it clickable for anyone without a trackpad. +const StripEdge: React.FC = ({ side, visible, onNudge }) => ( + + + {side === 'left' ? : } + + +); + const SpacesStrip: React.FC = () => { const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -44,18 +81,20 @@ const SpacesStrip: React.FC = () => { [dashboards], ); + const strip = useStripScroll(activeId, list.length, open); + // Mission Control dismissal: while open, watch the cursor globally and close only once it has // moved a comfortable distance BELOW the bar; hovering within or near the bar never flickers. - // The watcher pauses while a context menu or rename is live, so those can extend below the bar. + // 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) return undefined; + if (!open || menu || renamingId || strip.dragging) return undefined; const onMove = (e: MouseEvent): void => { 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]); + }, [open, menu, renamingId, strip.dragging]); // The context menu closes on any outside press or Esc, like a native menu. React.useEffect(() => { @@ -114,8 +153,7 @@ const SpacesStrip: React.FC = () => { ref={barRef} sx={{ position: 'fixed', top: 0, left: 0, right: 0, zIndex: 99999, - display: 'flex', alignItems: 'flex-start', justifyContent: 'center', gap: 2.25, - px: 3, pt: 2, pb: 1.75, + pt: 2, pb: 1.75, background: 'rgba(22,20,26,0.45)', backdropFilter: 'blur(30px) saturate(150%)', WebkitBackdropFilter: 'blur(30px) saturate(150%)', @@ -125,80 +163,97 @@ const SpacesStrip: React.FC = () => { pointerEvents: open ? 'auto' : 'none', }} > - {list.map((d) => { - const active = d.id === activeId; - const renaming = renamingId === d.id; - return ( - { 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 }); }} - sx={{ - display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.75, - p: 0, border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit', - '&:hover .osw-space-tile': { boxShadow: '0 0 0 3px rgba(255,255,255,0.65), 0 10px 26px rgba(0,0,0,0.4)' }, - }} - > - - {d.thumbnail ? ( - - ) : ( - - )} - - {renaming ? ( - ) => setRenameValue(e.target.value)} - onClick={(e: React.MouseEvent) => e.stopPropagation()} - onKeyDown={(e: React.KeyboardEvent) => { - e.stopPropagation(); - if (e.key === 'Enter') commitRename(d.id); - if (e.key === 'Escape') setRenamingId(null); - }} - onBlur={() => commitRename(d.id)} - sx={{ - width: TILE_W - 24, textAlign: 'center', border: '1px solid rgba(255,255,255,0.35)', - borderRadius: '6px', background: 'rgba(0,0,0,0.35)', outline: 'none', - color: 'rgba(255,255,255,0.95)', fontFamily: 'inherit', fontSize: '0.8125rem', py: 0.2, - }} - /> - ) : ( - - {d.name || 'Untitled'} - - )} - - ); - })} - + {list.map((d) => { + const active = d.id === activeId; + const renaming = renamingId === d.id; + return ( + { 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 }); }} + sx={{ + display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.75, flexShrink: 0, + p: 0, border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit', + '&:hover .osw-space-tile': { boxShadow: '0 0 0 3px rgba(255,255,255,0.65), 0 10px 26px rgba(0,0,0,0.4)' }, + }} + > + + {d.thumbnail ? ( + + ) : ( + + )} + + {renaming ? ( + ) => setRenameValue(e.target.value)} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + onKeyDown={(e: React.KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Enter') commitRename(d.id); + if (e.key === 'Escape') setRenamingId(null); + }} + onBlur={() => commitRename(d.id)} + sx={{ + width: TILE_W - 24, textAlign: 'center', border: '1px solid rgba(255,255,255,0.35)', + borderRadius: '6px', background: 'rgba(0,0,0,0.35)', outline: 'none', userSelect: 'text', + color: 'rgba(255,255,255,0.95)', fontFamily: 'inherit', fontSize: '0.8125rem', py: 0.2, + }} + /> + ) : ( + + {d.name || 'Untitled'} + + )} + + ); + })} + + + + strip.scrollByPage(-1)} /> + strip.scrollByPage(1)} /> {menu && ( ; + atStart: boolean; + atEnd: boolean; + overflowing: boolean; + dragging: boolean; + scrollByPage: (dir: -1 | 1) => void; +} + +const wantsMotion = (): boolean => !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + +export function useStripScroll(activeId: string | null, itemCount: number, animate: boolean): StripScroll { + const scrollRef = React.useRef(null); + const [edges, setEdges] = React.useState({ atStart: true, atEnd: true, overflowing: false }); + const [dragging, setDragging] = React.useState(false); + + const syncEdges = React.useCallback((): void => { + const el = scrollRef.current; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + const next = { + atStart: el.scrollLeft <= EDGE_EPSILON_PX, + atEnd: el.scrollLeft >= max - EDGE_EPSILON_PX, + overflowing: max > EDGE_EPSILON_PX, + }; + setEdges((prev) => ( + prev.atStart === next.atStart && prev.atEnd === next.atEnd && prev.overflowing === next.overflowing ? prev : next + )); + }, []); + + React.useEffect(() => { + const el = scrollRef.current; + if (!el) return undefined; + // The canvas zooms on wheel, so anything this rail consumes must stop here and never bubble onward. + const onWheel = (e: WheelEvent): void => { + if (el.scrollWidth - el.clientWidth <= EDGE_EPSILON_PX) return; + const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + if (delta === 0) return; + el.scrollLeft += delta; + e.preventDefault(); + e.stopPropagation(); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + el.addEventListener('scroll', syncEdges, { passive: true }); + const ro = new ResizeObserver(syncEdges); + ro.observe(el); + return () => { + el.removeEventListener('wheel', onWheel); + el.removeEventListener('scroll', syncEdges); + ro.disconnect(); + }; + }, [syncEdges]); + + React.useEffect(() => { syncEdges(); }, [syncEdges, itemCount]); + + React.useEffect(() => { + const el = scrollRef.current; + if (!el) return undefined; + let startX = 0; + let startLeft = 0; + let moved = false; + let swallowClick = false; + + const onMove = (e: PointerEvent): void => { + const dx = e.clientX - startX; + if (!moved) { + if (Math.abs(dx) < DRAG_THRESHOLD_PX) return; + moved = true; + setDragging(true); + } + el.scrollLeft = startLeft - dx; + e.preventDefault(); + }; + const onUp = (): void => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + if (!moved) return; + swallowClick = true; + setDragging(false); + }; + const onDown = (e: PointerEvent): void => { + if (e.button !== 0) return; // right-click belongs to the tile context menu + if ((e.target as HTMLElement).closest('input')) return; // the inline rename field keeps its own caret + swallowClick = false; + startX = e.clientX; + startLeft = el.scrollLeft; + moved = false; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + }; + // A pan that ends on a tile still fires a click, so eat exactly that one and let real clicks through. + const onClickCapture = (e: MouseEvent): void => { + if (!swallowClick) return; + swallowClick = false; + e.preventDefault(); + e.stopPropagation(); + }; + + el.addEventListener('pointerdown', onDown); + el.addEventListener('click', onClickCapture, true); + return () => { + el.removeEventListener('pointerdown', onDown); + el.removeEventListener('click', onClickCapture, true); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + }, []); + + React.useEffect(() => { + const el = scrollRef.current; + if (!el || !activeId) return; + const target = el.querySelector(`[${ACTIVE_TILE_ATTR}="true"]`); + if (!target) return; + const max = el.scrollWidth - el.clientWidth; + if (max <= EDGE_EPSILON_PX) return; + const left = target.offsetLeft; + if (left >= el.scrollLeft && left + target.offsetWidth <= el.scrollLeft + el.clientWidth) return; + const centered = Math.max(0, Math.min(max, left - (el.clientWidth - target.offsetWidth) / 2)); + el.scrollTo({ left: centered, behavior: animate && wantsMotion() ? 'smooth' : 'auto' }); + }, [activeId, itemCount, animate]); + + const scrollByPage = React.useCallback((dir: -1 | 1): void => { + const el = scrollRef.current; + if (!el) return; + const step = Math.max(MIN_PAGE_PX, el.clientWidth * 0.8); + el.scrollBy({ left: dir * step, behavior: wantsMotion() ? 'smooth' : 'auto' }); + }, []); + + return { scrollRef, ...edges, dragging, scrollByPage }; +}