gallery/harnesses/gtauri/frontend/dist/index.html
Greg Pomerantz 695e2417cc gtauri viewer: mid-commit touch/key takes over the swipe (chained swipes)
A second swipe while the new image was still sliding in got dropped
(pointerdown bailed while the commit tween was in flight). Now a
touch — or arrow key — during a commit takes it over: the commit
finishes instantly (roles swap at the settle positions, same
dimension-adoption path as natural completion) and the input starts a
fresh gesture on the landed image, so rapid successive swipes chain
through multiple pictures. A plain tap during a commit simply settles
it early.

Verified on the emulator: two 100 ms flicks 120 ms apart (the second
lands mid-commit) end on the exact same image — pixel-identical — as
two deliberate slow swipes.
2026-11-03 13:47:39 -05:00

743 lines
26 KiB
HTML
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.

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Gallery</title>
<style>
/* Chromium owns: layout, compositor-driven scroll, image cache,
viewer compositing. Rust owns: scan, SQLite, thumbnail decode and
the on-disk cache behind gallery://. */
/*
VIRTUALIZED GRID
The document contains ONE spacer div (full scroll height) and ONE
band of recycled cells (~2.5 screens + buffer). Total DOM nodes:
~80 for a 10,000-photo library — layout cost is independent of
library size, so launch renders the first screen as soon as the
WebView boots + the first thumbnails arrive.
*/
html, body {
margin: 0; padding: 0;
background: #0a0a0c;
overscroll-behavior: none;
-webkit-user-select: none; user-select: none;
}
body { overflow-y: scroll; padding-top: 26px; }
#status {
position: fixed; top: 0; left: 0; right: 0; height: 26px;
display: flex; align-items: center; padding: 0 12px;
background: rgba(10,10,12,0.87);
color: #cfd2da; font: 14px/26px system-ui, sans-serif;
z-index: 10;
}
#grid { position: relative; }
#spacer { /* scroll length driver — no content */ }
#band {
position: absolute; left: 0; right: 0; top: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 4px;
will-change: transform; /* compositor-positioned, zero layout */
}
.cell {
overflow: hidden;
background: #1c1c22;
border-radius: 2px;
}
.cell img {
width: 100%; height: 100%;
object-fit: cover;
display: block;
}
/* ---- viewer ---- */
#viewer {
position: fixed; inset: 0;
background: #000;
z-index: 100;
display: none;
touch-action: none;
}
#viewer.open { display: block; }
#vimg, #vimg2 {
position: absolute; left: 50%; top: 50%;
max-width: none;
transform-origin: 0 0;
will-change: transform;
/* compositor-only animation: pan/zoom never re-rasterizes */
}
#vclose {
position: fixed; top: 8px; right: 8px;
width: 44px; height: 44px;
border-radius: 22px;
background: rgba(20,20,24,0.7);
color: #fff; font: 26px/40px system-ui;
text-align: center;
z-index: 101;
touch-action: none;
}
#vload {
position: fixed; left: 0; right: 0; top: 50%;
color: #888; text-align: center;
font: 14px system-ui;
z-index: 99;
}
</style>
</head>
<body>
<div id="status"><span id="stext">starting…</span></div>
<div id="grid">
<div id="spacer"></div>
<div id="band"></div>
</div>
<div id="viewer">
<div id="vload"></div>
<img id="vimg2" alt="">
<img id="vimg" alt="">
<div id="vclose">×</div>
</div>
<script>
"use strict";
const grid = document.getElementById("grid");
const spacer = document.getElementById("spacer");
const band = document.getElementById("band");
const stext = document.getElementById("stext");
const viewer = document.getElementById("viewer");
const vimg = document.getElementById("vimg");
const vimg2 = document.getElementById("vimg2");
const vload = document.getElementById("vload");
const COLS = 3, GAP = 4, PAD = 4, BUFFER_ROWS = 12;
let ids = []; // display order (mtime desc)
const idSet = new Set(); // id membership (O(1) stale-id checks on 404)
let total = 0, scanning = false, lastSig = "";
let cellW = 0, rowH = 0, rows = 0;
const loadedSet = new Set(); // ids whose real thumb has rendered
const imgById = new Map(); // id -> <img> currently bound in the band
const pool = []; // recycled .cell elements (band children)
function statusLine() {
stext.textContent = total + " photos" +
(scanning ? " • scanning…" : "") +
(loadedSet.size < total ? " (" + loadedSet.size + ")" : "");
}
/* ---------------- layout metrics (uniform 3-col squares) ------------- */
function layoutMetrics() {
const w = grid.clientWidth || innerWidth;
cellW = Math.floor((w - PAD * 2 - GAP * (COLS - 1)) / COLS);
if (cellW < 10) cellW = 10;
rowH = cellW + GAP;
rows = Math.ceil(total / COLS);
for (const c of pool) c.style.height = cellW + "px";
spacer.style.height = (rows * rowH - (rows ? GAP : 0) + PAD * 2) + "px";
}
/* ---------------- band recycling -------------------------------------- */
function cellAt(i) { // create pool cells on demand
while (pool.length <= i) {
const c = document.createElement("div");
c.className = "cell";
const im = new Image();
im.decoding = "async";
im.__retry = 0;
im.addEventListener("load", () => {
// im.__id = the id this img was last requested for (a stale
// load event from a previous assignment must not be credited
// to the recycled cell's new id).
const id = im.__id;
if (im.naturalWidth > 2) {
if (id !== undefined && !loadedSet.has(id)) { loadedSet.add(id); statusLine(); }
} else if (im.__retry < 4) {
im.__retry++;
// Cold cell: Rust served the 1x1 placeholder and queued the
// decode. Only bother re-requesting if the cell is still in
// (or near) the band — far cells will be re-requested when they
// cycle in, or via the thumb-ready event.
const r = im.getBoundingClientRect();
if (r.bottom < -2 * innerHeight || r.top > 3 * innerHeight) return;
jlog("js retry id=" + id + " v=" + im.__retry);
im.src = "http://gallery.localhost/thumb/" + id + "?v=" + im.__retry;
}
});
im.addEventListener("error", () => {
// The protocol returned no image (404: the file was deleted and
// the list update has not reached this cell yet, or a transient
// read failure). Without this handler the cell would sit as an
// empty dark box forever.
const id = im.__id;
if (id === undefined) return;
if (!idSet.has(id)) {
// id no longer in the list — drop the content; the next band
// pass reassigns (or hides) this cell.
im.removeAttribute("src");
markBand();
} else if (im.__retry < 4) {
im.__retry++;
jlog("js err id=" + id + " v=" + im.__retry);
im.src = "http://gallery.localhost/thumb/" + id + "?v=" + im.__retry;
}
});
im.addEventListener("click", () => {
const id = +c.dataset.id;
if (id) openViewer(id);
});
c.appendChild(im);
band.appendChild(c);
pool.push(c);
}
return pool[i];
}
let bandDirty = true;
function markBand() { bandDirty = true; }
function updateBand() {
if (total === 0 || rowH === 0) return;
const y = window.scrollY;
const r0 = Math.max(0, Math.floor(y / rowH) - BUFFER_ROWS);
const r1 = Math.min(rows, Math.ceil((y + innerHeight) / rowH) + BUFFER_ROWS);
const i0 = r0 * COLS, i1 = r1 * COLS;
band.style.transform = "translateY(" + (r0 * rowH) + "px)";
const need = i1 - i0;
while (pool.length < need) cellAt(pool.length);
for (let s = 0; s < pool.length; s++) {
const c = pool[s];
if (s >= need) { c.style.display = "none"; continue; }
const idx = i0 + s;
const id = ids[idx];
if (id === undefined) { c.style.display = "none"; continue; }
c.style.display = "";
// Compare BOTH idx and id: after a deletion the same slot index
// holds a different photo — idx alone would keep the stale image.
if (+c.dataset.idx !== idx || c.dataset.id != id) {
c.dataset.idx = idx;
c.dataset.id = id;
const im = c.firstElementChild;
im.__retry = 0;
im.__id = id;
// swap tracking: this id is now bound to this img
const old = +c.dataset.idold || 0;
if (old && imgById.get(old) === im) imgById.delete(old);
c.dataset.idold = id;
imgById.set(id, im);
im.src = "http://gallery.localhost/thumb/" + id;
}
}
}
function bandLoop() {
if (bandDirty) { bandDirty = false; updateBand(); }
requestAnimationFrame(bandLoop);
}
requestAnimationFrame(bandLoop);
addEventListener("scroll", () => {
markBand();
sendAnchor(false);
}, { passive: true });
addEventListener("resize", () => { layoutMetrics(); markBand(); });
/* -------- rebuild (initial list + live rescan) ------------------------ */
function rebuild(newIds, isScanning, found) {
const sig = newIds.length + ":" + (newIds[0] ?? 0) + ":" + (newIds[newIds.length - 1] ?? 0);
if (sig === lastSig && scanning === isScanning) return;
// pin the viewport: the cell currently on top keeps its screen slot
const keepId = (ids.length > 0 && rowH > 0)
? ids[Math.min(ids.length - 1, Math.floor(window.scrollY / rowH) * COLS)]
: null;
ids = newIds;
lastSig = sig;
scanning = isScanning;
total = ids.length;
idSet.clear();
for (const id of ids) idSet.add(id);
layoutMetrics();
bandDirty = true;
statusLine();
if (keepId !== null && !scanning) {
const i = ids.indexOf(keepId);
if (i >= 0) {
requestAnimationFrame(() => scrollTo(0, PAD + Math.floor(i / COLS) * rowH));
}
}
}
/* -------- scroll anchor for Rust-side thumbnail prefetch --------------
`a` = index of the first visible grid cell. Rust pre-decodes rows
ahead of the viewport so fast scrolls hit a warm mem cache. */
let lastAnchor = 0, anchorTrail = null;
function sendAnchor(force) {
const now = performance.now();
if (!force && now - lastAnchor < 150) {
if (!anchorTrail) {
anchorTrail = setTimeout(() => { anchorTrail = null; sendAnchor(true); }, 160);
}
return;
}
lastAnchor = now;
if (rowH > 0 && total > 0) {
const a = Math.min(total - 1, Math.floor(window.scrollY / rowH) * COLS);
try {
window.__TAURI__.event.emit("gtauri-scroll", { a: Math.max(0, a) });
window.__TAURI__.event.emit("gtauri-log", "js anchor a=" + Math.max(0, a) + " y=" + Math.round(window.scrollY));
} catch (e) {}
}
}
function jlog(s) {
try { window.__TAURI__.event.emit("gtauri-log", s); } catch (e) {}
}
try {
window.__TAURI__.event.listen("gallery-files", (e) => {
let p = e.payload || {};
if (typeof p === "string") {
try { p = JSON.parse(p); } catch (err) { jlog("files parse err: " + err); }
}
jlog("js files ids=" + ((p && p.ids) || []).length +
" scanning=" + !!p.scanning + " type=" + typeof e.payload);
rebuild((p && p.ids) || [], !!(p && p.scanning), (p && p.found) || 0);
});
// first anchor (top of grid) — Rust also warms 0..36 on its side.
setTimeout(() => sendAnchor(true), 300);
// READY HANDSHAKE: the initial gallery-files emit can fire before this
// page's JS has loaded (small/fast DBs) and be lost (Tauri events are
// fire-and-forget). Tell Rust we're listening; it re-emits the state.
try { window.__TAURI__.event.emit("gtauri-js-ready"); } catch (e) {}
// Rust served a 1x1 placeholder and finished the decode off-main:
// re-request (now a mem hit). Payloads arrive as JSON strings on some
// WebView builds — normalize both.
const pid = (e) => {
const p = e.payload;
return (typeof p === "string" ? JSON.parse(p) : p) || {};
};
window.__TAURI__.event.listen("thumb-ready", (e) => {
const id = pid(e).id;
const im = imgById.get(id);
if (im && im.naturalWidth <= 2) {
jlog("js ready id=" + id + " v=" + (im.__retry + 1));
im.src = "http://gallery.localhost/thumb/" + id + "?v=" + (++im.__retry);
}
});
window.__TAURI__.event.listen("full-ready", (e) => {
const id = pid(e).id;
if (viewerId === id && !vFullDone) {
jlog("js full-ready id=" + id);
tryShow(id, "http://gallery.localhost/full/" + id + "?v=f" + (++vFullSeq), true);
}
});
// Android back: registering ANY "back-button" listener makes Tauri
// route the press here instead of finishing the activity.
window.__TAURI__.app.onBackButtonPress(() => {
if (viewer.classList.contains("open")) closeViewer();
else window.__TAURI__.app.exit(0);
});
} catch (err) { stext.textContent = "no tauri bridge: " + err; }
/* ---------------- frame-rate logger (FR lines for tools/fr_capture.py) ---- */
let frLast = performance.now(), frLines = 0, frBatch = [];
function frFlush() {
if (frBatch.length === 0) return;
frBatch.push("IMG t=" + frLast.toFixed(1) + " loaded=" + loadedSet.size + "/" + total +
" scrollY=" + Math.round(window.scrollY));
const payload = frBatch.join("\n") + "\n";
frBatch = [];
try { window.__TAURI__.event.emit("gtauri-fr", payload); } catch (e) {}
}
function frTick(now) {
const work = performance.now() - now;
frBatch.push("FR " + now.toFixed(2).padStart(11) +
" gap=" + (now - frLast).toFixed(2).padStart(7) +
" cost=" + work.toFixed(2).padStart(6) + " drew=1");
frLast = now;
if (++frLines % 120 === 0) frFlush();
requestAnimationFrame(frTick);
}
requestAnimationFrame(frTick);
/* ---------------- viewer: compositor-only pan/zoom ---------------- */
let vScale = 1, vTx = 0, vTy = 0;
const base = { w: 0, h: 0 };
function fitScale() {
return Math.min(innerWidth / base.w, innerHeight / base.h);
}
function vApply() {
// Clamp: never zoom out past fit; pan limited to image edges — no
// blank space around the image, letterboxed axes stay centered.
const s = fitScale();
if (vScale < s) vScale = s;
const mx = Math.max(0, (base.w * vScale - innerWidth) / 2);
const my = Math.max(0, (base.h * vScale - innerHeight) / 2);
if (vTx > mx) vTx = mx; else if (vTx < -mx) vTx = -mx;
if (vTy > my) vTy = my; else if (vTy < -my) vTy = -my;
vimg.style.transform =
"translate(" + (vTx - base.w * vScale / 2) + "px," + (vTy - base.h * vScale / 2) + "px) scale(" + vScale + ")";
}
let viewerId = null, vFullTimer = null, vFullDone = false, vFullSeq = 0;
/* Live swipe preview: while a horizontal drag is in flight at fit
scale, the picture being swiped IN renders on the page strip
(vimg2) and follows the finger — no blank side. Its grid thumb is
a mem hit (~1 ms) in the common case (the grid already requested
its neighbors' thumbs). */
let nav = null; // {id, dir(+1 = right/next, -1 = left/prev), w, h, scale, ready}
function navSet(id, dir) {
if (nav && nav.id === id) return;
navHide();
if (id == null) return;
nav = { id, dir, w: 0, h: 0, scale: 1, ready: false };
vimg2.style.visibility = "hidden";
vimg2.onload = () => {
if (!nav || nav.id !== id || vimg2.naturalWidth <= 2) return;
nav.w = vimg2.naturalWidth; nav.h = vimg2.naturalHeight;
nav.scale = Math.min(innerWidth / nav.w, innerHeight / nav.h);
nav.ready = true;
vimg2.style.width = nav.w + "px";
vimg2.style.height = nav.h + "px";
vimg2.style.visibility = "visible";
navLayout();
};
vimg2.src = "http://gallery.localhost/thumb/" + id;
}
function navHide() {
nav = null;
vimg2.style.visibility = "hidden";
}
function navLayout() {
if (!nav || !nav.ready) return;
const s = nav.scale;
vimg2.style.transform =
"translate(" + (vTx + nav.dir * innerWidth - nav.w * s / 2) + "px," +
(0 - nav.h * s / 2) + "px) scale(" + s + ")";
}
function fitViewer(w, h) {
base.w = w; base.h = h;
vScale = Math.min(innerWidth / w, innerHeight / h);
vimg.style.width = w + "px";
vimg.style.height = h + "px";
vimg.style.visibility = "visible";
vload.textContent = "";
if (tween) return; // a settle/commit animation owns the transform
vApply();
}
/// Fetch url; promote to the viewer only if REAL image bytes (not the
/// 1x1 placeholder).
function tryShow(id, url, isFull) {
const im = new Image();
im.addEventListener("load", () => {
if (viewerId !== id) return;
if (im.naturalWidth <= 2) return;
vimg.src = im.src;
fitViewer(im.naturalWidth, im.naturalHeight);
if (isFull) {
vFullDone = true;
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
}
});
im.src = url;
}
/// openViewer(id, keepVisible): keepVisible = the image was promoted
/// by a swipe commit — vimg ALREADY shows its thumbnail, so don't
/// hide it or re-request the thumb (only full-res is wanted).
function openViewer(id, keepVisible) {
viewerId = id;
if (!keepVisible) navHide();
vFullDone = false;
vFullSeq = 0;
vScale = 1; vTx = 0; vTy = 0;
if (keepVisible) {
vimg.style.visibility = "visible";
vload.textContent = "";
} else {
vload.textContent = "loading…";
vimg.style.visibility = "hidden";
// 1) INSTANT: the grid thumb (Rust mem cache, ~1 ms).
tryShow(id, "http://gallery.localhost/thumb/" + id, false);
}
// 2) Full-res: Rust reads the file OFF the main thread; first request
// gets the placeholder, `full-ready` triggers the swap.
tryShow(id, "http://gallery.localhost/full/" + id, true);
// 3) Safety net in case the ready event is ever lost.
if (vFullTimer) clearInterval(vFullTimer);
vFullTimer = setInterval(() => {
if (viewerId !== id || vFullDone) {
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
return;
}
if (++vFullSeq > 10) {
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
if (viewerId === id && vimg.naturalWidth <= 2) {
vload.textContent = "image unavailable";
}
return;
}
tryShow(id, "http://gallery.localhost/full/" + id + "?v=" + vFullSeq, true);
}, 400);
viewer.classList.add("open");
}
function closeViewer() {
viewerId = null;
vFullDone = false;
navHide();
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
viewer.classList.remove("open");
vimg.src = "";
}
document.getElementById("vclose").addEventListener("click", closeViewer);
addEventListener("keydown", (e) => {
if (e.key === "Escape") closeViewer();
else if (viewer.classList.contains("open")) {
if (e.key === "ArrowRight") navigate(+1);
else if (e.key === "ArrowLeft") navigate(-1);
}
});
/* pointer-based pan + pinch (touch-action:none keeps the browser from
stealing the gesture). At FIT scale a horizontal drag is a SWIPE:
it navigates to the neighbor in the list and slides the incoming
image in from the drag direction. When zoomed, drags pan. */
const pts = new Map();
let pinchMid = { x: 0, y: 0 }, pinchDist = 0;
let gesture = null; // {mode, x0, y0, sx, t, vx} for a 1-pointer gesture
let tween = null; // in-flight slide/settle animation {raf}
/// Below fitScale()*1.02 the user is "at fit" (swipes navigate).
function atFit() {
return vScale <= fitScale() * 1.02;
}
/// Raw transform write (no clamping) — used by the tween and swipes.
function applyTransform() {
vimg.style.transform =
"translate(" + (vTx - base.w * vScale / 2) + "px," + (vTy - base.h * vScale / 2) + "px) scale(" + vScale + ")";
}
/// Ease-out tween of the view transform to (tx, ty) over `ms`.
/// The swipe preview (if any) tracks the primary image each frame.
function tweenTo(tx, ty, ms, onDone) {
if (tween) cancelAnimationFrame(tween.raf);
const x0 = vTx, y0 = vTy, t0 = performance.now();
const step = (now) => {
const t = Math.min(1, (now - t0) / ms);
const e = 1 - Math.pow(1 - t, 3);
vTx = x0 + (tx - x0) * e;
vTy = y0 + (ty - y0) * e;
applyTransform();
navLayout();
if (t < 1) tween.raf = requestAnimationFrame(step);
else { tween = null; vTx = tx; vTy = ty; if (onDone) onDone(); }
};
tween = { raf: requestAnimationFrame(step) };
}
/// Go to the neighbor `delta` steps down the list (+1 = swipe left).
/// Both images stay on the page strip for the whole animation:
/// the incoming one slides home while the outgoing one slides off
/// screen; only then do the roles swap (returns false at the edges).
function navigate(delta, vel) {
// Key nav during a commit takes it over the same way a touch does.
if (tween && tween.commit) takeover();
const i = ids.indexOf(viewerId);
const ni = i + delta;
if (i < 0 || ni < 0 || ni >= ids.length) return false;
commitTo(ni, delta, vel || 0);
return true;
}
/// Commit a swipe: animate vTx to the outgoing offset (the incoming
/// preview tracks at vTx + delta*W and lands exactly on 0), then swap
/// the elements' roles. The commit tween is locked: input is ignored
/// until it finishes. `vel` = release velocity in px/ms: a flick
/// continues the finger's motion (duration scales with the remaining
/// distance at that velocity, ease-out — fast flicks are fast), a
/// slow release settles gently.
function commitTo(ni, delta, vel) {
navSet(ids[ni], delta); // the incoming (no-op if the drag showed it)
const out = -delta * innerWidth;
const x0 = vTx, t0 = performance.now();
let ms, ease;
if (vel > 0.35) {
const remain = Math.abs(out - x0);
// Time to cover the remaining distance at the release velocity,
// 15 % snappier than the raw continuation, clamped 80–240 ms.
ms = Math.max(80, Math.min(240, remain / vel * 0.85));
ease = (t) => 1 - Math.pow(1 - t, 3); // ease-out: land soft
} else {
ms = vel > 0 ? 180 : 150; // drag-settle / keyboard
ease = (t) => t * t * (3 - 2 * t);
}
const step = (now) => {
const t = Math.min(1, (now - t0) / ms);
const e = ease(t);
vTx = x0 + (out - x0) * e;
vTy = 0;
applyTransform();
navLayout();
if (t < 1) {
tween = { raf: requestAnimationFrame(step), commit: true, target: ni };
} else {
tween = null;
finishCommit(ni);
}
};
tween = { raf: requestAnimationFrame(step), commit: true, target: ni };
}
/// Swap the elements' roles at the settled positions: vimg2 (the
/// incoming image) becomes primary at 0 with its own dimensions (see
/// the aspect note there), vimg shows it next paint, the old image is
/// gone. Called when the commit tween completes AND on mid-flight
/// takeover — a new touch/key during a commit finishes it instantly
/// so the new gesture acts on the landed image (swipes chain).
function finishCommit(ni) {
const id = ids[ni];
if (nav && nav.id === id && nav.ready) {
const nw = nav.w, nh = nav.h, ns = nav.scale;
vimg.src = vimg2.src; // cached + decoded → paints immediately
base.w = nw; base.h = nh;
vScale = ns;
vimg.style.width = nw + "px";
vimg.style.height = nh + "px";
vTx = 0; vTy = 0;
applyTransform();
openViewer(id, true);
// Hide the preview only once the primary has the bytes.
vimg.onload = () => { if (viewerId === id) navHide(); };
} else {
// Preview never made it (keyboard nav / slow thumb): plain open —
// the thumb round-trip sets the right dimensions.
openViewer(id, false);
}
}
/// Instantly settle an in-flight commit (touch/key takeover).
function takeover() {
const target = tween.target;
cancelAnimationFrame(tween.raf);
tween = null;
finishCommit(target);
}
viewer.addEventListener("pointerdown", (e) => {
// A touch during a swipe commit takes it over: finish the commit
// now and start this gesture on the landed image — a second swipe
// while the new image is sliding in acts as another swipe.
if (tween && tween.commit) takeover();
viewer.setPointerCapture(e.pointerId);
if (tween) { cancelAnimationFrame(tween.raf); tween = null; }
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) {
gesture = null;
navHide(); // pinch cancels the swipe preview
const [a, b] = [...pts.values()];
pinchMid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
} else {
const now = performance.now();
gesture = { mode: "free", x0: e.clientX, y0: e.clientY,
sx: e.clientX, sy: e.clientY, t: now, vx: 0 };
}
});
viewer.addEventListener("pointermove", (e) => {
const p = pts.get(e.pointerId);
if (!p) return;
if (pts.size === 1 && gesture) {
if (atFit()) {
// At fit: horizontal drag = swipe navigation, vertical = inert.
const dx = e.clientX - gesture.x0;
const dy = e.clientY - gesture.y0;
if (gesture.mode === "free") {
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
gesture.mode = Math.abs(dx) >= Math.abs(dy) * 1.15 ? "swipe" : "vblank";
}
if (gesture.mode === "swipe") {
// Velocity over the last ~30 ms (px/ms) for flick detection.
const now = performance.now();
if (now - gesture.t > 30) {
gesture.vx = (e.clientX - gesture.sx) / (now - gesture.t);
gesture.t = now; gesture.sx = e.clientX;
}
// 1:1 follow, rubber-banded once the page edge is passed.
const w = innerWidth;
vTx = Math.abs(dx) > w ? Math.sign(dx) * (w + (Math.abs(dx) - w) * 0.4) : dx;
vTy = 0;
// Live preview of the picture being swiped in (page strip).
if (Math.abs(dx) > 24) {
const dir = dx < 0 ? +1 : -1;
const i = ids.indexOf(viewerId);
const ni = i + dir;
navSet(ni >= 0 && ni < ids.length ? ids[ni] : null, dir);
}
applyTransform();
navLayout();
}
return;
}
// Zoomed: free pan (incremental, as before).
vTx += e.clientX - p.x;
vTy += e.clientY - p.y;
p.x = e.clientX; p.y = e.clientY;
vApply();
return;
}
if (pts.size === 2) {
p.x = e.clientX; p.y = e.clientY;
const [a, b] = [...pts.values()];
const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const d = Math.hypot(a.x - b.x, a.y - b.y);
// The image follows the hand (midpoint drift)...
vTx += m.x - pinchMid.x;
vTy += m.y - pinchMid.y;
// ...and zooms ABOUT the midpoint.
if (pinchDist > 0 && d > 0) {
const ratio = d / pinchDist;
const cx = innerWidth / 2 + vTx, cy = innerHeight / 2 + vTy;
vTx = m.x - innerWidth / 2 - (m.x - cx) * ratio;
vTy = m.y - innerHeight / 2 - (m.y - cy) * ratio;
vScale *= ratio;
}
pinchMid = m;
pinchDist = d;
vApply();
}
});
const endPt = (e) => {
const g = gesture;
pts.delete(e.pointerId);
if (pts.size === 0 && g && g.mode === "swipe") {
const dx = e.clientX - g.x0;
const w = innerWidth;
// Commit past the threshold; at the library edges navigate
// refuses (nothing to commit to) and we snap back instead.
if (dx < -w * 0.25 || g.vx < -0.35) {
if (!navigate(+1, Math.abs(g.vx))) tweenTo(0, 0, 120, navHide);
} else if (dx > w * 0.25 || g.vx > 0.35) {
if (!navigate(-1, Math.abs(g.vx))) tweenTo(0, 0, 120, navHide);
} else {
tweenTo(0, 0, 120, navHide); // not far enough: snap back
}
}
gesture = null;
};
viewer.addEventListener("pointerup", endPt);
viewer.addEventListener("pointercancel", endPt);
viewer.addEventListener("dblclick", () => {
vScale = fitScale();
vTx = 0; vTy = 0; vApply();
});
</script>
</body>
</html>