mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[eric] Canvas UX overhaul: minimap, momentum panning, arrow navigation, smooth transitions, and card focus behavior + slight title prompt tweak
This commit is contained in:
@@ -1591,9 +1591,9 @@ class AgentManager:
|
||||
global_settings = load_settings()
|
||||
client = get_anthropic_client(global_settings)
|
||||
resp = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=30,
|
||||
system="Generate a 2-4 word title for what the user wants. Be terse. No filler words. No quotes, no punctuation. Return only the title.",
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=20,
|
||||
system="Generate a short 2-4 word title summarizing the user's request. Examples: 'Travel Planning', 'Code Review', 'Simple Greeting'. No quotes, no punctuation, no explanation. Return ONLY the title.",
|
||||
messages=[{"role": "user", "content": first_prompt}],
|
||||
)
|
||||
generated = resp.content[0].text.strip().strip('"\'')
|
||||
|
||||
@@ -160,22 +160,22 @@ async def generate_name(dashboard_id: str):
|
||||
|
||||
if len(prompts) == 1:
|
||||
system = (
|
||||
"Generate a short, clear 2-4 word workspace name based on this task. "
|
||||
"Use plain language like 'Travel Planning', 'Code Review', 'Sales Dashboard'. "
|
||||
"No quotes, no punctuation, no emojis. Return only the name."
|
||||
"Generate a short 2-4 word workspace name summarizing this task. "
|
||||
"Examples: 'Travel Planning', 'Code Review', 'Sales Dashboard'. "
|
||||
"No quotes, no punctuation, no emojis, no explanation. Return ONLY the name."
|
||||
)
|
||||
user_content = prompts[0]
|
||||
else:
|
||||
system = (
|
||||
"Generate a short, clear 2-4 word workspace name that captures the theme of these tasks. "
|
||||
"Use plain language like 'Research & Analysis', 'Content Creation', 'Project Setup'. "
|
||||
"No quotes, no punctuation, no emojis. Return only the name."
|
||||
"Generate a short 2-4 word workspace name capturing the theme of these tasks. "
|
||||
"Examples: 'Research & Analysis', 'Content Creation', 'Project Setup'. "
|
||||
"No quotes, no punctuation, no emojis, no explanation. Return ONLY the name."
|
||||
)
|
||||
user_content = "\n".join(f"- {p}" for p in prompts)
|
||||
|
||||
resp = await client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=30,
|
||||
max_tokens=20,
|
||||
system=system,
|
||||
messages=[{"role": "user", "content": user_content}],
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ import { motion } from 'framer-motion';
|
||||
import {
|
||||
AgentSession,
|
||||
handleApproval,
|
||||
toggleExpandSession,
|
||||
collapseSession,
|
||||
closeSession,
|
||||
} from '@/shared/state/agentsSlice';
|
||||
@@ -175,6 +174,8 @@ interface Props {
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
zoom?: number;
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
spawnFrom?: { x: number; y: number; type?: 'branch' };
|
||||
exitTarget?: { x: number; y: number };
|
||||
isSelected?: boolean;
|
||||
@@ -182,14 +183,16 @@ interface Props {
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: 'agent' | 'view') => void;
|
||||
onDragMove?: (dx: number, dy: number) => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
onBranch?: (sourceSessionId: string, newSessionId: string) => void;
|
||||
onMeasuredHeight?: (sessionId: string, height: number) => void;
|
||||
snapColumn?: { x: number; width: number };
|
||||
autoFocusInput?: boolean;
|
||||
cardZOrder?: number;
|
||||
onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser') => void;
|
||||
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
|
||||
shakeDirection?: 'left' | 'right' | 'up' | 'down' | null;
|
||||
}
|
||||
|
||||
const MIN_W = 480;
|
||||
@@ -204,9 +207,10 @@ const GLOW_FADE_MS = 2500;
|
||||
const SNAP_THRESHOLD = 60;
|
||||
|
||||
const AgentCard: React.FC<Props> = ({
|
||||
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom, exitTarget,
|
||||
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, spawnFrom, exitTarget,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onBringToFront,
|
||||
onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onDoubleClick, onBringToFront,
|
||||
shakeDirection,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -261,51 +265,86 @@ const AgentCard: React.FC<Props> = ({
|
||||
|
||||
// ---- Drag via header (pointer events) ----
|
||||
const DRAG_THRESHOLD = 3;
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
const justDraggedRef = useRef(false);
|
||||
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
|
||||
|
||||
// Use refs for pan so drag callbacks don't recreate on every pan frame
|
||||
const panRef = useRef({ panX, panY });
|
||||
panRef.current = { panX, panY };
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
|
||||
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY };
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
|
||||
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
onDragStart?.(session.id, 'agent');
|
||||
}, [cardX, cardY, onDragStart, session.id]);
|
||||
|
||||
// Recompute localDragPos from latest pointer + pan (shared by move handler and pan-change effect)
|
||||
const recomputeDragPos = useCallback(() => {
|
||||
const ds = dragState.current;
|
||||
if (!ds || !didDrag.current) return;
|
||||
const { clientX, clientY } = lastPointerRef.current;
|
||||
const rawDx = clientX - ds.startX;
|
||||
const rawDy = clientY - ds.startY;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - ds.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - ds.startPanY) / z;
|
||||
const dx = rawDx / z - panDx;
|
||||
const dy = rawDy / z - panDy;
|
||||
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
|
||||
onDragMove?.(dx, dy, clientX, clientY);
|
||||
}, [onDragMove]);
|
||||
|
||||
// When pan changes during an active drag, recompute position so card tracks cursor
|
||||
useEffect(() => {
|
||||
if (isDragging && didDrag.current) {
|
||||
recomputeDragPos();
|
||||
}
|
||||
}, [panX, panY, isDragging, recomputeDragPos]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const rawDx = e.clientX - dragState.current.startX;
|
||||
const rawDy = e.clientY - dragState.current.startY;
|
||||
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const dx = rawDx / zoom;
|
||||
const dy = rawDy / zoom;
|
||||
setLocalDragPos({
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
});
|
||||
onDragMove?.(dx, dy);
|
||||
}, [zoom, onDragMove]);
|
||||
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
|
||||
recomputeDragPos();
|
||||
}, [recomputeDragPos]);
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const dx = (e.clientX - dragState.current.startX) / zoom;
|
||||
const dy = (e.clientY - dragState.current.startY) / zoom;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const dx = (e.clientX - dragState.current.startX) / z - panDx;
|
||||
const dy = (e.clientY - dragState.current.startY) / z - panDy;
|
||||
if (didDrag.current) {
|
||||
let finalX = dragState.current.origX + dx;
|
||||
const finalY = dragState.current.origY + dy;
|
||||
let finalY = dragState.current.origY + dy;
|
||||
|
||||
if (snapColumn && Math.abs(finalX - snapColumn.x) < SNAP_THRESHOLD) {
|
||||
finalX = snapColumn.x;
|
||||
dispatch(setCardSize({ sessionId: session.id, width: snapColumn.width, height: cardHeight }));
|
||||
}
|
||||
|
||||
// Snap to 24px grid (hold Shift to bypass)
|
||||
if (!e.shiftKey) {
|
||||
finalX = Math.round(finalX / 24) * 24;
|
||||
finalY = Math.round(finalY / 24) * 24;
|
||||
}
|
||||
|
||||
dispatch(setCardPosition({ sessionId: session.id, x: finalX, y: finalY }));
|
||||
justDraggedRef.current = true;
|
||||
requestAnimationFrame(() => { justDraggedRef.current = false; });
|
||||
@@ -316,7 +355,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [zoom, dispatch, session.id, onDragEnd, snapColumn, cardHeight]);
|
||||
}, [dispatch, session.id, onDragEnd, snapColumn, cardHeight]);
|
||||
|
||||
// ---- Unified edge / corner resize ----
|
||||
const resizeRef = useRef<{
|
||||
@@ -484,11 +523,12 @@ const AgentCard: React.FC<Props> = ({
|
||||
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
if (!isSelected && !e.shiftKey) {
|
||||
dispatch(toggleExpandSession(session.id));
|
||||
}
|
||||
onCardSelect?.(session.id, 'agent', e.shiftKey);
|
||||
}}
|
||||
onDoubleClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(session.id, 'agent');
|
||||
}}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
|
||||
@@ -527,6 +567,31 @@ const AgentCard: React.FC<Props> = ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
...(shakeDirection && {
|
||||
animation: `card-shake-${shakeDirection} 0.3s ease 2`,
|
||||
border: `2px solid ${c.status.error}90`,
|
||||
boxShadow: `0 0 0 2px ${c.status.error}30, ${c.shadow.md}`,
|
||||
'@keyframes card-shake-left': {
|
||||
'0%,100%': { transform: 'translateX(0)' },
|
||||
'25%': { transform: 'translateX(-6px)' },
|
||||
'75%': { transform: 'translateX(4px)' },
|
||||
},
|
||||
'@keyframes card-shake-right': {
|
||||
'0%,100%': { transform: 'translateX(0)' },
|
||||
'25%': { transform: 'translateX(6px)' },
|
||||
'75%': { transform: 'translateX(-4px)' },
|
||||
},
|
||||
'@keyframes card-shake-up': {
|
||||
'0%,100%': { transform: 'translateY(0)' },
|
||||
'25%': { transform: 'translateY(-6px)' },
|
||||
'75%': { transform: 'translateY(4px)' },
|
||||
},
|
||||
'@keyframes card-shake-down': {
|
||||
'0%,100%': { transform: 'translateY(0)' },
|
||||
'25%': { transform: 'translateY(6px)' },
|
||||
'75%': { transform: 'translateY(-4px)' },
|
||||
},
|
||||
}),
|
||||
...(isHighlighted && {
|
||||
animation: 'card-highlight-pulse 2s ease-out forwards',
|
||||
'@keyframes card-highlight-pulse': {
|
||||
|
||||
@@ -93,23 +93,26 @@ interface Props {
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
zoom?: number;
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
cmdHeld?: boolean;
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void;
|
||||
onDragMove?: (dx: number, dy: number) => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
cardZOrder?: number;
|
||||
onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser') => void;
|
||||
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
|
||||
}
|
||||
|
||||
|
||||
const BrowserCard: React.FC<Props> = ({
|
||||
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, cmdHeld = false,
|
||||
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
cardZOrder = 0, onBringToFront,
|
||||
cardZOrder = 0, onDoubleClick, onBringToFront,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -374,47 +377,78 @@ const BrowserCard: React.FC<Props> = ({
|
||||
|
||||
// ---- Card drag via tab bar background ----
|
||||
const DRAG_THRESHOLD = 3;
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
const justDraggedRef = useRef(false);
|
||||
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
|
||||
|
||||
const panRef = useRef({ panX, panY });
|
||||
panRef.current = { panX, panY };
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
|
||||
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY };
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
|
||||
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
onDragStart?.(browserId, 'browser');
|
||||
}, [cardX, cardY, onDragStart, browserId]);
|
||||
|
||||
const recomputeDragPos = useCallback(() => {
|
||||
const ds = dragState.current;
|
||||
if (!ds || !didDrag.current) return;
|
||||
const { clientX, clientY } = lastPointerRef.current;
|
||||
const rawDx = clientX - ds.startX;
|
||||
const rawDy = clientY - ds.startY;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - ds.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - ds.startPanY) / z;
|
||||
const dx = rawDx / z - panDx;
|
||||
const dy = rawDy / z - panDy;
|
||||
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
|
||||
onDragMove?.(dx, dy, clientX, clientY);
|
||||
}, [onDragMove]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging && didDrag.current) recomputeDragPos();
|
||||
}, [panX, panY, isDragging, recomputeDragPos]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const rawDx = e.clientX - dragState.current.startX;
|
||||
const rawDy = e.clientY - dragState.current.startY;
|
||||
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const dx = rawDx / zoom;
|
||||
const dy = rawDy / zoom;
|
||||
setLocalDragPos({
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
});
|
||||
onDragMove?.(dx, dy);
|
||||
}, [zoom, onDragMove]);
|
||||
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
|
||||
recomputeDragPos();
|
||||
}, [recomputeDragPos]);
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const dx = (e.clientX - dragState.current.startX) / zoom;
|
||||
const dy = (e.clientY - dragState.current.startY) / zoom;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const dx = (e.clientX - dragState.current.startX) / z - panDx;
|
||||
const dy = (e.clientY - dragState.current.startY) / z - panDy;
|
||||
if (didDrag.current) {
|
||||
let finalX = dragState.current.origX + dx;
|
||||
let finalY = dragState.current.origY + dy;
|
||||
// Snap to 24px grid (hold Shift to bypass)
|
||||
if (!e.shiftKey) {
|
||||
finalX = Math.round(finalX / 24) * 24;
|
||||
finalY = Math.round(finalY / 24) * 24;
|
||||
}
|
||||
dispatch(setBrowserCardPosition({
|
||||
browserId,
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
}));
|
||||
justDraggedRef.current = true;
|
||||
requestAnimationFrame(() => { justDraggedRef.current = false; });
|
||||
@@ -425,7 +459,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [zoom, dispatch, browserId, onDragEnd]);
|
||||
}, [dispatch, browserId, onDragEnd]);
|
||||
|
||||
// ---- Resize ----
|
||||
const resizeRef = useRef<{
|
||||
@@ -545,6 +579,10 @@ const BrowserCard: React.FC<Props> = ({
|
||||
if (justDraggedRef.current) return;
|
||||
onCardSelect?.(browserId, 'browser', e.shiftKey);
|
||||
}}
|
||||
onDoubleClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(browserId, 'browser');
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: displayX,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
@@ -7,79 +7,116 @@ import RemoveIcon from '@mui/icons-material/Remove';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import FitScreenIcon from '@mui/icons-material/FitScreen';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import MapIcon from '@mui/icons-material/Map';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { CanvasActions } from './useCanvasControls';
|
||||
import Minimap from './Minimap';
|
||||
import type { MinimapProps } from './Minimap';
|
||||
|
||||
interface Props {
|
||||
zoom: number;
|
||||
actions: CanvasActions;
|
||||
onFitToView: () => void;
|
||||
onTidy: () => void;
|
||||
minimapProps: Omit<MinimapProps, 'onPan'>;
|
||||
onMinimapPan: (panX: number, panY: number) => void;
|
||||
}
|
||||
|
||||
const CanvasControls: React.FC<Props> = ({ zoom, actions, onTidy }) => {
|
||||
const CanvasControls: React.FC<Props> = ({ zoom, actions, onFitToView, onTidy, minimapProps, onMinimapPan }) => {
|
||||
const c = useClaudeTokens();
|
||||
const pct = Math.round(zoom * 100);
|
||||
const [minimapOpen, setMinimapOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
boxShadow: c.shadow.sm,
|
||||
py: 0.25,
|
||||
px: 0.5,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Zoom out" placement="top">
|
||||
<IconButton size="small" onClick={actions.zoomOut} sx={{ color: c.text.muted }}>
|
||||
<RemoveIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Reset to 100%" placement="top">
|
||||
<Typography
|
||||
onClick={actions.resetZoom}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0.75 }}>
|
||||
{/* Minimap panel — sits above the toolbar */}
|
||||
{minimapOpen && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
color: c.text.secondary,
|
||||
minWidth: 40,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1,
|
||||
'&:hover': { color: c.text.primary },
|
||||
width: 200,
|
||||
height: 140,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
boxShadow: c.shadow.md,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Minimap {...minimapProps} onPan={onMinimapPan} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Tooltip title="Zoom in" placement="top">
|
||||
<IconButton size="small" onClick={actions.zoomIn} sx={{ color: c.text.muted }}>
|
||||
<AddIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* Toolbar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
boxShadow: c.shadow.sm,
|
||||
py: 0.25,
|
||||
px: 0.5,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Zoom out" placement="top">
|
||||
<IconButton size="small" onClick={actions.zoomOut} sx={{ color: c.text.muted }}>
|
||||
<RemoveIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Box sx={{ width: 1, height: 16, bgcolor: c.border.medium, mx: 0.5 }} />
|
||||
<Tooltip title="Reset to 100%" placement="top">
|
||||
<Typography
|
||||
onClick={actions.resetZoom}
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
color: c.text.secondary,
|
||||
minWidth: 40,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1,
|
||||
'&:hover': { color: c.text.primary },
|
||||
}}
|
||||
>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Fit to view" placement="top">
|
||||
<IconButton size="small" onClick={actions.fitToView} sx={{ color: c.text.muted }}>
|
||||
<FitScreenIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Zoom in" placement="top">
|
||||
<IconButton size="small" onClick={actions.zoomIn} sx={{ color: c.text.muted }}>
|
||||
<AddIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Box sx={{ width: 1, height: 16, bgcolor: c.border.medium, mx: 0.5 }} />
|
||||
<Box sx={{ width: 1, height: 16, bgcolor: c.border.medium, mx: 0.5 }} />
|
||||
|
||||
<Tooltip title="Tidy layout" placement="top">
|
||||
<IconButton size="small" onClick={onTidy} sx={{ color: c.text.muted }}>
|
||||
<AutoAwesomeIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Fit to view" placement="top">
|
||||
<IconButton size="small" onClick={onFitToView} sx={{ color: c.text.muted }}>
|
||||
<FitScreenIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Tidy layout" placement="top">
|
||||
<IconButton size="small" onClick={onTidy} sx={{ color: c.text.muted }}>
|
||||
<AutoAwesomeIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Box sx={{ width: 1, height: 16, bgcolor: c.border.medium, mx: 0.5 }} />
|
||||
|
||||
<Tooltip title={minimapOpen ? 'Hide minimap' : 'Show minimap'} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setMinimapOpen((v) => !v)}
|
||||
sx={{ color: minimapOpen ? c.accent.primary : c.text.muted }}
|
||||
>
|
||||
<MapIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { AgentSession } from '@/shared/state/agentsSlice';
|
||||
|
||||
interface CardSearchItem {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'agent' | 'view' | 'browser';
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onNavigate: (rect: { x: number; y: number; width: number; height: number }) => void;
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
sessions: Record<string, AgentSession>;
|
||||
}
|
||||
|
||||
const CardSearchPalette: React.FC<Props> = ({
|
||||
open, onClose, onNavigate,
|
||||
cards, viewCards, browserCards, sessions,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Build searchable items
|
||||
const items = useMemo((): CardSearchItem[] => {
|
||||
const result: CardSearchItem[] = [];
|
||||
for (const card of Object.values(cards)) {
|
||||
const session = sessions[card.session_id];
|
||||
result.push({
|
||||
id: card.session_id,
|
||||
label: session?.name || `Agent ${card.session_id.slice(0, 8)}`,
|
||||
type: 'agent',
|
||||
rect: { x: card.x, y: card.y, width: card.width, height: card.height },
|
||||
});
|
||||
}
|
||||
for (const vc of Object.values(viewCards)) {
|
||||
result.push({
|
||||
id: vc.output_id,
|
||||
label: `View: ${vc.output_id.slice(0, 12)}`,
|
||||
type: 'view',
|
||||
rect: { x: vc.x, y: vc.y, width: vc.width, height: vc.height },
|
||||
});
|
||||
}
|
||||
for (const bc of Object.values(browserCards)) {
|
||||
const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId);
|
||||
result.push({
|
||||
id: bc.browser_id,
|
||||
label: activeTab?.title || activeTab?.url || `Browser ${bc.browser_id.slice(0, 8)}`,
|
||||
type: 'browser',
|
||||
rect: { x: bc.x, y: bc.y, width: bc.width, height: bc.height },
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [cards, viewCards, browserCards, sessions]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return items;
|
||||
const q = query.toLowerCase();
|
||||
return items.filter((item) => item.label.toLowerCase().includes(q));
|
||||
}, [items, query]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = useCallback((item: CardSearchItem) => {
|
||||
onNavigate(item.rect);
|
||||
onClose();
|
||||
}, [onNavigate, onClose]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filtered[selectedIndex]) {
|
||||
handleSelect(filtered[selectedIndex]);
|
||||
}
|
||||
}
|
||||
}, [filtered, selectedIndex, handleSelect, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'agent': return 'Agent';
|
||||
case 'view': return 'View';
|
||||
case 'browser': return 'Browser';
|
||||
default: return type;
|
||||
}
|
||||
};
|
||||
|
||||
const typeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'agent': return c.accent.primary;
|
||||
case 'view': return c.status.info;
|
||||
case 'browser': return c.status.success;
|
||||
default: return c.text.muted;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<Box
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
bgcolor: 'rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Palette */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: '20%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 440,
|
||||
maxHeight: 400,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: `${c.radius.xl}px`,
|
||||
boxShadow: c.shadow.lg,
|
||||
zIndex: 1001,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Search input */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 1.5, borderBottom: `1px solid ${c.border.subtle}` }}>
|
||||
<SearchIcon sx={{ fontSize: '1.25rem', color: c.text.muted, mr: 1.5 }} />
|
||||
<InputBase
|
||||
inputRef={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search cards..."
|
||||
fullWidth
|
||||
sx={{
|
||||
fontSize: '0.9375rem',
|
||||
fontFamily: c.font.sans,
|
||||
color: c.text.primary,
|
||||
'& input::placeholder': { color: c.text.muted, opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Results */}
|
||||
<Box sx={{ overflowY: 'auto', maxHeight: 320 }}>
|
||||
{filtered.length === 0 ? (
|
||||
<Typography sx={{ px: 2, py: 2, fontSize: '0.875rem', color: c.text.muted, textAlign: 'center' }}>
|
||||
No cards found
|
||||
</Typography>
|
||||
) : (
|
||||
filtered.map((item, i) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
px: 2,
|
||||
py: 1,
|
||||
cursor: 'pointer',
|
||||
bgcolor: i === selectedIndex ? c.bg.secondary : 'transparent',
|
||||
'&:hover': { bgcolor: c.bg.secondary },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: typeColor(item.type),
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
color: c.text.primary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, flexShrink: 0 }}>
|
||||
{typeLabel(item.type)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardSearchPalette;
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
fetchSessions,
|
||||
fetchHistory,
|
||||
collapseSession,
|
||||
collapseAllSessions,
|
||||
closeSession,
|
||||
duplicateSession,
|
||||
expandSession,
|
||||
@@ -53,6 +54,8 @@ import AgentCard from './AgentCard';
|
||||
import DashboardViewCard from './DashboardViewCard';
|
||||
import BrowserCard from './BrowserCard';
|
||||
import CanvasControls from './CanvasControls';
|
||||
import CardSearchPalette from './CardSearchPalette';
|
||||
import DirectionHints from './DirectionHints';
|
||||
import DashboardToolbar from './DashboardToolbar';
|
||||
import { captureDashboardThumbnail } from './captureDashboardThumbnail';
|
||||
import { useCanvasControls } from './useCanvasControls';
|
||||
@@ -107,7 +110,24 @@ const DashboardInner: React.FC = () => {
|
||||
const glowingBrowserCards = useAppSelector((state) => state.dashboardLayout.glowingBrowserCards);
|
||||
const sessionList = Object.values(sessions);
|
||||
|
||||
const canvas = useCanvasControls(zoomSensitivity);
|
||||
const contentBounds = useMemo(() => {
|
||||
const allRects = [
|
||||
...Object.values(cards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
|
||||
...Object.values(viewCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
|
||||
...Object.values(browserCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
|
||||
];
|
||||
if (allRects.length === 0) return undefined;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const r of allRects) {
|
||||
minX = Math.min(minX, r.x);
|
||||
minY = Math.min(minY, r.y);
|
||||
maxX = Math.max(maxX, r.x + r.w);
|
||||
maxY = Math.max(maxY, r.y + r.h);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}, [cards, viewCards, browserCards]);
|
||||
|
||||
const canvas = useCanvasControls(zoomSensitivity, contentBounds);
|
||||
const selection = useDashboardSelection(
|
||||
{ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom, viewportRef: canvas.viewportRef },
|
||||
cards,
|
||||
@@ -117,10 +137,12 @@ const DashboardInner: React.FC = () => {
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [toolbarOpen, setToolbarOpen] = useState(false);
|
||||
const [searchPaletteOpen, setSearchPaletteOpen] = useState(false);
|
||||
const [highlightedCardId, setHighlightedCardId] = useState<string | null>(null);
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [autoFocusSessionId, setAutoFocusSessionId] = useState<string | null>(null);
|
||||
const [pendingSelectSessionId, setPendingSelectSessionId] = useState<string | null>(null);
|
||||
const [focusedCardId, setFocusedCardId] = useState<string | null>(null);
|
||||
|
||||
const handleHighlightCard = useCallback((cardId: string) => {
|
||||
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
||||
@@ -165,14 +187,63 @@ const DashboardInner: React.FC = () => {
|
||||
const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom });
|
||||
canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom };
|
||||
|
||||
// ---- Edge panning during card drag ----
|
||||
const EDGE_ZONE = 60;
|
||||
const EDGE_MAX_SPEED = 8;
|
||||
const edgePanFrameRef = useRef<number | null>(null);
|
||||
const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
// Track pan at drag start so cards can compensate for edge-pan offset
|
||||
const dragStartPanRef = useRef<{ panX: number; panY: number }>({ panX: 0, panY: 0 });
|
||||
|
||||
const stopEdgePan = useCallback(() => {
|
||||
if (edgePanFrameRef.current) {
|
||||
cancelAnimationFrame(edgePanFrameRef.current);
|
||||
edgePanFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tickEdgePan = useCallback(() => {
|
||||
const vp = canvas.viewportRef.current;
|
||||
if (!vp) return;
|
||||
const rect = vp.getBoundingClientRect();
|
||||
const { x: mx, y: my } = lastMousePosRef.current;
|
||||
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
|
||||
if (mx < rect.left + EDGE_ZONE) {
|
||||
dx = EDGE_MAX_SPEED * ((rect.left + EDGE_ZONE - mx) / EDGE_ZONE);
|
||||
} else if (mx > rect.right - EDGE_ZONE) {
|
||||
dx = -EDGE_MAX_SPEED * ((mx - (rect.right - EDGE_ZONE)) / EDGE_ZONE);
|
||||
}
|
||||
if (my < rect.top + EDGE_ZONE) {
|
||||
dy = EDGE_MAX_SPEED * ((rect.top + EDGE_ZONE - my) / EDGE_ZONE);
|
||||
} else if (my > rect.bottom - EDGE_ZONE) {
|
||||
dy = -EDGE_MAX_SPEED * ((my - (rect.bottom - EDGE_ZONE)) / EDGE_ZONE);
|
||||
}
|
||||
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
canvas.actions.setState((prev: { panX: number; panY: number; zoom: number }) => ({
|
||||
...prev,
|
||||
panX: prev.panX + dx,
|
||||
panY: prev.panY + dy,
|
||||
}));
|
||||
}
|
||||
|
||||
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
|
||||
}, [canvas.viewportRef, canvas.actions]);
|
||||
|
||||
// ---- Multi-drag coordination ----
|
||||
const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null);
|
||||
const [liveDragInfo, setLiveDragInfo] = useState<{ cardId: string; dx: number; dy: number } | null>(null);
|
||||
const activeDragCardRef = useRef<string | null>(null);
|
||||
const isMultiDragRef = useRef(false);
|
||||
|
||||
const edgePanStartedRef = useRef(false);
|
||||
|
||||
const handleCardDragStart = useCallback((id: string, _type: CardType) => {
|
||||
activeDragCardRef.current = id;
|
||||
edgePanStartedRef.current = false;
|
||||
if (selection.isSelected(id)) {
|
||||
isMultiDragRef.current = true;
|
||||
} else {
|
||||
@@ -181,16 +252,25 @@ const DashboardInner: React.FC = () => {
|
||||
}
|
||||
}, [selection]);
|
||||
|
||||
const handleCardDragMove = useCallback((dx: number, dy: number) => {
|
||||
const handleCardDragMove = useCallback((dx: number, dy: number, mouseX?: number, mouseY?: number) => {
|
||||
if (mouseX !== undefined && mouseY !== undefined) {
|
||||
lastMousePosRef.current = { x: mouseX, y: mouseY };
|
||||
}
|
||||
// Start edge panning only once actual dragging begins
|
||||
if (!edgePanStartedRef.current) {
|
||||
edgePanStartedRef.current = true;
|
||||
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
|
||||
}
|
||||
if (isMultiDragRef.current) {
|
||||
setMultiDragDelta({ dx, dy });
|
||||
}
|
||||
if (activeDragCardRef.current) {
|
||||
setLiveDragInfo({ cardId: activeDragCardRef.current, dx, dy });
|
||||
}
|
||||
}, []);
|
||||
}, [tickEdgePan]);
|
||||
|
||||
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
|
||||
stopEdgePan();
|
||||
if (isMultiDragRef.current && didDrag) {
|
||||
const items = selection.selectedArray()
|
||||
.filter((s) => s.id !== activeDragCardRef.current);
|
||||
@@ -202,11 +282,62 @@ const DashboardInner: React.FC = () => {
|
||||
isMultiDragRef.current = false;
|
||||
setMultiDragDelta(null);
|
||||
setLiveDragInfo(null);
|
||||
}, [selection, dispatch]);
|
||||
}, [selection, dispatch, stopEdgePan]);
|
||||
|
||||
// Helper: get a card's rect from Redux state (uses collapsed height for zoom calculation)
|
||||
const getCardRect = useCallback((id: string, type: CardType) => {
|
||||
const layoutState = store.getState().dashboardLayout;
|
||||
if (type === 'agent') {
|
||||
const card = layoutState.cards[id];
|
||||
if (!card) return undefined;
|
||||
return { x: card.x, y: card.y, width: card.width, height: card.height };
|
||||
} else if (type === 'view') {
|
||||
const vc = layoutState.viewCards[id];
|
||||
if (!vc) return undefined;
|
||||
return { x: vc.x, y: vc.y, width: vc.width, height: vc.height };
|
||||
} else if (type === 'browser') {
|
||||
const bc = layoutState.browserCards[id];
|
||||
if (!bc) return undefined;
|
||||
return { x: bc.x, y: bc.y, width: bc.width, height: bc.height };
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
// Delay single-click collapse so double-click can override
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => {
|
||||
selection.selectCard(id, type, shiftKey);
|
||||
}, [selection]);
|
||||
if (shiftKey) {
|
||||
selection.selectCard(id, type, true);
|
||||
return;
|
||||
}
|
||||
|
||||
selection.selectCard(id, type, false);
|
||||
|
||||
const alreadyExpanded = type === 'agent' && expandedSessionIds.includes(id);
|
||||
if (alreadyExpanded) {
|
||||
// Delay collapse so double-click can cancel it
|
||||
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
dispatch(collapseSession(id));
|
||||
setFocusedCardId(null);
|
||||
clickTimerRef.current = null;
|
||||
}, 250);
|
||||
} else {
|
||||
// Expand + center + zoom + bring to front
|
||||
if (type === 'agent') {
|
||||
dispatch(expandSession(id));
|
||||
}
|
||||
dispatch(bringToFront({ id, type }));
|
||||
setFocusedCardId(id);
|
||||
setTimeout(() => {
|
||||
const rect = getCardRect(id, type);
|
||||
if (rect) canvas.actions.fitToCards([rect], 1.15, true);
|
||||
// Blur after expansion settles so arrow keys work for navigation
|
||||
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
|
||||
}, 100);
|
||||
}
|
||||
}, [selection, getCardRect, canvas.actions, dispatch, expandedSessionIds]);
|
||||
|
||||
const handleBringToFront = useCallback((id: string, type: CardType) => {
|
||||
dispatch(bringToFront({ id, type }));
|
||||
@@ -253,6 +384,31 @@ const DashboardInner: React.FC = () => {
|
||||
selection.handleCanvasMouseUp(e.nativeEvent);
|
||||
}, [canvas.handlers, selection]);
|
||||
|
||||
// Double-click empty canvas → fit all cards
|
||||
const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if (isCardTarget(e.target, e.currentTarget)) return;
|
||||
canvas.actions.fitToView();
|
||||
}, [canvas.actions]);
|
||||
|
||||
// Double-click a card → always expand + center + zoom (cancels pending collapse from single-click)
|
||||
const handleCardDoubleClick = useCallback((id: string, type: CardType) => {
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
clickTimerRef.current = null;
|
||||
}
|
||||
if (type === 'agent') {
|
||||
dispatch(expandSession(id));
|
||||
}
|
||||
dispatch(bringToFront({ id, type }));
|
||||
setFocusedCardId(id);
|
||||
setTimeout(() => {
|
||||
const rect = getCardRect(id, type);
|
||||
if (rect) canvas.actions.fitToCards([rect], 1.15, true);
|
||||
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
|
||||
}, 100);
|
||||
}, [getCardRect, canvas.actions, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId) return;
|
||||
hasFittedRef.current = false;
|
||||
@@ -342,7 +498,7 @@ const DashboardInner: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
const card = store.getState().dashboardLayout.cards[agentId];
|
||||
if (card) {
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
|
||||
handleHighlightCard(agentId);
|
||||
}
|
||||
}, 350);
|
||||
@@ -354,6 +510,19 @@ const DashboardInner: React.FC = () => {
|
||||
dispatch(setExpandedSessionIds(persistedExpandedSessionIds));
|
||||
}, [layoutInitialized, persistedExpandedSessionIds, dispatch]);
|
||||
|
||||
// Auto-collapse all expanded sessions when zooming out
|
||||
const prevZoomRef = useRef(canvas.zoom);
|
||||
useEffect(() => {
|
||||
const wasZoomedIn = prevZoomRef.current >= 0.9;
|
||||
const isZoomedOut = canvas.zoom < 0.9;
|
||||
prevZoomRef.current = canvas.zoom;
|
||||
|
||||
if (wasZoomedIn && isZoomedOut && expandedSessionIds.length > 0) {
|
||||
dispatch(collapseAllSessions());
|
||||
setFocusedCardId(null);
|
||||
}
|
||||
}, [canvas.zoom, expandedSessionIds.length, dispatch]);
|
||||
|
||||
const prevSessionIdsRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -553,6 +722,19 @@ const DashboardInner: React.FC = () => {
|
||||
return () => window.removeEventListener('keydown', handleDelete);
|
||||
}, [selection, dispatch]);
|
||||
|
||||
// Cmd+F to open card search palette
|
||||
useEffect(() => {
|
||||
const handleSearch = (e: KeyboardEvent) => {
|
||||
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'f') return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
setSearchPaletteOpen(true);
|
||||
};
|
||||
window.addEventListener('keydown', handleSearch);
|
||||
return () => window.removeEventListener('keydown', handleSearch);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCopy = (e: KeyboardEvent) => {
|
||||
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'c') return;
|
||||
@@ -656,6 +838,127 @@ const DashboardInner: React.FC = () => {
|
||||
return () => window.removeEventListener('keydown', handlePaste);
|
||||
}, [dispatch, dashboardId, expandedSessionIds, selection]);
|
||||
|
||||
// ---- Arrow key card navigation (when zoomed in on a card) ----
|
||||
const findNearestCard = useCallback((
|
||||
currentId: string,
|
||||
direction: 'left' | 'right' | 'up' | 'down',
|
||||
): { id: string; type: CardType } | null => {
|
||||
const allCardEntries: Array<{ id: string; type: CardType; cx: number; cy: number }> = [];
|
||||
for (const card of Object.values(cards)) {
|
||||
allCardEntries.push({ id: card.session_id, type: 'agent', cx: card.x + card.width / 2, cy: card.y + card.height / 2 });
|
||||
}
|
||||
for (const vc of Object.values(viewCards)) {
|
||||
allCardEntries.push({ id: vc.output_id, type: 'view', cx: vc.x + vc.width / 2, cy: vc.y + vc.height / 2 });
|
||||
}
|
||||
for (const bc of Object.values(browserCards)) {
|
||||
allCardEntries.push({ id: bc.browser_id, type: 'browser', cx: bc.x + bc.width / 2, cy: bc.y + bc.height / 2 });
|
||||
}
|
||||
|
||||
const current = allCardEntries.find((c) => c.id === currentId);
|
||||
if (!current) return null;
|
||||
|
||||
let best: typeof allCardEntries[0] | null = null;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (const card of allCardEntries) {
|
||||
if (card.id === currentId) continue;
|
||||
const dx = card.cx - current.cx;
|
||||
const dy = card.cy - current.cy;
|
||||
|
||||
// Filter to the correct half-plane
|
||||
let inDirection = false;
|
||||
let primary = 0;
|
||||
let secondary = 0;
|
||||
switch (direction) {
|
||||
case 'right': inDirection = dx > 20; primary = dx; secondary = Math.abs(dy); break;
|
||||
case 'left': inDirection = dx < -20; primary = -dx; secondary = Math.abs(dy); break;
|
||||
case 'down': inDirection = dy > 20; primary = dy; secondary = Math.abs(dx); break;
|
||||
case 'up': inDirection = dy < -20; primary = -dy; secondary = Math.abs(dx); break;
|
||||
}
|
||||
if (!inDirection) continue;
|
||||
|
||||
const score = primary + secondary * 0.3;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = card;
|
||||
}
|
||||
}
|
||||
|
||||
return best ? { id: best.id, type: best.type } : null;
|
||||
}, [cards, viewCards, browserCards]);
|
||||
|
||||
// Compute which directions have neighbors from the focused card
|
||||
const neighborDirections = useMemo(() => {
|
||||
if (!focusedCardId || canvas.zoom < 0.9) return { left: false, right: false, up: false, down: false };
|
||||
return {
|
||||
left: !!findNearestCard(focusedCardId, 'left'),
|
||||
right: !!findNearestCard(focusedCardId, 'right'),
|
||||
up: !!findNearestCard(focusedCardId, 'up'),
|
||||
down: !!findNearestCard(focusedCardId, 'down'),
|
||||
};
|
||||
}, [focusedCardId, canvas.zoom, findNearestCard]);
|
||||
|
||||
// Shake animation state: direction + timer
|
||||
const [shakeDirection, setShakeDirection] = useState<'left' | 'right' | 'up' | 'down' | null>(null);
|
||||
const shakeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Use refs for values read inside the keydown handler to avoid stale closures
|
||||
const focusedCardIdRef = useRef(focusedCardId);
|
||||
focusedCardIdRef.current = focusedCardId;
|
||||
const canvasZoomRef = useRef(canvas.zoom);
|
||||
canvasZoomRef.current = canvas.zoom;
|
||||
|
||||
useEffect(() => {
|
||||
const handleArrowNav = (e: KeyboardEvent) => {
|
||||
const currentFocused = focusedCardIdRef.current;
|
||||
if (!currentFocused || canvasZoomRef.current < 0.9) return;
|
||||
|
||||
// Skip if typing in an input
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
|
||||
|
||||
let direction: 'left' | 'right' | 'up' | 'down' | null = null;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft': direction = 'left'; break;
|
||||
case 'ArrowRight': direction = 'right'; break;
|
||||
case 'ArrowUp': direction = 'up'; break;
|
||||
case 'ArrowDown': direction = 'down'; break;
|
||||
default: return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const target = findNearestCard(currentFocused, direction);
|
||||
|
||||
if (!target) {
|
||||
// No card in that direction — shake
|
||||
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
|
||||
setShakeDirection(direction);
|
||||
shakeTimerRef.current = setTimeout(() => {
|
||||
setShakeDirection(null);
|
||||
shakeTimerRef.current = null;
|
||||
}, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collapse current, expand + navigate to target + bring to front
|
||||
dispatch(collapseSession(currentFocused));
|
||||
if (target.type === 'agent') {
|
||||
dispatch(expandSession(target.id));
|
||||
}
|
||||
dispatch(bringToFront({ id: target.id, type: target.type }));
|
||||
setFocusedCardId(target.id);
|
||||
|
||||
setTimeout(() => {
|
||||
const rect = getCardRect(target.id, target.type);
|
||||
if (rect) canvas.actions.fitToCards([rect], 1.15, true);
|
||||
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleArrowNav);
|
||||
return () => window.removeEventListener('keydown', handleArrowNav);
|
||||
}, [findNearestCard, getCardRect, canvas.actions, dispatch]);
|
||||
|
||||
const handleBranchFromCard = useCallback(
|
||||
(sourceSessionId: string, newSessionId: string) => {
|
||||
const sourceCard = cards[sourceSessionId];
|
||||
@@ -776,12 +1079,13 @@ const DashboardInner: React.FC = () => {
|
||||
setPendingSelectSessionId(realId);
|
||||
}
|
||||
|
||||
// Expand the chat so user can see responses
|
||||
dispatch(expandSession(realId));
|
||||
|
||||
setTimeout(() => {
|
||||
const card = store.getState().dashboardLayout.cards[realId];
|
||||
if (card) {
|
||||
const isExp = store.getState().agents.expandedSessionIds.includes(realId);
|
||||
const height = isExp ? Math.max(EXPANDED_CARD_MIN_H, card.height) : card.height;
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height }], 1.0, true);
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
|
||||
handleHighlightCard(realId);
|
||||
}
|
||||
}, 200);
|
||||
@@ -814,7 +1118,7 @@ const DashboardInner: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
const card = store.getState().dashboardLayout.viewCards[outputId];
|
||||
if (card) {
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
|
||||
handleHighlightCard(outputId);
|
||||
}
|
||||
}, 200);
|
||||
@@ -828,7 +1132,7 @@ const DashboardInner: React.FC = () => {
|
||||
const newId = Object.keys(allBrowserCards).find((id) => !prevIds.has(id));
|
||||
if (newId) {
|
||||
const card = allBrowserCards[newId];
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
|
||||
handleHighlightCard(newId);
|
||||
}
|
||||
}, 200);
|
||||
@@ -842,7 +1146,7 @@ const DashboardInner: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
const card = store.getState().dashboardLayout.cards[sessionId];
|
||||
if (card) {
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
|
||||
handleHighlightCard(sessionId);
|
||||
}
|
||||
}, 200);
|
||||
@@ -850,6 +1154,19 @@ const DashboardInner: React.FC = () => {
|
||||
});
|
||||
}, [dispatch, canvas.actions, handleHighlightCard, setAutoFocusSessionId]);
|
||||
|
||||
// Context-aware fit: if a card is selected, zoom to it; otherwise fit all
|
||||
const handleFitToView = useCallback(() => {
|
||||
if (selection.selectedIds.size === 1) {
|
||||
const [[id, type]] = selection.selectedIds;
|
||||
const rect = getCardRect(id, type);
|
||||
if (rect) {
|
||||
canvas.actions.fitToCards([rect], 1.15, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
canvas.actions.fitToView();
|
||||
}, [selection.selectedIds, getCardRect, canvas.actions]);
|
||||
|
||||
const handleTidy = useCallback(() => {
|
||||
const currentExpanded = store.getState().agents.expandedSessionIds;
|
||||
dispatch(tidyLayout({ expandedSessionIds: currentExpanded }));
|
||||
@@ -1136,6 +1453,7 @@ const DashboardInner: React.FC = () => {
|
||||
onMouseDown={handleViewportMouseDown}
|
||||
onMouseMove={handleViewportMouseMove}
|
||||
onMouseUp={handleViewportMouseUp}
|
||||
onDoubleClick={handleViewportDoubleClick}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -1364,6 +1682,8 @@ const DashboardInner: React.FC = () => {
|
||||
cardHeight={card.height}
|
||||
cardZOrder={card.zOrder ?? 0}
|
||||
zoom={canvas.zoom}
|
||||
panX={canvas.panX}
|
||||
panY={canvas.panY}
|
||||
spawnFrom={origin}
|
||||
exitTarget={exitTarget}
|
||||
isSelected={selection.isSelected(session.id)}
|
||||
@@ -1377,7 +1697,9 @@ const DashboardInner: React.FC = () => {
|
||||
onMeasuredHeight={handleMeasuredHeight}
|
||||
snapColumn={snapColumn}
|
||||
autoFocusInput={autoFocusSessionId === session.id}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
onBringToFront={handleBringToFront}
|
||||
shakeDirection={focusedCardId === session.id ? shakeDirection : null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1395,6 +1717,8 @@ const DashboardInner: React.FC = () => {
|
||||
cardHeight={vc.height}
|
||||
cardZOrder={vc.zOrder ?? 0}
|
||||
zoom={canvas.zoom}
|
||||
panX={canvas.panX}
|
||||
panY={canvas.panY}
|
||||
cmdHeld={canvas.cmdHeld}
|
||||
isSelected={selection.isSelected(vc.output_id)}
|
||||
isHighlighted={highlightedCardId === vc.output_id}
|
||||
@@ -1403,6 +1727,7 @@ const DashboardInner: React.FC = () => {
|
||||
onDragStart={handleCardDragStart}
|
||||
onDragMove={handleCardDragMove}
|
||||
onDragEnd={handleCardDragEnd}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
onBringToFront={handleBringToFront}
|
||||
/>
|
||||
);
|
||||
@@ -1419,6 +1744,8 @@ const DashboardInner: React.FC = () => {
|
||||
cardHeight={bc.height}
|
||||
cardZOrder={bc.zOrder ?? 0}
|
||||
zoom={canvas.zoom}
|
||||
panX={canvas.panX}
|
||||
panY={canvas.panY}
|
||||
cmdHeld={canvas.cmdHeld}
|
||||
isSelected={selection.isSelected(bc.browser_id)}
|
||||
isHighlighted={highlightedCardId === bc.browser_id}
|
||||
@@ -1427,6 +1754,7 @@ const DashboardInner: React.FC = () => {
|
||||
onDragStart={handleCardDragStart}
|
||||
onDragMove={handleCardDragMove}
|
||||
onDragEnd={handleCardDragEnd}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
onBringToFront={handleBringToFront}
|
||||
/>
|
||||
))}
|
||||
@@ -1466,11 +1794,48 @@ const DashboardInner: React.FC = () => {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Floating zoom controls */}
|
||||
{/* Arrow navigation hints when zoomed in on a card */}
|
||||
{focusedCardId && canvas.zoom >= 0.9 && (
|
||||
<DirectionHints
|
||||
hasLeft={neighborDirections.left}
|
||||
hasRight={neighborDirections.right}
|
||||
hasUp={neighborDirections.up}
|
||||
hasDown={neighborDirections.down}
|
||||
shakeDirection={shakeDirection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating zoom controls + minimap */}
|
||||
<Box sx={{ position: 'absolute', bottom: 16, right: 16, zIndex: 10 }}>
|
||||
<CanvasControls zoom={canvas.zoom} actions={canvas.actions} onTidy={handleTidy} />
|
||||
<CanvasControls
|
||||
zoom={canvas.zoom}
|
||||
actions={canvas.actions}
|
||||
onFitToView={handleFitToView}
|
||||
onTidy={handleTidy}
|
||||
minimapProps={{
|
||||
panX: canvas.panX,
|
||||
panY: canvas.panY,
|
||||
zoom: canvas.zoom,
|
||||
viewportRef: canvas.viewportRef,
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
}}
|
||||
onMinimapPan={(px, py) => canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Card search palette (Cmd+F) */}
|
||||
<CardSearchPalette
|
||||
open={searchPaletteOpen}
|
||||
onClose={() => setSearchPaletteOpen(false)}
|
||||
onNavigate={(rect) => canvas.actions.fitToCards([rect], 1.15, true)}
|
||||
cards={cards}
|
||||
viewCards={viewCards}
|
||||
browserCards={browserCards}
|
||||
sessions={sessions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(cardId: string, card: { x: number; y: number; width: number; height: number }) => {
|
||||
canvasActions.fitToCards([card], 1.0, true);
|
||||
canvasActions.fitToCards([card], 1.15, true);
|
||||
onHighlightCard?.(cardId);
|
||||
setExpanded(false);
|
||||
},
|
||||
|
||||
@@ -46,22 +46,25 @@ interface Props {
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
zoom?: number;
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
cmdHeld?: boolean;
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: 'agent' | 'view') => void;
|
||||
onDragMove?: (dx: number, dy: number) => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
cardZOrder?: number;
|
||||
onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser') => void;
|
||||
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
|
||||
}
|
||||
|
||||
const DashboardViewCard: React.FC<Props> = ({
|
||||
output, cardX, cardY, cardWidth, cardHeight, zoom = 1, cmdHeld = false,
|
||||
output, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
cardZOrder = 0, onBringToFront,
|
||||
cardZOrder = 0, onDoubleClick, onBringToFront,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -76,47 +79,78 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
|
||||
// ---- Drag via header ----
|
||||
const DRAG_THRESHOLD = 3;
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
const justDraggedRef = useRef(false);
|
||||
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
|
||||
|
||||
const panRef = useRef({ panX, panY });
|
||||
panRef.current = { panX, panY };
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
|
||||
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY };
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
|
||||
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
onDragStart?.(output.id, 'view');
|
||||
}, [cardX, cardY, onDragStart, output.id]);
|
||||
|
||||
const recomputeDragPos = useCallback(() => {
|
||||
const ds = dragState.current;
|
||||
if (!ds || !didDrag.current) return;
|
||||
const { clientX, clientY } = lastPointerRef.current;
|
||||
const rawDx = clientX - ds.startX;
|
||||
const rawDy = clientY - ds.startY;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - ds.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - ds.startPanY) / z;
|
||||
const dx = rawDx / z - panDx;
|
||||
const dy = rawDy / z - panDy;
|
||||
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
|
||||
onDragMove?.(dx, dy, clientX, clientY);
|
||||
}, [onDragMove]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging && didDrag.current) recomputeDragPos();
|
||||
}, [panX, panY, isDragging, recomputeDragPos]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const rawDx = e.clientX - dragState.current.startX;
|
||||
const rawDy = e.clientY - dragState.current.startY;
|
||||
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const dx = rawDx / zoom;
|
||||
const dy = rawDy / zoom;
|
||||
setLocalDragPos({
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
});
|
||||
onDragMove?.(dx, dy);
|
||||
}, [zoom, onDragMove]);
|
||||
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
|
||||
recomputeDragPos();
|
||||
}, [recomputeDragPos]);
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const dx = (e.clientX - dragState.current.startX) / zoom;
|
||||
const dy = (e.clientY - dragState.current.startY) / zoom;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const dx = (e.clientX - dragState.current.startX) / z - panDx;
|
||||
const dy = (e.clientY - dragState.current.startY) / z - panDy;
|
||||
if (didDrag.current) {
|
||||
let finalX = dragState.current.origX + dx;
|
||||
let finalY = dragState.current.origY + dy;
|
||||
// Snap to 24px grid (hold Shift to bypass)
|
||||
if (!e.shiftKey) {
|
||||
finalX = Math.round(finalX / 24) * 24;
|
||||
finalY = Math.round(finalY / 24) * 24;
|
||||
}
|
||||
dispatch(setViewCardPosition({
|
||||
outputId: output.id,
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
}));
|
||||
justDraggedRef.current = true;
|
||||
requestAnimationFrame(() => { justDraggedRef.current = false; });
|
||||
@@ -127,7 +161,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [zoom, dispatch, output.id, onDragEnd]);
|
||||
}, [dispatch, output.id, onDragEnd]);
|
||||
|
||||
// ---- Resize ----
|
||||
const resizeRef = useRef<{
|
||||
@@ -267,6 +301,10 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
if (justDraggedRef.current) return;
|
||||
onCardSelect?.(output.id, 'view', e.shiftKey);
|
||||
}}
|
||||
onDoubleClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(output.id, 'view');
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: displayX,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
hasLeft: boolean;
|
||||
hasRight: boolean;
|
||||
hasUp: boolean;
|
||||
hasDown: boolean;
|
||||
shakeDirection: 'left' | 'right' | 'up' | 'down' | null;
|
||||
}
|
||||
|
||||
const shakeKeyframes: Record<string, string> = {
|
||||
left: `
|
||||
@keyframes shake-left { 0%,100% { transform: translateY(-50%) translateX(0); } 25% { transform: translateY(-50%) translateX(-6px); } 75% { transform: translateY(-50%) translateX(4px); } }
|
||||
`,
|
||||
right: `
|
||||
@keyframes shake-right { 0%,100% { transform: translateY(-50%) translateX(0); } 25% { transform: translateY(-50%) translateX(6px); } 75% { transform: translateY(-50%) translateX(-4px); } }
|
||||
`,
|
||||
up: `
|
||||
@keyframes shake-up { 0%,100% { transform: translateX(-50%) translateY(0); } 25% { transform: translateX(-50%) translateY(-6px); } 75% { transform: translateX(-50%) translateY(4px); } }
|
||||
`,
|
||||
down: `
|
||||
@keyframes shake-down { 0%,100% { transform: translateX(-50%) translateY(0); } 25% { transform: translateX(-50%) translateY(6px); } 75% { transform: translateX(-50%) translateY(-4px); } }
|
||||
`,
|
||||
};
|
||||
|
||||
const DirectionHints: React.FC<Props> = ({ hasLeft, hasRight, hasUp, hasDown, shakeDirection }) => {
|
||||
const c = useClaudeTokens();
|
||||
|
||||
const hintSx = {
|
||||
position: 'absolute' as const,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
boxShadow: c.shadow.sm,
|
||||
color: c.text.muted,
|
||||
opacity: 0.5,
|
||||
transition: 'opacity 0.2s',
|
||||
pointerEvents: 'none' as const,
|
||||
};
|
||||
|
||||
const shakingSx = (dir: string) => ({
|
||||
...hintSx,
|
||||
opacity: 1,
|
||||
color: c.accent.primary,
|
||||
animation: `shake-${dir} 0.3s ease 2`,
|
||||
});
|
||||
|
||||
// Show shake indicator even when there's no neighbor in that direction
|
||||
const showLeft = hasLeft || shakeDirection === 'left';
|
||||
const showRight = hasRight || shakeDirection === 'right';
|
||||
const showUp = hasUp || shakeDirection === 'up';
|
||||
const showDown = hasDown || shakeDirection === 'down';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Inject shake keyframes */}
|
||||
{shakeDirection && (
|
||||
<style>{shakeKeyframes[shakeDirection]}</style>
|
||||
)}
|
||||
|
||||
{showLeft && (
|
||||
<Box sx={{
|
||||
...(shakeDirection === 'left' ? shakingSx('left') : hintSx),
|
||||
left: 16, top: '50%', transform: 'translateY(-50%)',
|
||||
}}>
|
||||
<ChevronLeftIcon sx={{ fontSize: '1.1rem' }} />
|
||||
</Box>
|
||||
)}
|
||||
{showRight && (
|
||||
<Box sx={{
|
||||
...(shakeDirection === 'right' ? shakingSx('right') : hintSx),
|
||||
right: 16, top: '50%', transform: 'translateY(-50%)',
|
||||
}}>
|
||||
<ChevronRightIcon sx={{ fontSize: '1.1rem' }} />
|
||||
</Box>
|
||||
)}
|
||||
{showUp && (
|
||||
<Box sx={{
|
||||
...(shakeDirection === 'up' ? shakingSx('up') : hintSx),
|
||||
top: 16, left: '50%', transform: 'translateX(-50%)',
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ fontSize: '1.1rem' }} />
|
||||
</Box>
|
||||
)}
|
||||
{showDown && (
|
||||
<Box sx={{
|
||||
...(shakeDirection === 'down' ? shakingSx('down') : hintSx),
|
||||
bottom: 56, left: '50%', transform: 'translateX(-50%)',
|
||||
}}>
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: '1.1rem' }} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectionHints;
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useRef, useCallback, useMemo } from 'react';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
const MINIMAP_W = 200;
|
||||
const MINIMAP_H = 140;
|
||||
const PADDING = 20;
|
||||
|
||||
export interface MinimapProps {
|
||||
panX: number;
|
||||
panY: number;
|
||||
zoom: number;
|
||||
viewportRef: React.RefObject<HTMLDivElement | null>;
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
onPan: (panX: number, panY: number) => void;
|
||||
}
|
||||
|
||||
interface CardRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
type: 'agent' | 'view' | 'browser';
|
||||
}
|
||||
|
||||
const Minimap: React.FC<MinimapProps> = ({
|
||||
panX, panY, zoom, viewportRef,
|
||||
cards, viewCards, browserCards,
|
||||
onPan,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const allCards = useMemo((): CardRect[] => {
|
||||
const result: CardRect[] = [];
|
||||
for (const card of Object.values(cards)) {
|
||||
result.push({ x: card.x, y: card.y, width: card.width, height: card.height, type: 'agent' });
|
||||
}
|
||||
for (const vc of Object.values(viewCards)) {
|
||||
result.push({ x: vc.x, y: vc.y, width: vc.width, height: vc.height, type: 'view' });
|
||||
}
|
||||
for (const bc of Object.values(browserCards)) {
|
||||
result.push({ x: bc.x, y: bc.y, width: bc.width, height: bc.height, type: 'browser' });
|
||||
}
|
||||
return result;
|
||||
}, [cards, viewCards, browserCards]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const vp = viewportRef.current;
|
||||
const vpW = vp ? vp.clientWidth : 1200;
|
||||
const vpH = vp ? vp.clientHeight : 800;
|
||||
|
||||
const vpRect = {
|
||||
x: -panX / zoom,
|
||||
y: -panY / zoom,
|
||||
width: vpW / zoom,
|
||||
height: vpH / zoom,
|
||||
};
|
||||
|
||||
if (allCards.length === 0) {
|
||||
const scale = Math.min(
|
||||
(MINIMAP_W - PADDING * 2) / vpRect.width,
|
||||
(MINIMAP_H - PADDING * 2) / vpRect.height,
|
||||
);
|
||||
return {
|
||||
scale,
|
||||
offsetX: MINIMAP_W / 2 - (vpRect.x + vpRect.width / 2) * scale,
|
||||
offsetY: MINIMAP_H / 2 - (vpRect.y + vpRect.height / 2) * scale,
|
||||
vpRect,
|
||||
};
|
||||
}
|
||||
|
||||
let minX = vpRect.x, minY = vpRect.y;
|
||||
let maxX = vpRect.x + vpRect.width, maxY = vpRect.y + vpRect.height;
|
||||
for (const card of allCards) {
|
||||
minX = Math.min(minX, card.x);
|
||||
minY = Math.min(minY, card.y);
|
||||
maxX = Math.max(maxX, card.x + card.width);
|
||||
maxY = Math.max(maxY, card.y + card.height);
|
||||
}
|
||||
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const scale = Math.min(
|
||||
(MINIMAP_W - PADDING * 2) / contentW,
|
||||
(MINIMAP_H - PADDING * 2) / contentH,
|
||||
);
|
||||
return {
|
||||
scale,
|
||||
offsetX: (MINIMAP_W - contentW * scale) / 2 - minX * scale,
|
||||
offsetY: (MINIMAP_H - contentH * scale) / 2 - minY * scale,
|
||||
vpRect,
|
||||
};
|
||||
}, [allCards, panX, panY, zoom, viewportRef]);
|
||||
|
||||
const minimapToCanvas = useCallback((clientX: number, clientY: number) => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const mx = clientX - rect.left;
|
||||
const my = clientY - rect.top;
|
||||
const canvasX = (mx - layout.offsetX) / layout.scale;
|
||||
const canvasY = (my - layout.offsetY) / layout.scale;
|
||||
onPan(
|
||||
-(canvasX - layout.vpRect.width / 2) * zoom,
|
||||
-(canvasY - layout.vpRect.height / 2) * zoom,
|
||||
);
|
||||
}, [layout, zoom, onPan]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDraggingRef.current = true;
|
||||
minimapToCanvas(e.clientX, e.clientY);
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (isDraggingRef.current) minimapToCanvas(ev.clientX, ev.clientY);
|
||||
};
|
||||
const onUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, [minimapToCanvas]);
|
||||
|
||||
const typeColor = (type: 'agent' | 'view' | 'browser') => {
|
||||
switch (type) {
|
||||
case 'agent': return c.accent.primary;
|
||||
case 'view': return c.status.info;
|
||||
case 'browser': return c.status.success;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={MINIMAP_W}
|
||||
height={MINIMAP_H}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: 'pointer', display: 'block' }}
|
||||
>
|
||||
{allCards.map((card, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={card.x * layout.scale + layout.offsetX}
|
||||
y={card.y * layout.scale + layout.offsetY}
|
||||
width={card.width * layout.scale}
|
||||
height={card.height * layout.scale}
|
||||
fill={typeColor(card.type)}
|
||||
opacity={0.6}
|
||||
rx={1}
|
||||
/>
|
||||
))}
|
||||
<rect
|
||||
x={layout.vpRect.x * layout.scale + layout.offsetX}
|
||||
y={layout.vpRect.y * layout.scale + layout.offsetY}
|
||||
width={layout.vpRect.width * layout.scale}
|
||||
height={layout.vpRect.height * layout.scale}
|
||||
fill="none"
|
||||
stroke={c.accent.primary}
|
||||
strokeWidth={1.5}
|
||||
opacity={0.8}
|
||||
rx={1}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Minimap;
|
||||
@@ -22,7 +22,14 @@ function clamp(val: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, val));
|
||||
}
|
||||
|
||||
export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
export interface ContentBounds {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: ContentBounds) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -38,7 +45,125 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
const cmdRef = useRef(false);
|
||||
const sensitivityRef = useRef(zoomSensitivity);
|
||||
sensitivityRef.current = zoomSensitivity;
|
||||
const contentBoundsRef = useRef(contentBounds);
|
||||
contentBoundsRef.current = contentBounds;
|
||||
const animFrameRef = useRef<number | null>(null);
|
||||
const inertiaFrameRef = useRef<number | null>(null);
|
||||
|
||||
// ---- Velocity tracking for momentum panning ----
|
||||
const velocityHistoryRef = useRef<Array<{ x: number; y: number; t: number }>>([]);
|
||||
const FRICTION = 0.93;
|
||||
const MIN_VELOCITY = 0.5;
|
||||
|
||||
const cancelInertia = useCallback(() => {
|
||||
if (inertiaFrameRef.current) {
|
||||
cancelAnimationFrame(inertiaFrameRef.current);
|
||||
inertiaFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startInertia = useCallback((vx: number, vy: number) => {
|
||||
cancelInertia();
|
||||
let velocityX = vx;
|
||||
let velocityY = vy;
|
||||
|
||||
const step = () => {
|
||||
velocityX *= FRICTION;
|
||||
velocityY *= FRICTION;
|
||||
|
||||
if (Math.abs(velocityX) < MIN_VELOCITY && Math.abs(velocityY) < MIN_VELOCITY) {
|
||||
inertiaFrameRef.current = null;
|
||||
springBackIfNeeded();
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
panX: prev.panX + velocityX,
|
||||
panY: prev.panY + velocityY,
|
||||
}));
|
||||
|
||||
inertiaFrameRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
inertiaFrameRef.current = requestAnimationFrame(step);
|
||||
}, [cancelInertia]);
|
||||
|
||||
// ---- Soft pan boundaries: spring back if viewport drifts too far from content ----
|
||||
const BOUNDARY_MARGIN = 800; // extra px beyond content bounds before spring-back
|
||||
const springBackIfNeeded = useCallback(() => {
|
||||
const bounds = contentBoundsRef.current;
|
||||
const vp = viewportRef.current;
|
||||
if (!bounds || !vp) return;
|
||||
|
||||
const cur = stateRef.current;
|
||||
const vpW = vp.clientWidth;
|
||||
const vpH = vp.clientHeight;
|
||||
|
||||
// Viewport in canvas coords
|
||||
const vpLeft = -cur.panX / cur.zoom;
|
||||
const vpTop = -cur.panY / cur.zoom;
|
||||
const vpRight = vpLeft + vpW / cur.zoom;
|
||||
const vpBottom = vpTop + vpH / cur.zoom;
|
||||
|
||||
const bLeft = bounds.minX - BOUNDARY_MARGIN;
|
||||
const bTop = bounds.minY - BOUNDARY_MARGIN;
|
||||
const bRight = bounds.maxX + BOUNDARY_MARGIN;
|
||||
const bBottom = bounds.maxY + BOUNDARY_MARGIN;
|
||||
|
||||
let newPanX = cur.panX;
|
||||
let newPanY = cur.panY;
|
||||
|
||||
// If viewport is completely outside bounds, nudge it back
|
||||
if (vpRight < bLeft) {
|
||||
newPanX = -(bLeft - vpW / cur.zoom) * cur.zoom;
|
||||
} else if (vpLeft > bRight) {
|
||||
newPanX = -bRight * cur.zoom;
|
||||
}
|
||||
if (vpBottom < bTop) {
|
||||
newPanY = -(bTop - vpH / cur.zoom) * cur.zoom;
|
||||
} else if (vpTop > bBottom) {
|
||||
newPanY = -bBottom * cur.zoom;
|
||||
}
|
||||
|
||||
if (newPanX !== cur.panX || newPanY !== cur.panY) {
|
||||
// animateTo will be available by the time this runs
|
||||
animateToRef.current?.({ panX: newPanX, panY: newPanY, zoom: cur.zoom }, 250);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- Reusable animation helper ----
|
||||
const cancelAnimation = useCallback(() => {
|
||||
if (animFrameRef.current) {
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
animFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const animateToRef = useRef<((target: CanvasState, duration?: number) => void) | null>(null);
|
||||
|
||||
const animateTo = useCallback((target: CanvasState, duration: number = 320) => {
|
||||
cancelAnimation();
|
||||
const start = { ...stateRef.current };
|
||||
const startTime = performance.now();
|
||||
|
||||
const step = (now: number) => {
|
||||
const t = Math.min((now - startTime) / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
|
||||
setState({
|
||||
panX: start.panX + (target.panX - start.panX) * ease,
|
||||
panY: start.panY + (target.panY - start.panY) * ease,
|
||||
zoom: start.zoom + (target.zoom - start.zoom) * ease,
|
||||
});
|
||||
if (t < 1) {
|
||||
animFrameRef.current = requestAnimationFrame(step);
|
||||
} else {
|
||||
animFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(step);
|
||||
}, [cancelAnimation]);
|
||||
|
||||
animateToRef.current = animateTo;
|
||||
|
||||
// Wheel zoom centered on cursor
|
||||
useEffect(() => {
|
||||
@@ -49,7 +174,10 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
// Pinch-to-zoom on trackpads sets ctrlKey; plain scroll does not
|
||||
const isPinchZoom = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Let scrollable children handle the event when appropriate
|
||||
// Let scrollable children handle the event when appropriate,
|
||||
// but fall through to canvas pan if the child is at its scroll boundary.
|
||||
const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
|
||||
const dx = e.deltaMode === 1 ? e.deltaX * 40 : e.deltaX;
|
||||
let target = e.target as HTMLElement | null;
|
||||
while (target && target !== el) {
|
||||
const style = getComputedStyle(target);
|
||||
@@ -64,12 +192,29 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
(overflowX === 'auto' || overflowX === 'scroll');
|
||||
|
||||
if ((canScrollY || canScrollX) && !isPinchZoom) {
|
||||
// Check if at scroll boundary in the scroll direction
|
||||
const atYBoundary = !canScrollY ||
|
||||
(dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) ||
|
||||
(dy < 0 && target.scrollTop <= 1);
|
||||
const atXBoundary = !canScrollX ||
|
||||
(dx > 0 && target.scrollLeft + target.clientWidth >= target.scrollWidth - 1) ||
|
||||
(dx < 0 && target.scrollLeft <= 1);
|
||||
|
||||
if (atYBoundary && atXBoundary) {
|
||||
// At boundary — fall through to canvas pan
|
||||
target = target.parentElement;
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
target = target.parentElement;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (inertiaFrameRef.current) {
|
||||
cancelAnimationFrame(inertiaFrameRef.current);
|
||||
inertiaFrameRef.current = null;
|
||||
}
|
||||
|
||||
if (isPinchZoom) {
|
||||
// Pinch gesture → zoom centered on cursor
|
||||
@@ -78,8 +223,7 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
const cy = e.clientY - rect.top;
|
||||
|
||||
setState((prev) => {
|
||||
const delta = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
|
||||
const factor = Math.pow(2, -delta * sensitivityToMultiplier(sensitivityRef.current));
|
||||
const factor = Math.pow(2, -dy * sensitivityToMultiplier(sensitivityRef.current));
|
||||
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
const ratio = newZoom / prev.zoom;
|
||||
return {
|
||||
@@ -90,9 +234,6 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
});
|
||||
} else {
|
||||
// Two-finger scroll → pan
|
||||
const dx = e.deltaMode === 1 ? e.deltaX * 40 : e.deltaX;
|
||||
const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
panX: prev.panX - dx,
|
||||
@@ -105,6 +246,120 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
cancelAnimation();
|
||||
cancelInertia();
|
||||
setIsPanning(true);
|
||||
velocityHistoryRef.current = [{ x: e.clientX, y: e.clientY, t: performance.now() }];
|
||||
panStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
panX: stateRef.current.panX,
|
||||
panY: stateRef.current.panY,
|
||||
};
|
||||
}, [cancelAnimation, cancelInertia]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const start = panStartRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.x;
|
||||
const dy = e.clientY - start.y;
|
||||
|
||||
// Track velocity (keep last 5 positions)
|
||||
const now = performance.now();
|
||||
const history = velocityHistoryRef.current;
|
||||
history.push({ x: e.clientX, y: e.clientY, t: now });
|
||||
if (history.length > 5) history.shift();
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
panX: start.panX + dx,
|
||||
panY: start.panY + dy,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
const wasPanning = !!panStartRef.current;
|
||||
let didInertia = false;
|
||||
if (wasPanning) {
|
||||
// Compute velocity from recent mouse history
|
||||
const history = velocityHistoryRef.current;
|
||||
if (history.length >= 2) {
|
||||
const oldest = history[0];
|
||||
const newest = history[history.length - 1];
|
||||
const dt = newest.t - oldest.t;
|
||||
if (dt > 0 && dt < 200) {
|
||||
const vx = (newest.x - oldest.x) / (dt / 16.67); // px per frame
|
||||
const vy = (newest.y - oldest.y) / (dt / 16.67);
|
||||
if (Math.abs(vx) > MIN_VELOCITY || Math.abs(vy) > MIN_VELOCITY) {
|
||||
startInertia(vx, vy);
|
||||
didInertia = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
velocityHistoryRef.current = [];
|
||||
}
|
||||
panStartRef.current = null;
|
||||
setIsPanning(false);
|
||||
// Only spring back if we were actually panning (not on simple clicks)
|
||||
if (wasPanning && !didInertia) {
|
||||
springBackIfNeeded();
|
||||
}
|
||||
}, [startInertia, springBackIfNeeded]);
|
||||
|
||||
// Clean up panning if mouse leaves the window
|
||||
useEffect(() => {
|
||||
const onUp = () => {
|
||||
if (panStartRef.current) {
|
||||
panStartRef.current = null;
|
||||
setIsPanning(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => window.removeEventListener('mouseup', onUp);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { cancelAnimation(); cancelInertia(); };
|
||||
}, [cancelAnimation, cancelInertia]);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
const prev = stateRef.current;
|
||||
const newZoom = clamp(prev.zoom * ZOOM_IN_FACTOR, MIN_ZOOM, MAX_ZOOM);
|
||||
const el = viewportRef.current;
|
||||
if (!el) { animateTo({ ...prev, zoom: newZoom }, 150); return; }
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = rect.width / 2;
|
||||
const cy = rect.height / 2;
|
||||
const ratio = newZoom / prev.zoom;
|
||||
animateTo({ panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom }, 150);
|
||||
}, [animateTo]);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
const prev = stateRef.current;
|
||||
const newZoom = clamp(prev.zoom * ZOOM_OUT_FACTOR, MIN_ZOOM, MAX_ZOOM);
|
||||
const el = viewportRef.current;
|
||||
if (!el) { animateTo({ ...prev, zoom: newZoom }, 150); return; }
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = rect.width / 2;
|
||||
const cy = rect.height / 2;
|
||||
const ratio = newZoom / prev.zoom;
|
||||
animateTo({ panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom }, 150);
|
||||
}, [animateTo]);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
animateTo({ panX: 0, panY: 0, zoom: 1 });
|
||||
}, [animateTo]);
|
||||
|
||||
// Stable refs for keyboard handler (avoids re-registering keydown listener)
|
||||
const zoomInRef = useRef(zoomIn);
|
||||
zoomInRef.current = zoomIn;
|
||||
const zoomOutRef = useRef(zoomOut);
|
||||
zoomOutRef.current = zoomOut;
|
||||
const resetZoomRef = useRef(resetZoom);
|
||||
resetZoomRef.current = resetZoom;
|
||||
|
||||
// Space key tracking
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -121,31 +376,13 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
setState({ panX: 0, panY: 0, zoom: 1 });
|
||||
resetZoomRef.current();
|
||||
} else if (e.key === '=' || e.key === '+') {
|
||||
e.preventDefault();
|
||||
setState((prev) => {
|
||||
const newZoom = clamp(prev.zoom * ZOOM_IN_FACTOR, MIN_ZOOM, MAX_ZOOM);
|
||||
const el = viewportRef.current;
|
||||
if (!el) return { ...prev, zoom: newZoom };
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = rect.width / 2;
|
||||
const cy = rect.height / 2;
|
||||
const ratio = newZoom / prev.zoom;
|
||||
return { panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom };
|
||||
});
|
||||
zoomInRef.current();
|
||||
} else if (e.key === '-') {
|
||||
e.preventDefault();
|
||||
setState((prev) => {
|
||||
const newZoom = clamp(prev.zoom * ZOOM_OUT_FACTOR, MIN_ZOOM, MAX_ZOOM);
|
||||
const el = viewportRef.current;
|
||||
if (!el) return { ...prev, zoom: newZoom };
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = rect.width / 2;
|
||||
const cy = rect.height / 2;
|
||||
const ratio = newZoom / prev.zoom;
|
||||
return { panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom };
|
||||
});
|
||||
zoomOutRef.current();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -168,80 +405,6 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsPanning(true);
|
||||
panStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
panX: stateRef.current.panX,
|
||||
panY: stateRef.current.panY,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const start = panStartRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.x;
|
||||
const dy = e.clientY - start.y;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
panX: start.panX + dx,
|
||||
panY: start.panY + dy,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
panStartRef.current = null;
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
// Clean up panning if mouse leaves the window
|
||||
useEffect(() => {
|
||||
const onUp = () => {
|
||||
if (panStartRef.current) {
|
||||
panStartRef.current = null;
|
||||
setIsPanning(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => window.removeEventListener('mouseup', onUp);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); };
|
||||
}, []);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setState((prev) => {
|
||||
const newZoom = clamp(prev.zoom * ZOOM_IN_FACTOR, MIN_ZOOM, MAX_ZOOM);
|
||||
const el = viewportRef.current;
|
||||
if (!el) return { ...prev, zoom: newZoom };
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = rect.width / 2;
|
||||
const cy = rect.height / 2;
|
||||
const ratio = newZoom / prev.zoom;
|
||||
return { panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setState((prev) => {
|
||||
const newZoom = clamp(prev.zoom * ZOOM_OUT_FACTOR, MIN_ZOOM, MAX_ZOOM);
|
||||
const el = viewportRef.current;
|
||||
if (!el) return { ...prev, zoom: newZoom };
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = rect.width / 2;
|
||||
const cy = rect.height / 2;
|
||||
const ratio = newZoom / prev.zoom;
|
||||
return { panX: cx - (cx - prev.panX) * ratio, panY: cy - (cy - prev.panY) * ratio, zoom: newZoom };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setState({ panX: 0, panY: 0, zoom: 1 });
|
||||
}, []);
|
||||
|
||||
const fitToView = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
const content = contentRef.current;
|
||||
@@ -250,39 +413,38 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
const vRect = viewport.getBoundingClientRect();
|
||||
const children = content.children;
|
||||
if (children.length === 0) {
|
||||
setState({ panX: 0, panY: 0, zoom: 1 });
|
||||
animateTo({ panX: 0, panY: 0, zoom: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const r = children[i].getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
const sx = (r.left - vRect.left - prev.panX) / prev.zoom;
|
||||
const sy = (r.top - vRect.top - prev.panY) / prev.zoom;
|
||||
minX = Math.min(minX, sx);
|
||||
minY = Math.min(minY, sy);
|
||||
maxX = Math.max(maxX, sx + r.width / prev.zoom);
|
||||
maxY = Math.max(maxY, sy + r.height / prev.zoom);
|
||||
}
|
||||
const prev = stateRef.current;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const r = children[i].getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
const sx = (r.left - vRect.left - prev.panX) / prev.zoom;
|
||||
const sy = (r.top - vRect.top - prev.panY) / prev.zoom;
|
||||
minX = Math.min(minX, sx);
|
||||
minY = Math.min(minY, sy);
|
||||
maxX = Math.max(maxX, sx + r.width / prev.zoom);
|
||||
maxY = Math.max(maxY, sy + r.height / prev.zoom);
|
||||
}
|
||||
|
||||
if (!isFinite(minX)) return { panX: 0, panY: 0, zoom: 1 };
|
||||
if (!isFinite(minX)) { animateTo({ panX: 0, panY: 0, zoom: 1 }); return; }
|
||||
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
const availW = vRect.width - FIT_PADDING * 2;
|
||||
const availH = vRect.height - FIT_PADDING * 2;
|
||||
const newZoom = clamp(Math.min(availW / contentWidth, availH / contentHeight), MIN_ZOOM, MAX_ZOOM);
|
||||
const newPanX = (vRect.width - contentWidth * newZoom) / 2 - minX * newZoom;
|
||||
const newPanY = (vRect.height - contentHeight * newZoom) / 2 - minY * newZoom;
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
const availW = vRect.width - FIT_PADDING * 2;
|
||||
const availH = vRect.height - FIT_PADDING * 2;
|
||||
const newZoom = clamp(Math.min(availW / contentWidth, availH / contentHeight), MIN_ZOOM, MAX_ZOOM);
|
||||
const newPanX = (vRect.width - contentWidth * newZoom) / 2 - minX * newZoom;
|
||||
const newPanY = (vRect.height - contentHeight * newZoom) / 2 - minY * newZoom;
|
||||
|
||||
return { panX: newPanX, panY: newPanY, zoom: newZoom };
|
||||
});
|
||||
}, []);
|
||||
animateTo({ panX: newPanX, panY: newPanY, zoom: newZoom });
|
||||
}, [animateTo]);
|
||||
|
||||
const fitToCards = useCallback((cardRects: Array<{ x: number; y: number; width: number; height: number }>, maxZoom?: number, animate?: boolean) => {
|
||||
if (animFrameRef.current) { cancelAnimationFrame(animFrameRef.current); animFrameRef.current = null; }
|
||||
cancelAnimation();
|
||||
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || cardRects.length === 0) {
|
||||
@@ -312,33 +474,24 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
const ceiling = maxZoom ?? MAX_ZOOM;
|
||||
const targetZoom = clamp(Math.min(availW / contentWidth, availH / contentHeight), MIN_ZOOM, ceiling);
|
||||
const targetPanX = (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom;
|
||||
const targetPanY = (vRect.height - contentHeight * targetZoom) / 2 - minY * targetZoom;
|
||||
// For single cards, position near top of viewport (80px padding) instead of dead center
|
||||
const topBiased = cardRects.length === 1;
|
||||
const targetPanY = topBiased
|
||||
? (FIT_PADDING * 0.4) - minY * targetZoom
|
||||
: (vRect.height - contentHeight * targetZoom) / 2 - minY * targetZoom;
|
||||
|
||||
if (!animate) {
|
||||
setState({ panX: targetPanX, panY: targetPanY, zoom: targetZoom });
|
||||
return;
|
||||
const target = { panX: targetPanX, panY: targetPanY, zoom: targetZoom };
|
||||
if (animate) {
|
||||
// Skip if already at target (avoids jitter on re-click)
|
||||
const cur = stateRef.current;
|
||||
const dPan = Math.abs(cur.panX - target.panX) + Math.abs(cur.panY - target.panY);
|
||||
const dZoom = Math.abs(cur.zoom - target.zoom);
|
||||
if (dPan < 5 && dZoom < 0.01) return;
|
||||
animateTo(target);
|
||||
} else {
|
||||
setState(target);
|
||||
}
|
||||
|
||||
const start = { ...stateRef.current };
|
||||
const startTime = performance.now();
|
||||
const duration = 320;
|
||||
|
||||
const step = (now: number) => {
|
||||
const t = Math.min((now - startTime) / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - t, 3);
|
||||
setState({
|
||||
panX: start.panX + (targetPanX - start.panX) * ease,
|
||||
panY: start.panY + (targetPanY - start.panY) * ease,
|
||||
zoom: start.zoom + (targetZoom - start.zoom) * ease,
|
||||
});
|
||||
if (t < 1) {
|
||||
animFrameRef.current = requestAnimationFrame(step);
|
||||
} else {
|
||||
animFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(step);
|
||||
}, []);
|
||||
}, [cancelAnimation, animateTo]);
|
||||
|
||||
const handlers = useMemo(() => ({
|
||||
onMouseDown: handleMouseDown,
|
||||
@@ -347,8 +500,8 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
}), [handleMouseDown, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const actions = useMemo(() => ({
|
||||
zoomIn, zoomOut, resetZoom, fitToView, fitToCards,
|
||||
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards]);
|
||||
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation, setState,
|
||||
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user