// Navigation for the single photo page: arrow keys and touch swipe. // // Both read their destinations from the neighbour links the template already // renders, so a photo with no newer/older sibling simply has nothing to find // and the gesture rubber-bands instead of navigating. If this file fails to // load the page still works — the links remain clickable. (function () { 'use strict'; var newer = document.querySelector('[data-nav="newer"]'); var older = document.querySelector('[data-nav="older"]'); // ── Keyboard ────────────────────────────────────────────────────────── document.addEventListener('keydown', function (e) { // Alt+Arrow is back/forward in most browsers; don't shadow it. if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; if (e.target instanceof Element && e.target.matches('input, textarea')) return; if (e.key === 'ArrowLeft' && newer) location.href = newer.href; if (e.key === 'ArrowRight' && older) location.href = older.href; }); // ── Swipe ───────────────────────────────────────────────────────────── var wrap = document.querySelector('.dimg-wrap'); var img = wrap && wrap.querySelector('img'); if (!wrap || !img) return; var EDGE_DEAD_ZONE = 24; // px from either screen edge, left to the browser var AXIS_LOCK = 10; // px of travel before we decide scroll vs swipe var MIN_TRAVEL = 60; // px needed to commit var TRAVEL_RATIO = 0.18; // ...or this fraction of the viewport, whichever is more var MIN_VELOCITY = 0.5; // px/ms; a fast flick commits below MIN_TRAVEL var VELOCITY_WINDOW = 30; // ms between velocity samples var RESISTANCE = 0.35; // drag factor when there is no neighbour that way var FADE = 0.35; // how far the image dims across a full-width drag var EXIT_MS = 180; // keep in sync with the CSS transition duration var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; var pointerId = null; var startX = 0, startY = 0; var prevX = 0, prevT = 0; var dx = 0; var locked = false; // horizontal drag in progress var abandoned = false; // decided this gesture was a vertical scroll var leaving = false; // committed, animating out var swiped = false; // suppress the click that trails a swipe // Swipe left (content moves left, the thing to the right comes in) → older. // Matches ArrowRight above. function targetFor(delta) { return delta < 0 ? older : newer; } function paint() { var shift = targetFor(dx) ? dx : dx * RESISTANCE; var progress = Math.min(Math.abs(shift) / wrap.clientWidth, 1); img.style.transform = 'translate3d(' + shift + 'px, 0, 0)'; img.style.opacity = String(1 - progress * FADE); } function release() { pointerId = null; locked = false; abandoned = false; dx = 0; } function snapBack() { wrap.classList.remove('dragging'); wrap.classList.add('animating'); img.style.transform = ''; img.style.opacity = ''; window.setTimeout(function () { wrap.classList.remove('animating'); }, EXIT_MS); release(); } function commit(target) { if (reduceMotion) { location.href = target.href; return; } leaving = true; wrap.classList.remove('dragging'); wrap.classList.add('animating'); img.style.transform = 'translate3d(' + (dx < 0 ? -wrap.clientWidth : wrap.clientWidth) + 'px, 0, 0)'; img.style.opacity = '0'; var went = false; function go() { if (went) return; went = true; location.href = target.href; } // transitionend does not fire if the tab is backgrounded mid-animation. img.addEventListener('transitionend', go, { once: true }); window.setTimeout(go, EXIT_MS + 70); release(); } wrap.addEventListener('pointerdown', function (e) { // A click can only follow a pointerdown, so clearing here is enough to // keep a genuine tap working after an earlier swipe snapped back. swiped = false; if (leaving) return; if (pointerId !== null) return; // already tracking a finger if (e.pointerType !== 'touch') return; // leave mouse drag-to-save alone if (!e.isPrimary) return; // second finger: pinch, not swipe if (e.clientX < EDGE_DEAD_ZONE || e.clientX > window.innerWidth - EDGE_DEAD_ZONE) return; pointerId = e.pointerId; startX = prevX = e.clientX; startY = e.clientY; prevT = e.timeStamp; dx = 0; locked = false; abandoned = false; }); wrap.addEventListener('pointermove', function (e) { if (e.pointerId !== pointerId || abandoned) return; dx = e.clientX - startX; var dy = e.clientY - startY; if (!locked) { if (Math.abs(dx) < AXIS_LOCK && Math.abs(dy) < AXIS_LOCK) return; if (Math.abs(dy) >= Math.abs(dx)) { abandoned = true; // vertical: hand the gesture back to the scroller return; } locked = true; wrap.classList.remove('animating'); wrap.classList.add('dragging'); wrap.setPointerCapture(pointerId); } if (e.cancelable) e.preventDefault(); if (e.timeStamp - prevT > VELOCITY_WINDOW) { prevX = e.clientX; prevT = e.timeStamp; } paint(); }, { passive: false }); wrap.addEventListener('pointerup', function (e) { if (e.pointerId !== pointerId) return; if (!locked) { release(); return; } swiped = true; var target = targetFor(dx); var elapsed = e.timeStamp - prevT; var velocity = elapsed > 0 ? (e.clientX - prevX) / elapsed : 0; var far = Math.abs(dx) > Math.max(MIN_TRAVEL, window.innerWidth * TRAVEL_RATIO); // A flick only counts if it is still moving the way the drag went. var flicked = Math.abs(velocity) > MIN_VELOCITY && (velocity < 0) === (dx < 0); if (target && (far || flicked)) { commit(target); } else { snapBack(); } }); wrap.addEventListener('pointercancel', function (e) { if (e.pointerId !== pointerId) return; if (locked) snapBack(); else release(); }); // The image sits inside an to the full-size original. Without this a // swipe that ends over it opens that link in a new tab. wrap.addEventListener('click', function (e) { if (!swiped) return; e.preventDefault(); e.stopPropagation(); }, true); })();