feat(FN-4225): add mobile double-tap to select graph task nodes
Adds touch double-tap detection to the node drag hook, enabling mobile-friendly interactions on the graph canvas, with coverage in the node drag and GraphTaskNode test suites. Fusion-Task-Id: FN-4225
This commit is contained in:
5
.changeset/fn-4225-mobile-double-tap.md
Normal file
5
.changeset/fn-4225-mobile-double-tap.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Dependency graph: double-tap a task node on mobile to open task detail.
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
border-color var(--transition-fast);
|
border-color var(--transition-fast);
|
||||||
border: var(--btn-border-width) solid transparent;
|
border: var(--btn-border-width) solid transparent;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-task-node:hover {
|
.graph-task-node:hover {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export function GraphTaskNode({
|
|||||||
onPositionChange: onNodePositionChange,
|
onPositionChange: onNodePositionChange,
|
||||||
onDragStateChange: onNodeDragStateChange,
|
onDragStateChange: onNodeDragStateChange,
|
||||||
onDragEnd: onNodeDragEnd,
|
onDragEnd: onNodeDragEnd,
|
||||||
|
onDoubleTap: () => onOpenDetail(task),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function createProps(task: Task) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,6 +231,72 @@ describe("GraphTaskNode", () => {
|
|||||||
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
|
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("touch double-tap opens task detail exactly once", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const props = createProps(createTask());
|
||||||
|
render(<GraphTaskNode {...props} />);
|
||||||
|
|
||||||
|
const node = screen.getByTestId("graph-task-node-FN-TEST");
|
||||||
|
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||||
|
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||||
|
vi.advanceTimersByTime(120);
|
||||||
|
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 24, clientY: 32 });
|
||||||
|
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 24, clientY: 32 });
|
||||||
|
|
||||||
|
expect(props.onOpenDetail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("touch taps outside the double-tap window do not open task detail", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const props = createProps(createTask());
|
||||||
|
render(<GraphTaskNode {...props} />);
|
||||||
|
|
||||||
|
const node = screen.getByTestId("graph-task-node-FN-TEST");
|
||||||
|
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||||
|
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||||
|
vi.advanceTimersByTime(320);
|
||||||
|
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||||
|
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||||
|
|
||||||
|
expect(props.onOpenDetail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("touch drag gestures do not open task detail on pointer up", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const props = createProps(createTask());
|
||||||
|
render(<GraphTaskNode {...props} isSelected={true} />);
|
||||||
|
|
||||||
|
const node = screen.getByTestId("graph-task-node-FN-TEST");
|
||||||
|
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||||
|
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||||
|
vi.advanceTimersByTime(120);
|
||||||
|
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||||
|
fireEvent.pointerMove(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 28, clientY: 30 });
|
||||||
|
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 28, clientY: 30 });
|
||||||
|
|
||||||
|
expect(props.onOpenDetail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mouse pointer taps do not trigger the touch double-tap path", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const props = createProps(createTask());
|
||||||
|
render(<GraphTaskNode {...props} />);
|
||||||
|
|
||||||
|
const node = screen.getByTestId("graph-task-node-FN-TEST");
|
||||||
|
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "mouse", clientX: 20, clientY: 30 });
|
||||||
|
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "mouse", clientX: 20, clientY: 30 });
|
||||||
|
vi.advanceTimersByTime(120);
|
||||||
|
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "mouse", clientX: 22, clientY: 30 });
|
||||||
|
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "mouse", clientX: 22, clientY: 30 });
|
||||||
|
|
||||||
|
expect(props.onOpenDetail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("single click on active indicator surface does not open task detail", () => {
|
it("single click on active indicator surface does not open task detail", () => {
|
||||||
const props = createProps(createTask({ column: "in-progress", status: "executing" }));
|
const props = createProps(createTask({ column: "in-progress", status: "executing" }));
|
||||||
const { container } = render(<GraphTaskNode {...props} />);
|
const { container } = render(<GraphTaskNode {...props} />);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { __internal, useNodeDrag } from "../hooks/useNodeDrag";
|
import { __internal, useNodeDrag } from "../hooks/useNodeDrag";
|
||||||
|
|
||||||
function pointerEvent(overrides: Partial<PointerEvent> = {}) {
|
function pointerEvent(overrides: Partial<PointerEvent> = {}) {
|
||||||
@@ -12,15 +12,23 @@ function pointerEvent(overrides: Partial<PointerEvent> = {}) {
|
|||||||
return {
|
return {
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
pointerId: 1,
|
pointerId: 1,
|
||||||
|
pointerType: "mouse",
|
||||||
clientX: 0,
|
clientX: 0,
|
||||||
clientY: 0,
|
clientY: 0,
|
||||||
|
timeStamp: 0,
|
||||||
|
defaultPrevented: false,
|
||||||
stopPropagation: vi.fn(),
|
stopPropagation: vi.fn(),
|
||||||
|
preventDefault: vi.fn(),
|
||||||
currentTarget: target,
|
currentTarget: target,
|
||||||
...overrides,
|
...overrides,
|
||||||
} as unknown as React.PointerEvent<HTMLElement>;
|
} as unknown as React.PointerEvent<HTMLElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useNodeDrag", () => {
|
describe("useNodeDrag", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("transitions pending to dragging and back on pointer up", () => {
|
it("transitions pending to dragging and back on pointer up", () => {
|
||||||
const onPositionChange = vi.fn();
|
const onPositionChange = vi.fn();
|
||||||
const onDragStateChange = vi.fn();
|
const onDragStateChange = vi.fn();
|
||||||
@@ -79,16 +87,99 @@ describe("useNodeDrag", () => {
|
|||||||
expect(result.current.isDragging).toBe(false);
|
expect(result.current.isDragging).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores pointer interactions when dragging is disabled", () => {
|
it("ignores position updates when dragging is disabled", () => {
|
||||||
const onPositionChange = vi.fn();
|
const onPositionChange = vi.fn();
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange }),
|
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange }),
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => result.current.onPointerDown(pointerEvent()));
|
act(() => result.current.onPointerDown(pointerEvent({ pointerType: "touch", timeStamp: 100 })));
|
||||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: 8, clientY: 0 })));
|
act(() => result.current.onPointerMove(pointerEvent({ pointerType: "touch", clientX: 8, clientY: 0, timeStamp: 120 })));
|
||||||
|
|
||||||
expect(result.current.isDragging).toBe(false);
|
expect(result.current.isDragging).toBe(false);
|
||||||
expect(onPositionChange).not.toHaveBeenCalled();
|
expect(onPositionChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fires onDoubleTap for qualifying touch taps and suppresses the follow-up click", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const onDoubleTap = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange: vi.fn(), onDoubleTap }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstDown = pointerEvent({ pointerId: 1, pointerType: "touch", clientX: 12, clientY: 16 });
|
||||||
|
const firstUp = pointerEvent({ pointerId: 1, pointerType: "touch", clientX: 12, clientY: 16, currentTarget: firstDown.currentTarget });
|
||||||
|
const secondDown = pointerEvent({ pointerId: 2, pointerType: "touch", clientX: 18, clientY: 20 });
|
||||||
|
const secondUp = pointerEvent({ pointerId: 2, pointerType: "touch", clientX: 18, clientY: 20, currentTarget: secondDown.currentTarget });
|
||||||
|
|
||||||
|
act(() => result.current.onPointerDown(firstDown));
|
||||||
|
act(() => result.current.onPointerUp(firstUp));
|
||||||
|
act(() => vi.advanceTimersByTime(120));
|
||||||
|
act(() => result.current.onPointerDown(secondDown));
|
||||||
|
act(() => result.current.onPointerUp(secondUp));
|
||||||
|
|
||||||
|
expect(onDoubleTap).toHaveBeenCalledTimes(1);
|
||||||
|
expect(secondUp.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const clickEvent = {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopPropagation: vi.fn(),
|
||||||
|
} as unknown as React.MouseEvent<HTMLElement>;
|
||||||
|
act(() => result.current.onClickCapture(clickEvent));
|
||||||
|
expect(clickEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(clickEvent.stopPropagation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire onDoubleTap when the second tap is too late", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const onDoubleTap = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange: vi.fn(), onDoubleTap }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 1, pointerType: "touch" })));
|
||||||
|
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 1, pointerType: "touch" })));
|
||||||
|
act(() => vi.advanceTimersByTime(320));
|
||||||
|
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 2, pointerType: "touch" })));
|
||||||
|
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 2, pointerType: "touch" })));
|
||||||
|
|
||||||
|
expect(onDoubleTap).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire onDoubleTap for mouse pointer events", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const onDoubleTap = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange: vi.fn(), onDoubleTap }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 1, pointerType: "mouse" })));
|
||||||
|
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 1, pointerType: "mouse" })));
|
||||||
|
act(() => vi.advanceTimersByTime(120));
|
||||||
|
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 2, pointerType: "mouse" })));
|
||||||
|
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 2, pointerType: "mouse" })));
|
||||||
|
|
||||||
|
expect(onDoubleTap).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire onDoubleTap after movement reaches the drag threshold", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const onDoubleTap = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange: vi.fn(), onDoubleTap }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 1, pointerType: "touch", clientX: 0, clientY: 0 })));
|
||||||
|
act(() => result.current.onPointerMove(pointerEvent({ pointerId: 1, pointerType: "touch", clientX: __internal.DRAG_THRESHOLD_PX, clientY: 0 })));
|
||||||
|
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 1, pointerType: "touch", clientX: __internal.DRAG_THRESHOLD_PX, clientY: 0 })));
|
||||||
|
act(() => vi.advanceTimersByTime(120));
|
||||||
|
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 2, pointerType: "touch", clientX: 0, clientY: 0 })));
|
||||||
|
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 2, pointerType: "touch", clientX: 0, clientY: 0 })));
|
||||||
|
|
||||||
|
expect(onDoubleTap).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent }
|
|||||||
import type { GraphPosition } from "../types.js";
|
import type { GraphPosition } from "../types.js";
|
||||||
|
|
||||||
const DRAG_THRESHOLD_PX = 4;
|
const DRAG_THRESHOLD_PX = 4;
|
||||||
|
const DOUBLE_TAP_MAX_DELAY_MS = 300;
|
||||||
|
const DOUBLE_TAP_MAX_DISTANCE_PX = 24;
|
||||||
|
|
||||||
interface UseNodeDragOptions {
|
interface UseNodeDragOptions {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -12,88 +14,218 @@ interface UseNodeDragOptions {
|
|||||||
onPositionChange: (taskId: string, position: GraphPosition) => void;
|
onPositionChange: (taskId: string, position: GraphPosition) => void;
|
||||||
onDragStateChange?: (isDragging: boolean) => void;
|
onDragStateChange?: (isDragging: boolean) => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
|
onDoubleTap?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingState {
|
interface PendingState {
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
|
pointerType: string;
|
||||||
startPointer: { x: number; y: number };
|
startPointer: { x: number; y: number };
|
||||||
startPosition: GraphPosition;
|
startPosition: GraphPosition;
|
||||||
|
defaultPrevented: boolean;
|
||||||
|
movedBeyondThreshold: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNodeDrag({ taskId, position, scale, canDrag, onPositionChange, onDragStateChange, onDragEnd }: UseNodeDragOptions) {
|
interface TapState {
|
||||||
|
timeStamp: number;
|
||||||
|
point: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNodeDrag({
|
||||||
|
taskId,
|
||||||
|
position,
|
||||||
|
scale,
|
||||||
|
canDrag,
|
||||||
|
onPositionChange,
|
||||||
|
onDragStateChange,
|
||||||
|
onDragEnd,
|
||||||
|
onDoubleTap,
|
||||||
|
}: 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);
|
||||||
const suppressClickRef = useRef(false);
|
const suppressClickRef = useRef(false);
|
||||||
|
const lastTapRef = useRef<TapState | null>(null);
|
||||||
|
const dragStateRef = useRef(false);
|
||||||
|
|
||||||
positionRef.current = position;
|
positionRef.current = position;
|
||||||
|
dragStateRef.current = isDragging;
|
||||||
|
|
||||||
const endDrag = useCallback((dragging: boolean) => {
|
const resetTapState = useCallback(() => {
|
||||||
pendingRef.current = null;
|
lastTapRef.current = null;
|
||||||
setIsDragging(false);
|
}, []);
|
||||||
if (dragging) {
|
|
||||||
onDragStateChange?.(false);
|
const endDrag = useCallback(
|
||||||
onDragEnd?.();
|
(dragging: boolean) => {
|
||||||
|
pendingRef.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
dragStateRef.current = false;
|
||||||
|
if (dragging) {
|
||||||
|
onDragStateChange?.(false);
|
||||||
|
onDragEnd?.();
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
resetTapState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDragEnd, onDragStateChange, resetTapState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLElement>) => {
|
||||||
|
if (!event.isPrimary) return;
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
resetTapState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (lastTapRef.current && now - lastTapRef.current.timeStamp > DOUBLE_TAP_MAX_DELAY_MS) {
|
||||||
|
resetTapState();
|
||||||
|
}
|
||||||
|
if (canDrag) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
const currentTarget = event.currentTarget;
|
||||||
|
if (canDrag && typeof currentTarget.setPointerCapture === "function") {
|
||||||
|
currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
pendingRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
pointerType: event.pointerType,
|
||||||
|
startPointer: { x: event.clientX, y: event.clientY },
|
||||||
|
startPosition: positionRef.current,
|
||||||
|
defaultPrevented: event.defaultPrevented,
|
||||||
|
movedBeyondThreshold: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[canDrag, resetTapState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLElement>) => {
|
||||||
|
const pending = pendingRef.current;
|
||||||
|
if (!pending || pending.pointerId !== event.pointerId) return;
|
||||||
|
if (canDrag) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = event.clientX - pending.startPointer.x;
|
||||||
|
const deltaY = event.clientY - pending.startPointer.y;
|
||||||
|
const distance = Math.hypot(deltaX, deltaY);
|
||||||
|
|
||||||
|
if (distance >= DRAG_THRESHOLD_PX) {
|
||||||
|
pending.movedBeyondThreshold = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canDrag) {
|
||||||
|
if (pending.movedBeyondThreshold) {
|
||||||
|
resetTapState();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dragStateRef.current && distance >= DRAG_THRESHOLD_PX) {
|
||||||
|
setIsDragging(true);
|
||||||
|
dragStateRef.current = true;
|
||||||
|
onDragStateChange?.(true);
|
||||||
|
resetTapState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distance < DRAG_THRESHOLD_PX) return;
|
||||||
|
|
||||||
|
const safeScale = scale > 0 ? scale : 1;
|
||||||
|
onPositionChange(taskId, {
|
||||||
|
x: pending.startPosition.x + deltaX / safeScale,
|
||||||
|
y: pending.startPosition.y + deltaY / safeScale,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[canDrag, onDragStateChange, onPositionChange, resetTapState, scale, taskId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const maybeHandleDoubleTap = useCallback(
|
||||||
|
(_event: ReactPointerEvent<HTMLElement>, pending: PendingState) => {
|
||||||
|
if (!onDoubleTap || pending.defaultPrevented || pending.movedBeyondThreshold) {
|
||||||
|
if (Date.now() - (lastTapRef.current?.timeStamp ?? 0) > DOUBLE_TAP_MAX_DELAY_MS) {
|
||||||
|
resetTapState();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (pending.pointerType === "mouse") {
|
||||||
|
resetTapState();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentTap = {
|
||||||
|
timeStamp: Date.now(),
|
||||||
|
point: { x: pending.startPointer.x, y: pending.startPointer.y },
|
||||||
|
};
|
||||||
|
const previousTap = lastTapRef.current;
|
||||||
|
if (!previousTap) {
|
||||||
|
lastTapRef.current = currentTap;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const elapsed = currentTap.timeStamp - previousTap.timeStamp;
|
||||||
|
if (elapsed > DOUBLE_TAP_MAX_DELAY_MS) {
|
||||||
|
lastTapRef.current = currentTap;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const distance = Math.hypot(currentTap.point.x - previousTap.point.x, currentTap.point.y - previousTap.point.y);
|
||||||
|
if (distance > DOUBLE_TAP_MAX_DISTANCE_PX) {
|
||||||
|
lastTapRef.current = currentTap;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
suppressClickRef.current = true;
|
suppressClickRef.current = true;
|
||||||
}
|
resetTapState();
|
||||||
}, [onDragEnd, onDragStateChange]);
|
onDoubleTap();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[onDoubleTap, resetTapState],
|
||||||
|
);
|
||||||
|
|
||||||
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
const onPointerUp = useCallback(
|
||||||
if (!event.isPrimary || !canDrag) return;
|
(event: ReactPointerEvent<HTMLElement>) => {
|
||||||
event.stopPropagation();
|
const pending = pendingRef.current;
|
||||||
const currentTarget = event.currentTarget;
|
if (!pending || pending.pointerId !== event.pointerId) return;
|
||||||
if (typeof currentTarget.setPointerCapture === "function") {
|
if (canDrag) {
|
||||||
currentTarget.setPointerCapture(event.pointerId);
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
pendingRef.current = {
|
if (
|
||||||
pointerId: event.pointerId,
|
canDrag &&
|
||||||
startPointer: { x: event.clientX, y: event.clientY },
|
typeof event.currentTarget.hasPointerCapture === "function" &&
|
||||||
startPosition: positionRef.current,
|
event.currentTarget.hasPointerCapture(event.pointerId) &&
|
||||||
};
|
typeof event.currentTarget.releasePointerCapture === "function"
|
||||||
}, [canDrag]);
|
) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
const didDrag = dragStateRef.current;
|
||||||
const pending = pendingRef.current;
|
const didDoubleTap = !didDrag && maybeHandleDoubleTap(event, pending);
|
||||||
if (!pending || pending.pointerId !== event.pointerId) return;
|
if (didDoubleTap) {
|
||||||
event.stopPropagation();
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
endDrag(didDrag);
|
||||||
|
},
|
||||||
|
[canDrag, endDrag, maybeHandleDoubleTap],
|
||||||
|
);
|
||||||
|
|
||||||
const deltaX = event.clientX - pending.startPointer.x;
|
const onPointerCancel = useCallback(
|
||||||
const deltaY = event.clientY - pending.startPointer.y;
|
(event: ReactPointerEvent<HTMLElement>) => {
|
||||||
const distance = Math.hypot(deltaX, deltaY);
|
const pending = pendingRef.current;
|
||||||
|
if (!pending || pending.pointerId !== event.pointerId) return;
|
||||||
if (!isDragging && distance >= DRAG_THRESHOLD_PX) {
|
if (canDrag) {
|
||||||
setIsDragging(true);
|
event.stopPropagation();
|
||||||
onDragStateChange?.(true);
|
}
|
||||||
}
|
if (
|
||||||
|
canDrag &&
|
||||||
if (distance < DRAG_THRESHOLD_PX) return;
|
typeof event.currentTarget.hasPointerCapture === "function" &&
|
||||||
|
event.currentTarget.hasPointerCapture(event.pointerId) &&
|
||||||
const safeScale = scale > 0 ? scale : 1;
|
typeof event.currentTarget.releasePointerCapture === "function"
|
||||||
onPositionChange(taskId, {
|
) {
|
||||||
x: pending.startPosition.x + deltaX / safeScale,
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
y: pending.startPosition.y + deltaY / safeScale,
|
}
|
||||||
});
|
resetTapState();
|
||||||
}, [isDragging, onDragStateChange, onPositionChange, scale, taskId]);
|
endDrag(dragStateRef.current);
|
||||||
|
},
|
||||||
const onPointerUp = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
[canDrag, endDrag, resetTapState],
|
||||||
const pending = pendingRef.current;
|
);
|
||||||
if (!pending || pending.pointerId !== event.pointerId) return;
|
|
||||||
event.stopPropagation();
|
|
||||||
if (typeof event.currentTarget.hasPointerCapture === "function" && event.currentTarget.hasPointerCapture(event.pointerId) && typeof event.currentTarget.releasePointerCapture === "function") {
|
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
endDrag(isDragging);
|
|
||||||
}, [endDrag, isDragging]);
|
|
||||||
|
|
||||||
const onPointerCancel = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
|
||||||
const pending = pendingRef.current;
|
|
||||||
if (!pending || pending.pointerId !== event.pointerId) return;
|
|
||||||
event.stopPropagation();
|
|
||||||
if (typeof event.currentTarget.hasPointerCapture === "function" && event.currentTarget.hasPointerCapture(event.pointerId) && typeof event.currentTarget.releasePointerCapture === "function") {
|
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
endDrag(isDragging);
|
|
||||||
}, [endDrag, isDragging]);
|
|
||||||
|
|
||||||
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
|
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
|
||||||
if (!suppressClickRef.current) return;
|
if (!suppressClickRef.current) return;
|
||||||
@@ -102,14 +234,17 @@ export function useNodeDrag({ taskId, position, scale, canDrag, onPositionChange
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(
|
||||||
isDragging,
|
() => ({
|
||||||
onPointerDown,
|
isDragging,
|
||||||
onPointerMove,
|
onPointerDown,
|
||||||
onPointerUp,
|
onPointerMove,
|
||||||
onPointerCancel,
|
onPointerUp,
|
||||||
onClickCapture,
|
onPointerCancel,
|
||||||
}), [isDragging, onClickCapture, onPointerCancel, onPointerDown, onPointerMove, onPointerUp]);
|
onClickCapture,
|
||||||
|
}),
|
||||||
|
[isDragging, onClickCapture, onPointerCancel, onPointerDown, onPointerMove, onPointerUp],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __internal = { DRAG_THRESHOLD_PX };
|
export const __internal = { DRAG_THRESHOLD_PX, DOUBLE_TAP_MAX_DELAY_MS, DOUBLE_TAP_MAX_DISTANCE_PX };
|
||||||
|
|||||||
Reference in New Issue
Block a user