feat(FN-3086): enhance graph navigation with toolbar and keyboard controls

The merge introduces a new Cursor CLI plugin provider with dashboard authentication (FN-3396), enabling Fusion to bundle Cursor's CLI as a native AI model source alongside native shell guide and bridge contract documentation (FN-3577). It also completes the dependency graph plugin's navigation contr

Fusion-Task-Id: FN-3086
This commit is contained in:
Fusion
2026-05-07 04:21:33 -07:00
committed by gsxdsm
parent 3735e0565a
commit f859108779
10 changed files with 518 additions and 126 deletions

View File

@@ -54,7 +54,7 @@ Behavior:
- Excludes `done` and `archived`
- Uses Sugiyama-style layered auto-layout to place nodes by dependency depth
- Renders directed bezier dependency edges (dependent → dependency) with arrowheads
- Supports pan/zoom and fit-to-graph controls via floating toolbar actions
- Supports cursor-centered wheel zoom, pinch zoom, keyboard shortcuts (`Ctrl/Cmd+=`, `Ctrl/Cmd+-`, `Ctrl/Cmd+0`, `Ctrl/Cmd+Shift+F`, `Escape`), and fit/reset controls via the floating toolbar with live zoom percentage
- Dependency graph nodes reuse the same `TaskCard` UI as board/list views, so status badges, progress/steps, mission badges, retry/archive controls, and active-task glow stay visually consistent
- Active graph nodes also add a dedicated top status indicator bar and current-step row highlighting so in-progress execution state stays visible even when zoomed out

View File

@@ -9,8 +9,10 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
- **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**: pan, wheel zoom, pinch zoom, zoom-in/out controls, reset, and fit-to-screen
- **Fit-to-screen**: computes node bounding box with layout node dimensions and applies zoom/pan so the graph fits in viewport with padding
- **Interaction**: drag-to-pan canvas, cursor-centered wheel zoom, pinch-to-zoom with stationary midpoint, keyboard shortcuts, zoom toolbar, reset, and fit-to-graph
- **Fit-to-graph**: computes node bounding box 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
- **Animated transitions**: fit/reset operations animate `transform` (`var(--transition-normal)`), while continuous drag/wheel/pinch stays transition-free for responsiveness
- **Node rendering**: each graph node renders the real dashboard `TaskCard` via `GraphTaskNode` (no duplicated card markup)
- **In-progress behavior**: steps are visible by default and active-task glow (`agent-active`) is preserved because node cards reuse TaskCard directly
- **Active-state indicator bar**: active nodes render a compact top bar (`.graph-task-active-indicator`) with the current execution status label (for example `Executing`, `Planning`) and pulsing `--in-progress` emphasis
@@ -22,8 +24,17 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
## Controls
- **Fit to screen** (`Maximize`)
- **Zoom in** (`ZoomIn`)
- **Zoom out** (`ZoomOut`)
### Toolbar (bottom-right)
All controls are rendered as floating `.btn-icon` actions in the bottom-right corner, with mobile-friendly sizing in the `@media (max-width: 768px)` override.
- **Zoom in** (`ZoomIn`) — button + `Ctrl+=` / `Cmd+=`
- **Zoom out** (`ZoomOut`) — button + `Ctrl+-` / `Cmd+-`
- **Zoom percent label** — live readout (for example `100%`, `75%`, `250%`)
- **Fit to graph** (`Maximize`) — button + `Ctrl+Shift+F` / `Cmd+Shift+F`
- **Reset view** (`RotateCcw`) — button + `Ctrl+0` / `Cmd+0`
### Additional keyboard behavior
- **Escape** resets to default view (`zoom=1`, `pan=0,0`)
- Shortcuts are suppressed when focus is inside `input`, `textarea`, `select`, or `contentEditable` elements
All controls are rendered as floating `.btn-icon` actions in the bottom-right corner, with mobile-friendly `44px` touch targets in the `@media (max-width: 768px)` override.

View File

