/* * 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 . */ 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 | React.TouchEvent; interface LongPressableProps extends React.HTMLAttributes { delay?: number; onLongPress?: (event: LongPressEvent) => void; disabled?: boolean; pressedClassName?: string; onPressStateChange?: (isPressed: boolean) => void; } export const LongPressable = React.forwardRef( ( {delay = LONG_PRESS_DURATION_MS, onLongPress, disabled, pressedClassName, onPressStateChange, ...rest}, forwardedRef, ) => { const innerRef = useRef(null); const longPressTimer = useRef | null>(null); const highlightTimer = useRef | null>(null); const pressStartPos = useRef<{x: number; y: number} | null>(null); const pointerIdRef = useRef(null); const storedEvent = useRef(null); const velocitySamples = useRef>([]); 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) => { 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) => { 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) => { if (pointerIdRef.current === event.pointerId) { clearTimer(); } userOnPointerUp?.(event); }, [clearTimer, userOnPointerUp], ); const handlePointerCancel = useCallback( (event: React.PointerEvent) => { if (pointerIdRef.current === event.pointerId) { clearTimer(); } userOnPointerCancel?.(event); }, [clearTimer, userOnPointerCancel], ); const handleTouchStart = useCallback( (event: React.TouchEvent) => { 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) => { 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) => { clearTimer(); userOnTouchEnd?.(event); }, [clearTimer, userOnTouchEnd], ); const handleTouchCancel = useCallback( (event: React.TouchEvent) => { 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 (
); }, ); LongPressable.displayName = 'LongPressable';