Add swipe navigation to the photo detail page
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.
This commit is contained in:
@@ -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 <a> 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);
|
||||||
|
})();
|
||||||
@@ -248,6 +248,33 @@ body {
|
|||||||
height: auto;
|
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 {
|
.dsidebar {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
+5
-13
@@ -8,8 +8,10 @@
|
|||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
|
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
|
||||||
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
|
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
|
||||||
{{if .Newer}}<link rel="prefetch" href="/photo/{{.Newer.Slug}}/">{{end}}
|
{{if .Newer}}<link rel="prefetch" href="/photo/{{.Newer.Slug}}/">
|
||||||
{{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/">{{end}}
|
<link rel="prefetch" as="image" href="/{{.Newer.Medium}}">{{end}}
|
||||||
|
{{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/">
|
||||||
|
<link rel="prefetch" as="image" href="/{{.Older.Medium}}">{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="photo-page">
|
<div class="photo-page">
|
||||||
@@ -43,16 +45,6 @@
|
|||||||
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script src="/static/photo-nav.js" defer></script>
|
||||||
(function () {
|
|
||||||
const newer = document.querySelector('[data-nav="newer"]');
|
|
||||||
const older = document.querySelector('[data-nav="older"]');
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.target.matches('input, textarea')) return;
|
|
||||||
if (e.key === 'ArrowLeft' && newer) location.href = newer.href;
|
|
||||||
if (e.key === 'ArrowRight' && older) location.href = older.href;
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user