mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-25 22:12:22 +02:00
[eric] dashboard: pan/zoom writes the camera imperatively, React commits once per gesture (per-frame setState re-rendered every card at 120Hz = the webview-canvas lag); cards read live camera via getCanvasState
This commit is contained in:
@@ -158,6 +158,11 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
|
||||
useWebviewSuspend(browserCards, canvas.panX, canvas.panY, canvas.zoom, canvas.viewportRef);
|
||||
|
||||
// Gestures write the transform imperatively (no React commit per frame), so a foreign render mid-gesture would paint the stale committed transform for a frame. Re-applying live after EVERY render seals that; do not remove.
|
||||
React.useLayoutEffect(() => {
|
||||
canvas.actions.syncTransform();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', height: '100%', overflow: 'hidden' }}>
|
||||
@@ -215,8 +220,9 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
: 'default',
|
||||
}}
|
||||
>
|
||||
{/* Dot grid background */}
|
||||
{/* Dot grid background; gestures move it imperatively via gridRef (phase + scale), commits re-render it here (dot radius included) */}
|
||||
<Box
|
||||
ref={canvas.gridRef}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -252,9 +258,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
outputs={outputs}
|
||||
glowingAgentCards={glowingAgentCards}
|
||||
expandedSessionIds={expandedSessionIds}
|
||||
zoom={canvas.zoom}
|
||||
panX={canvas.panX}
|
||||
panY={canvas.panY}
|
||||
cmdHeld={canvas.cmdHeld}
|
||||
selection={selection}
|
||||
highlightedCardId={highlightedCardId}
|
||||
|
||||
@@ -39,9 +39,6 @@ interface DashboardCardLayerProps {
|
||||
outputs: Record<string, Output>;
|
||||
glowingAgentCards: Record<string, GlowingAgentCard>;
|
||||
expandedSessionIds: string[];
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
cmdHeld: boolean;
|
||||
selection: Selection;
|
||||
highlightedCardId: string | null;
|
||||
@@ -76,9 +73,6 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
outputs,
|
||||
glowingAgentCards,
|
||||
expandedSessionIds,
|
||||
zoom,
|
||||
panX,
|
||||
panY,
|
||||
cmdHeld,
|
||||
selection,
|
||||
highlightedCardId,
|
||||
@@ -205,9 +199,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
cardWidth={vc.width}
|
||||
cardHeight={vc.height}
|
||||
cardZOrder={vc.zOrder ?? 0}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
getCanvasState={getCanvasState}
|
||||
cmdHeld={cmdHeld}
|
||||
isSelected={selection.isSelected(cardKey)}
|
||||
isHighlighted={highlightedCardId === cardKey}
|
||||
@@ -234,9 +226,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
cardWidth={bc.width}
|
||||
cardHeight={bc.height}
|
||||
cardZOrder={bc.zOrder ?? 0}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
getCanvasState={getCanvasState}
|
||||
cmdHeld={cmdHeld}
|
||||
isSelected={selection.isSelected(bc.browser_id)}
|
||||
isHighlighted={highlightedCardId === bc.browser_id}
|
||||
@@ -258,9 +248,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
cardWidth={n.width}
|
||||
cardHeight={n.height}
|
||||
cardZOrder={n.zOrder ?? 0}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
getCanvasState={getCanvasState}
|
||||
cmdHeld={cmdHeld}
|
||||
content={n.content}
|
||||
color={n.color}
|
||||
@@ -282,9 +270,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
cardWidth={workflowsHub.width}
|
||||
cardHeight={workflowsHub.height}
|
||||
cardZOrder={workflowsHub.zOrder ?? 0}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={selection.isSelected('workflows-hub')}
|
||||
isHighlighted={highlightedCardId === 'workflows-hub'}
|
||||
multiDragDelta={selection.isSelected('workflows-hub') ? multiDragDelta : null}
|
||||
@@ -303,9 +289,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
cardWidth={monitorCard.width}
|
||||
cardHeight={monitorCard.height}
|
||||
cardZOrder={monitorCard.zOrder ?? 0}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
getCanvasState={getCanvasState}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
|
||||
@@ -157,9 +157,7 @@ interface Props {
|
||||
cardY: number;
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
zoom?: number;
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
cmdHeld?: boolean;
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
@@ -177,7 +175,7 @@ interface Props {
|
||||
|
||||
|
||||
const BrowserCard: React.FC<Props> = ({
|
||||
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
|
||||
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, getCanvasState, cmdHeld = false,
|
||||
isSelected = false, isHighlighted = false, keepAliveHidden = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
cardZOrder = 0, onDoubleClick, onBringToFront,
|
||||
}) => {
|
||||
@@ -624,8 +622,9 @@ const BrowserCard: React.FC<Props> = ({
|
||||
// Screen -> canvas: derive the transform origin from this card's own strip (screenX = originX + canvasX * zoom).
|
||||
const barRect = tabBarRef.current?.getBoundingClientRect();
|
||||
if (barRect) {
|
||||
const dropX = (e.clientX - (barRect.left - cardX * zoomRef.current)) / zoomRef.current - 40;
|
||||
const dropY = (e.clientY - (barRect.top - cardY * zoomRef.current)) / zoomRef.current - 16;
|
||||
const z = getCanvasState().zoom;
|
||||
const dropX = (e.clientX - (barRect.left - cardX * z)) / z - 40;
|
||||
const dropY = (e.clientY - (barRect.top - cardY * z)) / z - 16;
|
||||
dispatch(moveBrowserTab({ fromBrowserId: browserId, tabId: drag.tabId, x: dropX, y: dropY }));
|
||||
}
|
||||
}
|
||||
@@ -635,7 +634,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
setDragTabOffset(0);
|
||||
setDetachGhost(null);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [handleSwitchTab, dispatch, browserId, cardX, cardY]);
|
||||
}, [handleSwitchTab, dispatch, browserId, cardX, cardY, getCanvasState]);
|
||||
|
||||
const DRAG_THRESHOLD = 3;
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
@@ -645,22 +644,18 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
|
||||
const cs = getCanvasState();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.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]);
|
||||
}, [cardX, cardY, onDragStart, browserId, getCanvasState]);
|
||||
|
||||
const recomputeDragPos = useCallback(() => {
|
||||
const ds = dragState.current;
|
||||
@@ -668,18 +663,25 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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 cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const panDx = (cs.panX - ds.startPanX) / z;
|
||||
const panDy = (cs.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]);
|
||||
}, [onDragMove, getCanvasState]);
|
||||
|
||||
// Edge-pan/wheel-zoom moves the camera without a React commit; the pan-changed event is the live signal to re-pin the card to the cursor.
|
||||
useEffect(() => {
|
||||
if (isDragging && didDrag.current) recomputeDragPos();
|
||||
}, [panX, panY, isDragging, recomputeDragPos]);
|
||||
if (!isDragging) return;
|
||||
const onPanChange = () => {
|
||||
if (didDrag.current) recomputeDragPos();
|
||||
};
|
||||
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
|
||||
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
|
||||
}, [isDragging, recomputeDragPos]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
@@ -693,9 +695,10 @@ const BrowserCard: React.FC<Props> = ({
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const panDx = (cs.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (cs.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) {
|
||||
@@ -720,7 +723,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, browserId, onDragEnd]);
|
||||
}, [dispatch, browserId, onDragEnd, getCanvasState]);
|
||||
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir; startX: number; startY: number;
|
||||
@@ -748,6 +751,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
(e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
|
||||
const zoom = getCanvasState().zoom;
|
||||
const dx = (e.clientX - startX) / zoom;
|
||||
const dy = (e.clientY - startY) / zoom;
|
||||
let newX = origX, newY = origY, newW = origW, newH = origH;
|
||||
@@ -759,7 +763,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
|
||||
return { x: newX, y: newY, w: newW, h: newH };
|
||||
},
|
||||
[zoom],
|
||||
[getCanvasState],
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
|
||||
@@ -72,9 +72,7 @@ interface Props {
|
||||
cardY: number;
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
zoom?: number;
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
cmdHeld?: boolean;
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
@@ -124,7 +122,7 @@ const BootingBody: React.FC = () => {
|
||||
};
|
||||
|
||||
const DashboardViewCard: React.FC<Props> = ({
|
||||
output, cardKey: cardKeyProp, instance = 1, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
|
||||
output, cardKey: cardKeyProp, instance = 1, cardX, cardY, cardWidth, cardHeight, getCanvasState, cmdHeld = false,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
cardZOrder = 0, onDoubleClick, onBringToFront,
|
||||
}) => {
|
||||
@@ -223,22 +221,19 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
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, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
|
||||
const cs = getCanvasState();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY };
|
||||
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
onDragStart?.(cardKey, 'view');
|
||||
}, [cardX, cardY, onDragStart, cardKey]);
|
||||
}, [cardX, cardY, onDragStart, cardKey, getCanvasState]);
|
||||
|
||||
const recomputeDragPos = useCallback(() => {
|
||||
const ds = dragState.current;
|
||||
@@ -246,18 +241,25 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
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 cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const panDx = (cs.panX - ds.startPanX) / z;
|
||||
const panDy = (cs.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]);
|
||||
}, [onDragMove, getCanvasState]);
|
||||
|
||||
// Edge-pan/wheel-zoom moves the camera without a React commit; the pan-changed event is the live signal to re-pin the card to the cursor.
|
||||
useEffect(() => {
|
||||
if (isDragging && didDrag.current) recomputeDragPos();
|
||||
}, [panX, panY, isDragging, recomputeDragPos]);
|
||||
if (!isDragging) return;
|
||||
const onPanChange = () => {
|
||||
if (didDrag.current) recomputeDragPos();
|
||||
};
|
||||
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
|
||||
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
|
||||
}, [isDragging, recomputeDragPos]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
@@ -271,9 +273,10 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const panDx = (cs.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (cs.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) {
|
||||
@@ -298,7 +301,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, cardKey, onDragEnd]);
|
||||
}, [dispatch, cardKey, onDragEnd, getCanvasState]);
|
||||
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir; startX: number; startY: number;
|
||||
@@ -326,6 +329,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
(e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
|
||||
const zoom = getCanvasState().zoom;
|
||||
const dx = (e.clientX - startX) / zoom;
|
||||
const dy = (e.clientY - startY) / zoom;
|
||||
let newX = origX, newY = origY, newW = origW, newH = origH;
|
||||
@@ -337,7 +341,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
|
||||
return { x: newX, y: newY, w: newW, h: newH };
|
||||
},
|
||||
[zoom],
|
||||
[getCanvasState],
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
|
||||
@@ -55,9 +55,7 @@ interface Props {
|
||||
cardY: number;
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
zoom?: number;
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
cmdHeld?: boolean;
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
@@ -74,7 +72,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const NoteCard: React.FC<Props> = ({
|
||||
noteId, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0,
|
||||
noteId, cardX, cardY, cardWidth, cardHeight, getCanvasState,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta, content, color,
|
||||
cardZOrder = 0, autoFocus, onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
|
||||
}) => {
|
||||
@@ -89,10 +87,6 @@ const NoteCard: React.FC<Props> = ({
|
||||
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 [showColorPicker, setShowColorPicker] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -109,17 +103,18 @@ const NoteCard: React.FC<Props> = ({
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const cs = getCanvasState();
|
||||
dragState.current = {
|
||||
startX: e.clientX, startY: e.clientY,
|
||||
origX: cardX, origY: cardY,
|
||||
startPanX: panRef.current.panX, startPanY: panRef.current.panY,
|
||||
startPanX: cs.panX, startPanY: cs.panY,
|
||||
};
|
||||
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
onDragStart?.(noteId, 'note');
|
||||
}, [cardX, cardY, noteId, onDragStart]);
|
||||
}, [cardX, cardY, noteId, onDragStart, getCanvasState]);
|
||||
|
||||
const recomputeDragPos = useCallback(() => {
|
||||
const ds = dragState.current;
|
||||
@@ -127,18 +122,25 @@ const NoteCard: React.FC<Props> = ({
|
||||
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 cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const panDx = (cs.panX - ds.startPanX) / z;
|
||||
const panDy = (cs.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]);
|
||||
}, [onDragMove, getCanvasState]);
|
||||
|
||||
// Edge-pan/wheel-zoom moves the camera without a React commit; the pan-changed event is the live signal to re-pin the card to the cursor.
|
||||
useEffect(() => {
|
||||
if (isDragging && didDrag.current) recomputeDragPos();
|
||||
}, [panX, panY, isDragging, recomputeDragPos]);
|
||||
if (!isDragging) return;
|
||||
const onPanChange = () => {
|
||||
if (didDrag.current) recomputeDragPos();
|
||||
};
|
||||
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
|
||||
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
|
||||
}, [isDragging, recomputeDragPos]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
@@ -152,9 +154,10 @@ const NoteCard: React.FC<Props> = ({
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const panDx = (cs.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (cs.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) {
|
||||
@@ -174,7 +177,7 @@ const NoteCard: React.FC<Props> = ({
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, noteId, onDragEnd]);
|
||||
}, [dispatch, noteId, onDragEnd, getCanvasState]);
|
||||
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir; startX: number; startY: number;
|
||||
@@ -202,6 +205,7 @@ const NoteCard: React.FC<Props> = ({
|
||||
(e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
|
||||
const zoom = getCanvasState().zoom;
|
||||
const dx = (e.clientX - startX) / zoom;
|
||||
const dy = (e.clientY - startY) / zoom;
|
||||
let newX = origX, newY = origY, newW = origW, newH = origH;
|
||||
@@ -213,7 +217,7 @@ const NoteCard: React.FC<Props> = ({
|
||||
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
|
||||
return { x: newX, y: newY, w: newW, h: newH };
|
||||
},
|
||||
[zoom],
|
||||
[getCanvasState],
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface ContentBounds {
|
||||
export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: ContentBounds, enabled: boolean = true) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [state, setState] = useState<CanvasState>({ panX: 0, panY: 0, zoom: 1 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
@@ -49,8 +50,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
const [cmdHeld, setCmdHeld] = useState(false);
|
||||
|
||||
const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
|
||||
// stateRef is the LIVE camera truth (single writer: applyLive / setCanvasState below). React state is a lagging copy committed once per gesture-end, so a 120Hz pan doesn't re-render the card tree per frame. Never sync stateRef FROM state: a render mid-gesture would clobber live with stale.
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
const liveDirtyRef = useRef(false);
|
||||
const spaceRef = useRef(false);
|
||||
const cmdRef = useRef(false);
|
||||
const sensitivityRef = useRef(zoomSensitivity);
|
||||
@@ -66,6 +68,44 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
const FRICTION = 0.93;
|
||||
const MIN_VELOCITY = 0.5;
|
||||
|
||||
// Paints stateRef onto the DOM: content transform (compositor-only) + dot-grid phase/scale. Also the after-render re-apply, so a foreign React render mid-gesture can't paint the stale committed transform for a frame.
|
||||
const applyLiveToDom = useCallback(() => {
|
||||
const { panX, panY, zoom } = stateRef.current;
|
||||
const content = contentRef.current;
|
||||
if (content) content.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
|
||||
const grid = gridRef.current;
|
||||
if (grid) {
|
||||
const spacing = 24 * zoom;
|
||||
grid.style.backgroundPosition = `${panX % spacing}px ${panY % spacing}px`;
|
||||
// Dot RADIUS lives in the committed backgroundImage and lags to gesture-end; at 1-4px dots the mid-pinch error is invisible and skipping the per-frame gradient rebuild keeps this handler pure style writes.
|
||||
grid.style.backgroundSize = `${spacing}px ${spacing}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Per-frame camera write during a gesture: DOM + live ref only, NO React commit. Dragging cards re-pin to the cursor off the pan-changed event, same signal the old per-frame commit produced.
|
||||
const applyLive = useCallback((next: CanvasState) => {
|
||||
stateRef.current = next;
|
||||
liveDirtyRef.current = true;
|
||||
applyLiveToDom();
|
||||
window.dispatchEvent(new Event('openswarm:canvas-pan-changed'));
|
||||
}, [applyLiveToDom]);
|
||||
|
||||
// Gesture-end: reconcile React (minimap, zoom label, webview suspend) with the live camera in ONE render.
|
||||
const commitLive = useCallback(() => {
|
||||
if (!liveDirtyRef.current) return;
|
||||
liveDirtyRef.current = false;
|
||||
setState(stateRef.current);
|
||||
}, []);
|
||||
|
||||
// Discrete camera set (minimap jump, fit fallbacks): live + committed in the same call. The ONLY sanctioned writers are this and applyLive; a new pan path calling raw setState reintroduces the camera-snaps-back class.
|
||||
const setCanvasState = useCallback((updater: CanvasState | ((prev: CanvasState) => CanvasState)) => {
|
||||
const next = typeof updater === 'function' ? updater(stateRef.current) : updater;
|
||||
stateRef.current = next;
|
||||
liveDirtyRef.current = false;
|
||||
applyLiveToDom();
|
||||
setState(next);
|
||||
}, [applyLiveToDom]);
|
||||
|
||||
const cancelInertia = useCallback(() => {
|
||||
if (inertiaFrameRef.current) {
|
||||
cancelAnimationFrame(inertiaFrameRef.current);
|
||||
@@ -84,20 +124,18 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
|
||||
if (Math.abs(velocityX) < MIN_VELOCITY && Math.abs(velocityY) < MIN_VELOCITY) {
|
||||
inertiaFrameRef.current = null;
|
||||
commitLive();
|
||||
springBackIfNeeded();
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
panX: prev.panX + velocityX,
|
||||
panY: prev.panY + velocityY,
|
||||
}));
|
||||
const prev = stateRef.current;
|
||||
applyLive({ ...prev, panX: prev.panX + velocityX, panY: prev.panY + velocityY });
|
||||
|
||||
inertiaFrameRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
inertiaFrameRef.current = requestAnimationFrame(step);
|
||||
}, [cancelInertia]);
|
||||
}, [cancelInertia, applyLive, commitLive]);
|
||||
|
||||
// ---- Soft pan boundaries: spring back if viewport drifts too far from content ----
|
||||
const BOUNDARY_MARGIN = 800; // extra px beyond content bounds before spring-back
|
||||
@@ -164,7 +202,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
const step = (now: number) => {
|
||||
const t = Math.min((now - startTime) / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
|
||||
setState({
|
||||
applyLive({
|
||||
panX: start.panX + (target.panX - start.panX) * ease,
|
||||
panY: start.panY + (target.panY - start.panY) * ease,
|
||||
zoom: start.zoom + (target.zoom - start.zoom) * ease,
|
||||
@@ -173,10 +211,11 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
animFrameRef.current = requestAnimationFrame(step);
|
||||
} else {
|
||||
animFrameRef.current = null;
|
||||
commitLive();
|
||||
}
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(step);
|
||||
}, [cancelAnimation]);
|
||||
}, [cancelAnimation, applyLive, commitLive]);
|
||||
|
||||
animateToRef.current = animateTo;
|
||||
|
||||
@@ -201,24 +240,19 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
pendingPanDx = 0; pendingPanDy = 0;
|
||||
pendingZoomDy = 0; pendingZoomCenter = null;
|
||||
|
||||
const prev = stateRef.current;
|
||||
if (zCenter && zDy !== 0) {
|
||||
setState((prev) => {
|
||||
const factor = Math.pow(2, -zDy * sensitivityToMultiplier(sensitivityRef.current));
|
||||
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
const ratio = newZoom / prev.zoom;
|
||||
// Apply any pan accumulated in the same frame too: a zoom and a pan can now land together (vertical zoom + horizontal pan across a RAF boundary, or a forwarded pan), and dropping it would swallow the gesture.
|
||||
return {
|
||||
panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio - dx,
|
||||
panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio - dy,
|
||||
zoom: newZoom,
|
||||
};
|
||||
const factor = Math.pow(2, -zDy * sensitivityToMultiplier(sensitivityRef.current));
|
||||
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
const ratio = newZoom / prev.zoom;
|
||||
// Apply any pan accumulated in the same frame too: a zoom and a pan can now land together (vertical zoom + horizontal pan across a RAF boundary, or a forwarded pan), and dropping it would swallow the gesture.
|
||||
applyLive({
|
||||
panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio - dx,
|
||||
panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio - dy,
|
||||
zoom: newZoom,
|
||||
});
|
||||
} else if (dx !== 0 || dy !== 0) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
panX: prev.panX - dx,
|
||||
panY: prev.panY - dy,
|
||||
}));
|
||||
applyLive({ ...prev, panX: prev.panX - dx, panY: prev.panY - dy });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -229,6 +263,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
wheelIdleTimer = setTimeout(() => {
|
||||
wheelIdleTimer = null;
|
||||
setCanvasInteractionActive(false);
|
||||
commitLive();
|
||||
}, 140);
|
||||
if (wheelRafId != null) return;
|
||||
wheelRafId = requestAnimationFrame(flushWheel);
|
||||
@@ -370,7 +405,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
// Don't leave the flag stuck on if the canvas unmounts mid-gesture.
|
||||
setCanvasInteractionActive(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [enabled, applyLive, commitLive]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -395,12 +430,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
const start = panStartRef.current;
|
||||
const latest = latestDragRef.current;
|
||||
if (!start || !latest) return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
applyLive({
|
||||
...stateRef.current,
|
||||
panX: start.panX + latest.dx,
|
||||
panY: start.panY + latest.dy,
|
||||
}));
|
||||
}, []);
|
||||
});
|
||||
}, [applyLive]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const start = panStartRef.current;
|
||||
@@ -451,11 +486,13 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
panStartRef.current = null;
|
||||
setIsPanning(false);
|
||||
setCanvasInteractionActive(false);
|
||||
// Inertia keeps writing live and commits when it settles; otherwise this gesture ends here.
|
||||
if (!didInertia) commitLive();
|
||||
// Only spring back if we were actually panning (not on simple clicks)
|
||||
if (wasPanning && !didInertia) {
|
||||
springBackIfNeeded();
|
||||
}
|
||||
}, [startInertia, springBackIfNeeded]);
|
||||
}, [startInertia, springBackIfNeeded, commitLive]);
|
||||
|
||||
// Clean up panning if mouse leaves the window
|
||||
useEffect(() => {
|
||||
@@ -464,11 +501,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
panStartRef.current = null;
|
||||
setIsPanning(false);
|
||||
setCanvasInteractionActive(false);
|
||||
commitLive();
|
||||
}
|
||||
};
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => window.removeEventListener('mouseup', onUp);
|
||||
}, []);
|
||||
}, [commitLive]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { cancelAnimation(); cancelInertia(); };
|
||||
@@ -665,7 +703,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
if (!target) {
|
||||
// Keep current camera; snapping to (0,0,1) used to desync the minimap.
|
||||
if (cardRects.length === 0 || !viewportRef.current) {
|
||||
setState({ panX: 0, panY: 0, zoom: 1 });
|
||||
setCanvasState({ panX: 0, panY: 0, zoom: 1 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -686,13 +724,13 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
Math.abs(cur2.panX - fresh.panX) +
|
||||
Math.abs(cur2.panY - fresh.panY) +
|
||||
Math.abs(cur2.zoom - fresh.zoom) * 1000;
|
||||
if (drift > 8) setState(fresh);
|
||||
if (drift > 8) setCanvasState(fresh);
|
||||
}, FIT_SETTLE_DELAY);
|
||||
} else {
|
||||
setState(target);
|
||||
setCanvasState(target);
|
||||
}
|
||||
},
|
||||
[cancelAnimation, animateTo, computeFitTarget],
|
||||
[cancelAnimation, animateTo, computeFitTarget, setCanvasState],
|
||||
);
|
||||
|
||||
// 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.
|
||||
@@ -741,9 +779,18 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
onMouseUp: handleMouseUp,
|
||||
}), [handleMouseDown, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// Per-frame pan for edge-pan-during-card-drag: live-only, the caller commits when the drag ends.
|
||||
const panBy = useCallback((dx: number, dy: number) => {
|
||||
const prev = stateRef.current;
|
||||
applyLive({ ...prev, panX: prev.panX + dx, panY: prev.panY + dy });
|
||||
}, [applyLive]);
|
||||
|
||||
const getLiveState = useCallback((): CanvasState => stateRef.current, []);
|
||||
|
||||
const actions = useMemo(() => ({
|
||||
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, setState,
|
||||
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation]);
|
||||
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation,
|
||||
setState: setCanvasState, panBy, commit: commitLive, syncTransform: applyLiveToDom, getLiveState,
|
||||
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -752,6 +799,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
cmdHeld,
|
||||
viewportRef,
|
||||
contentRef,
|
||||
gridRef,
|
||||
handlers,
|
||||
actions,
|
||||
} as const;
|
||||
|
||||
@@ -8,9 +8,6 @@ import type { CanvasActions } from './useCanvasControls';
|
||||
type Selection = ReturnType<typeof useDashboardSelection>;
|
||||
|
||||
interface UseCardDragArgs {
|
||||
panX: number;
|
||||
panY: number;
|
||||
zoom: number;
|
||||
viewportRef: RefObject<HTMLDivElement | null>;
|
||||
canvasActions: CanvasActions;
|
||||
selection: Selection;
|
||||
@@ -27,20 +24,12 @@ function axisIntensity(pos: number, lo: number, hi: number): number {
|
||||
}
|
||||
|
||||
export function useCardDrag({
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
viewportRef,
|
||||
canvasActions,
|
||||
selection,
|
||||
}: UseCardDragArgs) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Notify the currently dragging card (if any) that pan/zoom changed so it can re-pin to the cursor. useEffect rather than render-body dispatchEvent: side effects during render are a React anti-pattern and can fire twice in strict mode. Effect runs after commit, so exactly once per real pan/zoom delta. Edge-pan mutates pan via canvasActions.setState below, so the dispatch lives in the same hook.
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(new Event('openswarm:canvas-pan-changed'));
|
||||
}, [panX, panY, zoom]);
|
||||
|
||||
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);
|
||||
@@ -69,11 +58,8 @@ export function useCardDrag({
|
||||
const dy = EDGE_MAX_SPEED * axisIntensity(my, rect.top, rect.bottom);
|
||||
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
canvasActions.setState((prev: { panX: number; panY: number; zoom: number }) => ({
|
||||
...prev,
|
||||
panX: prev.panX + dx,
|
||||
panY: prev.panY + dy,
|
||||
}));
|
||||
// Live-only write (no React commit per frame); clearDrag commits once when the drag ends.
|
||||
canvasActions.panBy(dx, dy);
|
||||
}
|
||||
|
||||
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
|
||||
@@ -110,12 +96,14 @@ export function useCardDrag({
|
||||
|
||||
const clearDrag = useCallback(() => {
|
||||
stopEdgePan();
|
||||
// Reconcile React with whatever edge-pan wrote live during the drag.
|
||||
canvasActions.commit();
|
||||
activeDragCardRef.current = null;
|
||||
document.body.classList.remove('dashboard-marquee-active');
|
||||
isMultiDragRef.current = false;
|
||||
setMultiDragDelta(null);
|
||||
setLiveDragInfo(null);
|
||||
}, [stopEdgePan]);
|
||||
}, [stopEdgePan, canvasActions]);
|
||||
|
||||
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
|
||||
if (didDrag) report('dashboard', 'card_dragged');
|
||||
|
||||
@@ -93,10 +93,11 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
setNewAgentBounce(canvasEmpty && !bounceDismissedRef.current);
|
||||
}, [canvasEmpty, setNewAgentBounce]);
|
||||
|
||||
const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom });
|
||||
canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom };
|
||||
// Stable getter, AgentCards read pan/zoom on demand during drag math.
|
||||
const getCanvasState = useCallback(() => canvasStateRef.current, []);
|
||||
// Live camera reads: gestures write the transform imperatively and only commit React state at gesture-end, so a render-synced ref would be stale mid-edge-pan (drag math) and inside the 140ms wheel-settle window (spawn placement). Both delegate to the canvas hook's live truth.
|
||||
const getCanvasState = useCallback(() => canvas.actions.getLiveState(), [canvas.actions]);
|
||||
const canvasStateRef = useMemo(() => ({
|
||||
get current() { return canvas.actions.getLiveState(); },
|
||||
}), [canvas.actions]);
|
||||
|
||||
const {
|
||||
multiDragDelta,
|
||||
@@ -105,9 +106,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
handleCardDragMove,
|
||||
handleCardDragEnd,
|
||||
} = useCardDrag({
|
||||
panX: canvas.panX,
|
||||
panY: canvas.panY,
|
||||
zoom: canvas.zoom,
|
||||
viewportRef: canvas.viewportRef,
|
||||
canvasActions: canvas.actions,
|
||||
selection,
|
||||
|
||||
@@ -34,16 +34,14 @@ interface Props {
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
cardZOrder: number;
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
onDragStart: (id: string, type: CardType) => void;
|
||||
onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
onDragEnd: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
}
|
||||
|
||||
// The live run view, a real canvas card (standard claudeTokens chrome) spawned beside the Workflows window. The orange connector back to the window is drawn by the shared TetherLayer, same mechanism as an agent spinning up a browser.
|
||||
const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHeight, cardZOrder, zoom, panX, panY, onDragStart, onDragMove, onDragEnd }) => {
|
||||
const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHeight, cardZOrder, getCanvasState, onDragStart, onDragMove, onDragEnd }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const runs = useAppSelector((s) => s.workflows.runs[workflow.id]);
|
||||
@@ -53,10 +51,6 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
|
||||
|
||||
useEffect(() => { dispatch(fetchRuns(workflow.id)); }, [workflow.id, dispatch]);
|
||||
|
||||
const panRef = useRef({ panX, panY });
|
||||
panRef.current = { panX, panY };
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
const dragState = useRef<{ sx: number; sy: number; ox: number; oy: number; spx: number; spy: number } | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
const [localPos, setLocalPos] = useState<{ x: number; y: number } | null>(null);
|
||||
@@ -67,11 +61,12 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
|
||||
if (t.closest('button, [role="button"]')) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dispatch(bringToFront({ id: 'workflows-monitor', type: 'workflows-monitor' }));
|
||||
dragState.current = { sx: e.clientX, sy: e.clientY, ox: cardX, oy: cardY, spx: panRef.current.panX, spy: panRef.current.panY };
|
||||
const cs = getCanvasState();
|
||||
dragState.current = { sx: e.clientX, sy: e.clientY, ox: cardX, oy: cardY, spx: cs.panX, spy: cs.panY };
|
||||
didDrag.current = false;
|
||||
onDragStart('workflows-monitor', 'workflows-monitor');
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [cardX, cardY, dispatch, onDragStart]);
|
||||
}, [cardX, cardY, dispatch, onDragStart, getCanvasState]);
|
||||
|
||||
const onHeaderMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
@@ -79,21 +74,23 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
|
||||
const rdy = e.clientY - dragState.current.sy;
|
||||
if (!didDrag.current && Math.sqrt(rdx * rdx + rdy * rdy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const z = zoomRef.current;
|
||||
const pdx = (panRef.current.panX - dragState.current.spx) / z;
|
||||
const pdy = (panRef.current.panY - dragState.current.spy) / z;
|
||||
const cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const pdx = (cs.panX - dragState.current.spx) / z;
|
||||
const pdy = (cs.panY - dragState.current.spy) / z;
|
||||
const dx = rdx / z - pdx;
|
||||
const dy = rdy / z - pdy;
|
||||
setLocalPos({ x: dragState.current.ox + dx, y: dragState.current.oy + dy });
|
||||
// Feed the shared drag channel so the tether tracks live, same as cards.
|
||||
onDragMove(dx, dy, e.clientX, e.clientY);
|
||||
}, [onDragMove]);
|
||||
}, [onDragMove, getCanvasState]);
|
||||
|
||||
const onHeaderUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const z = zoomRef.current;
|
||||
const pdx = (panRef.current.panX - dragState.current.spx) / z;
|
||||
const pdy = (panRef.current.panY - dragState.current.spy) / z;
|
||||
const cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const pdx = (cs.panX - dragState.current.spx) / z;
|
||||
const pdy = (cs.panY - dragState.current.spy) / z;
|
||||
const dx = (e.clientX - dragState.current.sx) / z - pdx;
|
||||
const dy = (e.clientY - dragState.current.sy) / z - pdy;
|
||||
if (didDrag.current) {
|
||||
@@ -107,7 +104,7 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
|
||||
didDrag.current = false;
|
||||
setLocalPos(null);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, onDragEnd]);
|
||||
}, [dispatch, onDragEnd, getCanvasState]);
|
||||
|
||||
// A pinned run id (clicked from history) wins; otherwise follow the latest run.
|
||||
const run: WorkflowRun | null =
|
||||
|
||||
@@ -34,9 +34,7 @@ interface Props {
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
cardZOrder?: number;
|
||||
zoom?: number;
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
@@ -49,17 +47,13 @@ interface Props {
|
||||
|
||||
const WorkflowsAppCard: React.FC<Props> = ({
|
||||
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
|
||||
zoom = 1, panX = 0, panY = 0,
|
||||
getCanvasState,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta = null,
|
||||
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
|
||||
}) => {
|
||||
const WC = useWC();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const panRef = useRef({ panX, panY });
|
||||
panRef.current = { panX, panY };
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
|
||||
// ---- Drag (title bar is the handle) ----
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
@@ -77,12 +71,13 @@ const WorkflowsAppCard: React.FC<Props> = ({
|
||||
if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
|
||||
const cs = getCanvasState();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY };
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
onDragStart?.('workflows-hub', 'workflows-hub');
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [cardX, cardY, onDragStart]);
|
||||
}, [cardX, cardY, onDragStart, getCanvasState]);
|
||||
|
||||
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
@@ -90,20 +85,22 @@ const WorkflowsAppCard: React.FC<Props> = ({
|
||||
const rawDy = e.clientY - dragState.current.startY;
|
||||
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const panDx = (cs.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (cs.panY - dragState.current.startPanY) / z;
|
||||
const dx = rawDx / z - panDx;
|
||||
const dy = rawDy / z - panDy;
|
||||
setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy });
|
||||
onDragMove?.(dx, dy, e.clientX, e.clientY);
|
||||
}, [onDragMove]);
|
||||
}, [onDragMove, getCanvasState]);
|
||||
|
||||
const onHeaderPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const panDx = (cs.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (cs.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) {
|
||||
@@ -120,7 +117,7 @@ const WorkflowsAppCard: React.FC<Props> = ({
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, onDragEnd]);
|
||||
}, [dispatch, onDragEnd, getCanvasState]);
|
||||
|
||||
// ---- Resize ----
|
||||
const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null);
|
||||
@@ -139,8 +136,9 @@ const WorkflowsAppCard: React.FC<Props> = ({
|
||||
const compute = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current;
|
||||
const dx = (e.clientX - sx0) / zoomRef.current;
|
||||
const dy = (e.clientY - sy0) / zoomRef.current;
|
||||
const z2 = getCanvasState().zoom;
|
||||
const dx = (e.clientX - sx0) / z2;
|
||||
const dy = (e.clientY - sy0) / z2;
|
||||
let nx = ox, ny = oy, nw = ow, nh = oh;
|
||||
if (dir.includes('e')) nw = ow + dx;
|
||||
if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; }
|
||||
|
||||
Reference in New Issue
Block a user