mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] spaces: make the strip scrollable so off-screen spaces are reachable (wheel, drag, chevrons, keep-active-in-view)
This commit is contained in:
@@ -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<StripEdgeProps> = ({ side, visible, onNudge }) => (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', top: 0, bottom: 0, [side]: 0, width: EDGE_W,
|
||||
display: 'flex', alignItems: 'center', justifyContent: side === 'left' ? 'flex-start' : 'flex-end',
|
||||
px: 0.75, pointerEvents: 'none', zIndex: 1,
|
||||
opacity: visible ? 1 : 0, transition: 'opacity 180ms ease',
|
||||
background: `linear-gradient(to ${side === 'left' ? 'right' : 'left'}, rgba(20,18,24,0.9), rgba(20,18,24,0))`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
aria-label={side === 'left' ? 'Scroll spaces left' : 'Scroll spaces right'}
|
||||
onClick={onNudge}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 28, height: 28, borderRadius: '50%', cursor: 'pointer',
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
border: '1px solid rgba(255,255,255,0.18)', background: 'rgba(255,255,255,0.12)',
|
||||
color: 'rgba(255,255,255,0.92)', transition: 'background 140ms',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.26)' },
|
||||
}}
|
||||
>
|
||||
{side === 'left' ? <ChevronLeft size={17} /> : <ChevronRight size={17} />}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Box
|
||||
key={d.id}
|
||||
component="button"
|
||||
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 }); }}
|
||||
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)' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="osw-space-tile"
|
||||
sx={{
|
||||
width: TILE_W, height: TILE_H, borderRadius: '10px', overflow: 'hidden',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
boxShadow: active
|
||||
? '0 0 0 3px rgba(255,255,255,0.9), 0 10px 26px rgba(0,0,0,0.4)'
|
||||
: '0 0 0 1px rgba(255,255,255,0.14), 0 8px 20px rgba(0,0,0,0.35)',
|
||||
transition: 'box-shadow 140ms',
|
||||
}}
|
||||
>
|
||||
{d.thumbnail ? (
|
||||
<Box component="img" src={d.thumbnail} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: '100%', background: 'linear-gradient(135deg, rgba(255,255,255,0.16), rgba(255,255,255,0.05))' }} />
|
||||
)}
|
||||
</Box>
|
||||
{renaming ? (
|
||||
<Box
|
||||
component="input"
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.92)', fontSize: '0.8125rem', fontWeight: active ? 600 : 500, maxWidth: TILE_W, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{d.name || 'Untitled'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box
|
||||
component="button"
|
||||
aria-label="New dashboard"
|
||||
onClick={addSpace}
|
||||
ref={strip.scrollRef}
|
||||
data-spaces-rail
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 64, height: TILE_H, borderRadius: '10px', cursor: 'pointer',
|
||||
border: '1.5px dashed rgba(255,255,255,0.35)', background: 'rgba(255,255,255,0.05)',
|
||||
color: 'rgba(255,255,255,0.85)', flexShrink: 0,
|
||||
transition: 'background 140ms, border-color 140ms',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.14)', borderColor: 'rgba(255,255,255,0.6)' },
|
||||
position: 'relative',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 2.25, px: 3,
|
||||
// "safe center" centers a short row but falls back to start-aligned once it overflows, so tile one is never clipped out of reach.
|
||||
justifyContent: 'safe center',
|
||||
overflowX: 'auto', overflowY: 'hidden', userSelect: 'none',
|
||||
cursor: strip.dragging ? 'grabbing' : 'default',
|
||||
scrollbarWidth: 'none', '&::-webkit-scrollbar': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<Plus size={22} />
|
||||
{list.map((d) => {
|
||||
const active = d.id === activeId;
|
||||
const renaming = renamingId === d.id;
|
||||
return (
|
||||
<Box
|
||||
key={d.id}
|
||||
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 }); }}
|
||||
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)' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="osw-space-tile"
|
||||
sx={{
|
||||
width: TILE_W, height: TILE_H, borderRadius: '10px', overflow: 'hidden',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
boxShadow: active
|
||||
? '0 0 0 3px rgba(255,255,255,0.9), 0 10px 26px rgba(0,0,0,0.4)'
|
||||
: '0 0 0 1px rgba(255,255,255,0.14), 0 8px 20px rgba(0,0,0,0.35)',
|
||||
transition: 'box-shadow 140ms',
|
||||
}}
|
||||
>
|
||||
{d.thumbnail ? (
|
||||
<Box component="img" src={d.thumbnail} alt="" draggable={false} sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: '100%', background: 'linear-gradient(135deg, rgba(255,255,255,0.16), rgba(255,255,255,0.05))' }} />
|
||||
)}
|
||||
</Box>
|
||||
{renaming ? (
|
||||
<Box
|
||||
component="input"
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.92)', fontSize: '0.8125rem', fontWeight: active ? 600 : 500, maxWidth: TILE_W, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{d.name || 'Untitled'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box
|
||||
component="button"
|
||||
aria-label="New dashboard"
|
||||
onClick={addSpace}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 64, height: TILE_H, borderRadius: '10px', cursor: 'pointer',
|
||||
border: '1.5px dashed rgba(255,255,255,0.35)', background: 'rgba(255,255,255,0.05)',
|
||||
color: 'rgba(255,255,255,0.85)', flexShrink: 0,
|
||||
transition: 'background 140ms, border-color 140ms',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.14)', borderColor: 'rgba(255,255,255,0.6)' },
|
||||
}}
|
||||
>
|
||||
<Plus size={22} />
|
||||
</Box>
|
||||
</Box>
|
||||
<StripEdge side="left" visible={strip.overflowing && !strip.atStart} onNudge={() => strip.scrollByPage(-1)} />
|
||||
<StripEdge side="right" visible={strip.overflowing && !strip.atEnd} onNudge={() => strip.scrollByPage(1)} />
|
||||
</Box>
|
||||
{menu && (
|
||||
<Box
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
|
||||
// Mission Control's spaces rail: a vertical wheel scrolls it sideways, a press-drag pans it, and the
|
||||
// active space is always kept in view. Lives apart from SpacesStrip so the strip stays about tiles.
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
const EDGE_EPSILON_PX = 2;
|
||||
const MIN_PAGE_PX = 220;
|
||||
|
||||
export const ACTIVE_TILE_ATTR = 'data-space-active';
|
||||
|
||||
export interface StripScroll {
|
||||
scrollRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
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<HTMLDivElement | null>(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<HTMLElement>(`[${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 };
|
||||
}
|
||||
Reference in New Issue
Block a user