88 lines
2.5 KiB
TypeScript
88 lines
2.5 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Distinguishes a tap from a press-and-hold on the same control.
|
|
*
|
|
* Built on Pointer Events so one implementation covers touch, mouse and pen.
|
|
* Two details make it behave:
|
|
*
|
|
* - A hold that fires must swallow the click that follows it, or the tap
|
|
* action runs straight after the hold action.
|
|
* - Small finger movement during a hold is normal, so the press only
|
|
* cancels once it drifts past a slop threshold — otherwise holds fail
|
|
* constantly on touch.
|
|
*
|
|
* Timing and slop match the chat bubbles' gesture handler so a hold feels the
|
|
* same everywhere in the app.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
|
|
const LONG_PRESS_MS = 450;
|
|
const LONG_PRESS_SLOP_PX = 8;
|
|
|
|
export function useLongPress({
|
|
onClick,
|
|
onLongPress,
|
|
disabled = false,
|
|
}: {
|
|
onClick: () => void;
|
|
onLongPress: () => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const start = useRef<{ x: number; y: number } | null>(null);
|
|
const fired = useRef(false);
|
|
|
|
const clear = useCallback(() => {
|
|
if (timer.current) clearTimeout(timer.current);
|
|
timer.current = null;
|
|
start.current = null;
|
|
}, []);
|
|
|
|
// A hold can end with the pointer outside the button, or with the component
|
|
// unmounting behind whatever the hold opened; either would leak the timer.
|
|
useEffect(() => clear, [clear]);
|
|
|
|
const onPointerDown = (e: React.PointerEvent) => {
|
|
if (disabled) return;
|
|
fired.current = false;
|
|
start.current = { x: e.clientX, y: e.clientY };
|
|
timer.current = setTimeout(() => {
|
|
fired.current = true;
|
|
clear();
|
|
onLongPress();
|
|
}, LONG_PRESS_MS);
|
|
};
|
|
|
|
const onPointerMove = (e: React.PointerEvent) => {
|
|
if (!start.current) return;
|
|
const dx = e.clientX - start.current.x;
|
|
const dy = e.clientY - start.current.y;
|
|
if (Math.abs(dx) > LONG_PRESS_SLOP_PX || Math.abs(dy) > LONG_PRESS_SLOP_PX) {
|
|
clear();
|
|
}
|
|
};
|
|
|
|
return {
|
|
onPointerDown,
|
|
onPointerMove,
|
|
onPointerUp: clear,
|
|
onPointerCancel: clear,
|
|
onPointerLeave: clear,
|
|
// Suppress iOS's press-and-hold callout, which would otherwise cover the
|
|
// sheet the hold just opened.
|
|
onContextMenu: (e: React.MouseEvent) => {
|
|
if (fired.current) e.preventDefault();
|
|
},
|
|
onClick: () => {
|
|
// The hold already acted; the browser's trailing click is not a tap.
|
|
if (fired.current) {
|
|
fired.current = false;
|
|
return;
|
|
}
|
|
if (!disabled) onClick();
|
|
},
|
|
};
|
|
}
|