// Svelte action — fires a `longpress` CustomEvent when the user holds the pointer
// down for `duration` ms without moving too far.
//
// Usage:
//
openMenu()} />
//
// Cancels on:
// - pointerup before duration elapses
// - pointermove > MOVE_THRESHOLD pixels (so a scroll attempt doesn't accidentally
// trigger the menu)
// - pointercancel / pointerleave / contextmenu
//
// When the long-press fires we also swallow the *next* click that comes from the same
// pointer-release. Without this, holding on a post card would (a) open the context
// sheet and then (b) trigger the post's onclick → lightbox at pointer-up. Same goes
// for the native contextmenu event on long-press desktop right-click.
import type { ActionReturn } from 'svelte/action';
const MOVE_THRESHOLD = 10; // px
export interface LongpressOptions {
duration?: number;
}
interface LongpressAttributes {
onlongpress?: (event: CustomEvent) => void;
}
export function longpress(
node: HTMLElement,
options: LongpressOptions = {}
): ActionReturn {
let duration = options.duration ?? 500;
let timer: ReturnType | null = null;
let startX = 0;
let startY = 0;
/** Set true when the long-press fires; the very next click is then swallowed. */
let suppressNextClick = false;
const cancel = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const fireLongpress = () => {
suppressNextClick = true;
// Reset the flag on the next event loop if no click followed — protects against
// the case where the user lifts their finger by lifting the device, no click.
setTimeout(() => {
suppressNextClick = false;
}, 400);
node.dispatchEvent(new CustomEvent('longpress'));
timer = null;
};
const onPointerDown = (e: PointerEvent) => {
startX = e.clientX;
startY = e.clientY;
suppressNextClick = false;
cancel();
timer = setTimeout(fireLongpress, duration);
};
const onPointerMove = (e: PointerEvent) => {
if (timer === null) return;
const dx = Math.abs(e.clientX - startX);
const dy = Math.abs(e.clientY - startY);
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) cancel();
};
const onClickCapture = (e: Event) => {
if (suppressNextClick) {
suppressNextClick = false;
e.stopPropagation();
e.preventDefault();
}
};
const onContextMenu = (e: Event) => {
// Block the desktop right-click menu when we've already fired our own.
if (suppressNextClick) e.preventDefault();
};
node.addEventListener('pointerdown', onPointerDown);
node.addEventListener('pointermove', onPointerMove);
node.addEventListener('pointerup', cancel);
node.addEventListener('pointerleave', cancel);
node.addEventListener('pointercancel', cancel);
node.addEventListener('contextmenu', onContextMenu);
// Capture phase so we beat the inner button's bubbling click handler.
node.addEventListener('click', onClickCapture, true);
return {
update(newOptions) {
duration = newOptions.duration ?? 500;
},
destroy() {
cancel();
node.removeEventListener('pointerdown', onPointerDown);
node.removeEventListener('pointermove', onPointerMove);
node.removeEventListener('pointerup', cancel);
node.removeEventListener('pointerleave', cancel);
node.removeEventListener('pointercancel', cancel);
node.removeEventListener('contextmenu', onContextMenu);
node.removeEventListener('click', onClickCapture, true);
}
};
}