[eric] dashboard: spawn camera is reveal-only (no zoom-in ever, no move when visible, minimal pan otherwise)

This commit is contained in:
ciregenz
2026-07-15 16:44:47 -07:00
parent a88549cbb3
commit d1fd0dd5c4
5 changed files with 51 additions and 26 deletions
@@ -687,6 +687,46 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
[cancelAnimation, animateTo, computeFitTarget],
);
// Figma-style spawn camera: never zoom IN, never move if the cards are already on screen; otherwise the minimal pan that reveals them, zooming out only when they cannot fit at the current zoom.
const revealCards = useCallback(
(cardRects: Array<{ x: number; y: number; width: number; height: number }>) => {
const viewport = viewportRef.current;
if (!viewport || cardRects.length === 0) return;
const v = viewport.getBoundingClientRect();
if (v.width <= 0 || v.height <= 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const r of cardRects) {
minX = Math.min(minX, r.x);
minY = Math.min(minY, r.y);
maxX = Math.max(maxX, r.x + r.width);
maxY = Math.max(maxY, r.y + r.height);
}
if (!isFinite(minX)) return;
const REVEAL_MARGIN = 48;
const cur = stateRef.current;
const fitZoom = Math.min(
(v.width - REVEAL_MARGIN * 2) / (maxX - minX),
(v.height - REVEAL_MARGIN * 2) / (maxY - minY),
);
const zoom = clamp(Math.min(cur.zoom, fitZoom), MIN_ZOOM, MAX_ZOOM);
// If zooming out, keep the viewport-center world point fixed first, then clamp.
const ratio = zoom / cur.zoom;
let panX = v.width / 2 - (v.width / 2 - cur.panX) * ratio;
let panY = v.height / 2 - (v.height / 2 - cur.panY) * ratio;
const left = minX * zoom + panX, right = maxX * zoom + panX;
if (left < REVEAL_MARGIN) panX += REVEAL_MARGIN - left;
else if (right > v.width - REVEAL_MARGIN) panX -= right - (v.width - REVEAL_MARGIN);
const top = minY * zoom + panY, bottom = maxY * zoom + panY;
if (top < REVEAL_MARGIN) panY += REVEAL_MARGIN - top;
else if (bottom > v.height - REVEAL_MARGIN) panY -= bottom - (v.height - REVEAL_MARGIN);
const cur2 = stateRef.current;
if (Math.abs(panX - cur2.panX) < 2 && Math.abs(panY - cur2.panY) < 2 && Math.abs(zoom - cur2.zoom) < 0.005) return;
cancelAnimation();
animateTo({ panX, panY, zoom }, FIT_DURATION);
},
[cancelAnimation, animateTo],
);
const handlers = useMemo(() => ({
onMouseDown: handleMouseDown,
onMouseMove: handleMouseMove,
@@ -694,8 +734,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
}), [handleMouseDown, handleMouseMove, handleMouseUp]);
const actions = useMemo(() => ({
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation, setState,
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation]);
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, setState,
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation]);
return {
...state,
@@ -21,7 +21,6 @@ import {
EXPANDED_CARD_MIN_H,
GRID_GAP,
type CardPosition,
SPAWN_FOCUS_MAX_ZOOM,
} from '@/shared/state/dashboardLayoutSlice';
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
import type { ContextPath } from '@/app/components/editor/DirectoryBrowser';
@@ -199,7 +198,7 @@ export function useAgentSpawn({
if (bc) rects.push({ x: bc.x, y: bc.y, width: bc.width, height: bc.height });
}
}
canvasActions.fitToCards(rects, SPAWN_FOCUS_MAX_ZOOM, true, undefined, true);
canvasActions.revealCards(rects);
handleHighlightCard(draftId);
}
@@ -16,7 +16,6 @@ import {
DEFAULT_NOTE_W,
DEFAULT_NOTE_H,
EXPANDED_CARD_MIN_H,
SPAWN_FOCUS_MAX_ZOOM,
} from '@/shared/state/dashboardLayoutSlice';
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
import type { CanvasActions } from '../interaction/useCanvasControls';
@@ -66,7 +65,7 @@ export function useDashboardCardActions({
}
const card = viewCards[focusKey];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], SPAWN_FOCUS_MAX_ZOOM, true, undefined, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(focusKey);
}
}, 200);
@@ -89,7 +88,7 @@ export function useDashboardCardActions({
const newId = Object.keys(allNotes).find((id) => !prevIds.has(id));
if (newId) {
const note = allNotes[newId];
canvasActions.fitToCards([{ x: note.x, y: note.y, width: note.width, height: note.height }], SPAWN_FOCUS_MAX_ZOOM, true, undefined, true);
canvasActions.revealCards([{ x: note.x, y: note.y, width: note.width, height: note.height }]);
handleHighlightCard(newId);
}
}, 200);
@@ -110,7 +109,7 @@ export function useDashboardCardActions({
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[sessionId];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], SPAWN_FOCUS_MAX_ZOOM, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(sessionId);
}
}, 200);
@@ -20,7 +20,6 @@ import {
clearPendingFocusWorkflowId,
clearPendingFocusWorkflowsHub,
type ViewCardPosition,
SPAWN_FOCUS_MAX_ZOOM,
} from '@/shared/state/dashboardLayoutSlice';
import { fetchOutputs, type Output } from '@/shared/state/outputsSlice';
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
@@ -216,7 +215,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[agentId];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], SPAWN_FOCUS_MAX_ZOOM, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(agentId);
}
}, 350);
@@ -232,13 +231,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.browserCards[browserId];
if (card) {
canvasActions.fitToCards(
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
1.15,
true,
0.8,
true,
);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(browserId);
}
}, 200);
@@ -254,7 +247,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.viewCards[cardKey];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], SPAWN_FOCUS_MAX_ZOOM, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(cardKey);
}
}, 200);
@@ -269,11 +262,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.workflowCards[workflowId];
if (card) {
canvasActions.fitToCards(
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
SPAWN_FOCUS_MAX_ZOOM,
true,
);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(workflowId);
}
}, 200);
@@ -353,7 +342,7 @@ export function useDashboardLifecycle({
const rects = [{ x: vc.x, y: vc.y, width: vc.width, height: vc.height }];
const ac = store.getState().dashboardLayout.cards[sid];
if (ac) rects.push({ x: ac.x, y: ac.y, width: ac.width, height: ac.height });
canvasActions.fitToCards(rects, SPAWN_FOCUS_MAX_ZOOM, true);
canvasActions.revealCards(rects);
handleHighlightCard(outputId);
}, 200);
}
@@ -26,8 +26,6 @@ export const DEFAULT_WORKFLOWS_HUB_W = DEFAULT_BROWSER_CARD_W;
export const DEFAULT_WORKFLOWS_HUB_H = DEFAULT_BROWSER_CARD_H;
export const EXPANDED_CARD_MIN_H = 620;
export const GRID_GAP = 24;
// Zoom ceiling when the camera frames a freshly spawned card. 1.15 slammed the user to 115% on every spawn; 0.75 keeps the new card readable without the lurch-into-your-face zoom.
export const SPAWN_FOCUS_MAX_ZOOM = 0.75;
// Gap between the Workflows window and the cards it spawns (run monitor, that monitor's browser). Keeps the hub -> monitor -> browser row evenly spaced.
export const WORKFLOW_CARD_GAP = 140;
const GRID_ORIGIN = { x: 40, y: 100 };