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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
35
plugins/fusion-plugin-dependency-graph/src/GraphToolbar.css
Normal file
35
plugins/fusion-plugin-dependency-graph/src/GraphToolbar.css
Normal 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);
|
||||
}
|
||||
}
|
||||
36
plugins/fusion-plugin-dependency-graph/src/GraphToolbar.tsx
Normal file
36
plugins/fusion-plugin-dependency-graph/src/GraphToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user