gallery/harnesses/gtauri/frontend/dist/index.html
Greg Pomerantz 8f700e7f66 gtauri: clamp viewer pan/zoom to image edges
Pan is now limited to the image bounds (|offset| <= (rendered -
viewport)/2 per axis), so the image can never be dragged off-screen
leaving blank space around it; on a letterboxed axis panning is
locked to centered. Pinch can no longer zoom out past fit. Clamping
lives in vApply() so pan, pinch and reset all share it; still
compositor-only (transform only).
2026-11-01 17:44:14 -05:00

401 lines
13 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, lazy fetch+decode,
image cache/eviction, viewer compositing. Rust owns: scan, SQLite,
on-disk thumbnail cache behind gallery://. */
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 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 4px;
}
.cell {
aspect-ratio: 1 / 1;
overflow: hidden;
background: #1c1c22;
border-radius: 2px;
}
/* explicit 1:1 box via aspect-ratio → zero layout shift on arrival */
.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 {
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>
<div id="viewer">
<div id="vload"></div>
<img id="vimg" alt="">
<div id="vclose">×</div>
</div>
<script>
"use strict";
const grid = document.getElementById("grid");
const stext = document.getElementById("stext");
const viewer = document.getElementById("viewer");
const vimg = document.getElementById("vimg");
const vload = document.getElementById("vload");
let total = 0, loaded = 0, lastSig = "", scanning = false;
const imgById = new Map(); // id -> <img> (for thumb-ready re-requests)
function statusLine() {
stext.textContent = total + " photos" +
(scanning ? " • scanning…" : "") +
(loaded < total ? " (" + loaded + ")" : "");
}
function rebuild(ids, isScanning, found) {
const sig = ids.length + ":" + (ids[0] ?? 0) + ":" + (ids[ids.length - 1] ?? 0);
if (sig === lastSig && scanning === isScanning) return;
lastSig = sig;
scanning = isScanning;
total = ids.length; loaded = 0;
grid.replaceChildren();
imgById.clear();
const frag = document.createDocumentFragment();
for (const id of ids) {
const c = document.createElement("div");
c.className = "cell";
const im = new Image();
im.loading = "lazy";
im.decoding = "async";
im.dataset.url = "http://gallery.localhost/thumb/" + id;
im.__ph = false, im.__retry = 0, im.__lazy = true;
im.src = im.dataset.url;
imgById.set(id, im);
im.addEventListener("load", () => {
// naturalWidth 1 => the Rust placeholder, not a real thumb
if (im.naturalWidth > 2) { loaded++; statusLine(); }
else if (im.__retry < 4) {
im.__retry++;
jlog("js ph-load id=" + id + " retry=" + im.__retry);
// safety net in case thumb-ready was lost (page rebuild race)
setTimeout(() => {
if (!im.complete || im.naturalWidth <= 2) {
// Skip cells far outside the viewport — a fast scroll pass
// leaves hundreds of cold cells behind; re-requesting them
// all would flood the queue with junk the user cannot see.
const r = im.getBoundingClientRect();
if (r.bottom < -innerHeight || r.top > 2 * innerHeight) return;
jlog("js retry id=" + id + " v=" + im.__retry);
im.src = im.dataset.url + "?v=" + im.__retry;
}
}, 1500);
}
});
im.addEventListener("click", () => openViewer(id));
c.appendChild(im);
frag.appendChild(c);
}
grid.appendChild(frag);
statusLine();
eagerBand();
}
/* -------- scroll anchor for Rust-side thumbnail prefetch --------------
Tell Rust where the viewport is (throttled to ~7 Hz); the Rust worker
pre-decodes the rows ahead of us so fast scrolls hit a warm cache. */
let rowH = 0, lastAnchor = 0, anchorSent = false, anchorTrail = null;
function sendAnchor(force) {
const now = performance.now();
if (!force && now - lastAnchor < 150) {
// Throttled tick: schedule a TRAILING send so the final scroll
// position is never left unreported (a stale anchor makes Rust
// drop fresh urgent work as "off-screen").
if (!anchorTrail) {
anchorTrail = setTimeout(() => {
anchorTrail = null;
sendAnchor(true);
}, 160);
}
return;
}
lastAnchor = now;
if (rowH === 0) {
const c = grid.querySelector(".cell");
if (c) rowH = c.getBoundingClientRect().height + 4; // + grid gap
}
if (rowH > 0) {
const a = Math.floor(window.scrollY / rowH) * 3;
anchorSent = true;
try {
window.__TAURI__.event.emit("gtauri-scroll", { a: a });
window.__TAURI__.event.emit("gtauri-log", "js anchor a=" + a + " y=" + Math.round(window.scrollY));
} catch (e) {}
eagerBand();
}
}
/* Force the visible band + next screen to load NOW (bypass Chromium's
lazy-loader intersection delay after a fast jump). Far cells stay lazy. */
function eagerBand() {
if (rowH === 0) return;
const i0 = Math.max(0, Math.floor((window.scrollY - innerHeight) / rowH) * 3 - 3);
const i1 = Math.min(total, Math.ceil((window.scrollY + 2.2 * innerHeight) / rowH) * 3 + 3);
const kids = grid.children;
let forced = 0;
for (let i = i0; i < i1; i++) {
const im = kids[i] && kids[i].firstElementChild;
if (im && !im.complete) {
im.loading = "eager";
if (im.__lazy) { im.__lazy = false; forced++; }
}
}
if (forced > 0) jlog("js eager forced=" + forced + " range=[" + i0 + "," + i1 + "]");
}
function jlog(s) {
try { window.__TAURI__.event.emit("gtauri-log", s); } catch (e) {}
}
addEventListener("scroll", () => sendAnchor(false), { passive: true });
try {
window.__TAURI__.event.listen("gallery-files", (e) => {
const p = e.payload || {};
rebuild(p.ids || [], !!p.scanning, p.found || 0);
});
// first anchor (top of grid) — Rust also warms 0..36 on its side.
setTimeout(() => sendAnchor(true), 300);
// Cold-miss round-trip: Rust served a 1x1 placeholder and queued the real
// decode off the main thread; when it lands, re-request (now a mem hit).
window.__TAURI__.event.listen("thumb-ready", (e) => {
const id = e.payload.id;
const im = imgById.get(id);
if (!im) return;
if (im.naturalWidth <= 2) {
jlog("js ready id=" + id + " v=" + (im.__retry + 1));
im.src = im.dataset.url + "?v=" + (++im.__retry);
}
});
// Full-res read finished (worker thread) — re-request; now a mem hit.
window.__TAURI__.event.listen("full-ready", (e) => {
const id = e.payload.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 (see AppPlugin.kt).
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=" + loaded + "/" + 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, and pan is limited to the image
// edges — the image can never be dragged off-screen leaving blank
// space around it. On a letterboxed axis (image smaller than the
// viewport in that dimension) panning is locked to 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;
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 = "";
vApply();
}
/// Fetch url and, if it is REAL image bytes (not the 1x1 placeholder),
/// promote it into the viewer.
function tryShow(id, url, isFull) {
const im = new Image();
im.addEventListener("load", () => {
if (viewerId !== id) return;
if (im.naturalWidth <= 2) return; // placeholder — wait for the ready event
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) {
viewerId = id;
vFullDone = false;
vScale = 1; vTx = 0; vTy = 0;
vload.textContent = "loading…";
vimg.style.visibility = "hidden";
// 1) INSTANT: the grid thumb (served from the Rust mem cache, ~1 ms).
// Same aspect ratio as the full image — the fit stays valid.
tryShow(id, "http://gallery.localhost/thumb/" + id, false);
// 2) Full-res: Rust reads the file OFF the main thread; the 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; }
// Corrupt/unreadable file: stop saying "loading…" forever.
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;
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(); });
/* pointer-based pan + pinch (touch-action:none on #viewer keeps the browser
from stealing the gesture) */
const pts = new Map();
let pinchD0 = 0, pinchS0 = 1;
viewer.addEventListener("pointerdown", (e) => {
viewer.setPointerCapture(e.pointerId);
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) {
const [a, b] = [...pts.values()];
pinchD0 = Math.hypot(a.x - b.x, a.y - b.y);
pinchS0 = vScale;
}
});
viewer.addEventListener("pointermove", (e) => {
const p = pts.get(e.pointerId);
if (!p) return;
if (pts.size === 1) {
vTx += e.clientX - p.x;
vTy += e.clientY - p.y;
p.x = e.clientX; p.y = e.clientY;
vApply();
} else if (pts.size === 2) {
p.x = e.clientX; p.y = e.clientY;
const [a, b] = [...pts.values()];
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (pinchD0 > 0) vScale = Math.max(fitScale(), pinchS0 * d / pinchD0);
vApply();
}
});
const endPt = (e) => { pts.delete(e.pointerId); };
viewer.addEventListener("pointerup", endPt);
viewer.addEventListener("pointercancel", endPt);
viewer.addEventListener("dblclick", () => {
vScale = fitScale();
vTx = 0; vTy = 0; vApply();
});
</script>
</body>
</html>