mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] dock: shrink-to-fit down to a 14px floor, magnify to an absolute size, and pin the rail's ends like the macOS Dock
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
|
||||
import KeyboardArrowUpRoundedIcon from '@mui/icons-material/KeyboardArrowUpRounded';
|
||||
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded';
|
||||
import { openSettingsCard, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import AppsRoundedIcon from '@mui/icons-material/AppsRounded';
|
||||
@@ -15,6 +16,7 @@ import { openCardContextMenu } from './openCardContextMenu';
|
||||
import { dockTileMenuRows } from './dockTileMenuRows';
|
||||
import { useDockLayout } from './useDockLayout';
|
||||
import { DockTileIcon } from './DockTileIcon';
|
||||
import DockHoverPreview from './DockHoverPreview';
|
||||
import type { AgentSession } from '@/shared/state/agentsSlice';
|
||||
import type {
|
||||
CardPosition,
|
||||
@@ -37,9 +39,8 @@ interface DesktopDockProps {
|
||||
onAddBrowser: () => void;
|
||||
}
|
||||
|
||||
const PREVIEW_W = 190;
|
||||
const ACTION_COUNT = 4;
|
||||
const FADE = 18;
|
||||
const CARET_H = 13;
|
||||
|
||||
/** Left-edge desktop dock: one tile per open card, hover previews, click focuses the window. */
|
||||
function DesktopDock({
|
||||
@@ -111,9 +112,10 @@ function DesktopDock({
|
||||
else setEdges((prev) => (prev.top || prev.bottom ? { top: false, bottom: false } : prev));
|
||||
}, [scrolls, scrollHeight, entries.length, readEdges, scrollRef]);
|
||||
|
||||
// Only fade the end that still has tiles behind it, so a magnified first or last tile stays crisp.
|
||||
// The fade is exactly the bleed band, which (since scrolling only happens at the tile floor, where bleed > tile)
|
||||
// is the only place a partly-scrolled tile can ever show: so a cut icon always fades out, never hard-clips.
|
||||
const mask = scrolls
|
||||
? `linear-gradient(to bottom, rgba(0,0,0,${edges.top ? 0 : 1}) 0px, #000 ${FADE}px, #000 calc(100% - ${FADE}px), rgba(0,0,0,${edges.bottom ? 0 : 1}) 100%)`
|
||||
? `linear-gradient(to bottom, rgba(0,0,0,${edges.top ? 0 : 1}) 0px, #000 ${bleed}px, #000 calc(100% - ${bleed}px), rgba(0,0,0,${edges.bottom ? 0 : 1}) 100%)`
|
||||
: undefined;
|
||||
|
||||
const hoveredEntry = hovered ? entries.find((e) => e.id === hovered.id) : undefined;
|
||||
@@ -121,6 +123,11 @@ function DesktopDock({
|
||||
? (liveShot?.id === hoveredEntry.id ? liveShot.dataUrl : hoveredEntry.thumbnail || undefined)
|
||||
: undefined;
|
||||
|
||||
// Past the shrink floor the column scrolls, and a hidden scrollbar with no caret reads as "the rest is gone".
|
||||
const carets: { key: string; top: number; icon: React.ReactNode }[] = [];
|
||||
if (scrolls && edges.top) carets.push({ key: 'up', top: 0, icon: <KeyboardArrowUpRoundedIcon sx={{ fontSize: '0.75rem' }} /> });
|
||||
if (scrolls && edges.bottom) carets.push({ key: 'down', top: scrollHeight - CARET_H, icon: <KeyboardArrowDownRoundedIcon sx={{ fontSize: '0.75rem' }} /> });
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={dockRef}
|
||||
@@ -148,6 +155,7 @@ function DesktopDock({
|
||||
'& .osw-dock-tile': {
|
||||
borderRadius: '12px',
|
||||
transition: 'transform 0.12s ease-out',
|
||||
transformOrigin: 'left center',
|
||||
willChange: 'transform',
|
||||
},
|
||||
// One source of truth for glyph size, so favicons and every icon pack shrink with the tile.
|
||||
@@ -157,6 +165,7 @@ function DesktopDock({
|
||||
{entries.length > 0 && (
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
data-dock-scroll
|
||||
onScroll={scrolls ? (e: React.UIEvent<HTMLDivElement>) => { readEdges(e.currentTarget); endHover(); } : undefined}
|
||||
// The canvas zooms on wheel; a wheel we consume here must never reach it.
|
||||
onWheel={scrolls ? (e: React.WheelEvent) => e.stopPropagation() : undefined}
|
||||
@@ -260,36 +269,29 @@ function DesktopDock({
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{hoveredEntry && (
|
||||
{/* Anchored to the root's padding box, whose top edge IS the scroll box's top edge. */}
|
||||
{carets.map((c) => (
|
||||
<Box
|
||||
key={c.key}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 'calc(100% + 10px)',
|
||||
top: Math.max(0, hovered!.top - 34),
|
||||
width: PREVIEW_W,
|
||||
borderRadius: '10px',
|
||||
overflow: 'hidden',
|
||||
background: previewImage ? '#fff' : 'rgba(22,12,34,0.9)',
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.4)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: `${c.top}px`,
|
||||
height: `${CARET_H}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
{previewImage ? (
|
||||
<Box component="img" src={previewImage} alt="" sx={{ width: '100%', display: 'block' }} />
|
||||
) : (
|
||||
<Box sx={{ p: 1.25 }}>
|
||||
<Typography sx={{ color: '#fff', fontSize: '0.75rem', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{hoveredEntry.label}
|
||||
</Typography>
|
||||
{hoveredEntry.snippet && (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.6)', fontSize: '0.6875rem', mt: 0.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{hoveredEntry.snippet}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{c.icon}
|
||||
</Box>
|
||||
)}
|
||||
))}
|
||||
|
||||
{hoveredEntry && <DockHoverPreview entry={hoveredEntry} top={hovered!.top} image={previewImage} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import type { DockEntry } from './dockEntries';
|
||||
|
||||
const PREVIEW_W = 190;
|
||||
|
||||
interface DockHoverPreviewProps {
|
||||
entry: DockEntry;
|
||||
top: number;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/** The card that floats beside a hovered dock tile: a live shot when we have one, title + snippet otherwise. */
|
||||
function DockHoverPreview({ entry, top, image }: DockHoverPreviewProps): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 'calc(100% + 10px)',
|
||||
top: Math.max(0, top - 34),
|
||||
width: PREVIEW_W,
|
||||
borderRadius: '10px',
|
||||
overflow: 'hidden',
|
||||
background: image ? '#fff' : 'rgba(22,12,34,0.9)',
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.4)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{image ? (
|
||||
<Box component="img" src={image} alt="" sx={{ width: '100%', display: 'block' }} />
|
||||
) : (
|
||||
<Box sx={{ p: 1.25 }}>
|
||||
<Typography sx={{ color: '#fff', fontSize: '0.75rem', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{entry.label}
|
||||
</Typography>
|
||||
{entry.snippet && (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.6)', fontSize: '0.6875rem', mt: 0.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default DockHoverPreview;
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const TILE_MAX = 30;
|
||||
const TILE_MIN = 18;
|
||||
// Apple's floor is deliberately tiny: magnification, not tile size, is what keeps a small tile hittable.
|
||||
const TILE_MIN = 14;
|
||||
const ROOT_PAD = 7;
|
||||
const GAP_RATIO = 0.3;
|
||||
const GAP_MIN = 3;
|
||||
const ICON_RATIO = 0.58;
|
||||
// Breathing room above and below the dock; the canvas root clips overflow, so this is also the magnify headroom.
|
||||
const EDGE_MARGIN = 16;
|
||||
// Slack inside the scroll box so a magnified tile grows past the column without the scroll clip cutting it.
|
||||
const BLEED = 14;
|
||||
const BOOST = 0.5;
|
||||
// macOS magnifies to an ABSOLUTE size, not a fixed ratio, so a 14px tile still grows to something you can hit.
|
||||
const MAGNIFY_TARGET = 44;
|
||||
// The bell curve was hand-tuned as 44px against a 30px tile; keep that ratio so it narrows as tiles shrink.
|
||||
const FALLOFF_RATIO = 44 / 30;
|
||||
|
||||
@@ -41,6 +41,26 @@ function columnHeight(tile: number, tiles: number, dividers: number): number {
|
||||
return ROOT_PAD * 2 + tiles * tile + dividers + gaps * gapFor(tile);
|
||||
}
|
||||
|
||||
interface DockGeom {
|
||||
els: HTMLElement[];
|
||||
bases: number[];
|
||||
rootTop: number;
|
||||
written: string[];
|
||||
}
|
||||
|
||||
function beginGesture(root: HTMLElement, box: HTMLDivElement | null): DockGeom {
|
||||
const els = Array.from(root.querySelectorAll<HTMLElement>('.osw-dock-tile'));
|
||||
const boxShift = box ? box.offsetTop - box.scrollTop : 0;
|
||||
// The curve already tracks the cursor frame by frame; easing every tile on top of that is a tax, not motion.
|
||||
els.forEach((t) => { t.style.transition = 'none'; });
|
||||
return {
|
||||
els,
|
||||
bases: els.map((t) => (box?.contains(t) ? boxShift : 0) + t.offsetTop + t.offsetHeight / 2),
|
||||
rootTop: root.getBoundingClientRect().top,
|
||||
written: els.map(() => ''),
|
||||
};
|
||||
}
|
||||
|
||||
/** macOS Dock sizing: tiles shrink to fit the column and only scroll once they hit the floor. */
|
||||
export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayoutInput): DockLayout {
|
||||
const dockRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -67,41 +87,74 @@ export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayo
|
||||
while (tile > TILE_MIN && columnHeight(tile, tileCount, dividerCount) > budget) tile -= 1;
|
||||
const gap = gapFor(tile);
|
||||
const scrolls = columnHeight(tile, tileCount, dividerCount) > budget;
|
||||
// Slack inside the scroll box so a magnified tile grows past the column without the scroll clip cutting it.
|
||||
const bleed = Math.ceil((MAGNIFY_TARGET - tile) / 2) + 2;
|
||||
|
||||
// Pinned rows keep their full height; whatever is left is what the card column may occupy.
|
||||
const pinned = ROOT_PAD * 2 + dividerCount + actionCount * tile + (dividerCount + actionCount) * gap;
|
||||
const scrollHeight = Math.max(tile * 3 + gap * 2 + BLEED * 2, budget - pinned);
|
||||
const step = tile + gap;
|
||||
// Whole tile steps only, so the clip edge always lands in a gap instead of bisecting an icon.
|
||||
const rows = Math.max(1, Math.floor((budget - pinned - bleed * 2 + gap) / step));
|
||||
const scrollHeight = rows * step - gap + bleed * 2;
|
||||
|
||||
const tileRef = useRef(tile);
|
||||
tileRef.current = tile;
|
||||
const geomRef = useRef<DockGeom | null>(null);
|
||||
const dropGeom = useCallback((): void => { geomRef.current = null; }, []);
|
||||
|
||||
// Tiles cannot move mid-hover, so re-measuring them per mousemove was pure style-recalc tax; these are
|
||||
// every signal that CAN move them.
|
||||
useEffect(dropGeom, [dropGeom, tile, gap, scrollHeight, scrolls, containerH, cardCount, actionCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const box = scrollRef.current;
|
||||
if (!box) return undefined;
|
||||
box.addEventListener('scroll', dropGeom, { passive: true });
|
||||
return () => box.removeEventListener('scroll', dropGeom);
|
||||
}, [dropGeom, scrolls, cardCount]);
|
||||
|
||||
// macOS Dock magnification: the tile under the cursor grows on a bell curve and its neighbors SLIDE
|
||||
// AWAY to make room. Each tile that grows by `extra` pushes every tile past it by extra/2.
|
||||
const applyMagnify = useCallback((clientY: number | null) => {
|
||||
const root = dockRef.current;
|
||||
if (!root) return;
|
||||
const els = Array.from(root.querySelectorAll<HTMLElement>('.osw-dock-tile'));
|
||||
if (els.length === 0) return;
|
||||
if (clientY == null) {
|
||||
els.forEach((t) => { t.style.transform = ''; t.style.zIndex = ''; });
|
||||
// Handing the transition back before clearing the transform is what makes the settle glide instead of snap.
|
||||
root.querySelectorAll<HTMLElement>('.osw-dock-tile').forEach((t) => {
|
||||
t.style.transition = '';
|
||||
t.style.transform = '';
|
||||
t.style.zIndex = '';
|
||||
});
|
||||
geomRef.current = null;
|
||||
return;
|
||||
}
|
||||
const geom = geomRef.current ?? beginGesture(root, scrollRef.current);
|
||||
geomRef.current = geom;
|
||||
const { els, bases } = geom;
|
||||
if (els.length === 0) return;
|
||||
const size = tileRef.current;
|
||||
const boost = MAGNIFY_TARGET / size - 1;
|
||||
const falloff = size * FALLOFF_RATIO;
|
||||
const box = scrollRef.current;
|
||||
const boxShift = box ? box.offsetTop - box.scrollTop : 0;
|
||||
const cy = clientY - root.getBoundingClientRect().top;
|
||||
const bases = els.map((t) => (box?.contains(t) ? boxShift : 0) + t.offsetTop + t.offsetHeight / 2);
|
||||
const scales = bases.map((b) => 1 + BOOST * Math.exp(-(((cy - b) / falloff) ** 2)));
|
||||
const cy = clientY - geom.rootTop;
|
||||
const scales = bases.map((b) => 1 + boost * Math.exp(-(((cy - b) / falloff) ** 2)));
|
||||
const extra = scales.map((s) => size * (s - 1));
|
||||
const total = extra.reduce((a, b) => a + b, 0);
|
||||
// Bases run in DOM order, top to bottom, so "everything before me" is a running sum, not an inner loop.
|
||||
const head = (extra[0] - total) / 2;
|
||||
const tail = (total - extra[els.length - 1]) / 2;
|
||||
const span = bases[els.length - 1] - bases[0];
|
||||
// Apple's Dock never grows longer than its rail: pin both ends and let the spread squeeze the middle.
|
||||
const slope = span > 0 ? (tail - head) / span : 0;
|
||||
let before = 0;
|
||||
els.forEach((t, i) => {
|
||||
let shift = 0;
|
||||
for (let j = 0; j < els.length; j++) {
|
||||
if (j === i) continue;
|
||||
shift += (extra[j] / 2) * Math.sign(bases[i] - bases[j]);
|
||||
}
|
||||
t.style.transform = `translateY(${shift.toFixed(1)}px) scale(${scales[i].toFixed(3)})`;
|
||||
t.style.transformOrigin = 'left center';
|
||||
const raw = before - (total - before - extra[i]);
|
||||
const shift = raw / 2 - head - slope * (bases[i] - bases[0]);
|
||||
before += extra[i];
|
||||
const next = `translateY(${shift.toFixed(1)}px) scale(${scales[i].toFixed(3)})`;
|
||||
// Tiles outside the bell curve land on the same transform move after move, and a no-op style write is not free.
|
||||
if (geom.written[i] === next) return;
|
||||
geom.written[i] = next;
|
||||
t.style.transform = next;
|
||||
t.style.zIndex = String(10 + Math.round((scales[i] - 1) * 100));
|
||||
});
|
||||
}, []);
|
||||
@@ -114,7 +167,7 @@ export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayo
|
||||
iconSize: Math.round(tile * ICON_RATIO),
|
||||
scrolls,
|
||||
scrollHeight,
|
||||
bleed: BLEED,
|
||||
bleed,
|
||||
applyMagnify,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user