feat(FN-3089): add draggable dependency graph nodes

Adds draggable interaction to dependency graph nodes (FN-3089), introducing a `useNodeDrag` hook to manage drag state and positioning, with corresponding tests and a new drag stylesheet; the `GraphTaskNode` and `DependencyGraph` components are updated to wire up the drag behavior.

Fusion-Task-Id: FN-3089
This commit is contained in:
Fusion
2026-05-07 05:21:13 -07:00
committed by gsxdsm
parent e56f60371b
commit 70105b2481
8 changed files with 341 additions and 6 deletions

View File

@@ -74,10 +74,22 @@ export function DependencyGraph({
const activeTaskId = hoveredTaskId ?? selectedTaskId;
const highlightedTaskIds = useMemo(() => (activeTaskId ? getChain(activeTaskId) : new Set<string>()), [activeTaskId, getChain]);
const positions = useMemo(
const autoLayoutPositions = useMemo(
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
[graphData],
);
const [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(autoLayoutPositions);
const [isNodeDragging, setIsNodeDragging] = useState(false);
useEffect(() => {
setPositions((current) => {
const next = new Map<string, { x: number; y: number }>();
for (const [taskId, layoutPosition] of autoLayoutPositions.entries()) {
next.set(taskId, current.get(taskId) ?? layoutPosition);
}
return next;
});
}, [autoLayoutPositions]);
const {
transform,
@@ -125,11 +137,13 @@ export function DependencyGraph({
ref={viewportRef}
className="dependency-graph__viewport"
onPointerDown={(event) => {
if (isNodeDragging) return;
pointerDownRef.current = { x: event.clientX, y: event.clientY };
pointerDraggedRef.current = false;
onPointerDown(event.pointerId, { x: event.clientX, y: event.clientY });
}}
onPointerMove={(event) => {
if (isNodeDragging) return;
const viewport = viewportRef.current;
if (!viewport) return;
const pointerDown = pointerDownRef.current;
@@ -143,11 +157,15 @@ export function DependencyGraph({
onPointerMove(event.pointerId, { x: event.clientX, y: event.clientY }, viewport.clientWidth, viewport.clientHeight);
}}
onPointerUp={(event) => {
onPointerUp(event.pointerId);
if (!isNodeDragging) {
onPointerUp(event.pointerId);
}
pointerDownRef.current = null;
}}
onPointerCancel={(event) => {
onPointerUp(event.pointerId);
if (!isNodeDragging) {
onPointerUp(event.pointerId);
}
pointerDownRef.current = null;
pointerDraggedRef.current = false;
}}
@@ -166,7 +184,7 @@ export function DependencyGraph({
tabIndex={0}
style={{ outline: "none" }}
onClick={() => {
if (pointerDraggedRef.current) return;
if (pointerDraggedRef.current || isNodeDragging) return;
setSelectedTaskId(null);
}}
>
@@ -200,6 +218,18 @@ export function DependencyGraph({
task={node.task}
projectId={projectId}
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
position={position}
scale={zoom}
onNodePositionChange={(taskId, nextPosition) => {
setPositions((current) => {
const existing = current.get(taskId);
if (existing && existing.x === nextPosition.x && existing.y === nextPosition.y) return current;
const next = new Map(current);
next.set(taskId, nextPosition);
return next;
});
}}
onNodeDragStateChange={setIsNodeDragging}
isHighlighted={highlightedTaskIds.size > 0 && highlightedTaskIds.has(node.task.id)}
isDimmed={highlightedTaskIds.size > 0 && !highlightedTaskIds.has(node.task.id)}
onOpenDetail={onOpenDetail ?? ((task) => onOpenTaskDetail?.(task.id))}