feat(FN-4129): add wheel pan and modifier-key zoom to dependency graph

Adds wheel-based panning to the dependency graph plugin, separating pan behavior from zoom when the wheel event is horizontal or un修饰, with new tests covering both interactions and updated graph interaction documentation in the plugin README.

Fusion-Task-Id: FN-4129
This commit is contained in:
Fusion
2026-05-12 12:14:09 -07:00
committed by gsxdsm
parent 3d6edb409b
commit db60a65884
5 changed files with 99 additions and 4 deletions

View File

@@ -98,6 +98,7 @@ export function DependencyGraph({
onPointerDown,
onPointerMove,
onPointerUp,
onWheelPan,
onWheelZoom,
handleKeyDown,
setGraphBounds,
@@ -210,8 +211,14 @@ export function DependencyGraph({
event.preventDefault();
const viewport = viewportRef.current;
if (!viewport) return;
const rect = viewport.getBoundingClientRect();
onWheelZoom(event.deltaY, { x: event.clientX - rect.left, y: event.clientY - rect.top }, viewport.clientWidth, viewport.clientHeight);
if (event.ctrlKey || event.metaKey) {
const rect = viewport.getBoundingClientRect();
onWheelZoom(event.deltaY, { x: event.clientX - rect.left, y: event.clientY - rect.top }, viewport.clientWidth, viewport.clientHeight);
return;
}
onWheelPan(event.deltaX, event.deltaY, viewport.clientWidth, viewport.clientHeight);
}}
onKeyDown={(event) => {
const viewport = viewportRef.current;

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { DependencyGraph } from "../DependencyGraph";
@@ -26,8 +26,27 @@ function createTask(id: string, column: Task["column"] = "todo", dependencies: s
} as Task;
}
function parseTransform(transform: string) {
const match = /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([-\d.]+)\)/.exec(transform);
if (!match) throw new Error(`Unexpected transform: ${transform}`);
return {
x: Number(match[1]),
y: Number(match[2]),
scale: Number(match[3]),
};
}
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600);
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
() => ({ x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, toJSON: () => ({}) }) as DOMRect,
);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("dependency graph interactions", () => {
@@ -72,6 +91,38 @@ describe("dependency graph interactions", () => {
expect(result.current.pan.y).toBeCloseTo(150, 3);
});
it("wheel pan updates pan by negated delta", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.onWheelPan(50, 30, 800, 600);
});
expect(result.current.pan).toEqual({ x: -50, y: -30 });
});
it("wheel pan does not change zoom", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.onWheelPan(50, 30, 800, 600);
});
expect(result.current.zoom).toBe(1);
});
it("wheel pan is clamped by graph bounds", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.setGraphBounds({ minX: 0, minY: 0, maxX: 2000, maxY: 1500 });
result.current.onWheelPan(99999, 99999, 800, 600);
});
expect(result.current.pan.x).toBe(800 - 2000);
expect(result.current.pan.y).toBe(600 - 1500);
});
it("double-clicking a node opens task detail", () => {
const onOpenDetail = vi.fn();
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenDetail={onOpenDetail} />);
@@ -147,4 +198,26 @@ describe("dependency graph interactions", () => {
expect(result.current.pan.x).toBeGreaterThanOrEqual(minPanX);
expect(result.current.pan.x).toBeLessThanOrEqual(0);
});
it("uses wheel pan without modifiers and keeps modifier wheel zoom", () => {
render(<DependencyGraph tasks={[createTask("A"), createTask("B", "todo", ["A"])]} onOpenDetail={vi.fn()} />);
const graph = screen.getByTestId("dependency-graph");
const viewport = graph.querySelector(".dependency-graph__viewport");
const canvas = graph.querySelector(".graph-canvas-transform") as HTMLElement | null;
if (!viewport || !canvas) throw new Error("missing graph viewport or canvas");
const initial = parseTransform(canvas.style.transform);
fireEvent.wheel(viewport, { deltaX: 50, deltaY: 30, clientX: 300, clientY: 250 });
const afterPan = parseTransform(canvas.style.transform);
expect(afterPan.x !== initial.x || afterPan.y !== initial.y).toBe(true);
expect(afterPan.scale).toBeCloseTo(initial.scale, 5);
fireEvent.wheel(viewport, { deltaY: -120, ctrlKey: true, clientX: 300, clientY: 250 });
const afterZoom = parseTransform(canvas.style.transform);
expect(afterZoom.scale).toBeGreaterThan(afterPan.scale);
});
});

View File

@@ -254,6 +254,15 @@ export function useGraphInteraction() {
}
}, []);
const onWheelPan = useCallback((deltaX: number, deltaY: number, viewportWidth: number, viewportHeight: number) => {
setAnimate(false);
const nextPan = {
x: panRef.current.x - deltaX,
y: panRef.current.y - deltaY,
};
setPan(clampPan(nextPan, viewportWidth, viewportHeight));
}, [clampPan, setAnimate]);
const onWheelZoom = useCallback((
deltaY: number,
point: PointerPoint,
@@ -325,6 +334,7 @@ export function useGraphInteraction() {
onPointerDown,
onPointerMove,
onPointerUp,
onWheelPan,
onWheelZoom,
handleKeyDown,
setGraphBounds,