Illustrations/web/static/js/swipe-nav.js

170 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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