@@ -15,11 +15,15 @@
touch-action: none;
}
.dependency-graph__canvas {
.graph-canvas-transform {
position: relative;
transform-origin: top left;
}
.graph-canvas-transform--animate {
transition: transform var(--transition-normal);
}
.dependency-graph__nodes-layer {
position: relative;
z-index: 1;
@@ -47,26 +51,8 @@
height: 100%;
}
.dependency-graph__toolbar {
position: absolute;
right: var(--space-md);
bottom: var(--space-md);
display: flex;
gap: var(--space-xs);
}
@media (max-width: 768px) {
.dependency-graph {
padding: var(--space-sm);
}
.dependency-graph__toolbar {
right: var(--space-sm);
bottom: var(--space-sm);
}
.dependency-graph__toolbar .btn-icon {
min-width: calc(var(--space-xs) * 11);
min-height: calc(var(--space-xs) * 11);
}
}

View File

@@ -1,7 +1,7 @@
import { Maximize, ZoomIn, ZoomOut } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import type { Task } from "@fusion/core";
import { GraphTaskNode } from "./GraphTaskNode";
import { GraphToolbar } from "./GraphToolbar";
import { GraphEdges } from "./edges";
import { filterGraphTasks } from "./filters";
import { computeAutoLayout } from "./layout";
@@ -11,6 +11,14 @@ import "./DependencyGraph.css";
const NODE_WIDTH = 280;
const NODE_HEIGHT = 100;
const POSITION_STORAGE_KEY = "fusion-plugin-dependency-graph:positions";
function getScopedPositionItem(projectId?: string): string | null {
if (typeof window === "undefined") return null;
if (typeof window.localStorage?.getItem !== "function") return null;
const key = projectId ? `kb:${projectId}:${POSITION_STORAGE_KEY}` : POSITION_STORAGE_KEY;
return window.localStorage.getItem(key);
}
export interface DependencyGraphProps {
tasks: Task[];
@@ -52,6 +60,7 @@ export function DependencyGraph({
workflowStepNameLookup,
}: DependencyGraphProps) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const initialFitDoneRef = useRef(false);
const filteredTasks = useMemo(() => filterGraphTasks(tasks), [tasks]);
const graphData = useGraphData(filteredTasks);
const positions = useMemo(
@@ -61,20 +70,35 @@ export function DependencyGraph({
const {
transform,
zoom,
transitioning,
zoomIn,
zoomOut,
resetView,
fitToGraph,
onPointerDown,
onPointerMove,
onPointerUp,
onWheelZoom,
handleKeyDown,
} = useGraphInteraction();
useEffect(() => {
if (initialFitDoneRef.current) return;
if (filteredTasks.length === 0) return;
const hasSavedPositions = Boolean(getScopedPositionItem(projectId));
if (hasSavedPositions) {
initialFitDoneRef.current = true;
return;
}
const viewport = viewportRef.current;
if (!viewport) return;
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
}, [fitToGraph, positions]);
initialFitDoneRef.current = true;
}, [filteredTasks.length, fitToGraph, positions, projectId]);
const bounds = useMemo(() => {
const values = Array.from(positions.values());
@@ -104,11 +128,18 @@ export function DependencyGraph({
const rect = viewport.getBoundingClientRect();
onWheelZoom(event.deltaY, { x: event.clientX - rect.left, y: event.clientY - rect.top }, viewport.clientWidth, viewport.clientHeight);
}}
onKeyDown={(event) => {
const viewport = viewportRef.current;
if (!viewport) return;
handleKeyDown(event, viewport.clientWidth, viewport.clientHeight, positions, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
}}
tabIndex={0}
style={{ outline: "none" }}
>
{filteredTasks.length === 0 ? (
<div className="dependency-graph__empty">No active tasks to display in graph view.</div>
) : (
<div className="dependency-graph__canvas" style={{ transform, width: `${bounds.width}px`, height: `${bounds.height}px` }}>
<div className={`graph-canvas-transform${transitioning ? " graph-canvas-transform--animate" : ""}`} style={{ transform, width: `${bounds.width}px`, height: `${bounds.height}px` }}>
<GraphEdges edges={graphData.edges} positions={positions} nodeWidth={NODE_WIDTH} nodeHeight={NODE_HEIGHT} />
<div className="dependency-graph__nodes-layer">
{graphData.nodes.map((node) => {
@@ -143,17 +174,25 @@ export function DependencyGraph({
)}
</div>
<div className="dependency-graph__toolbar">
<button className="btn btn-icon" aria-label="Fit to screen" onClick={() => {
<GraphToolbar
zoom={zoom}
onZoomIn={() => {
const viewport = viewportRef.current;
if (!viewport) return;
zoomIn(viewport.clientWidth, viewport.clientHeight);
}}
onZoomOut={() => {
const viewport = viewportRef.current;
if (!viewport) return;
zoomOut(viewport.clientWidth, viewport.clientHeight);
}}
onFitToGraph={() => {
const viewport = viewportRef.current;
if (!viewport) return;
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
}}>
<Maximize size={16} />
</button>
<button className="btn btn-icon" aria-label="Zoom in" onClick={zoomIn}><ZoomIn size={16} /></button>
<button className="btn btn-icon" aria-label="Zoom out" onClick={zoomOut}><ZoomOut size={16} /></button>
</div>
}}
onResetView={resetView}
/>
</section>
);
}

View File

@@ -0,0 +1,35 @@
.graph-toolbar {
position: absolute;
right: var(--space-md);
bottom: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm);
background: var(--surface);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
z-index: 10;
}
.graph-toolbar__zoom-label {
min-width: 3.5ch;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.75rem;
text-align: center;
}
@media (max-width: 768px) {
.graph-toolbar {
right: var(--space-sm);
bottom: var(--space-sm);
padding: var(--space-md);
}
.graph-toolbar .btn-icon {
min-width: calc(var(--space-xs) * 11);
min-height: calc(var(--space-xs) * 11);
}
}

View File

@@ -0,0 +1,36 @@
import { Maximize, RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
import "./GraphToolbar.css";
export interface GraphToolbarProps {
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
onFitToGraph: () => void;
onResetView: () => void;
}
export function GraphToolbar({
zoom,
onZoomIn,
onZoomOut,
onFitToGraph,
onResetView,
}: GraphToolbarProps) {
return (
<div className="graph-toolbar" data-testid="graph-toolbar">
<button className="btn btn-icon" title="Zoom in (Ctrl+=)" aria-label="Zoom in" onClick={onZoomIn}>
<ZoomIn size={16} />
</button>
<button className="btn btn-icon" title="Zoom out (Ctrl+-)" aria-label="Zoom out" onClick={onZoomOut}>
<ZoomOut size={16} />
</button>
<div className="graph-toolbar__zoom-label" aria-live="polite">{Math.round(zoom * 100)}%</div>
<button className="btn btn-icon" title="Fit to graph (Ctrl+Shift+F)" aria-label="Fit to graph" onClick={onFitToGraph}>
<Maximize size={16} />
</button>
<button className="btn btn-icon" title="Reset view (Ctrl+0)" aria-label="Reset view" onClick={onResetView}>
<RotateCcw size={16} />
</button>
</div>
);
}

View File

@@ -4,6 +4,10 @@ import type { Task } from "@fusion/core";
import { DependencyGraph } from "../DependencyGraph";
const fitToGraph = vi.fn();
const zoomIn = vi.fn();
const zoomOut = vi.fn();
const resetView = vi.fn();
const handleKeyDown = vi.fn();
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
TaskCard: ({ task, onOpenDetail, disableDrag }: { task: Task; onOpenDetail: (task: Task) => void; disableDrag?: boolean }) => (
@@ -14,13 +18,17 @@ vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
vi.mock("../useGraphInteraction", () => ({
useGraphInteraction: () => ({
transform: "translate(0px, 0px) scale(1)",
zoomIn: vi.fn(),
zoomOut: vi.fn(),
zoom: 1,
transitioning: false,
zoomIn,
zoomOut,
resetView,
fitToGraph,
onPointerDown: vi.fn(),
onPointerMove: vi.fn(),
onPointerUp: vi.fn(),
onWheelZoom: vi.fn(),
handleKeyDown,
}),
}));
@@ -31,6 +39,10 @@ function createTask(id: string, column: Task["column"], dependencies: string[] =
describe("DependencyGraph", () => {
beforeEach(() => {
fitToGraph.mockReset();
zoomIn.mockReset();
zoomOut.mockReset();
resetView.mockReset();
handleKeyDown.mockReset();
});
afterEach(() => {
@@ -65,43 +77,38 @@ describe("DependencyGraph", () => {
expect(screen.queryByTestId("graph-task-node-F")).toBeNull();
});
it("renders zero nodes and edges when only done tasks are provided", () => {
const { container } = render(<DependencyGraph tasks={[createTask("A", "done", ["B"]), createTask("B", "done")]} onOpenTaskDetail={vi.fn()} />);
expect(container.querySelectorAll("[data-testid^='graph-task-node-']")).toHaveLength(0);
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
it("auto-fits on initial load with active tasks", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
expect(fitToGraph).toHaveBeenCalled();
});
it("renders zero nodes and edges when only archived tasks are provided", () => {
const { container } = render(
<DependencyGraph tasks={[createTask("A", "archived", ["B"]), createTask("B", "archived")]} onOpenTaskDetail={vi.fn()} />,
);
expect(container.querySelectorAll("[data-testid^='graph-task-node-']")).toHaveLength(0);
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
it("forwards keyboard events to interaction hook", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
const viewport = document.querySelector(".dependency-graph__viewport");
if (!viewport) throw new Error("missing viewport");
fireEvent.keyDown(viewport, { key: "=", ctrlKey: true });
expect(handleKeyDown).toHaveBeenCalled();
});
it("drops edge from in-review task to done dependency while keeping node", () => {
const { container } = render(<DependencyGraph tasks={[createTask("A", "in-review", ["B"]), createTask("B", "done")]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
expect(screen.queryByTestId("graph-task-node-B")).toBeNull();
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
expect(container.querySelector(".graph-task-node--in-review")).toBeTruthy();
it("sets viewport tabIndex for keyboard focus", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
const viewport = document.querySelector(".dependency-graph__viewport");
expect(viewport?.getAttribute("tabindex")).toBe("0");
});
it("renders edge between in-progress task and in-review dependency", () => {
render(<DependencyGraph tasks={[createTask("A", "in-progress", ["B"]), createTask("B", "in-review")]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
expect(screen.getByTestId("graph-task-node-B")).toBeTruthy();
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(1);
expect(screen.getByTestId("graph-task-node-B").className).toContain("graph-task-node--in-review");
it("renders toolbar controls", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByRole("button", { name: "Zoom in" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Zoom out" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Fit to graph" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Reset view" })).toBeTruthy();
expect(screen.getByText("100%")).toBeTruthy();
});
it("renders embedded cards with native dragging disabled", () => {
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByTestId("task-A").getAttribute("draggable")).toBe("false");
it("fit-to-graph button triggers fitToGraph", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Fit to graph" }));
expect(fitToGraph).toHaveBeenCalled();
});
it("clicking a card triggers onOpenDetail", () => {
@@ -110,10 +117,4 @@ describe("DependencyGraph", () => {
fireEvent.click(screen.getByTestId("task-A"));
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }));
});
it("fit-to-screen button triggers fitToGraph", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Fit to screen" }));
expect(fitToGraph).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { GraphToolbar } from "../GraphToolbar";
describe("GraphToolbar", () => {
afterEach(() => {
cleanup();
});
it("renders controls and zoom percent", () => {
render(
<GraphToolbar
zoom={1.25}
onZoomIn={vi.fn()}
onZoomOut={vi.fn()}
onFitToGraph={vi.fn()}
onResetView={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Zoom in" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Zoom out" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Fit to graph" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Reset view" })).toBeTruthy();
expect(screen.getByText("125%")).toBeTruthy();
});
it("fires callbacks", () => {
const onZoomIn = vi.fn();
const onZoomOut = vi.fn();
const onFitToGraph = vi.fn();
const onResetView = vi.fn();
render(
<GraphToolbar
zoom={1}
onZoomIn={onZoomIn}
onZoomOut={onZoomOut}
onFitToGraph={onFitToGraph}
onResetView={onResetView}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
fireEvent.click(screen.getByRole("button", { name: "Fit to graph" }));
fireEvent.click(screen.getByRole("button", { name: "Reset view" }));
expect(onZoomIn).toHaveBeenCalledOnce();
expect(onZoomOut).toHaveBeenCalledOnce();
expect(onFitToGraph).toHaveBeenCalledOnce();
expect(onResetView).toHaveBeenCalledOnce();
});
it("applies toolbar class", () => {
render(
<GraphToolbar zoom={1} onZoomIn={vi.fn()} onZoomOut={vi.fn()} onFitToGraph={vi.fn()} onResetView={vi.fn()} />,
);
expect(screen.getByTestId("graph-toolbar").className).toContain("graph-toolbar");
});
});

View File

@@ -1,11 +1,27 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type React from "react";
import { useGraphInteraction } from "../useGraphInteraction";
function createKeyEvent(
key: string,
options?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; target?: EventTarget | null },
) {
return {
key,
ctrlKey: Boolean(options?.ctrlKey),
metaKey: Boolean(options?.metaKey),
shiftKey: Boolean(options?.shiftKey),
target: options?.target ?? document.createElement("div"),
preventDefault: vi.fn(),
} as unknown as React.KeyboardEvent;
}
describe("useGraphInteraction", () => {
it("starts with default pan/zoom", () => {
const { result } = renderHook(() => useGraphInteraction());
expect(result.current.zoom).toBe(1);
expect(result.current.zoomPercent).toBe(100);
expect(result.current.pan).toEqual({ x: 0, y: 0 });
});
@@ -23,14 +39,55 @@ describe("useGraphInteraction", () => {
expect(result.current.zoom).toBe(3);
});
it("fits single node", () => {
it("keeps wheel zoom anchored to cursor position", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.onWheelZoom(-120, { x: 200, y: 150 }, 800, 600);
});
expect(result.current.zoom).toBe(1.1);
expect(result.current.pan.x).toBeCloseTo(-20, 5);
expect(result.current.pan.y).toBeCloseTo(-15, 5);
});
it("supports pinch zoom with stationary midpoint", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.onPointerDown(1, { x: 100, y: 100 });
result.current.onPointerDown(2, { x: 200, y: 100 });
result.current.onPointerMove(2, { x: 250, y: 100 }, 800, 600);
});
expect(result.current.zoom).toBe(1.5);
expect(result.current.pan).toEqual({ x: -50, y: -50 });
});
it("applies animation state for fit and reset", () => {
vi.useFakeTimers();
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.fitToGraph(new Map([["A", { x: 0, y: 0 }]]), 800, 600);
});
expect(result.current.transitioning).toBe(true);
expect(result.current.zoom).toBeGreaterThan(0.1);
expect(result.current.zoom).toBeLessThanOrEqual(3);
act(() => {
vi.advanceTimersByTime(210);
});
expect(result.current.transitioning).toBe(false);
act(() => {
result.current.resetView();
});
expect(result.current.transitioning).toBe(true);
act(() => {
vi.advanceTimersByTime(210);
});
expect(result.current.transitioning).toBe(false);
vi.useRealTimers();
});
it("fits wide graph", () => {
@@ -45,16 +102,52 @@ describe("useGraphInteraction", () => {
expect(result.current.zoom).toBeLessThan(1);
});
it("fits tall graph", () => {
it("handles keyboard shortcuts for zoom in/out, reset, fit, and escape", () => {
const { result } = renderHook(() => useGraphInteraction());
const positions = new Map([
["A", { x: 0, y: 0 }],
["B", { x: 500, y: 200 }],
]);
act(() => {
result.current.fitToGraph(new Map([
["A", { x: 0, y: 0 }],
["B", { x: 0, y: 2000 }],
]), 800, 600);
result.current.handleKeyDown(createKeyEvent("=", { ctrlKey: true }), 800, 600, positions);
});
expect(result.current.zoom).toBe(1.2);
act(() => {
result.current.handleKeyDown(createKeyEvent("-", { ctrlKey: true }), 800, 600, positions);
});
expect(result.current.zoom).toBeCloseTo(1, 5);
act(() => {
result.current.handleKeyDown(createKeyEvent("F", { ctrlKey: true, shiftKey: true }), 800, 600, positions, { nodeWidth: 280, nodeHeight: 100 });
});
expect(result.current.zoom).toBeLessThan(1);
act(() => {
result.current.handleKeyDown(createKeyEvent("0", { ctrlKey: true }), 800, 600, positions);
});
expect(result.current.zoom).toBe(1);
expect(result.current.pan).toEqual({ x: 0, y: 0 });
act(() => {
result.current.zoomIn();
result.current.handleKeyDown(createKeyEvent("Escape"), 800, 600, positions);
});
expect(result.current.zoom).toBe(1);
expect(result.current.pan).toEqual({ x: 0, y: 0 });
});
it("does not run shortcuts when focused on editable targets", () => {
const { result } = renderHook(() => useGraphInteraction());
const input = document.createElement("input");
const positions = new Map([["A", { x: 0, y: 0 }]]);
act(() => {
result.current.handleKeyDown(createKeyEvent("=", { ctrlKey: true, target: input }), 800, 600, positions);
});
expect(result.current.zoom).toBeLessThan(1);
expect(result.current.zoom).toBe(1);
});
it("resets when positions are empty", () => {
@@ -71,19 +164,4 @@ describe("useGraphInteraction", () => {
expect(result.current.zoom).toBe(1);
expect(result.current.pan).toEqual({ x: 0, y: 0 });
});
it("resetView restores defaults", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.zoomIn();
result.current.onPointerDown(1, { x: 0, y: 0 });
result.current.onPointerMove(1, { x: 200, y: 200 }, 800, 600);
result.current.onPointerUp(1);
result.current.resetView();
});
expect(result.current.zoom).toBe(1);
expect(result.current.pan).toEqual({ x: 0, y: 0 });
});
});

View File

@@ -1,39 +1,82 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import type { LayoutOptions } from "./layout";
import type { GraphPosition } from "./types";
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 3;
const FIT_PADDING = 40;
const TRANSITION_TIMEOUT_MS = 200;
interface PointerPoint {
x: number;
y: number;
}
interface PinchState {
distance: number;
zoom: number;
pan: PointerPoint;
midpoint: PointerPoint;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return target.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select";
}
export function useGraphInteraction() {
const [pan, setPan] = useState<PointerPoint>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [transitioning, setTransitioning] = useState(false);
const panRef = useRef(pan);
const zoomRef = useRef(zoom);
const transitionTimerRef = useRef<number | null>(null);
const dragStateRef = useRef<{ start: PointerPoint; panStart: PointerPoint } | null>(null);
const pointersRef = useRef<Map<number, PointerPoint>>(new Map());
const pinchRef = useRef<{ distance: number; zoom: number } | null>(null);
const pinchRef = useRef<PinchState | null>(null);
useEffect(() => {
panRef.current = pan;
}, [pan]);
useEffect(() => {
zoomRef.current = zoom;
}, [zoom]);
useEffect(() => () => {
if (transitionTimerRef.current !== null) {
window.clearTimeout(transitionTimerRef.current);
}
}, []);
const transform = useMemo(() => `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, [pan.x, pan.y, zoom]);
const zoomPercent = useMemo(() => Math.round(zoom * 100), [zoom]);
const zoomIn = useCallback(() => {
setZoom((current) => clamp(current + 0.1, MIN_ZOOM, MAX_ZOOM));
}, []);
const zoomOut = useCallback(() => {
setZoom((current) => clamp(current - 0.1, MIN_ZOOM, MAX_ZOOM));
}, []);
const setAnimate = useCallback((enabled: boolean) => {
if (!enabled) {
if (transitionTimerRef.current !== null) {
window.clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = null;
}
setTransitioning(false);
return;
}
const resetView = useCallback(() => {
setPan({ x: 0, y: 0 });
setZoom(1);
setTransitioning(true);
if (transitionTimerRef.current !== null) {
window.clearTimeout(transitionTimerRef.current);
}
transitionTimerRef.current = window.setTimeout(() => {
setTransitioning(false);
transitionTimerRef.current = null;
}, TRANSITION_TIMEOUT_MS);
}, []);
const clampPan = useCallback((nextPan: PointerPoint, viewportWidth: number, viewportHeight: number) => ({
@@ -41,14 +84,64 @@ export function useGraphInteraction() {
y: clamp(nextPan.y, -viewportHeight, viewportHeight),
}), []);
const zoomAtPoint = useCallback((
nextZoomRaw: number,
anchor: PointerPoint,
viewportWidth: number,
viewportHeight: number,
) => {
const currentZoom = zoomRef.current;
const currentPan = panRef.current;
const nextZoom = clamp(nextZoomRaw, MIN_ZOOM, MAX_ZOOM);
const scaleRatio = nextZoom / currentZoom;
const nextPan = clampPan({
x: anchor.x - (anchor.x - currentPan.x) * scaleRatio,
y: anchor.y - (anchor.y - currentPan.y) * scaleRatio,
}, viewportWidth, viewportHeight);
setZoom(nextZoom);
setPan(nextPan);
}, [clampPan]);
const zoomByFactor = useCallback((factor: number, viewportWidth: number, viewportHeight: number, anchor?: PointerPoint) => {
setAnimate(false);
const point = anchor ?? { x: viewportWidth / 2, y: viewportHeight / 2 };
zoomAtPoint(zoomRef.current * factor, point, viewportWidth, viewportHeight);
}, [setAnimate, zoomAtPoint]);
const zoomIn = useCallback((viewportWidth?: number, viewportHeight?: number) => {
if (viewportWidth && viewportHeight) {
zoomByFactor(1.2, viewportWidth, viewportHeight);
return;
}
setZoom((current) => clamp(current + 0.1, MIN_ZOOM, MAX_ZOOM));
}, [zoomByFactor]);
const zoomOut = useCallback((viewportWidth?: number, viewportHeight?: number) => {
if (viewportWidth && viewportHeight) {
zoomByFactor(1 / 1.2, viewportWidth, viewportHeight);
return;
}
setZoom((current) => clamp(current - 0.1, MIN_ZOOM, MAX_ZOOM));
}, [zoomByFactor]);
const resetView = useCallback(() => {
setAnimate(true);
setPan({ x: 0, y: 0 });
setZoom(1);
}, [setAnimate]);
const fitToGraph = useCallback((
positions: Map<string, GraphPosition>,
viewportWidth: number,
viewportHeight: number,
layoutOptions?: LayoutOptions,
) => {
setAnimate(true);
if (positions.size === 0) {
resetView();
setPan({ x: 0, y: 0 });
setZoom(1);
return;
}
@@ -72,38 +165,53 @@ export function useGraphInteraction() {
setZoom(nextZoom);
setPan(clampPan({ x: panX, y: panY }, viewportWidth, viewportHeight));
}, [clampPan, resetView]);
}, [clampPan, setAnimate]);
const onPointerDown = useCallback((pointerId: number, point: PointerPoint) => {
pointersRef.current.set(pointerId, point);
if (pointersRef.current.size === 2) {
const [a, b] = Array.from(pointersRef.current.values());
pinchRef.current = { distance: Math.hypot(a.x - b.x, a.y - b.y), zoom };
pinchRef.current = {
distance: Math.hypot(a.x - b.x, a.y - b.y),
zoom: zoomRef.current,
pan: panRef.current,
midpoint: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
};
dragStateRef.current = null;
return;
}
dragStateRef.current = { start: point, panStart: pan };
}, [pan, zoom]);
dragStateRef.current = { start: point, panStart: panRef.current };
}, []);
const onPointerMove = useCallback((pointerId: number, point: PointerPoint, viewportWidth: number, viewportHeight: number) => {
if (pointersRef.current.has(pointerId)) pointersRef.current.set(pointerId, point);
if (pointersRef.current.size >= 2 && pinchRef.current) {
setAnimate(false);
const [a, b] = Array.from(pointersRef.current.values());
const distance = Math.hypot(a.x - b.x, a.y - b.y);
const factor = distance / Math.max(1, pinchRef.current.distance);
setZoom(clamp(pinchRef.current.zoom * factor, MIN_ZOOM, MAX_ZOOM));
const midpoint = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const nextZoom = clamp(pinchRef.current.zoom * factor, MIN_ZOOM, MAX_ZOOM);
const ratio = nextZoom / pinchRef.current.zoom;
const nextPan = clampPan({
x: midpoint.x - (pinchRef.current.midpoint.x - pinchRef.current.pan.x) * ratio,
y: midpoint.y - (pinchRef.current.midpoint.y - pinchRef.current.pan.y) * ratio,
}, viewportWidth, viewportHeight);
setZoom(nextZoom);
setPan(nextPan);
return;
}
const dragState = dragStateRef.current;
if (!dragState) return;
setAnimate(false);
const nextPan = {
x: dragState.panStart.x + (point.x - dragState.start.x),
y: dragState.panStart.y + (point.y - dragState.start.y),
};
setPan(clampPan(nextPan, viewportWidth, viewportHeight));
}, [clampPan]);
}, [clampPan, setAnimate]);
const onPointerUp = useCallback((pointerId: number) => {
pointersRef.current.delete(pointerId);
@@ -118,29 +226,67 @@ export function useGraphInteraction() {
viewportHeight: number,
) => {
const factor = deltaY < 0 ? 1.1 : 0.9;
const nextZoom = clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM);
const scaleRatio = nextZoom / zoom;
setAnimate(false);
zoomAtPoint(zoomRef.current * factor, point, viewportWidth, viewportHeight);
}, [setAnimate, zoomAtPoint]);
const nextPan = {
x: point.x - (point.x - pan.x) * scaleRatio,
y: point.y - (point.y - pan.y) * scaleRatio,
};
const handleKeyDown = useCallback((
event: ReactKeyboardEvent,
viewportWidth: number,
viewportHeight: number,
positions: Map<string, GraphPosition>,
layoutOptions?: LayoutOptions,
) => {
if (isEditableTarget(event.target)) return;
setZoom(nextZoom);
setPan(clampPan(nextPan, viewportWidth, viewportHeight));
}, [clampPan, pan.x, pan.y, zoom]);
const modifier = event.metaKey || event.ctrlKey;
if (event.key === "Escape") {
event.preventDefault();
resetView();
return;
}
if (!modifier) return;
if (event.key === "=" || event.key === "+") {
event.preventDefault();
zoomByFactor(1.2, viewportWidth, viewportHeight);
return;
}
if (event.key === "-") {
event.preventDefault();
zoomByFactor(1 / 1.2, viewportWidth, viewportHeight);
return;
}
if (event.key === "0") {
event.preventDefault();
resetView();
return;
}
if ((event.key === "f" || event.key === "F") && event.shiftKey) {
event.preventDefault();
fitToGraph(positions, viewportWidth, viewportHeight, layoutOptions);
}
}, [fitToGraph, resetView, zoomByFactor]);
return {
pan,
zoom,
zoomPercent,
transform,
transitioning,
zoomIn,
zoomOut,
resetView,
fitToGraph,
setAnimate,
onPointerDown,
onPointerMove,
onPointerUp,
onWheelZoom,
handleKeyDown,
};
}