From 15822ed227a1d34480adef92695a5b95316bf396 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Wed, 19 Aug 2026 23:24:42 +0200 Subject: [PATCH] Add swipe navigation to the photo detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swipe left for older, right for newer, matching the existing arrow keys. The image tracks the finger and slides out on commit; the exit animation covers the page load, so this stays plain static-site navigation. The keyboard handler moves out of the template into static/photo-nav.js, which the existing //go:embed of static/ picks up with no generator changes. Swipes starting within 24px of a screen edge are left alone — that strip is the browser's own back-gesture. Only touch pointers are handled, so desktop drag-to-save still works, and the axis locks after 10px so vertical drags stay scrolls. With no neighbour that way the image rubber-bands instead of navigating. Also prefetch the neighbour medium JPEGs, not just their HTML, or the next page still blocks on a fresh image request. The keyboard handler now bails on modifier keys; it previously hijacked Alt+ArrowLeft, which is browser-back on Linux and Windows. --- static/photo-nav.js | 193 +++++++++++++++++++++++++++++++++++++++++++ static/style.css | 27 ++++++ templates/photo.html | 18 ++-- 3 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 static/photo-nav.js diff --git a/static/photo-nav.js b/static/photo-nav.js new file mode 100644 index 0000000..5c4b3ea --- /dev/null +++ b/static/photo-nav.js @@ -0,0 +1,193 @@ +// 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); +})(); diff --git a/static/style.css b/static/style.css index d9356be..76fb71f 100644 --- a/static/style.css +++ b/static/style.css @@ -248,6 +248,33 @@ body { height: auto; } +/* Swipe navigation. pan-y claims the horizontal axis for the gesture handler + while leaving vertical scrolling native. will-change is scoped to the two + transient states so the image is not permanently promoted to its own layer. */ + +.dimg-wrap { + touch-action: pan-y; +} + +.dimg-wrap.dragging { + user-select: none; + -webkit-user-select: none; +} + +.dimg-wrap.dragging img { + transition: none; + will-change: transform; +} + +.dimg-wrap.animating img { + transition: transform 180ms ease-out, opacity 180ms ease-out; + will-change: transform; +} + +@media (prefers-reduced-motion: reduce) { + .dimg-wrap.animating img { transition: none; } +} + .dsidebar { width: 220px; flex-shrink: 0; diff --git a/templates/photo.html b/templates/photo.html index a2d79eb..ad1482f 100644 --- a/templates/photo.html +++ b/templates/photo.html @@ -8,8 +8,10 @@ - {{if .Newer}}{{end}} - {{if .Older}}{{end}} + {{if .Newer}} + {{end}} + {{if .Older}} + {{end}}
@@ -43,16 +45,6 @@ © 2000–{{.Year}} {{.Author}}. All rights reserved. - +