refactor double click handler to add quicker single click

This commit is contained in:
jeffvli
2025-11-15 14:07:11 -08:00
parent 81d3d2e620
commit 60cc564743
+75 -18
View File
@@ -1,45 +1,102 @@
import { useCallback, useEffect, useRef } from 'react';
export const useDoubleClick = ({
latency = 180,
doubleClickLatency = 300,
onDoubleClick = () => null,
onSingleClick = () => null,
singleClickLatency = 20,
}: {
latency?: number;
doubleClickLatency?: number;
onDoubleClick?: (e: any) => void;
onSingleClick?: (e: any) => void;
singleClickLatency?: number;
}) => {
const clickCountRef = useRef(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const singleClickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const doubleClickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const singleClickFiredRef = useRef(false);
const lastClickEventRef = useRef<any>(null);
// Use latency for backward compatibility, but prefer doubleClickLatency
const effectiveDoubleClickLatency = doubleClickLatency;
const effectiveSingleClickLatency = singleClickLatency ?? 50;
const clearSingleClick = useCallback(() => {
if (singleClickTimeoutRef.current) {
clearTimeout(singleClickTimeoutRef.current);
singleClickTimeoutRef.current = null;
}
}, []);
const handleClick = useCallback(
(e: any) => {
clickCountRef.current += 1;
lastClickEventRef.current = e;
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (clickCountRef.current === 1) {
// First click: fire single click optimistically after short delay
singleClickFiredRef.current = false;
// Set a new timeout to determine if it's a single or double click
timeoutRef.current = setTimeout(() => {
if (clickCountRef.current === 1) {
onSingleClick(e);
} else if (clickCountRef.current === 2) {
onDoubleClick(e);
// Set double-click detection window first
doubleClickTimeoutRef.current = setTimeout(() => {
clickCountRef.current = 0;
singleClickFiredRef.current = false;
}, effectiveDoubleClickLatency);
// Fire single click after delay (defaults to 0 for immediate response)
if (effectiveSingleClickLatency > 0) {
singleClickTimeoutRef.current = setTimeout(() => {
// Only fire if still a single click and double click hasn't been detected
if (clickCountRef.current === 1 && !singleClickFiredRef.current) {
singleClickFiredRef.current = true;
onSingleClick(lastClickEventRef.current);
}
}, effectiveSingleClickLatency);
} else {
// Fire immediately if latency is 0
// Note: If double click comes immediately after, both may fire
// For best UX, use a small delay (e.g., 50ms) instead of 0
singleClickFiredRef.current = true;
onSingleClick(lastClickEventRef.current);
}
} else if (clickCountRef.current === 2) {
// Second click detected within double-click latency
// Cancel single click if it hasn't fired yet
if (!singleClickFiredRef.current) {
clearSingleClick();
}
// Fire double click
onDoubleClick(e);
// Reset state
clickCountRef.current = 0;
}, latency);
singleClickFiredRef.current = false;
// Clear double-click timeout
if (doubleClickTimeoutRef.current) {
clearTimeout(doubleClickTimeoutRef.current);
doubleClickTimeoutRef.current = null;
}
}
},
[latency, onDoubleClick, onSingleClick],
[
effectiveDoubleClickLatency,
effectiveSingleClickLatency,
onDoubleClick,
onSingleClick,
clearSingleClick,
],
);
// Cleanup timeout on unmount
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
if (singleClickTimeoutRef.current) {
clearTimeout(singleClickTimeoutRef.current);
}
if (doubleClickTimeoutRef.current) {
clearTimeout(doubleClickTimeoutRef.current);
}
};
}, []);