Files
fluxer/fluxer_app/src/components/LongPressable.tsx
2026-02-17 12:22:36 +00:00

318 lines
9.8 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
const LONG_PRESS_MOVEMENT_THRESHOLD = 10;
const SWIPE_VELOCITY_THRESHOLD = 0.4;
const MIN_VELOCITY_SAMPLES = 2;
const MAX_VELOCITY_SAMPLE_AGE = 100;
const PRESS_HIGHLIGHT_DELAY_MS = 100;
const LONG_PRESS_DURATION_MS = 500;
const HAS_POINTER_EVENTS = 'PointerEvent' in window;
interface VelocitySample {
x: number;
y: number;
timestamp: number;
}
type LongPressEvent = React.PointerEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>;
interface LongPressableProps extends React.HTMLAttributes<HTMLDivElement> {
delay?: number;
onLongPress?: (event: LongPressEvent) => void;
disabled?: boolean;
pressedClassName?: string;
onPressStateChange?: (isPressed: boolean) => void;
}
export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps>(
(
{delay = LONG_PRESS_DURATION_MS, onLongPress, disabled, pressedClassName, onPressStateChange, ...rest},
forwardedRef,
) => {
const innerRef = useRef<HTMLDivElement | null>(null);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pressStartPos = useRef<{x: number; y: number} | null>(null);
const pointerIdRef = useRef<number | null>(null);
const storedEvent = useRef<LongPressEvent | null>(null);
const velocitySamples = useRef<Array<VelocitySample>>([]);
const isPressIntent = useRef(false);
const [isPressed, setIsPressed] = useState(false);
useImperativeHandle(forwardedRef, () => innerRef.current as HTMLDivElement);
const setPressed = useCallback(
(pressed: boolean) => {
setIsPressed(pressed);
onPressStateChange?.(pressed);
},
[onPressStateChange],
);
const calculateVelocity = useCallback((): number => {
const samples = velocitySamples.current;
if (samples.length < MIN_VELOCITY_SAMPLES) return 0;
const now = performance.now();
const recentSamples = samples.filter((s) => now - s.timestamp < MAX_VELOCITY_SAMPLE_AGE);
if (recentSamples.length < MIN_VELOCITY_SAMPLES) return 0;
const first = recentSamples[0];
const last = recentSamples[recentSamples.length - 1];
const dt = last.timestamp - first.timestamp;
if (dt === 0) return 0;
const dx = last.x - first.x;
const dy = last.y - first.y;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance / dt;
}, []);
const clearTimer = useCallback(() => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
highlightTimer.current = null;
}
if (pointerIdRef.current !== null && innerRef.current?.releasePointerCapture) {
try {
innerRef.current.releasePointerCapture(pointerIdRef.current);
} catch {}
}
pointerIdRef.current = null;
pressStartPos.current = null;
storedEvent.current = null;
velocitySamples.current = [];
isPressIntent.current = false;
setPressed(false);
}, [setPressed]);
const {
onPointerDown: userOnPointerDown,
onPointerMove: userOnPointerMove,
onPointerUp: userOnPointerUp,
onPointerCancel: userOnPointerCancel,
onTouchStart: userOnTouchStart,
onTouchMove: userOnTouchMove,
onTouchEnd: userOnTouchEnd,
onTouchCancel: userOnTouchCancel,
className,
...restWithoutPointer
} = rest;
const startLongPressTimer = useCallback(
(event: LongPressEvent, x: number, y: number, pointerId?: number, capturePointer = false) => {
if (disabled || !onLongPress) return;
clearTimer();
pressStartPos.current = {x, y};
pointerIdRef.current = pointerId ?? null;
velocitySamples.current = [{x, y, timestamp: performance.now()}];
isPressIntent.current = true;
if (capturePointer && pointerId != null && innerRef.current?.setPointerCapture) {
try {
innerRef.current.setPointerCapture(pointerId);
} catch {}
}
storedEvent.current = event;
highlightTimer.current = setTimeout(() => {
if (isPressIntent.current) {
setPressed(true);
}
highlightTimer.current = null;
}, PRESS_HIGHLIGHT_DELAY_MS);
longPressTimer.current = setTimeout(() => {
if (!disabled && onLongPress && storedEvent.current && isPressIntent.current) {
onLongPress(storedEvent.current);
setPressed(false);
}
clearTimer();
}, delay);
},
[clearTimer, delay, disabled, onLongPress, setPressed],
);
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
userOnPointerDown?.(event);
if (disabled || !onLongPress || event.button !== 0) return;
if (event.pointerType !== 'touch') return;
startLongPressTimer(event, event.clientX, event.clientY, event.pointerId, true);
},
[disabled, onLongPress, startLongPressTimer, userOnPointerDown],
);
const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
userOnPointerMove?.(event);
if (pointerIdRef.current !== event.pointerId) return;
const startPos = pressStartPos.current;
if (!startPos) return;
velocitySamples.current.push({x: event.clientX, y: event.clientY, timestamp: performance.now()});
if (velocitySamples.current.length > 10) {
velocitySamples.current = velocitySamples.current.slice(-10);
}
const deltaX = Math.abs(event.clientX - startPos.x);
const deltaY = Math.abs(event.clientY - startPos.y);
if (deltaX > LONG_PRESS_MOVEMENT_THRESHOLD || deltaY > LONG_PRESS_MOVEMENT_THRESHOLD) {
clearTimer();
return;
}
const velocity = calculateVelocity();
if (velocity > SWIPE_VELOCITY_THRESHOLD) {
clearTimer();
}
},
[clearTimer, calculateVelocity, userOnPointerMove],
);
const handlePointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (pointerIdRef.current === event.pointerId) {
clearTimer();
}
userOnPointerUp?.(event);
},
[clearTimer, userOnPointerUp],
);
const handlePointerCancel = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (pointerIdRef.current === event.pointerId) {
clearTimer();
}
userOnPointerCancel?.(event);
},
[clearTimer, userOnPointerCancel],
);
const handleTouchStart = useCallback(
(event: React.TouchEvent<HTMLDivElement>) => {
userOnTouchStart?.(event);
if (disabled || !onLongPress) return;
const touch = event.touches[0];
if (!touch) return;
startLongPressTimer(event, touch.clientX, touch.clientY);
},
[disabled, onLongPress, startLongPressTimer, userOnTouchStart],
);
const handleTouchMove = useCallback(
(event: React.TouchEvent<HTMLDivElement>) => {
userOnTouchMove?.(event);
if (!pressStartPos.current) return;
const touch = event.touches[0];
if (!touch) return;
velocitySamples.current.push({x: touch.clientX, y: touch.clientY, timestamp: performance.now()});
if (velocitySamples.current.length > 10) {
velocitySamples.current = velocitySamples.current.slice(-10);
}
const deltaX = Math.abs(touch.clientX - pressStartPos.current.x);
const deltaY = Math.abs(touch.clientY - pressStartPos.current.y);
if (deltaX > LONG_PRESS_MOVEMENT_THRESHOLD || deltaY > LONG_PRESS_MOVEMENT_THRESHOLD) {
clearTimer();
return;
}
const velocity = calculateVelocity();
if (velocity > SWIPE_VELOCITY_THRESHOLD) {
clearTimer();
}
},
[clearTimer, calculateVelocity, userOnTouchMove],
);
const handleTouchEnd = useCallback(
(event: React.TouchEvent<HTMLDivElement>) => {
clearTimer();
userOnTouchEnd?.(event);
},
[clearTimer, userOnTouchEnd],
);
const handleTouchCancel = useCallback(
(event: React.TouchEvent<HTMLDivElement>) => {
clearTimer();
userOnTouchCancel?.(event);
},
[clearTimer, userOnTouchCancel],
);
useEffect(() => {
const handleScroll = () => {
if (isPressIntent.current) {
clearTimer();
}
};
window.addEventListener('scroll', handleScroll, {capture: true, passive: true});
return () => {
window.removeEventListener('scroll', handleScroll, {capture: true});
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
}
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
};
}, [clearTimer]);
const finalClassName = isPressed && pressedClassName ? `${className ?? ''} ${pressedClassName}`.trim() : className;
return (
<div
ref={innerRef}
className={finalClassName}
onPointerDown={HAS_POINTER_EVENTS ? handlePointerDown : undefined}
onPointerMove={HAS_POINTER_EVENTS ? handlePointerMove : undefined}
onPointerUp={HAS_POINTER_EVENTS ? handlePointerUp : undefined}
onPointerCancel={HAS_POINTER_EVENTS ? handlePointerCancel : undefined}
onTouchStart={!HAS_POINTER_EVENTS ? handleTouchStart : undefined}
onTouchMove={!HAS_POINTER_EVENTS ? handleTouchMove : undefined}
onTouchEnd={!HAS_POINTER_EVENTS ? handleTouchEnd : undefined}
onTouchCancel={!HAS_POINTER_EVENTS ? handleTouchCancel : undefined}
{...restWithoutPointer}
/>
);
},
);
LongPressable.displayName = 'LongPressable';