From db60a65884da0a11c0fad88767c4228b532a542c Mon Sep 17 00:00:00 2001 From: Fusion Date: Tue, 12 May 2026 12:14:09 -0700 Subject: [PATCH] feat(FN-4129): add wheel pan and modifier-key zoom to dependency graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/FN-4129-wheel-pan.md | 5 ++ .../fusion-plugin-dependency-graph/README.md | 2 +- .../src/DependencyGraph.tsx | 11 ++- .../src/__tests__/interactions.test.tsx | 75 ++++++++++++++++++- .../src/useGraphInteraction.ts | 10 +++ 5 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 .changeset/FN-4129-wheel-pan.md 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,