// Svelte action — the single source of truth for tap gestures on a media element. // Fires a `doubletap` CustomEvent when two pointerup events occur within `interval` // ms on roughly the same spot (Instagram-style "double-tap to like"), and a // `singletap` CustomEvent once `interval` has elapsed with no second tap. Owning // both gestures in one place means a component no longer juggles its own debounce // timer alongside a separate onclick handler. // // Native `dblclick` exists, but on iOS Safari it also zooms the page; gating on // pointer events lets us preventDefault selectively and avoid the zoom. Keyboard // activation still arrives as a normal `click` (detail === 0) — handle that on the // element itself for an immediate, latency-free open. import type { ActionReturn } from 'svelte/action'; const INTERVAL_MS = 300; const MOVE_THRESHOLD = 12; export interface DoubletapOptions { interval?: number; } interface DoubletapAttributes { 'ondoubletap'?: (event: CustomEvent) => void; 'onsingletap'?: (event: CustomEvent) => void; } export function doubletap( node: HTMLElement, options: DoubletapOptions = {} ): ActionReturn { let interval = options.interval ?? INTERVAL_MS; let lastTime = 0; let lastX = 0; let lastY = 0; let singleTimer: ReturnType | null = null; const clearSingle = () => { if (singleTimer) { clearTimeout(singleTimer); singleTimer = null; } }; const onPointerUp = (e: PointerEvent) => { const now = performance.now(); const dx = Math.abs(e.clientX - lastX); const dy = Math.abs(e.clientY - lastY); if (now - lastTime < interval && dx < MOVE_THRESHOLD && dy < MOVE_THRESHOLD) { clearSingle(); // the pending single-tap was actually the first of a double e.preventDefault(); node.dispatchEvent(new CustomEvent('doubletap')); lastTime = 0; // reset so a triple-tap doesn't re-fire return; } lastTime = now; lastX = e.clientX; lastY = e.clientY; // Defer the single-tap action until we're sure no second tap follows. clearSingle(); singleTimer = setTimeout(() => { singleTimer = null; node.dispatchEvent(new CustomEvent('singletap')); }, interval); }; node.addEventListener('pointerup', onPointerUp); return { update(newOptions) { interval = newOptions.interval ?? INTERVAL_MS; }, destroy() { clearSingle(); node.removeEventListener('pointerup', onPointerUp); } }; }