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,