Files
cinny/src/app/hooks/useLongPress.ts
T

104 lines
3.4 KiB
TypeScript
Raw Normal View History

import { useCallback, useRef } from 'react';
import { useMediaQuery } from './useMediaQuery';
const LONG_PRESS_MS = 450;
const MOVE_TOLERANCE_PX = 10;
/**
* [Gitea #166] Long-press detection for touch screens. Fires `onLongPress`
* with the finger's viewport position after 450 ms of holding still (< 10 px
* of movement); a scroll or a lift cancels it. Android also fires
* `contextmenu` for a long-press — the returned `suppressContextMenu` ref is
* true briefly after we fired so the caller can drop that duplicate. Handlers
* are no-ops when the device has no coarse pointer.
*/
export function useLongPress(onLongPress: (x: number, y: number) => void) {
const coarse = useMediaQuery('(pointer: coarse)');
const timer = useRef<number | undefined>(undefined);
const start = useRef<{ x: number; y: number } | null>(null);
const fired = useRef(false);
// [Gitea #199] The element being pressed carries `data-pressing` (set on the
// DOM directly, no re-render) so iOS can't start a text selection under
// the finger; see `[data-touch-actions]` in room/message/styles.css.ts.
const pressed = useRef<HTMLElement | null>(null);
const stopTimer = useCallback(() => {
if (timer.current !== undefined) window.clearTimeout(timer.current);
timer.current = undefined;
start.current = null;
}, []);
// The finger is up (or the gesture was cancelled): only now drop
// `data-pressing`, so the system selection can't start under a finger that
// is still down after the sheet opened.
const clear = useCallback(() => {
stopTimer();
pressed.current?.removeAttribute('data-pressing');
pressed.current = null;
}, [stopTimer]);
const onTouchStart = useCallback(
(evt: React.TouchEvent) => {
if (!coarse || evt.touches.length !== 1) return;
const t = evt.touches[0];
start.current = { x: t.clientX, y: t.clientY };
fired.current = false;
pressed.current = evt.currentTarget as HTMLElement;
pressed.current.setAttribute('data-pressing', '');
timer.current = window.setTimeout(() => {
const s = start.current;
stopTimer();
if (!s) return;
fired.current = true;
window.setTimeout(() => {
fired.current = false;
}, 1000);
onLongPress(s.x, s.y);
}, LONG_PRESS_MS);
},
[coarse, stopTimer, onLongPress],
);
const onTouchMove = useCallback(
(evt: React.TouchEvent) => {
const s = start.current;
if (!s) return;
const t = evt.touches[0];
if (
!t ||
Math.abs(t.clientX - s.x) > MOVE_TOLERANCE_PX ||
Math.abs(t.clientY - s.y) > MOVE_TOLERANCE_PX
) {
clear();
}
},
[clear],
);
// Lifting the finger after a long-press would synthesise mousedown/click at
// that point — on whatever the sheet just put there. Swallow it.
const onTouchEnd = useCallback(
(evt: React.TouchEvent) => {
if (fired.current && evt.cancelable) evt.preventDefault();
clear();
},
[clear],
);
return {
coarse,
handlers: coarse
? {
// [Gitea #199] Marks the element for the iOS hardening CSS.
'data-touch-actions': '',
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel: clear,
}
: {},
/** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */
suppressContextMenu: fired,
};
}