gallery/harnesses/gtauri/frontend/dist/index.html
Greg Pomerantz 347b6825cc gtauri viewer: swap element roles on commit — no resize on takeover
A second drag while the first was settling showed the new image briefly
at the wrong size: the commit copied the preview's src into vimg (an
async paint — one frame of old content in the new box) and openViewer
then reset vScale=1, so the promoted image rendered at thumb scale
until full-res landed.

Commit now swaps the ELEMENTS' roles instead of copying pixels: the
preview element already shows the incoming image at its own size and
scale, so finishCommit promotes it in place (geometry adopted from
the loaded preview, moved to 0, brought to front, old primary hidden
immediately). No src copy, no async paint, no re-layout — the settled
frame is the drag frame. P/S role variables replace the fixed
vimg/vimg2 references throughout the viewer.

openViewer(keepVisible) no longer resets the view state (the commit
already set it), and a full-res landing while a slide owns
base/vScale is deferred to the safety timer instead of jumping the
animation.

Verified on the emulator: mid second-drag the promoted image holds
its exact fit size (1081 px) in every captured frame (the bug showed
~288), both images visible on the strip, and the chained landing is
pixel-identical to two deliberate swipes.
2026-11-03 14:32:24 -05:00

754 lines
27 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);
}
/// P = the element showing the current image, S = the swipe-preview
/// element. A commit PROMOTES S (it already shows the incoming image
/// at its own size) and demotes P — the elements swap roles, so no
/// src copy / re-decode / re-layout ever happens on a swipe.
let P = vimg, S = vimg2;
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;
P.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 (S)
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 };
S.style.visibility = "hidden";
S.onload = () => {
if (!nav || nav.id !== id || S.naturalWidth <= 2) return;
nav.w = S.naturalWidth; nav.h = S.naturalHeight;
nav.scale = Math.min(innerWidth / nav.w, innerHeight / nav.h);
nav.ready = true;
S.style.width = nav.w + "px";
S.style.height = nav.h + "px";
S.style.visibility = "visible";
navLayout();
};
S.src = "http://gallery.localhost/thumb/" + id;
}
function navHide() {
nav = null;
S.style.visibility = "hidden";
}
function navLayout() {
if (!nav || !nav.ready) return;
const s = nav.scale;
S.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);
P.style.width = w + "px";
P.style.height = h + "px";
P.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;
if (isFull && tween) return; // a slide owns base/vScale right now;
// the safety timer re-requests shortly
P.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 — P ALREADY shows its thumbnail at the right
/// scale/position (finishCommit set it), so don't reset the view or
/// re-request the thumb (only full-res is wanted).
function openViewer(id, keepVisible) {
viewerId = id;
if (!keepVisible) navHide();
vFullDone = false;
vFullSeq = 0;
if (keepVisible) {
P.style.visibility = "visible";
vload.textContent = "";
} else {
vScale = 1; vTx = 0; vTy = 0;
vload.textContent = "loading…";
P.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 && P.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");
P.src = "";
S.src = ""; // release any parked full-res bitmap
}
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() {
P.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 };
}
/// Settle the commit and swap the elements' roles. S (the preview)
/// ALREADY shows the incoming image at its own size and scale — it
/// is promoted to primary in place (moved to 0, brought to front),
/// and the old primary is hidden immediately. No src copy, no async
/// paint, no re-layout: the promoted pixels were on screen the whole
/// drag, so the settled frame is the drag frame — no size jump.
/// Called when the commit tween completes AND on mid-flight takeover
/// (a 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) {
base.w = nav.w; base.h = nav.h;
vScale = nav.scale;
vTx = 0; vTy = 0;
P.onload = null; S.onload = null; // stale preview/commit handlers
P.style.visibility = "hidden"; // park the outgoing image
viewer.insertBefore(S, P); // promoted element goes in front
[P, S] = [S, P];
applyTransform();
openViewer(id, true);
} else {
// Preview never loaded (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>