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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user