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:
@@ -19,7 +19,7 @@ The dependency graph view is registered as a **bundled plugin view** in the dash
|
||||
- **Orphan dependency handling**: if a visible task depends on an excluded/missing dependency (for example `done`/`archived` after filtering), the missing edge is silently dropped and graph rendering continues without broken connectors
|
||||
- **Auto-layout**: Sugiyama-style layered layout (`computeAutoLayout`) groups nodes by dependency depth and spaces layers consistently
|
||||
- **Edge drawing**: SVG bezier curves from source bottom-center to target top-center, with arrowheads showing dependent → dependency direction
|
||||
- **Interaction**: drag-to-pan canvas background, drag-to-reposition nodes, cursor-centered wheel zoom, pinch-to-zoom with stationary midpoint, keyboard shortcuts, zoom toolbar, reset, and fit-to-graph
|
||||
- **Interaction**: drag-to-pan canvas background, scroll/two-finger pan, Ctrl/Cmd-wheel zoom, pinch-to-zoom with stationary midpoint, drag-to-reposition nodes, keyboard shortcuts, zoom toolbar, reset, and fit-to-graph
|
||||
- **Zoomed navigation reachability**: pan clamping now scales with the full rendered graph bounds (min/max node extents) at the active zoom level, so zooming in no longer traps off-screen content behind fixed viewport-only limits
|
||||
- **Fit-to-graph**: computes node bounding box from both minimum and maximum node coordinates (including negative auto-layout origins) with layout node dimensions and applies zoom/pan so the graph fits in viewport with padding
|
||||
- **Initial auto-fit**: when no saved scoped positions exist, the first non-empty render auto-fits once; subsequent updates preserve user navigation state
|
||||
|
||||
@@ -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