diff --git a/.changeset/FN-4129-wheel-pan.md b/.changeset/FN-4129-wheel-pan.md
new file mode 100644
index 000000000..191ca7fcf
--- /dev/null
+++ b/.changeset/FN-4129-wheel-pan.md
@@ -0,0 +1,5 @@
+---
+"@runfusion/fusion": patch
+---
+
+Dependency graph: support trackpad/two-finger scroll and mouse wheel to pan when zoomed in. Hold Ctrl/Cmd (or use a trackpad pinch) to zoom.
diff --git a/plugins/fusion-plugin-dependency-graph/README.md b/plugins/fusion-plugin-dependency-graph/README.md
index f474cd868..04d61d55f 100644
--- a/plugins/fusion-plugin-dependency-graph/README.md
+++ b/plugins/fusion-plugin-dependency-graph/README.md
@@ -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
diff --git a/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx b/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx
index 01d85a1ed..aa87da5fb 100644
--- a/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx
+++ b/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx
@@ -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;
diff --git a/plugins/fusion-plugin-dependency-graph/src/__tests__/interactions.test.tsx b/plugins/fusion-plugin-dependency-graph/src/__tests__/interactions.test.tsx
index 4fc8d36e5..ec15f8a62 100644
--- a/plugins/fusion-plugin-dependency-graph/src/__tests__/interactions.test.tsx
+++ b/plugins/fusion-plugin-dependency-graph/src/__tests__/interactions.test.tsx
@@ -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();
@@ -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();
+
+ 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);
+ });
});
diff --git a/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts b/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts
index 0a1df5e99..ec0ce2f09 100644
--- a/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts
+++ b/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts
@@ -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,