gallery/harnesses/gtauri/frontend/dist/index.html
Greg Pomerantz 029780cc09 gtauri viewer: two-image swipe commit — outgoing stays until off-screen
Fix: on release the old image vanished instantly and only the new one
slid in. Now both images live on the page strip for the whole commit:
vimg (outgoing) slides off-screen while vimg2 (incoming, the drag
preview) slides home, over a 200 ms smoothstep; only then do the
elements swap roles (vimg.src = the already-decoded preview image,
vimg2 hidden after the swap paints — no blank frame).

Details:
- navigate() returns whether it committed; at the library edges it
  refuses and the drag snaps back (previously a past-the-edge drag
  could leave the image stuck mid-strip).
- commit tweens are locked: pointer/dblclick input is ignored for the
  ~200 ms so a second gesture can't fight the animation.
- openViewer gains a keepVisible (promoted) path that skips the
  hide+thumb round-trip; vFullSeq reset per open (safety-net counter
  could previously exhaust after ~11 opens).
- removed the now-dead slideFrom slide-in mechanism (its purpose is
  subsumed by the two-image commit).

Verified on the emulator: mid-commit frame shows both images
(outgoing sliding off left, incoming from right); snap-back slides
both back; edge drag right on the first image snaps home.
2026-11-03 13:04:53 -05:00

698 lines
24 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) {
if (tween && tween.commit) return false; // commit already in flight
const i = ids.indexOf(viewerId);
const ni = i + delta;
if (i < 0 || ni < 0 || ni >= ids.length) return false;
commitTo(ni, delta);
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 (~200 ms).
function commitTo(ni, delta) {
navSet(ids[ni], delta); // the incoming (no-op if the drag showed it)
const out = -delta * innerWidth;
const x0 = vTx, t0 = performance.now(), ms = 200;
const step = (now) => {
const t = Math.min(1, (now - t0) / ms);
const e = t * t * (3 - 2 * t); // smoothstep: pick up, land soft
vTx = x0 + (out - x0) * e;
vTy = 0;
applyTransform();
navLayout();
if (t < 1) {
tween = { raf: requestAnimationFrame(step), commit: true };
} else {
// vimg2 (incoming) is exactly at 0: it becomes primary.
const id = ids[ni];
vimg.src = vimg2.src; // cached + decoded → paints immediately
vTx = 0;
applyTransform();
tween = null;
openViewer(id, true);
// Hide the outgoing preview only once the primary has the bytes.
vimg.onload = () => { if (viewerId === id) navHide(); };
}
};
tween = { raf: requestAnimationFrame(step), commit: true };
}
viewer.addEventListener("pointerdown", (e) => {
if (tween && tween.commit) return; // a swipe commit is in flight
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)) tweenTo(0, 0, 120, navHide);
} else if (dx > w * 0.25 || g.vx > 0.35) {
if (!navigate(-1)) 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", () => {
if (tween && tween.commit) return;
vScale = fitScale();
vTx = 0; vTy = 0; vApply();
});
</script>
</body>
</html>