fix(agent-graph): stabilize drag and pan interactions
This commit is contained in:
parent
2fd06fcd48
commit
fb3d1ceb27
8 changed files with 692 additions and 69 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
* All state in refs — no React re-renders.
|
* All state in refs — no React re-renders.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, useCallback } from 'react';
|
import { useRef, useCallback, useMemo } from 'react';
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
|
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
|
||||||
import type { WorldBounds } from '../layout/launchAnchor';
|
import type { WorldBounds } from '../layout/launchAnchor';
|
||||||
|
|
@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult {
|
||||||
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
|
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
transformRef,
|
() => ({
|
||||||
screenToWorld,
|
transformRef,
|
||||||
worldToScreen,
|
screenToWorld,
|
||||||
handleWheel,
|
worldToScreen,
|
||||||
handlePanStart,
|
handleWheel,
|
||||||
handlePanMove,
|
handlePanStart,
|
||||||
handlePanEnd,
|
handlePanMove,
|
||||||
zoomToFit,
|
handlePanEnd,
|
||||||
zoomIn,
|
zoomToFit,
|
||||||
zoomOut,
|
zoomIn,
|
||||||
updateInertia,
|
zoomOut,
|
||||||
};
|
updateInertia,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
screenToWorld,
|
||||||
|
worldToScreen,
|
||||||
|
handleWheel,
|
||||||
|
handlePanStart,
|
||||||
|
handlePanMove,
|
||||||
|
handlePanEnd,
|
||||||
|
zoomToFit,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
updateInertia,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Delegates hit testing to strategy pattern.
|
* Delegates hit testing to strategy pattern.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, useCallback } from 'react';
|
import { useRef, useCallback, useMemo } from 'react';
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import { ANIM } from '../constants/canvas-constants';
|
import { ANIM } from '../constants/canvas-constants';
|
||||||
import { findNodeAt } from '../canvas/hit-detection';
|
import { findNodeAt } from '../canvas/hit-detection';
|
||||||
|
|
@ -81,13 +81,16 @@ export function useGraphInteraction(
|
||||||
return findNodeAt(wx, wy, nodes);
|
return findNodeAt(wx, wy, nodes);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
hoveredNodeId,
|
() => ({
|
||||||
dragNodeId,
|
hoveredNodeId,
|
||||||
isDragging,
|
dragNodeId,
|
||||||
handleMouseDown,
|
isDragging,
|
||||||
handleMouseMove,
|
handleMouseDown,
|
||||||
handleMouseUp,
|
handleMouseMove,
|
||||||
handleDoubleClick,
|
handleMouseUp,
|
||||||
};
|
handleDoubleClick,
|
||||||
|
}),
|
||||||
|
[handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
|
import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
|
||||||
import { getStateColor } from '../constants/colors';
|
import { getStateColor } from '../constants/colors';
|
||||||
|
|
@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
stateRef,
|
() => ({
|
||||||
updateData,
|
stateRef,
|
||||||
tick,
|
updateData,
|
||||||
setNodePosition,
|
tick,
|
||||||
clearNodePosition,
|
setNodePosition,
|
||||||
clearTransientOwnerPositions,
|
clearNodePosition,
|
||||||
resolveNearestOwnerSlot,
|
clearTransientOwnerPositions,
|
||||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
resolveNearestOwnerSlot,
|
||||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||||
};
|
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
updateData,
|
||||||
|
tick,
|
||||||
|
setNodePosition,
|
||||||
|
clearNodePosition,
|
||||||
|
clearTransientOwnerPositions,
|
||||||
|
resolveNearestOwnerSlot,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshotToNodes(
|
function applySnapshotToNodes(
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import {
|
||||||
findNodeAt,
|
findNodeAt,
|
||||||
getEdgeMidpoint,
|
getEdgeMidpoint,
|
||||||
} from '../canvas/hit-detection';
|
} from '../canvas/hit-detection';
|
||||||
import { ANIM_SPEED } from '../constants/canvas-constants';
|
import { ANIM, ANIM_SPEED } from '../constants/canvas-constants';
|
||||||
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
||||||
|
|
||||||
export interface GraphViewProps {
|
export interface GraphViewProps {
|
||||||
|
|
@ -148,13 +148,6 @@ export function GraphView({
|
||||||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||||
const simulation = useGraphSimulation();
|
const simulation = useGraphSimulation();
|
||||||
const camera = useGraphCamera();
|
const camera = useGraphCamera();
|
||||||
|
|
||||||
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
|
||||||
const simulationRef = useRef(simulation);
|
|
||||||
simulationRef.current = simulation;
|
|
||||||
const cameraRef = useRef(camera);
|
|
||||||
cameraRef.current = camera;
|
|
||||||
|
|
||||||
const interaction = useGraphInteraction(
|
const interaction = useGraphInteraction(
|
||||||
useCallback(
|
useCallback(
|
||||||
(nodeId: string, x: number, y: number) => {
|
(nodeId: string, x: number, y: number) => {
|
||||||
|
|
@ -164,6 +157,20 @@ export function GraphView({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
||||||
|
const simulationRef = useRef(simulation);
|
||||||
|
simulationRef.current = simulation;
|
||||||
|
const cameraRef = useRef(camera);
|
||||||
|
cameraRef.current = camera;
|
||||||
|
const interactionRef = useRef(interaction);
|
||||||
|
interactionRef.current = interaction;
|
||||||
|
const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const getVisibleNodes = useCallback(
|
const getVisibleNodes = useCallback(
|
||||||
(nodes: GraphNode[]): GraphNode[] =>
|
(nodes: GraphNode[]): GraphNode[] =>
|
||||||
nodes.filter((node) => {
|
nodes.filter((node) => {
|
||||||
|
|
@ -433,16 +440,16 @@ export function GraphView({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isSurfaceActive) {
|
if (isSurfaceActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
interaction.handleMouseUp();
|
interactionRef.current.handleMouseUp();
|
||||||
simulation.clearTransientOwnerPositions();
|
simulationRef.current.clearTransientOwnerPositions();
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
edgeMouseDownRef.current = null;
|
edgeMouseDownRef.current = null;
|
||||||
setInteractionGuards(false);
|
setInteractionGuards(false);
|
||||||
}, [interaction, isSurfaceActive, simulation]);
|
}, [isSurfaceActive, setInteractionGuards]);
|
||||||
|
|
||||||
const handleWheel = useCallback(
|
const handleWheel = useCallback(
|
||||||
(e: WheelEvent) => {
|
(e: WheelEvent) => {
|
||||||
|
|
@ -454,7 +461,13 @@ export function GraphView({
|
||||||
|
|
||||||
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
||||||
const isPanningRef = useRef(false);
|
const isPanningRef = useRef(false);
|
||||||
const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null);
|
const edgeMouseDownRef = useRef<{
|
||||||
|
id: string;
|
||||||
|
worldX: number;
|
||||||
|
worldY: number;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|
@ -491,7 +504,13 @@ export function GraphView({
|
||||||
if (hitEdge) {
|
if (hitEdge) {
|
||||||
markUserInteracted();
|
markUserInteracted();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y };
|
edgeMouseDownRef.current = {
|
||||||
|
id: hitEdge,
|
||||||
|
worldX: world.x,
|
||||||
|
worldY: world.y,
|
||||||
|
clientX: e.clientX,
|
||||||
|
clientY: e.clientY,
|
||||||
|
};
|
||||||
hoveredEdgeIdRef.current = hitEdge;
|
hoveredEdgeIdRef.current = hitEdge;
|
||||||
} else {
|
} else {
|
||||||
// Hit empty space → pan
|
// Hit empty space → pan
|
||||||
|
|
@ -518,11 +537,6 @@ export function GraphView({
|
||||||
|
|
||||||
const processActivePointerMove = useCallback(
|
const processActivePointerMove = useCallback(
|
||||||
(clientX: number, clientY: number) => {
|
(clientX: number, clientY: number) => {
|
||||||
if (!activePrimaryInteractionRef.current) {
|
|
||||||
dragPreviewRef.current = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPanningRef.current) {
|
if (isPanningRef.current) {
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.getSelection()?.removeAllRanges();
|
document.getSelection()?.removeAllRanges();
|
||||||
|
|
@ -531,6 +545,36 @@ export function GraphView({
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const edgeMouseDown = edgeMouseDownRef.current;
|
||||||
|
if (
|
||||||
|
edgeMouseDown &&
|
||||||
|
!interaction.dragNodeId.current &&
|
||||||
|
!interaction.isDragging.current
|
||||||
|
) {
|
||||||
|
const dx = clientX - edgeMouseDown.clientX;
|
||||||
|
const dy = clientY - edgeMouseDown.clientY;
|
||||||
|
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
hoveredEdgeIdRef.current = null;
|
||||||
|
edgeMouseDownRef.current = null;
|
||||||
|
isPanningRef.current = true;
|
||||||
|
camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY);
|
||||||
|
camera.handlePanMove(clientX, clientY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!activePrimaryInteractionRef.current &&
|
||||||
|
!interaction.dragNodeId.current &&
|
||||||
|
!interaction.isDragging.current
|
||||||
|
) {
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
|
|
@ -627,8 +671,8 @@ export function GraphView({
|
||||||
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||||
const dx = world.x - edgeMouseDownRef.current.x;
|
const dx = world.x - edgeMouseDownRef.current.worldX;
|
||||||
const dy = world.y - edgeMouseDownRef.current.y;
|
const dy = world.y - edgeMouseDownRef.current.worldY;
|
||||||
if (dx * dx + dy * dy <= 25) {
|
if (dx * dx + dy * dy <= 25) {
|
||||||
clickedEdgeId = edgeMouseDownRef.current.id;
|
clickedEdgeId = edgeMouseDownRef.current.id;
|
||||||
}
|
}
|
||||||
|
|
@ -656,6 +700,8 @@ export function GraphView({
|
||||||
},
|
},
|
||||||
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
||||||
);
|
);
|
||||||
|
processActivePointerMoveRef.current = processActivePointerMove;
|
||||||
|
completePointerInteractionRef.current = completePointerInteraction;
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|
@ -711,36 +757,40 @@ export function GraphView({
|
||||||
if (
|
if (
|
||||||
!activePrimaryInteractionRef.current &&
|
!activePrimaryInteractionRef.current &&
|
||||||
!isPanningRef.current &&
|
!isPanningRef.current &&
|
||||||
!interaction.dragNodeId.current &&
|
!interactionRef.current.dragNodeId.current &&
|
||||||
!interaction.isDragging.current &&
|
!interactionRef.current.isDragging.current &&
|
||||||
!edgeMouseDownRef.current
|
!edgeMouseDownRef.current
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
processActivePointerMove(event.clientX, event.clientY);
|
processActivePointerMoveRef.current?.(event.clientX, event.clientY);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWindowMouseUp = (event: MouseEvent): void => {
|
const handleWindowMouseUp = (event: MouseEvent): void => {
|
||||||
if (
|
if (
|
||||||
!activePrimaryInteractionRef.current &&
|
!activePrimaryInteractionRef.current &&
|
||||||
!isPanningRef.current &&
|
!isPanningRef.current &&
|
||||||
!interaction.dragNodeId.current &&
|
!interactionRef.current.dragNodeId.current &&
|
||||||
!interaction.isDragging.current &&
|
!interactionRef.current.isDragging.current &&
|
||||||
!edgeMouseDownRef.current
|
!edgeMouseDownRef.current
|
||||||
) {
|
) {
|
||||||
setInteractionGuards(false);
|
setInteractionGuards(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
completePointerInteraction(event.clientX, event.clientY);
|
completePointerInteractionRef.current?.(event.clientX, event.clientY);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearInteraction = (): void => {
|
const clearInteraction = (): void => {
|
||||||
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) {
|
if (
|
||||||
|
!activePrimaryInteractionRef.current &&
|
||||||
|
!isPanningRef.current &&
|
||||||
|
!interactionRef.current.isDragging.current
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
interaction.handleMouseUp();
|
interactionRef.current.handleMouseUp();
|
||||||
camera.handlePanEnd();
|
cameraRef.current.handlePanEnd();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
edgeMouseDownRef.current = null;
|
edgeMouseDownRef.current = null;
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
|
|
@ -756,9 +806,14 @@ export function GraphView({
|
||||||
window.removeEventListener('mouseup', handleWindowMouseUp);
|
window.removeEventListener('mouseup', handleWindowMouseUp);
|
||||||
window.removeEventListener('blur', clearInteraction);
|
window.removeEventListener('blur', clearInteraction);
|
||||||
window.removeEventListener('dragstart', clearInteraction);
|
window.removeEventListener('dragstart', clearInteraction);
|
||||||
|
};
|
||||||
|
}, [setInteractionGuards]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
setInteractionGuards(false);
|
setInteractionGuards(false);
|
||||||
};
|
};
|
||||||
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]);
|
}, [setInteractionGuards]);
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(
|
const handleDoubleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|
|
||||||
400
test/renderer/features/agent-graph/GraphView.test.ts
Normal file
400
test/renderer/features/agent-graph/GraphView.test.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
|
import { getEdgeMidpoint } from '../../../../packages/agent-graph/src/canvas/hit-detection';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => ({
|
||||||
|
handlePanStart: vi.fn(),
|
||||||
|
handlePanMove: vi.fn(),
|
||||||
|
handlePanEnd: vi.fn(),
|
||||||
|
zoomToFit: vi.fn(),
|
||||||
|
zoomIn: vi.fn(),
|
||||||
|
zoomOut: vi.fn(),
|
||||||
|
updateInertia: vi.fn(),
|
||||||
|
interaction: {
|
||||||
|
hoveredNodeId: { current: null as string | null },
|
||||||
|
dragNodeId: { current: null as string | null },
|
||||||
|
isDragging: { current: false },
|
||||||
|
handleMouseDown: vi.fn(),
|
||||||
|
handleMouseMove: vi.fn(),
|
||||||
|
handleMouseUp: vi.fn(() => null),
|
||||||
|
handleDoubleClick: vi.fn(() => null),
|
||||||
|
},
|
||||||
|
simulationState: {
|
||||||
|
nodes: [] as GraphNode[],
|
||||||
|
edges: [] as GraphEdge[],
|
||||||
|
particles: [],
|
||||||
|
effects: [],
|
||||||
|
time: 0,
|
||||||
|
},
|
||||||
|
clearTransientOwnerPositions: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../packages/agent-graph/src/hooks/useGraphCamera', () => ({
|
||||||
|
useGraphCamera: () => ({
|
||||||
|
transformRef: { current: { x: 0, y: 0, zoom: 1 } },
|
||||||
|
screenToWorld: (sx: number, sy: number) => ({ x: sx, y: sy }),
|
||||||
|
worldToScreen: (wx: number, wy: number) => ({ x: wx, y: wy }),
|
||||||
|
handleWheel: vi.fn(),
|
||||||
|
handlePanStart: hoisted.handlePanStart,
|
||||||
|
handlePanMove: hoisted.handlePanMove,
|
||||||
|
handlePanEnd: hoisted.handlePanEnd,
|
||||||
|
zoomToFit: hoisted.zoomToFit,
|
||||||
|
zoomIn: hoisted.zoomIn,
|
||||||
|
zoomOut: hoisted.zoomOut,
|
||||||
|
updateInertia: hoisted.updateInertia,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../packages/agent-graph/src/hooks/useGraphInteraction', () => ({
|
||||||
|
useGraphInteraction: () => hoisted.interaction,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../packages/agent-graph/src/hooks/useGraphSimulation', () => ({
|
||||||
|
useGraphSimulation: () => ({
|
||||||
|
stateRef: { current: hoisted.simulationState },
|
||||||
|
updateData: vi.fn(),
|
||||||
|
tick: vi.fn(),
|
||||||
|
getExtraWorldBounds: vi.fn(() => []),
|
||||||
|
getLaunchAnchorWorldPosition: vi.fn(() => null),
|
||||||
|
getActivityWorldRect: vi.fn(() => null),
|
||||||
|
resolveNearestOwnerSlot: vi.fn(() => null),
|
||||||
|
clearNodePosition: vi.fn(),
|
||||||
|
clearTransientOwnerPositions: hoisted.clearTransientOwnerPositions,
|
||||||
|
setNodePosition: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../packages/agent-graph/src/ui/GraphControls', () => ({
|
||||||
|
GraphControls: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../packages/agent-graph/src/ui/GraphOverlay', () => ({
|
||||||
|
GraphOverlay: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../packages/agent-graph/src/ui/GraphEdgeOverlay', () => ({
|
||||||
|
GraphEdgeOverlay: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { GraphView } from '../../../../packages/agent-graph/src/ui/GraphView';
|
||||||
|
|
||||||
|
describe('GraphView pan interactions', () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let originalGetBoundingClientRect: typeof HTMLCanvasElement.prototype.getBoundingClientRect;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
hoisted.interaction.hoveredNodeId.current = null;
|
||||||
|
hoisted.interaction.dragNodeId.current = null;
|
||||||
|
hoisted.interaction.isDragging.current = false;
|
||||||
|
hoisted.simulationState.nodes = [];
|
||||||
|
hoisted.simulationState.edges = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'ResizeObserver',
|
||||||
|
class {
|
||||||
|
observe(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
originalGetBoundingClientRect = HTMLCanvasElement.prototype.getBoundingClientRect;
|
||||||
|
HTMLCanvasElement.prototype.getBoundingClientRect = function getBoundingClientRect(): DOMRect {
|
||||||
|
return DOMRect.fromRect({ x: 0, y: 0, width: 800, height: 600 });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
HTMLCanvasElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts panning when dragging from a hit-tested edge instead of getting stuck on edge selection', async () => {
|
||||||
|
const source: GraphNode = {
|
||||||
|
id: 'member:alice',
|
||||||
|
kind: 'member',
|
||||||
|
label: 'alice',
|
||||||
|
state: 'idle',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
|
||||||
|
};
|
||||||
|
const target: GraphNode = {
|
||||||
|
id: 'task:1',
|
||||||
|
kind: 'task',
|
||||||
|
label: 'Task 1',
|
||||||
|
state: 'idle',
|
||||||
|
x: 160,
|
||||||
|
y: 90,
|
||||||
|
domainRef: { kind: 'task', teamName: 'demo-team', taskId: 'task:1' },
|
||||||
|
};
|
||||||
|
const edge: GraphEdge = {
|
||||||
|
id: 'edge:blocking',
|
||||||
|
source: source.id,
|
||||||
|
target: target.id,
|
||||||
|
type: 'blocking',
|
||||||
|
};
|
||||||
|
hoisted.simulationState.nodes = [source, target];
|
||||||
|
hoisted.simulationState.edges = [edge];
|
||||||
|
|
||||||
|
const midpoint = getEdgeMidpoint(edge, new Map([
|
||||||
|
[source.id, source],
|
||||||
|
[target.id, target],
|
||||||
|
]));
|
||||||
|
expect(midpoint).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(GraphView, {
|
||||||
|
data: {
|
||||||
|
teamName: 'demo-team',
|
||||||
|
nodes: [source, target],
|
||||||
|
edges: [edge],
|
||||||
|
particles: [],
|
||||||
|
},
|
||||||
|
config: { animationEnabled: false },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = container.querySelector('canvas');
|
||||||
|
expect(canvas).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
canvas!.dispatchEvent(
|
||||||
|
new MouseEvent('mousedown', {
|
||||||
|
bubbles: true,
|
||||||
|
button: 0,
|
||||||
|
clientX: midpoint!.x,
|
||||||
|
clientY: midpoint!.y,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
canvas!.dispatchEvent(
|
||||||
|
new MouseEvent('mousemove', {
|
||||||
|
bubbles: true,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: midpoint!.x + 24,
|
||||||
|
clientY: midpoint!.y + 4,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hoisted.handlePanStart).toHaveBeenCalledWith(midpoint!.x, midpoint!.y);
|
||||||
|
expect(hoisted.handlePanMove).toHaveBeenCalledWith(midpoint!.x + 24, midpoint!.y + 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not clear pan state on the rerender triggered by interaction lock', async () => {
|
||||||
|
const source: GraphNode = {
|
||||||
|
id: 'member:alice',
|
||||||
|
kind: 'member',
|
||||||
|
label: 'alice',
|
||||||
|
state: 'idle',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
|
||||||
|
};
|
||||||
|
hoisted.simulationState.nodes = [source];
|
||||||
|
hoisted.simulationState.edges = [];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(GraphView, {
|
||||||
|
data: {
|
||||||
|
teamName: 'demo-team',
|
||||||
|
nodes: [source],
|
||||||
|
edges: [],
|
||||||
|
particles: [],
|
||||||
|
},
|
||||||
|
config: { animationEnabled: false },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = container.querySelector('canvas');
|
||||||
|
expect(canvas).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
canvas!.dispatchEvent(
|
||||||
|
new MouseEvent('mousedown', {
|
||||||
|
bubbles: true,
|
||||||
|
button: 0,
|
||||||
|
clientX: 320,
|
||||||
|
clientY: 220,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
canvas!.dispatchEvent(
|
||||||
|
new MouseEvent('mousemove', {
|
||||||
|
bubbles: true,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: 352,
|
||||||
|
clientY: 248,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hoisted.handlePanStart).toHaveBeenCalledWith(320, 220);
|
||||||
|
expect(hoisted.handlePanMove).toHaveBeenCalledWith(352, 248);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not force-handleMouseUp when props rerender during an active member drag', async () => {
|
||||||
|
const source: GraphNode = {
|
||||||
|
id: 'member:demo-team:alice',
|
||||||
|
kind: 'member',
|
||||||
|
label: 'alice',
|
||||||
|
state: 'idle',
|
||||||
|
x: 80,
|
||||||
|
y: 80,
|
||||||
|
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
|
||||||
|
};
|
||||||
|
hoisted.simulationState.nodes = [source];
|
||||||
|
hoisted.simulationState.edges = [];
|
||||||
|
hoisted.interaction.handleMouseDown.mockImplementation(() => {
|
||||||
|
hoisted.interaction.dragNodeId.current = source.id;
|
||||||
|
});
|
||||||
|
hoisted.interaction.handleMouseMove.mockImplementation(() => {
|
||||||
|
hoisted.interaction.isDragging.current = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstEvents = {};
|
||||||
|
const secondEvents = {};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(GraphView, {
|
||||||
|
data: {
|
||||||
|
teamName: 'demo-team',
|
||||||
|
nodes: [source],
|
||||||
|
edges: [],
|
||||||
|
particles: [],
|
||||||
|
},
|
||||||
|
events: firstEvents,
|
||||||
|
config: { animationEnabled: false },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = container.querySelector('canvas');
|
||||||
|
expect(canvas).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
canvas!.dispatchEvent(
|
||||||
|
new MouseEvent('mousedown', {
|
||||||
|
bubbles: true,
|
||||||
|
button: 0,
|
||||||
|
clientX: 80,
|
||||||
|
clientY: 80,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
canvas!.dispatchEvent(
|
||||||
|
new MouseEvent('mousemove', {
|
||||||
|
bubbles: true,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: 95,
|
||||||
|
clientY: 95,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hoisted.interaction.isDragging.current).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(GraphView, {
|
||||||
|
data: {
|
||||||
|
teamName: 'demo-team',
|
||||||
|
nodes: [source],
|
||||||
|
edges: [],
|
||||||
|
particles: [],
|
||||||
|
},
|
||||||
|
events: secondEvents,
|
||||||
|
config: { animationEnabled: false },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hoisted.interaction.handleMouseUp).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MouseEvent('mousemove', {
|
||||||
|
bubbles: true,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: 112,
|
||||||
|
clientY: 112,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hoisted.interaction.handleMouseMove).toHaveBeenCalled();
|
||||||
|
expect(hoisted.interaction.isDragging.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears drag state when the graph surface becomes inactive', async () => {
|
||||||
|
const source: GraphNode = {
|
||||||
|
id: 'member:demo-team:alice',
|
||||||
|
kind: 'member',
|
||||||
|
label: 'alice',
|
||||||
|
state: 'idle',
|
||||||
|
x: 80,
|
||||||
|
y: 80,
|
||||||
|
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
|
||||||
|
};
|
||||||
|
hoisted.simulationState.nodes = [source];
|
||||||
|
hoisted.simulationState.edges = [];
|
||||||
|
hoisted.interaction.dragNodeId.current = source.id;
|
||||||
|
hoisted.interaction.isDragging.current = true;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(GraphView, {
|
||||||
|
data: {
|
||||||
|
teamName: 'demo-team',
|
||||||
|
nodes: [source],
|
||||||
|
edges: [],
|
||||||
|
particles: [],
|
||||||
|
},
|
||||||
|
config: { animationEnabled: false },
|
||||||
|
isSurfaceActive: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hoisted.interaction.handleMouseUp).not.toHaveBeenCalled();
|
||||||
|
expect(hoisted.clearTransientOwnerPositions).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(GraphView, {
|
||||||
|
data: {
|
||||||
|
teamName: 'demo-team',
|
||||||
|
nodes: [source],
|
||||||
|
edges: [],
|
||||||
|
particles: [],
|
||||||
|
},
|
||||||
|
config: { animationEnabled: false },
|
||||||
|
isSurfaceActive: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hoisted.interaction.handleMouseUp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hoisted.clearTransientOwnerPositions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,15 +7,29 @@ import { useGraphCamera, type UseGraphCameraResult } from '../../../../packages/
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
let capturedCamera: UseGraphCameraResult | null = null;
|
let capturedCamera: UseGraphCameraResult | null = null;
|
||||||
|
let firstCamera: UseGraphCameraResult | null = null;
|
||||||
|
let secondCamera: UseGraphCameraResult | null = null;
|
||||||
|
|
||||||
function CameraHarness(): React.JSX.Element | null {
|
function CameraHarness(): React.JSX.Element | null {
|
||||||
capturedCamera = useGraphCamera();
|
capturedCamera = useGraphCamera();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CameraIdentityHarness({ pass }: { pass: number }): React.JSX.Element | null {
|
||||||
|
const camera = useGraphCamera();
|
||||||
|
if (pass === 1) {
|
||||||
|
firstCamera = camera;
|
||||||
|
} else {
|
||||||
|
secondCamera = camera;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
describe('useGraphCamera zoomToFit', () => {
|
describe('useGraphCamera zoomToFit', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
capturedCamera = null;
|
capturedCamera = null;
|
||||||
|
firstCamera = null;
|
||||||
|
secondCamera = null;
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -71,4 +85,29 @@ describe('useGraphCamera zoomToFit', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns a referentially stable result across rerenders', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(CameraIdentityHarness, { pass: 1 }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(CameraIdentityHarness, { pass: 2 }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstCamera).toBeTruthy();
|
||||||
|
expect(secondCamera).toBe(firstCamera);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useGraphInteraction, type UseGraphInteractionResult } from '../../../../packages/agent-graph/src/hooks/useGraphInteraction';
|
||||||
|
|
||||||
|
let firstInteraction: UseGraphInteractionResult | null = null;
|
||||||
|
let secondInteraction: UseGraphInteractionResult | null = null;
|
||||||
|
|
||||||
|
function InteractionHarness({ pass }: { pass: number }): React.JSX.Element | null {
|
||||||
|
const interaction = useGraphInteraction();
|
||||||
|
if (pass === 1) {
|
||||||
|
firstInteraction = interaction;
|
||||||
|
} else {
|
||||||
|
secondInteraction = interaction;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useGraphInteraction', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
firstInteraction = null;
|
||||||
|
secondInteraction = null;
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a referentially stable result across rerenders', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(InteractionHarness, { pass: 1 }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(InteractionHarness, { pass: 2 }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstInteraction).toBeTruthy();
|
||||||
|
expect(secondInteraction).toBe(firstInteraction);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useGraphSimulation, type UseGraphSimulationResult } from '../../../../packages/agent-graph/src/hooks/useGraphSimulation';
|
||||||
|
|
||||||
|
let firstSimulation: UseGraphSimulationResult | null = null;
|
||||||
|
let secondSimulation: UseGraphSimulationResult | null = null;
|
||||||
|
|
||||||
|
function SimulationHarness({ pass }: { pass: number }): React.JSX.Element | null {
|
||||||
|
const simulation = useGraphSimulation();
|
||||||
|
if (pass === 1) {
|
||||||
|
firstSimulation = simulation;
|
||||||
|
} else {
|
||||||
|
secondSimulation = simulation;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useGraphSimulation', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
firstSimulation = null;
|
||||||
|
secondSimulation = null;
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a referentially stable result across rerenders', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(SimulationHarness, { pass: 1 }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(SimulationHarness, { pass: 2 }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstSimulation).toBeTruthy();
|
||||||
|
expect(secondSimulation).toBe(firstSimulation);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue