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:
Fusion
2026-05-12 21:19:38 -07:00
committed by gsxdsm
parent db919df645
commit 55eeb818a5
6 changed files with 376 additions and 76 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Dependency graph: double-tap a task node on mobile to open task detail.

View File

@@ -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 {

View File

@@ -94,6 +94,7 @@ export function GraphTaskNode({
onPositionChange: onNodePositionChange, onPositionChange: onNodePositionChange,
onDragStateChange: onNodeDragStateChange, onDragStateChange: onNodeDragStateChange,
onDragEnd: onNodeDragEnd, onDragEnd: onNodeDragEnd,
onDoubleTap: () => onOpenDetail(task),
}); });
return ( return (

View File

@@ -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} />);

View File

@@ -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();
});
}); });

View File

@@ -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 };