Watch de-duplication (per the plan review): FileObserver (inotify,
push) and MediaStore ContentObserver (URI push) are the ONLY change
detectors. Removed: the 100 ms recv_timeout tick, the fast_dir_sig
mtime-diff watcher, collect_watched_dirs, changed_dirs, dir_sig,
rescan_subtrees, and the 5-minute full-rescan timer thread.
watch_files now blocks on the push channel; on Android each wake
coalesces ~50 ms so a burst of N file events becomes one payload
batch, one SQLite connection, and ONE grid emit (verified on the
emulator: 20-file copy = 1-2 emits, 20-file delete = 1 emit,
single add still lands in ~75-95 ms).
FileObserver rewritten: recursive registration (Pictures/DCIM tree,
cap 5000 dirs) and self-extending — a CREATE/MOVED_TO on a directory
spawns a new observer for it, so new albums are watched too.
tif/tiff added to the image filter.
Dead code removed (the Tauri port never wired them): scan::walk,
walk_dir_nostats, walk_dir_into, mtime_ns, decode_jpeg (full-size),
images::thumb_png, db::set_thumb, db::list_entries, db::count,
db::delete_by_media_id; Thumb shrunk to {rgb,width,height} (the
grid is uniform squares; the viewer uses full-res). Tests now
compile (tempfile dev-dependency was missing — the test module
never built) and were fixed to match the current API.
Viewer: live swipe preview — while a horizontal drag is in flight at
fit scale, the picture being swiped in renders on the page strip
(second <img>, its grid thumb, a ~1 ms mem hit) and tracks the
finger, so there is no blank side. On commit the primary image takes
over from exactly where the preview was; on snap-back the preview
slides back with the tween. Verified on the emulator (two images
visible mid-drag, clean slide home).
Not re-verified: 120 Hz jank (per instruction — measure once when
settled). Desktop paths untouched (deferred).
658 lines
23 KiB
HTML
658 lines
23 KiB
HTML
<!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;
|
||
|
||
let slideFrom = null; // pending slide start offset for a swiped-in image
|
||
|
||
/* 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 (slideFrom != null) {
|
||
// Incoming swiped image: start where the page strip puts it and
|
||
// slide home. The tween owns the transform from here on.
|
||
vTx = slideFrom; vTy = 0;
|
||
slideFrom = null;
|
||
tweenTo(0, 0, 150);
|
||
return;
|
||
}
|
||
if (tween) return; // settle/slide owns the transform right now
|
||
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;
|
||
}
|
||
|
||
function openViewer(id, fromX) {
|
||
viewerId = id;
|
||
navHide(); // the incoming image takes over from the preview
|
||
slideFrom = (fromX == null) ? null : fromX;
|
||
vFullDone = false;
|
||
vScale = 1; vTx = 0; vTy = 0;
|
||
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).
|
||
/// The incoming image starts on the page strip where the drag left
|
||
/// off and slides home.
|
||
function navigate(delta) {
|
||
const i = ids.indexOf(viewerId);
|
||
const ni = i + delta;
|
||
if (i < 0 || ni < 0 || ni >= ids.length) return;
|
||
const from = vTx + delta * innerWidth;
|
||
openViewer(ids[ni], from);
|
||
}
|
||
|
||
viewer.addEventListener("pointerdown", (e) => {
|
||
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;
|
||
if (dx < -w * 0.25 || g.vx < -0.35) navigate(+1);
|
||
else if (dx > w * 0.25 || g.vx > 0.35) navigate(-1);
|
||
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>
|