mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] canvas: right-click menus on every card, click-zoom retry beats the shield race, dbl-click zooms out in place, tile menu can't bait, bigger chat grab band, help pill sheds the mic
This commit is contained in:
@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
|
||||
import DashboardToolbar from '../DashboardToolbar';
|
||||
import CanvasControls from '../controls/CanvasControls';
|
||||
import HelpPill from '../desktop/HelpPill';
|
||||
import CardContextMenu from '../desktop/CardContextMenu';
|
||||
import CardSearchPalette from '../controls/CardSearchPalette';
|
||||
import DirectionHints from '../controls/DirectionHints';
|
||||
import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast';
|
||||
@@ -122,6 +123,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
|
||||
{!anyFullscreen && (
|
||||
<Box sx={{ position: 'absolute', top: 14, right: 16, zIndex: 10 }}>
|
||||
<HelpPill />
|
||||
<CardContextMenu />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
collapseSession,
|
||||
expandSession,
|
||||
closeSession,
|
||||
deleteSession,
|
||||
fetchSession,
|
||||
renameSession,
|
||||
} from '@/shared/state/agentsSlice';
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
import WindowControls, { ARC_CHIP_SX } from './WindowControls';
|
||||
import { useTiledStyle } from './tileZones';
|
||||
import AgentNarratorPill from '../desktop/AgentNarratorPill';
|
||||
import { openCardContextMenu } from '../desktop/CardContextMenu';
|
||||
import { extractLatestTodos } from '../desktop/agentTodos';
|
||||
import { extractLatestShowUi, extractPendingAskUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
|
||||
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
|
||||
@@ -802,6 +804,15 @@ const AgentCard: React.FC<Props> = ({
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(session.id, 'agent');
|
||||
}}
|
||||
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
|
||||
rename: { value: displayChatTitle(session), onCommit: (name) => dispatch(renameSession({ sessionId: session.id, name })) },
|
||||
items: [
|
||||
{ label: expanded ? 'Collapse' : 'Open', onClick: () => dispatch(expanded ? collapseSession(session.id) : expandSession(session.id)) },
|
||||
{ label: 'Full Screen', onClick: () => onTile('fullscreen') },
|
||||
{ label: 'Close', onClick: () => handleRemove() },
|
||||
{ label: 'Delete chat', danger: true, onClick: () => { void dispatch(deleteSession({ sessionId: session.id })); } },
|
||||
],
|
||||
})}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
// Hover runway for the pop-above header: the header is pointer-events:none until the CARD
|
||||
@@ -813,8 +824,8 @@ const AgentCard: React.FC<Props> = ({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: -40,
|
||||
height: 40,
|
||||
top: -48,
|
||||
height: 48,
|
||||
},
|
||||
}),
|
||||
// contain: streaming chat updates inside don't reflow the dashboard. Skipping `paint` here because the highlighted/selected/glow boxShadows legitimately extend past the card border, `paint` containment would clip those visuals.
|
||||
@@ -988,7 +999,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
onPointerUp={handleDragPointerUp}
|
||||
onPointerCancel={abortDrag}
|
||||
onLostPointerCapture={abortDrag}
|
||||
sx={{ position: 'absolute', top: 0, left: 12, right: 12, height: 14, zIndex: 18, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none' }}
|
||||
sx={{ position: 'absolute', top: 0, left: 8, right: 8, height: 26, zIndex: 18, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none' }}
|
||||
/>
|
||||
)}
|
||||
{pillMode && (
|
||||
|
||||
@@ -58,10 +58,12 @@ import {
|
||||
registerPendingLoad,
|
||||
wakePendingLoad,
|
||||
type BrowserWebview,
|
||||
getWebview,
|
||||
} from '@/shared/browserRegistry';
|
||||
import { setLastInteractedBrowser } from '@/shared/browserFocus';
|
||||
import { registerCapsuleForRestore } from '@/shared/browserStateCapsule';
|
||||
import BrowserFindBar from './BrowserFindBar';
|
||||
import { openCardContextMenu } from '../desktop/CardContextMenu';
|
||||
import { useBrowserActivity } from '@/shared/useBrowserActivity';
|
||||
import { getActionLabel } from '@/shared/browserCommandHandler';
|
||||
import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl';
|
||||
@@ -981,6 +983,16 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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 || isMinimized ? '1' : undefined}
|
||||
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
|
||||
items: [
|
||||
{ label: 'New Tab', onClick: () => dispatch(addBrowserTab({ browserId, url: browserHomepage })) },
|
||||
{ label: 'Reload', onClick: () => { try { (getWebview(browserId) as { reload?: () => void } | undefined)?.reload?.(); } catch { /* webview gone */ } } },
|
||||
{ label: 'Copy URL', onClick: () => { void navigator.clipboard.writeText(activeUrl); } },
|
||||
{ label: 'Full Screen', onClick: () => onTile('fullscreen') },
|
||||
{ label: 'Minimize', onClick: handleMinimize },
|
||||
{ label: 'Close', danger: true, onClick: () => { dispatch(recordClosedCard({ kind: 'browser', id: browserId })); removeBrowserCardCleanly(browserId, dispatch); } },
|
||||
],
|
||||
})}
|
||||
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. Pass the target so URL-bar/tab presses select without yanking the camera.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
|
||||
import { setViewCardPosition, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { removeViewCardCleanly } from '@/shared/viewTeardown';
|
||||
import WindowControls from './WindowControls';
|
||||
import { openCardContextMenu } from '../desktop/CardContextMenu';
|
||||
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
|
||||
import { useTiledStyle } from './tileZones';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
@@ -537,6 +538,13 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
<Box
|
||||
data-select-type="view-card"
|
||||
data-select-id={cardKey}
|
||||
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
|
||||
items: [
|
||||
{ label: 'Full Screen', onClick: () => onTile('fullscreen') },
|
||||
{ label: 'Minimize', onClick: onMinimize },
|
||||
{ label: 'Close', danger: true, onClick: () => handleRemove() },
|
||||
],
|
||||
})}
|
||||
data-select-meta={JSON.stringify({ name: output.name, description: output.description, path: output.workspace_path })}
|
||||
className="osw-card"
|
||||
onPointerDownCapture={() => onBringToFront?.(cardKey, 'view')}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import WindowControls from './WindowControls';
|
||||
import { openCardContextMenu } from '../desktop/CardContextMenu';
|
||||
import { useTiledStyle } from './tileZones';
|
||||
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
|
||||
|
||||
@@ -297,6 +298,13 @@ const NoteCard: React.FC<Props> = ({
|
||||
className="osw-card"
|
||||
data-select-type="note-card"
|
||||
data-select-id={noteId}
|
||||
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
|
||||
items: [
|
||||
{ label: 'Full Screen', onClick: () => onTile('fullscreen') },
|
||||
{ label: 'Minimize', onClick: onMinimize },
|
||||
{ label: 'Delete note', danger: true, onClick: () => handleRemove() },
|
||||
],
|
||||
})}
|
||||
data-select-meta={JSON.stringify({ name: 'Note', content: content.slice(0, 60) })}
|
||||
onPointerDownCapture={(e: React.PointerEvent) => {
|
||||
onBringToFront?.(noteId, 'note');
|
||||
|
||||
@@ -68,7 +68,7 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind
|
||||
if (!menuHot) { setMenuHot(true); requestAnimationFrame(() => setMenuOpen(true)); return; }
|
||||
setMenuOpen(true);
|
||||
};
|
||||
const scheduleClose = (): void => { closeTimer.current = window.setTimeout(() => setMenuOpen(false), 180); };
|
||||
const scheduleClose = (): void => { closeTimer.current = window.setTimeout(() => setMenuOpen(false), 320); };
|
||||
const stop = (e: React.PointerEvent | React.MouseEvent): void => { e.stopPropagation(); };
|
||||
|
||||
const btn = (color: string, symbol: string, onClick: () => void, label: string): React.ReactElement => (
|
||||
@@ -79,7 +79,7 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className="osw-window-lights" onPointerDown={stop}
|
||||
<Box className="osw-window-lights" data-tilemenu-open={menuOpen ? '1' : undefined} onPointerDown={stop}
|
||||
sx={{
|
||||
display: 'flex', gap: '8px', alignItems: 'center', flex: 'none', '&:hover span': { opacity: 1 },
|
||||
// Inert until the card is hovered: crossing a card can't hit-test or fire React enter/leave
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
// One right-click menu for every canvas entity (chats, browsers, notes, apps, workflow cards,
|
||||
// minimized pills). Cards call openCardContextMenu with their items; this overlay renders the
|
||||
// native-feeling glass menu (SpacesStrip grammar) and closes on outside press / Esc / item click.
|
||||
export interface CardMenuItem {
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface CardMenuRequest {
|
||||
x: number;
|
||||
y: number;
|
||||
items: CardMenuItem[];
|
||||
/** Optional inline-rename affordance: shown as the first row with an editable input. */
|
||||
rename?: { value: string; onCommit: (next: string) => void };
|
||||
}
|
||||
|
||||
const EVENT = 'openswarm:card-context-menu';
|
||||
|
||||
export function openCardContextMenu(e: { clientX: number; clientY: number; preventDefault: () => void; stopPropagation: () => void }, req: Omit<CardMenuRequest, 'x' | 'y'>): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new CustomEvent(EVENT, { detail: { ...req, x: e.clientX, y: e.clientY } }));
|
||||
}
|
||||
|
||||
const MENU_W = 208;
|
||||
|
||||
function CardContextMenu(): React.ReactElement | null {
|
||||
const [menu, setMenu] = useState<CardMenuRequest | null>(null);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event): void => {
|
||||
const req = (e as CustomEvent).detail as CardMenuRequest;
|
||||
setMenu(req);
|
||||
setRenaming(false);
|
||||
setRenameValue(req.rename?.value ?? '');
|
||||
};
|
||||
window.addEventListener(EVENT, onOpen);
|
||||
return () => window.removeEventListener(EVENT, onOpen);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
window.addEventListener('wheel', onDown, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', onDown);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('wheel', onDown);
|
||||
};
|
||||
}, [menu]);
|
||||
|
||||
if (!menu) return null;
|
||||
|
||||
const itemSx = {
|
||||
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)' },
|
||||
'&:disabled': { color: 'rgba(255,255,255,0.35)', cursor: 'default', '&:hover': { background: 'transparent' } },
|
||||
};
|
||||
|
||||
const commitRename = (): void => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed && menu.rename && trimmed !== menu.rename.value) menu.rename.onCommit(trimmed);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
onMouseDown={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
onContextMenu={(e: React.MouseEvent) => e.preventDefault()}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: Math.min(menu.y + 2, window.innerHeight - 44 * (menu.items.length + 1) - 16),
|
||||
left: Math.min(menu.x, window.innerWidth - MENU_W - 12),
|
||||
zIndex: 100001,
|
||||
width: MENU_W, 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)',
|
||||
}}
|
||||
>
|
||||
{menu.rename && (
|
||||
renaming ? (
|
||||
<Box
|
||||
component="input"
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') commitRename();
|
||||
if (e.key === 'Escape') setMenu(null);
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
sx={{
|
||||
width: '100%', boxSizing: 'border-box', mb: 0.25, px: 1.25, py: 0.6,
|
||||
border: '1px solid rgba(255,255,255,0.35)', borderRadius: '7px',
|
||||
background: 'rgba(0,0,0,0.35)', outline: 'none',
|
||||
color: 'rgba(255,255,255,0.95)', fontFamily: 'inherit', fontSize: '0.8125rem',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box component="button" sx={itemSx} onClick={() => setRenaming(true)}>
|
||||
Rename
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
{menu.items.map((item) => (
|
||||
<Box
|
||||
key={item.label}
|
||||
component="button"
|
||||
disabled={item.disabled}
|
||||
sx={{
|
||||
...itemSx,
|
||||
...(item.danger && { color: '#ff7b72', '&:hover': { background: 'rgba(255,123,114,0.12)' } }),
|
||||
}}
|
||||
onClick={() => { setMenu(null); item.onClick(); }}
|
||||
>
|
||||
{item.label}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default CardContextMenu;
|
||||
@@ -1,22 +1,12 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import { useVoice } from '@/shared/voice/voiceContext';
|
||||
import HelpPanel from './HelpPanel';
|
||||
|
||||
/** Top-right desktop pill: Help opens the help panel (ask, report a bug, docs); the mic dictates (local whisper) into the focused field. */
|
||||
/** Top-right desktop pill: opens the help panel (ask, report a bug, docs). Dictation lives on the composer mics + F5, not here. */
|
||||
function HelpPill(): React.ReactElement {
|
||||
const { state, pct, pressStart, pressEnd } = useVoice();
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const recording = state === 'recording';
|
||||
const transcribing = state === 'transcribing';
|
||||
const preparing = state === 'preparing';
|
||||
const busy = transcribing || preparing;
|
||||
|
||||
// Outside click / Esc closes the panel; listeners only live while it's open.
|
||||
useEffect(() => {
|
||||
@@ -36,39 +26,23 @@ function HelpPill(): React.ReactElement {
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
height: 30,
|
||||
pl: 1.5,
|
||||
pr: 1,
|
||||
px: 1.5,
|
||||
borderRadius: 999,
|
||||
background: recording ? 'rgba(150,30,40,0.72)' : 'rgba(22,12,34,0.66)',
|
||||
background: 'rgba(22,12,34,0.66)',
|
||||
backdropFilter: 'blur(20px) saturate(160%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(160%)',
|
||||
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'background 0.2s ease',
|
||||
'&:hover': { background: 'rgba(22,12,34,0.8)' },
|
||||
}}
|
||||
onClick={() => setHelpOpen((v) => !v)}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.72)', fontWeight: 500 }}>
|
||||
{recording ? 'Listening' : transcribing ? 'Transcribing' : preparing ? `Preparing ${pct}%` : 'Help'}
|
||||
Help
|
||||
</Typography>
|
||||
<Tooltip title={recording ? 'Stop dictation' : preparing ? 'Downloading voice model' : 'Dictate (F5)'} placement="bottom" arrow>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', color: recording ? '#fff' : 'rgba(255,255,255,0.55)' }}
|
||||
onPointerDown={(e) => { e.stopPropagation(); if (!busy) pressStart(); }}
|
||||
onPointerUp={(e) => { e.stopPropagation(); pressEnd(); }}
|
||||
onPointerLeave={() => pressEnd()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{busy
|
||||
? <CircularProgress size={13} thickness={5} sx={{ color: 'rgba(255,255,255,0.7)' }} />
|
||||
: recording
|
||||
? <MicIcon sx={{ fontSize: 16 }} />
|
||||
: <MicNoneOutlinedIcon sx={{ fontSize: 15 }} />}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{helpOpen && <HelpPanel onClose={() => setHelpOpen(false)} />}
|
||||
</Box>
|
||||
|
||||
@@ -4,3 +4,11 @@ body.dashboard-marquee-active * {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* While a traffic-light tile menu is open, its lights cluster stays visible and interactive even
|
||||
when the hover that revealed it has moved on (to the menu itself); otherwise the menu fades
|
||||
mid-approach and the click lands on nothing. */
|
||||
.osw-pill-lights:has(.osw-window-lights[data-tilemenu-open="1"]) {
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ export function useDashboardInteractions({
|
||||
// Clicking a control INSIDE a card (text field, button, browser URL bar/tabs, note textarea) selects + raises it but must NOT re-center the camera onto it: yanking focus to a card just to click into its input is hostile (same reasoning as the guest-page and Workflows carve-outs). Card frame/body clicks still auto-focus.
|
||||
if (pressLandedOnControl(originTarget)) return;
|
||||
|
||||
// The Workflows window is an app you click around inside, not a card you re-center every tap. Single-click only raises + selects it; double-click still zoom-to-fits (handleCardDoubleClick). Without this, clicking any button inside it yanked the canvas into a re-zoom.
|
||||
if (type === 'workflows-hub' || type === 'workflows-monitor') return;
|
||||
// The Workflows window fits like any card on frame/header presses; pressLandedOnControl above
|
||||
// already keeps taps on its buttons, rows, and inputs from yanking the camera.
|
||||
|
||||
// A tiled (fullscreen/snapped) card is pinned Arc-style: clicking inside it must not collapse
|
||||
// it or glide the camera; it leaves the mode via its own controls (yellow, Esc, dock swap).
|
||||
@@ -90,9 +90,15 @@ export function useDashboardInteractions({
|
||||
dispatch(expandSession(id));
|
||||
}
|
||||
setFocusedCardId(id);
|
||||
setTimeout(() => {
|
||||
// The capture-phase select fires this on pointer DOWN; if the press became a drag (or marquee), re-framing the camera mid-gesture is the "canvas yanks as I start dragging" nudge. The webview shield class is up for exactly that window.
|
||||
if (document.body.classList.contains('dashboard-marquee-active')) return;
|
||||
// The capture-phase select fires on pointer DOWN; if the press became a drag (or marquee),
|
||||
// re-framing mid-gesture is the "canvas yanks" nudge, so the shield class defers the fit. A slow
|
||||
// CLICK with a few px of jitter also arms the shield briefly, which used to abort the fit
|
||||
// entirely ("takes multiple clicks to zoom in"), so retry once the gesture settles.
|
||||
const tryFit = (attempt: number): void => {
|
||||
if (document.body.classList.contains('dashboard-marquee-active')) {
|
||||
if (attempt < 3) setTimeout(() => tryFit(attempt + 1), 160);
|
||||
return;
|
||||
}
|
||||
const rect = getCardRect(id, type);
|
||||
if (rect) canvas.actions.fitToCards([rect], 1.15, true, type === 'browser' ? 0.8 : undefined);
|
||||
setTimeout(() => {
|
||||
@@ -103,7 +109,8 @@ export function useDashboardInteractions({
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return;
|
||||
active.blur?.();
|
||||
}, 150);
|
||||
}, 100);
|
||||
};
|
||||
setTimeout(() => tryFit(0), 100);
|
||||
}, [selection, getCardRect, canvas.actions, dispatch, expandedSessionIds]);
|
||||
|
||||
const handleBringToFront = useCallback((id: string, type: CardType) => {
|
||||
@@ -194,12 +201,22 @@ export function useDashboardInteractions({
|
||||
selection.handleCanvasMouseUp(e.nativeEvent);
|
||||
}, [canvas.handlers, selection]);
|
||||
|
||||
// Double-click empty canvas → fit all cards
|
||||
// Double-click empty canvas → zoom OUT anchored at the cursor (Google Maps style). It must never
|
||||
// travel: the old fit-all panned the camera to wherever the cards were, which reads as teleporting.
|
||||
const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if (isCardTarget(e.target, e.currentTarget)) return;
|
||||
report('dashboard', 'canvas_double_clicked');
|
||||
canvas.actions.fitToView();
|
||||
const vp = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const cx = e.clientX - vp.left;
|
||||
const cy = e.clientY - vp.top;
|
||||
const cur = canvas.actions.getLiveState();
|
||||
const nextZoom = Math.max(0.15, cur.zoom * 0.55);
|
||||
canvas.actions.animateTo({
|
||||
zoom: nextZoom,
|
||||
panX: cx - ((cx - cur.panX) / cur.zoom) * nextZoom,
|
||||
panY: cy - ((cy - cur.panY) / cur.zoom) * nextZoom,
|
||||
});
|
||||
}, [canvas.actions]);
|
||||
|
||||
// Double-click a card → always expand + center + zoom (cancels pending collapse from single-click)
|
||||
|
||||
Reference in New Issue
Block a user