feat(FN-3941): separate graph pan from selection-gated drag
Separates graph panning from selection-gated drag by updating `useNodeDrag` and `useGraphInteraction` hooks to handle the two interaction modes independently, with `GraphTaskNode` now gating drag on selection state. Tests were added across the graph interaction, node drag, persistence, and integrati Fusion-Task-Id: FN-3941
This commit is contained in:
@@ -227,6 +227,7 @@ export function DependencyGraph({
|
|||||||
key={node.task.id}
|
key={node.task.id}
|
||||||
task={node.task}
|
task={node.task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
isSelected={selectedTaskId === node.task.id}
|
||||||
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
|
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
|
||||||
position={position}
|
position={position}
|
||||||
scale={zoom}
|
scale={zoom}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.graph-task-node--selected {
|
||||||
|
box-shadow: var(--focus-ring-strong), var(--shadow-md);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.graph-task-node--highlighted {
|
.graph-task-node--highlighted {
|
||||||
box-shadow: 0 0 0 var(--btn-border-width) var(--todo), var(--shadow-md);
|
box-shadow: 0 0 0 var(--btn-border-width) var(--todo), var(--shadow-md);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttrib
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
position: GraphPosition;
|
position: GraphPosition;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
isSelected?: boolean;
|
||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
onNodePositionChange: (taskId: string, position: GraphPosition) => void;
|
onNodePositionChange: (taskId: string, position: GraphPosition) => void;
|
||||||
@@ -54,6 +55,7 @@ export function GraphTaskNode({
|
|||||||
style,
|
style,
|
||||||
position,
|
position,
|
||||||
scale,
|
scale,
|
||||||
|
isSelected = false,
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
isDimmed = false,
|
isDimmed = false,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
@@ -88,6 +90,7 @@ export function GraphTaskNode({
|
|||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
position,
|
position,
|
||||||
scale,
|
scale,
|
||||||
|
canDrag: isSelected,
|
||||||
onPositionChange: onNodePositionChange,
|
onPositionChange: onNodePositionChange,
|
||||||
onDragStateChange: onNodeDragStateChange,
|
onDragStateChange: onNodeDragStateChange,
|
||||||
onDragEnd: onNodeDragEnd,
|
onDragEnd: onNodeDragEnd,
|
||||||
@@ -95,7 +98,7 @@ export function GraphTaskNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`graph-task-node graph-node--draggable${drag.isDragging ? " graph-node--dragging" : ""}${isHighlighted ? " graph-task-node--highlighted graph-node--highlighted" : ""}${isDimmed ? " graph-task-node--dimmed graph-node--dimmed" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`}
|
className={`graph-task-node${isSelected ? " graph-node--draggable graph-task-node--selected" : ""}${drag.isDragging ? " graph-node--dragging" : ""}${isHighlighted ? " graph-task-node--highlighted graph-node--highlighted" : ""}${isDimmed ? " graph-task-node--dimmed graph-node--dimmed" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`}
|
||||||
style={style}
|
style={style}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
data-testid={`graph-task-node-${task.id}`}
|
data-testid={`graph-task-node-${task.id}`}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ describe("DependencyGraph persistence", () => {
|
|||||||
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||||
|
|
||||||
const node = screen.getByTestId("graph-task-node-A");
|
const node = screen.getByTestId("graph-task-node-A");
|
||||||
|
fireEvent.click(node);
|
||||||
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
|
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
|
||||||
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
|
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
|
||||||
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
|
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const zoomIn = vi.fn();
|
|||||||
const zoomOut = vi.fn();
|
const zoomOut = vi.fn();
|
||||||
const resetView = vi.fn();
|
const resetView = vi.fn();
|
||||||
const handleKeyDown = vi.fn();
|
const handleKeyDown = vi.fn();
|
||||||
|
const onPointerDown = vi.fn();
|
||||||
|
const onPointerMove = vi.fn();
|
||||||
|
const onPointerUp = vi.fn();
|
||||||
|
|
||||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||||
TaskCard: ({ task, onOpenDetail, disableDrag }: { task: Task; onOpenDetail: (task: Task) => void; disableDrag?: boolean }) => (
|
TaskCard: ({ task, onOpenDetail, disableDrag }: { task: Task; onOpenDetail: (task: Task) => void; disableDrag?: boolean }) => (
|
||||||
@@ -24,9 +27,9 @@ vi.mock("../useGraphInteraction", () => ({
|
|||||||
zoomOut,
|
zoomOut,
|
||||||
resetView,
|
resetView,
|
||||||
fitToGraph,
|
fitToGraph,
|
||||||
onPointerDown: vi.fn(),
|
onPointerDown,
|
||||||
onPointerMove: vi.fn(),
|
onPointerMove,
|
||||||
onPointerUp: vi.fn(),
|
onPointerUp,
|
||||||
onWheelZoom: vi.fn(),
|
onWheelZoom: vi.fn(),
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
}),
|
}),
|
||||||
@@ -43,6 +46,9 @@ describe("DependencyGraph", () => {
|
|||||||
zoomOut.mockReset();
|
zoomOut.mockReset();
|
||||||
resetView.mockReset();
|
resetView.mockReset();
|
||||||
handleKeyDown.mockReset();
|
handleKeyDown.mockReset();
|
||||||
|
onPointerDown.mockReset();
|
||||||
|
onPointerMove.mockReset();
|
||||||
|
onPointerUp.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -125,4 +131,26 @@ describe("DependencyGraph", () => {
|
|||||||
fireEvent.click(screen.getByTestId("task-A"));
|
fireEvent.click(screen.getByTestId("task-A"));
|
||||||
expect(onOpenTaskDetail).toHaveBeenCalledWith("A");
|
expect(onOpenTaskDetail).toHaveBeenCalledWith("A");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires selection before node drag class is enabled", () => {
|
||||||
|
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||||
|
const node = screen.getByTestId("graph-task-node-A");
|
||||||
|
|
||||||
|
expect(node.className).not.toContain("graph-node--draggable");
|
||||||
|
fireEvent.click(node);
|
||||||
|
expect(node.className).toContain("graph-node--draggable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows pane panning from an unselected node surface", () => {
|
||||||
|
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||||
|
const node = screen.getByTestId("graph-task-node-A");
|
||||||
|
|
||||||
|
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||||
|
fireEvent.pointerMove(node, { pointerId: 1, clientX: 30, clientY: 20, isPrimary: true });
|
||||||
|
fireEvent.pointerUp(node, { pointerId: 1, clientX: 30, clientY: 20, isPrimary: true });
|
||||||
|
|
||||||
|
expect(onPointerDown).toHaveBeenCalled();
|
||||||
|
expect(onPointerMove).toHaveBeenCalled();
|
||||||
|
expect(onPointerUp).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function props(overrides: Partial<React.ComponentProps<typeof GraphTaskNode>> =
|
|||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
scale: 1,
|
scale: 1,
|
||||||
|
isSelected: false,
|
||||||
isHighlighted: false,
|
isHighlighted: false,
|
||||||
isDimmed: false,
|
isDimmed: false,
|
||||||
onNodePositionChange: vi.fn(),
|
onNodePositionChange: vi.fn(),
|
||||||
@@ -42,7 +43,7 @@ afterEach(() => {
|
|||||||
describe("GraphTaskNode drag", () => {
|
describe("GraphTaskNode drag", () => {
|
||||||
it("does not open detail after drag threshold is exceeded", () => {
|
it("does not open detail after drag threshold is exceeded", () => {
|
||||||
const onOpenDetail = vi.fn();
|
const onOpenDetail = vi.fn();
|
||||||
render(<GraphTaskNode {...props({ onOpenDetail })} />);
|
render(<GraphTaskNode {...props({ onOpenDetail, isSelected: true })} />);
|
||||||
const node = screen.getByTestId("graph-task-node-FN-1");
|
const node = screen.getByTestId("graph-task-node-FN-1");
|
||||||
|
|
||||||
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||||
@@ -55,7 +56,7 @@ describe("GraphTaskNode drag", () => {
|
|||||||
|
|
||||||
it("applies dragging class only after threshold move", () => {
|
it("applies dragging class only after threshold move", () => {
|
||||||
const onNodePositionChange = vi.fn();
|
const onNodePositionChange = vi.fn();
|
||||||
render(<GraphTaskNode {...props({ onNodePositionChange })} />);
|
render(<GraphTaskNode {...props({ onNodePositionChange, isSelected: true })} />);
|
||||||
const node = screen.getByTestId("graph-task-node-FN-1");
|
const node = screen.getByTestId("graph-task-node-FN-1");
|
||||||
|
|
||||||
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||||
@@ -71,7 +72,7 @@ describe("GraphTaskNode drag", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("composes highlight and dragging classes", () => {
|
it("composes highlight and dragging classes", () => {
|
||||||
render(<GraphTaskNode {...props({ isHighlighted: true })} />);
|
render(<GraphTaskNode {...props({ isSelected: true, isHighlighted: true })} />);
|
||||||
const node = screen.getByTestId("graph-task-node-FN-1");
|
const node = screen.getByTestId("graph-task-node-FN-1");
|
||||||
fireEvent.pointerDown(node, { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true });
|
fireEvent.pointerDown(node, { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true });
|
||||||
fireEvent.pointerMove(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
fireEvent.pointerMove(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||||
@@ -79,4 +80,17 @@ describe("GraphTaskNode drag", () => {
|
|||||||
expect(node.className).toContain("graph-task-node--highlighted");
|
expect(node.className).toContain("graph-task-node--highlighted");
|
||||||
expect(node.className).toContain("graph-node--dragging");
|
expect(node.className).toContain("graph-node--dragging");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not drag when node is not selected", () => {
|
||||||
|
const onNodePositionChange = vi.fn();
|
||||||
|
render(<GraphTaskNode {...props({ onNodePositionChange, isSelected: false })} />);
|
||||||
|
const node = screen.getByTestId("graph-task-node-FN-1");
|
||||||
|
|
||||||
|
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||||
|
fireEvent.pointerMove(node, { pointerId: 1, clientX: 25, clientY: 25, isPrimary: true });
|
||||||
|
|
||||||
|
expect(node.className).not.toContain("graph-node--dragging");
|
||||||
|
expect(node.className).not.toContain("graph-node--draggable");
|
||||||
|
expect(onNodePositionChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ describe("dependency graph interactions", () => {
|
|||||||
expect(result.current.zoom).toBeGreaterThan(1);
|
expect(result.current.zoom).toBeGreaterThan(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps single-pointer moves as pan-only without zoom changes", () => {
|
||||||
|
const { result } = renderHook(() => useGraphInteraction());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onPointerDown(1, { x: 10, y: 10 });
|
||||||
|
result.current.onPointerMove(1, { x: 40, y: 30 }, 800, 600);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.pan).toEqual({ x: 30, y: 20 });
|
||||||
|
expect(result.current.zoom).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("fit-to-graph computes bounds from actual node positions", () => {
|
it("fit-to-graph computes bounds from actual node positions", () => {
|
||||||
const { result } = renderHook(() => useGraphInteraction());
|
const { result } = renderHook(() => useGraphInteraction());
|
||||||
const positions = new Map([
|
const positions = new Map([
|
||||||
@@ -78,6 +90,7 @@ describe("dependency graph interactions", () => {
|
|||||||
task={createTask("A")}
|
task={createTask("A")}
|
||||||
position={{ x: 0, y: 0 }}
|
position={{ x: 0, y: 0 }}
|
||||||
scale={1}
|
scale={1}
|
||||||
|
isSelected={true}
|
||||||
isHighlighted={false}
|
isHighlighted={false}
|
||||||
isDimmed={false}
|
isDimmed={false}
|
||||||
onNodePositionChange={onNodePositionChange}
|
onNodePositionChange={onNodePositionChange}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ describe("dependency graph position persistence", () => {
|
|||||||
|
|
||||||
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||||
const node = screen.getByTestId("graph-task-node-A");
|
const node = screen.getByTestId("graph-task-node-A");
|
||||||
|
fireEvent.click(node);
|
||||||
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
|
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
|
||||||
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
|
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
|
||||||
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
|
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
|
||||||
@@ -75,6 +76,7 @@ describe("dependency graph position persistence", () => {
|
|||||||
it("clearing localStorage causes fresh auto-layout on remount", () => {
|
it("clearing localStorage causes fresh auto-layout on remount", () => {
|
||||||
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||||
const node = screen.getByTestId("graph-task-node-A");
|
const node = screen.getByTestId("graph-task-node-A");
|
||||||
|
fireEvent.click(node);
|
||||||
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
|
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
|
||||||
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });
|
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });
|
||||||
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });
|
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ describe("useGraphInteraction", () => {
|
|||||||
expect(result.current.pan).toEqual({ x: -50, y: -50 });
|
expect(result.current.pan).toEqual({ x: -50, y: -50 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("continues with pan after pinch when one pointer remains", () => {
|
||||||
|
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);
|
||||||
|
result.current.onPointerUp(2);
|
||||||
|
result.current.onPointerMove(1, { x: 120, y: 130 }, 800, 600);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.zoom).toBe(1.5);
|
||||||
|
expect(result.current.pan).toEqual({ x: 20, y: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
it("applies animation state for fit and reset", () => {
|
it("applies animation state for fit and reset", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const { result } = renderHook(() => useGraphInteraction());
|
const { result } = renderHook(() => useGraphInteraction());
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("useNodeDrag", () => {
|
|||||||
const onPositionChange = vi.fn();
|
const onPositionChange = vi.fn();
|
||||||
const onDragStateChange = vi.fn();
|
const onDragStateChange = vi.fn();
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useNodeDrag({ taskId: "A", position: { x: 10, y: 10 }, scale: 1, onPositionChange, onDragStateChange }),
|
useNodeDrag({ taskId: "A", position: { x: 10, y: 10 }, scale: 1, canDrag: true, onPositionChange, onDragStateChange }),
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => result.current.onPointerDown(pointerEvent({ clientX: 10, clientY: 20 })));
|
act(() => result.current.onPointerDown(pointerEvent({ clientX: 10, clientY: 20 })));
|
||||||
@@ -42,7 +42,7 @@ describe("useNodeDrag", () => {
|
|||||||
it("stays click-only below threshold", () => {
|
it("stays click-only below threshold", () => {
|
||||||
const onPositionChange = vi.fn();
|
const onPositionChange = vi.fn();
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }),
|
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: true, onPositionChange }),
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => result.current.onPointerDown(pointerEvent()));
|
act(() => result.current.onPointerDown(pointerEvent()));
|
||||||
@@ -56,7 +56,7 @@ describe("useNodeDrag", () => {
|
|||||||
it("divides pointer delta by zoom scale", () => {
|
it("divides pointer delta by zoom scale", () => {
|
||||||
const onPositionChange = vi.fn();
|
const onPositionChange = vi.fn();
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 2, onPositionChange }),
|
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 2, canDrag: true, onPositionChange }),
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => result.current.onPointerDown(pointerEvent()));
|
act(() => result.current.onPointerDown(pointerEvent()));
|
||||||
@@ -68,7 +68,7 @@ describe("useNodeDrag", () => {
|
|||||||
it("cancels drag cleanly on pointer cancel", () => {
|
it("cancels drag cleanly on pointer cancel", () => {
|
||||||
const onPositionChange = vi.fn();
|
const onPositionChange = vi.fn();
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }),
|
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: true, onPositionChange }),
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => result.current.onPointerDown(pointerEvent()));
|
act(() => result.current.onPointerDown(pointerEvent()));
|
||||||
@@ -78,4 +78,17 @@ describe("useNodeDrag", () => {
|
|||||||
act(() => result.current.onPointerCancel(pointerEvent({ clientX: 8, clientY: 0 })));
|
act(() => result.current.onPointerCancel(pointerEvent({ clientX: 8, clientY: 0 })));
|
||||||
expect(result.current.isDragging).toBe(false);
|
expect(result.current.isDragging).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores pointer interactions when dragging is disabled", () => {
|
||||||
|
const onPositionChange = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.onPointerDown(pointerEvent()));
|
||||||
|
act(() => result.current.onPointerMove(pointerEvent({ clientX: 8, clientY: 0 })));
|
||||||
|
|
||||||
|
expect(result.current.isDragging).toBe(false);
|
||||||
|
expect(onPositionChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface UseNodeDragOptions {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
position: GraphPosition;
|
position: GraphPosition;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
canDrag: boolean;
|
||||||
onPositionChange: (taskId: string, position: GraphPosition) => void;
|
onPositionChange: (taskId: string, position: GraphPosition) => void;
|
||||||
onDragStateChange?: (isDragging: boolean) => void;
|
onDragStateChange?: (isDragging: boolean) => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
@@ -19,7 +20,7 @@ interface PendingState {
|
|||||||
startPosition: GraphPosition;
|
startPosition: GraphPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragStateChange, onDragEnd }: UseNodeDragOptions) {
|
export function useNodeDrag({ taskId, position, scale, canDrag, onPositionChange, onDragStateChange, onDragEnd }: UseNodeDragOptions) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const pendingRef = useRef<PendingState | null>(null);
|
const pendingRef = useRef<PendingState | null>(null);
|
||||||
const positionRef = useRef(position);
|
const positionRef = useRef(position);
|
||||||
@@ -38,7 +39,7 @@ export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragS
|
|||||||
}, [onDragEnd, onDragStateChange]);
|
}, [onDragEnd, onDragStateChange]);
|
||||||
|
|
||||||
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||||
if (!event.isPrimary) return;
|
if (!event.isPrimary || !canDrag) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const currentTarget = event.currentTarget;
|
const currentTarget = event.currentTarget;
|
||||||
if (typeof currentTarget.setPointerCapture === "function") {
|
if (typeof currentTarget.setPointerCapture === "function") {
|
||||||
@@ -49,7 +50,7 @@ export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragS
|
|||||||
startPointer: { x: event.clientX, y: event.clientY },
|
startPointer: { x: event.clientX, y: event.clientY },
|
||||||
startPosition: positionRef.current,
|
startPosition: positionRef.current,
|
||||||
};
|
};
|
||||||
}, []);
|
}, [canDrag]);
|
||||||
|
|
||||||
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||||
const pending = pendingRef.current;
|
const pending = pendingRef.current;
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function useGraphInteraction() {
|
|||||||
const onPointerMove = useCallback((pointerId: number, point: PointerPoint, viewportWidth: number, viewportHeight: number) => {
|
const onPointerMove = useCallback((pointerId: number, point: PointerPoint, viewportWidth: number, viewportHeight: number) => {
|
||||||
if (pointersRef.current.has(pointerId)) pointersRef.current.set(pointerId, point);
|
if (pointersRef.current.has(pointerId)) pointersRef.current.set(pointerId, point);
|
||||||
|
|
||||||
if (pointersRef.current.size >= 2 && pinchRef.current) {
|
if (pointersRef.current.size === 2 && pinchRef.current) {
|
||||||
setAnimate(false);
|
setAnimate(false);
|
||||||
const [a, b] = Array.from(pointersRef.current.values());
|
const [a, b] = Array.from(pointersRef.current.values());
|
||||||
const distance = Math.hypot(a.x - b.x, a.y - b.y);
|
const distance = Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
@@ -215,8 +215,19 @@ export function useGraphInteraction() {
|
|||||||
|
|
||||||
const onPointerUp = useCallback((pointerId: number) => {
|
const onPointerUp = useCallback((pointerId: number) => {
|
||||||
pointersRef.current.delete(pointerId);
|
pointersRef.current.delete(pointerId);
|
||||||
if (pointersRef.current.size < 2) pinchRef.current = null;
|
if (pointersRef.current.size < 2) {
|
||||||
if (pointersRef.current.size === 0) dragStateRef.current = null;
|
pinchRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointersRef.current.size === 1) {
|
||||||
|
const [remainingPoint] = Array.from(pointersRef.current.values());
|
||||||
|
dragStateRef.current = { start: remainingPoint, panStart: panRef.current };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointersRef.current.size === 0) {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onWheelZoom = useCallback((
|
const onWheelZoom = useCallback((
|
||||||
|
|||||||
Reference in New Issue
Block a user