/* swipe-nav.js Mobile/desktop swipe & keyboard navigation for entry view. - One navigation per gesture - Edge protection: horizontal intent + distance + max duration - Cooldown prevents multi-advance on long swipes - Keyboard: Left/Right arrows (when not typing) */ (function () { // Bail if not on an entry page with forms const prevForm = document.getElementById("navPrevForm"); const nextForm = document.getElementById("navNextForm"); if (!prevForm && !nextForm) return; // Prefer a dedicated swipe zone if present; fall back to body const zone = document.getElementById("swipeZone") || document.body; // State let tracking = false; let startX = 0, startY = 0, startT = 0, pointerId = null; let didNavigate = false; let cooldown = false; // Tunables const MIN_X = 48; // px horizontal distance const MAX_ANGLE = 30; // deg off pure horizontal const MAX_MS = 650; // must complete within this time const COOLDOWN_MS = 450; // after we navigate, ignore further moves briefly // Helpers function degrees(dx, dy) { const a = Math.atan2(Math.abs(dy), Math.abs(dx)) * (180 / Math.PI); return a; // 0 = pure horizontal } function now() { return performance.now ? performance.now() : Date.now(); } function reset() { tracking = false; pointerId = null; startX = startY = 0; startT = 0; didNavigate = false; } function submit(form) { if (!form || cooldown) return; cooldown = true; form.submit(); // If navigation is prevented (e.g., disabled button), release cooldown after a tick setTimeout(() => { cooldown = false; }, COOLDOWN_MS); } // Pointer events if supported (best across devices). Fallback to touch. const usePointer = "PointerEvent" in window; function onDown(e) { if (cooldown) return; // Ignore right click / secondary if (usePointer && e.pointerType === "mouse" && e.button !== 0) return; tracking = true; didNavigate = false; const pt = usePointer ? e : (e.touches && e.touches[0]); if (!pt) return; pointerId = usePointer ? e.pointerId : pt.identifier; startX = pt.clientX; startY = pt.clientY; startT = now(); // Once we suspect horizontal motion, we'll call preventDefault in move } function onMove(e) { if (!tracking || didNavigate || cooldown) return; const pt = usePointer ? e : (e.touches && e.touches[0]); if (!pt) return; if (usePointer && e.pointerId !== pointerId) return; const dx = pt.clientX - startX; const dy = pt.clientY - startY; // If user is panning mostly horizontally, stop the browser from doing a horizontal scroll/jiggle if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) { // In touch events you must preventDefault during move to suppress scroll if (e.cancelable) e.preventDefault(); } } function onUp(e) { if (!tracking || didNavigate || cooldown) { reset(); return; } let pt; if (usePointer) { if (e.pointerId !== pointerId) return reset(); pt = e; } else { // On touchend, touches[] is empty; use changedTouches[] pt = (e.changedTouches && e.changedTouches[0]) || null; if (!pt) return reset(); } const dx = pt.clientX - startX; const dy = pt.clientY - startY; const dt = now() - startT; const ang = degrees(dx, dy); const horizontalEnough = ang <= MAX_ANGLE; // Must be a deliberate, reasonably quick horizontal swipe if (Math.abs(dx) >= MIN_X && horizontalEnough && dt <= MAX_MS) { didNavigate = true; if (dx < 0) { // left swipe -> NEXT const btn = nextForm && nextForm.querySelector("button[disabled]"); if (!btn) submit(nextForm); } else { // right swipe -> PREV const btn = prevForm && prevForm.querySelector("button[disabled]"); if (!btn) submit(prevForm); } if (e.cancelable) e.preventDefault(); } reset(); } function onCancel() { reset(); } if (usePointer) { zone.addEventListener("pointerdown", onDown, { passive: true }); zone.addEventListener("pointermove", onMove, { passive: false }); zone.addEventListener("pointerup", onUp, { passive: false }); zone.addEventListener("pointercancel", onCancel, { passive: true }); zone.addEventListener("pointerleave", onCancel, { passive: true }); } else { zone.addEventListener("touchstart", onDown, { passive: true }); zone.addEventListener("touchmove", onMove, { passive: false }); zone.addEventListener("touchend", onUp, { passive: false }); zone.addEventListener("touchcancel", onCancel, { passive: true }); } // Keyboard support (left/right arrows) – ignore if typing document.addEventListener("keydown", (e) => { if (cooldown) return; const tag = (e.target && e.target.tagName) || ""; const typing = /INPUT|TEXTAREA|SELECT/.test(tag); if (typing) return; if (e.key === "ArrowRight") { const btn = nextForm && nextForm.querySelector("button[disabled]"); if (!btn) { e.preventDefault(); submit(nextForm); } } else if (e.key === "ArrowLeft") { const btn = prevForm && prevForm.querySelector("button[disabled]"); if (!btn) { e.preventDefault(); submit(prevForm); } } }); // Defensive CSS: lock horizontal gestures to our handler only inside the zone (function injectCSS() { const css = ` #swipeZone{ touch-action: pan-y; overscroll-behavior-x: none; overflow-x: hidden; } html, body{ overscroll-behavior-x: none; } `; const el = document.createElement("style"); el.textContent = css; document.head.appendChild(el); })(); })();