Update web/static/js/swipe-nav.js
This commit is contained in:
parent
9ff40f1354
commit
cb895c50c4
@ -1,117 +1,170 @@
|
||||
/* Swipe/Keyboard navigation for Entry View
|
||||
- Respects localStorage 'swipeNavEnabled' (default ON)
|
||||
- Touch swipe on mobile, ArrowLeft/ArrowRight on keyboards
|
||||
- Edge protection and input-safe
|
||||
/* 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 () {
|
||||
// Feature flag (client-side). Default ON.
|
||||
let swipeEnabled = true;
|
||||
try { swipeEnabled = (localStorage.getItem('swipeNavEnabled') !== 'false'); } catch (e) {}
|
||||
// Bail if not on an entry page with forms
|
||||
const prevForm = document.getElementById("navPrevForm");
|
||||
const nextForm = document.getElementById("navNextForm");
|
||||
if (!prevForm && !nextForm) return;
|
||||
|
||||
// Only attach if enabled
|
||||
if (!swipeEnabled) return;
|
||||
// Prefer a dedicated swipe zone if present; fall back to body
|
||||
const zone = document.getElementById("swipeZone") || document.body;
|
||||
|
||||
// Forms to submit for previous/next (your existing IDs)
|
||||
const prevForm = document.getElementById('navPrevForm');
|
||||
const nextForm = document.getElementById('navNextForm');
|
||||
// State
|
||||
let tracking = false;
|
||||
let startX = 0, startY = 0, startT = 0, pointerId = null;
|
||||
let didNavigate = false;
|
||||
let cooldown = false;
|
||||
|
||||
// Create toast + hints lazily to keep HTML clean
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-swipe-error';
|
||||
toast.style.cssText = [
|
||||
'position:fixed', 'left:50%', 'bottom:16px', 'transform:translateX(-50%)',
|
||||
'padding:10px 14px', 'border-radius:10px', 'background:#111827', 'color:#fff',
|
||||
'box-shadow:0 6px 20px rgba(0,0,0,.25)', 'opacity:0', 'pointer-events:none',
|
||||
'transition:opacity .25s', 'z-index:1200', 'font-size:14px'
|
||||
].join(';');
|
||||
toast.textContent = "Can't navigate here.";
|
||||
document.body.appendChild(toast);
|
||||
// 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
|
||||
|
||||
function showToast() {
|
||||
toast.style.opacity = '1';
|
||||
setTimeout(() => { toast.style.opacity = '0'; }, 1200);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Only show swipe hints on touch devices, once per session
|
||||
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||||
let hintL, hintR;
|
||||
if (isTouch) {
|
||||
try {
|
||||
if (!sessionStorage.getItem('swipeHintShown')) {
|
||||
sessionStorage.setItem('swipeHintShown', '1');
|
||||
|
||||
const base = 'position:fixed;width:34px;height:34px;display:flex;align-items:center;justify-content:center;' +
|
||||
'background:rgba(0,0,0,.35);color:#fff;border-radius:999px;box-shadow:0 6px 20px rgba(0,0,0,.25);' +
|
||||
'z-index:1100;opacity:0;transform:scale(.9);transition:opacity .25s,transform .25s;top:45%';
|
||||
|
||||
hintL = document.createElement('div');
|
||||
hintL.style.cssText = base + ';left:10px;';
|
||||
hintL.textContent = '‹';
|
||||
document.body.appendChild(hintL);
|
||||
|
||||
hintR = document.createElement('div');
|
||||
hintR.style.cssText = base + ';right:10px;';
|
||||
hintR.textContent = '›';
|
||||
document.body.appendChild(hintR);
|
||||
|
||||
setTimeout(() => { hintL.style.opacity = '1'; hintR.style.opacity = '1'; hintL.style.transform = 'scale(1)'; hintR.style.transform = 'scale(1)'; }, 300);
|
||||
setTimeout(() => { hintL.style.opacity = '0'; hintR.style.opacity = '0'; }, 2200);
|
||||
}
|
||||
} catch (e) {}
|
||||
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);
|
||||
}
|
||||
|
||||
function navigate(dir) {
|
||||
try {
|
||||
if (dir < 0 && prevForm) { prevForm.submit(); return true; }
|
||||
if (dir > 0 && nextForm) { nextForm.submit(); return true; }
|
||||
} catch (e) {}
|
||||
showToast();
|
||||
return false;
|
||||
// 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
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!swipeEnabled) return;
|
||||
const tag = (e.target && e.target.tagName) || '';
|
||||
if (/INPUT|TEXTAREA|SELECT/.test(tag)) return;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); navigate(1); }
|
||||
});
|
||||
function onMove(e) {
|
||||
if (!tracking || didNavigate || cooldown) return;
|
||||
|
||||
// Touch swipe (edge-protected, vertical tolerance)
|
||||
if (isTouch) {
|
||||
let startX = 0, startY = 0, tracking = false;
|
||||
const EDGE = 20, THRESH = 40;
|
||||
const area = document.querySelector('main.page') || document.body;
|
||||
const pt = usePointer ? e : (e.touches && e.touches[0]);
|
||||
if (!pt) return;
|
||||
if (usePointer && e.pointerId !== pointerId) return;
|
||||
|
||||
function isInteractive(el) {
|
||||
return !!(el.closest && el.closest('input, textarea, select, button, a, [contenteditable], .no-swipe'));
|
||||
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();
|
||||
}
|
||||
|
||||
area.addEventListener('touchstart', function (e) {
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
if (isInteractive(e.target)) return;
|
||||
startX = t.clientX; startY = t.clientY; tracking = true;
|
||||
}, { passive: true });
|
||||
const dx = pt.clientX - startX;
|
||||
const dy = pt.clientY - startY;
|
||||
const dt = now() - startT;
|
||||
|
||||
area.addEventListener('touchmove', function (e) {
|
||||
if (!tracking) return;
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
const dx = t.clientX - startX;
|
||||
const dy = t.clientY - startY;
|
||||
const ang = degrees(dx, dy);
|
||||
const horizontalEnough = ang <= MAX_ANGLE;
|
||||
|
||||
if (Math.abs(dx) > THRESH && Math.abs(dy) < 28) {
|
||||
// Edge-protection: avoid accidental from extreme edges
|
||||
if (startX < EDGE && dx > 0) { tracking = false; return; }
|
||||
if (startX > (window.innerWidth - EDGE) && dx < 0) { tracking = false; return; }
|
||||
e.preventDefault();
|
||||
navigate(dx > 0 ? -1 : 1);
|
||||
tracking = false;
|
||||
// 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);
|
||||
}
|
||||
}, { passive: false });
|
||||
if (e.cancelable) e.preventDefault();
|
||||
}
|
||||
|
||||
area.addEventListener('touchend', function () { tracking = false; }, { passive: true });
|
||||
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);
|
||||
})();
|
||||
})();
|
||||
Loading…
Reference in New Issue
Block a user