// 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); } }; }