Files
tools/vue/toolkit/src/composables/sensors/onLongPress/index.ts
T
robonen ab6d8f6ce0
Publish to NPM / Check version changes and publish (push) Failing after 10m34s
build: bump new versions
2026-06-18 02:57:03 +07:00

228 lines
6.4 KiB
TypeScript

import type { VoidFunction } from '@robonen/stdlib';
import { isFunction } from '@robonen/stdlib';
import { computed } from 'vue';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
const DEFAULT_DELAY = 500;
const DEFAULT_THRESHOLD = 10;
export interface OnLongPressPosition {
x: number;
y: number;
}
export interface OnLongPressModifiers {
/**
* Call `stopPropagation` on the pointer events.
*
* @default false
*/
stop?: boolean;
/**
* Register the underlying listeners with `{ once: true }`.
*
* @default false
*/
once?: boolean;
/**
* Call `preventDefault` on the pointer events. Forces non-passive listeners.
*
* @default false
*/
prevent?: boolean;
/**
* Register the underlying listeners in the capture phase.
*
* @default false
*/
capture?: boolean;
/**
* Only react when the event originates from the target element itself
* (i.e. ignore events that bubbled up from descendants).
*
* @default false
*/
self?: boolean;
}
export interface OnLongPressOptions {
/**
* Time in milliseconds the pointer must be held before the handler fires.
* A function receives the originating `pointerdown` event and returns the delay.
*
* @default 500
*/
delay?: number | ((event: PointerEvent) => number);
/**
* Pointer-event behaviour flags.
*/
modifiers?: OnLongPressModifiers;
/**
* Maximum pointer travel (in pixels) tolerated before the press is cancelled.
* Pass `false` to disable movement cancellation entirely.
*
* @default 10
*/
distanceThreshold?: number | false;
/**
* Invoked on pointer release. Receives the press duration (ms), the distance
* travelled (px), whether the press qualified as a long press, and the event.
*/
onMouseUp?: (duration: number, distance: number, isLongPress: boolean, event: PointerEvent) => void;
}
export type OnLongPressReturn = VoidFunction;
/**
* @name onLongPress
* @category Sensors
* @description Directive-like helper that invokes a handler after a sustained long press
* on a target element. Movement beyond `distanceThreshold` cancels the press, and an
* optional `onMouseUp` callback reports the press duration, distance, and long-press status.
* Listeners are passive by default and registered via `useEventListener` for automatic cleanup.
*
* @param {MaybeComputedElementRef} target Element to watch for long presses
* @param {(event: PointerEvent) => void} handler Callback fired once the press passes `delay`
* @param {OnLongPressOptions} [options={}] Long-press configuration
* @returns {OnLongPressReturn} Stop handle that removes all listeners
*
* @example
* onLongPress(el, () => console.log('held!'));
*
* @example
* // Custom delay, cancel-on-move disabled, release reporting
* const stop = onLongPress(el, onHeld, {
* delay: 800,
* distanceThreshold: false,
* modifiers: { prevent: true },
* onMouseUp: (duration, distance, isLongPress) => {
* if (!isLongPress) onTap();
* },
* });
*
* @since 0.0.14
*/
export function onLongPress(
target: MaybeComputedElementRef,
handler: (event: PointerEvent) => void,
options: OnLongPressOptions = {},
): OnLongPressReturn {
const { delay = DEFAULT_DELAY, modifiers = {}, distanceThreshold = DEFAULT_THRESHOLD, onMouseUp } = options;
const { self: selfOnly = false, prevent = false, stop: stopProp = false, capture = false, once = false } = modifiers;
// Resolve the element once and cache it; reactive targets re-trigger this getter
// and the underlying useEventListener watch re-binds automatically.
const elementRef = computed(() => unrefElement(target));
// Hoisted as a function-valued delay only when the caller supplies one,
// so the common numeric path skips the per-press branch entirely.
const getDelay = isFunction(delay)
? delay
: (): number => delay;
let timeout: ReturnType<typeof setTimeout> | undefined;
let posStart: OnLongPressPosition | undefined;
let startTimestamp: number | undefined;
let hasLongPressed = false;
function clear(): void {
if (timeout !== undefined) {
clearTimeout(timeout);
timeout = undefined;
}
posStart = undefined;
startTimestamp = undefined;
hasLongPressed = false;
}
function isForeignTarget(event: PointerEvent): boolean {
return selfOnly && event.target !== elementRef.value;
}
function applyModifiers(event: PointerEvent): void {
if (prevent)
event.preventDefault();
if (stopProp)
event.stopPropagation();
}
function onDown(event: PointerEvent): void {
if (isForeignTarget(event))
return;
clear();
applyModifiers(event);
posStart = { x: event.x, y: event.y };
startTimestamp = event.timeStamp;
timeout = setTimeout(() => {
hasLongPressed = true;
handler(event);
}, getDelay(event));
}
function onMove(event: PointerEvent): void {
if (isForeignTarget(event) || !posStart || distanceThreshold === false)
return;
applyModifiers(event);
const dx = event.x - posStart.x;
const dy = event.y - posStart.y;
const distance = Math.hypot(dx, dy);
if (distance >= distanceThreshold)
clear();
}
function onRelease(event: PointerEvent): void {
const _posStart = posStart;
const _startTimestamp = startTimestamp;
const _hasLongPressed = hasLongPressed;
clear();
if (!onMouseUp || !_posStart || _startTimestamp === undefined)
return;
if (isForeignTarget(event))
return;
applyModifiers(event);
const dx = event.x - _posStart.x;
const dy = event.y - _posStart.y;
const distance = Math.hypot(dx, dy);
onMouseUp(event.timeStamp - _startTimestamp, distance, _hasLongPressed, event);
}
// Passive unless the caller intends to preventDefault, in which case the
// listener must be active for the browser to honour it.
const listenerOptions: AddEventListenerOptions = {
capture,
once,
passive: !prevent,
};
const cleanups = [
useEventListener(elementRef, 'pointerdown', onDown, listenerOptions),
useEventListener(elementRef, 'pointermove', onMove, listenerOptions),
useEventListener(elementRef, ['pointerup', 'pointerleave'], onRelease, listenerOptions),
];
return (): void => {
clear();
cleanups.forEach(stop => stop());
};
}