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.
This commit is contained in:
Greg Pomerantz 2026-11-03 14:32:24 -05:00
parent 695e2417cc
commit 347b6825cc

View File

@ -368,6 +368,12 @@ function fitScale() {
return Math.min(innerWidth / base.w, innerHeight / base.h); 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() { function vApply() {
// Clamp: never zoom out past fit; pan limited to image edges — no // Clamp: never zoom out past fit; pan limited to image edges — no
// blank space around the image, letterboxed axes stay centered. // blank space around the image, letterboxed axes stay centered.
@ -377,44 +383,44 @@ function vApply() {
const my = Math.max(0, (base.h * vScale - innerHeight) / 2); const my = Math.max(0, (base.h * vScale - innerHeight) / 2);
if (vTx > mx) vTx = mx; else if (vTx < -mx) vTx = -mx; if (vTx > mx) vTx = mx; else if (vTx < -mx) vTx = -mx;
if (vTy > my) vTy = my; else if (vTy < -my) vTy = -my; if (vTy > my) vTy = my; else if (vTy < -my) vTy = -my;
vimg.style.transform = P.style.transform =
"translate(" + (vTx - base.w * vScale / 2) + "px," + (vTy - base.h * vScale / 2) + "px) scale(" + vScale + ")"; "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 viewerId = null, vFullTimer = null, vFullDone = false, vFullSeq = 0;
/* Live swipe preview: while a horizontal drag is in flight at fit /* Live swipe preview: while a horizontal drag is in flight at fit
scale, the picture being swiped IN renders on the page strip scale, the picture being swiped IN renders on the page strip (S)
(vimg2) and follows the finger — no blank side. Its grid thumb is and follows the finger — no blank side. Its grid thumb is a mem
a mem hit (~1 ms) in the common case (the grid already requested hit (~1 ms) in the common case (the grid already requested its
its neighbors' thumbs). */ neighbors' thumbs). */
let nav = null; // {id, dir(+1 = right/next, -1 = left/prev), w, h, scale, ready} let nav = null; // {id, dir(+1 = right/next, -1 = left/prev), w, h, scale, ready}
function navSet(id, dir) { function navSet(id, dir) {
if (nav && nav.id === id) return; if (nav && nav.id === id) return;
navHide(); navHide();
if (id == null) return; if (id == null) return;
nav = { id, dir, w: 0, h: 0, scale: 1, ready: false }; nav = { id, dir, w: 0, h: 0, scale: 1, ready: false };
vimg2.style.visibility = "hidden"; S.style.visibility = "hidden";
vimg2.onload = () => { S.onload = () => {
if (!nav || nav.id !== id || vimg2.naturalWidth <= 2) return; if (!nav || nav.id !== id || S.naturalWidth <= 2) return;
nav.w = vimg2.naturalWidth; nav.h = vimg2.naturalHeight; nav.w = S.naturalWidth; nav.h = S.naturalHeight;
nav.scale = Math.min(innerWidth / nav.w, innerHeight / nav.h); nav.scale = Math.min(innerWidth / nav.w, innerHeight / nav.h);
nav.ready = true; nav.ready = true;
vimg2.style.width = nav.w + "px"; S.style.width = nav.w + "px";
vimg2.style.height = nav.h + "px"; S.style.height = nav.h + "px";
vimg2.style.visibility = "visible"; S.style.visibility = "visible";
navLayout(); navLayout();
}; };
vimg2.src = "http://gallery.localhost/thumb/" + id; S.src = "http://gallery.localhost/thumb/" + id;
} }
function navHide() { function navHide() {
nav = null; nav = null;
vimg2.style.visibility = "hidden"; S.style.visibility = "hidden";
} }
function navLayout() { function navLayout() {
if (!nav || !nav.ready) return; if (!nav || !nav.ready) return;
const s = nav.scale; const s = nav.scale;
vimg2.style.transform = S.style.transform =
"translate(" + (vTx + nav.dir * innerWidth - nav.w * s / 2) + "px," + "translate(" + (vTx + nav.dir * innerWidth - nav.w * s / 2) + "px," +
(0 - nav.h * s / 2) + "px) scale(" + s + ")"; (0 - nav.h * s / 2) + "px) scale(" + s + ")";
} }
@ -422,9 +428,9 @@ function navLayout() {
function fitViewer(w, h) { function fitViewer(w, h) {
base.w = w; base.h = h; base.w = w; base.h = h;
vScale = Math.min(innerWidth / w, innerHeight / h); vScale = Math.min(innerWidth / w, innerHeight / h);
vimg.style.width = w + "px"; P.style.width = w + "px";
vimg.style.height = h + "px"; P.style.height = h + "px";
vimg.style.visibility = "visible"; P.style.visibility = "visible";
vload.textContent = ""; vload.textContent = "";
if (tween) return; // a settle/commit animation owns the transform if (tween) return; // a settle/commit animation owns the transform
vApply(); vApply();
@ -437,7 +443,9 @@ function tryShow(id, url, isFull) {
im.addEventListener("load", () => { im.addEventListener("load", () => {
if (viewerId !== id) return; if (viewerId !== id) return;
if (im.naturalWidth <= 2) return; if (im.naturalWidth <= 2) return;
vimg.src = im.src; 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); fitViewer(im.naturalWidth, im.naturalHeight);
if (isFull) { if (isFull) {
vFullDone = true; vFullDone = true;
@ -448,20 +456,21 @@ function tryShow(id, url, isFull) {
} }
/// openViewer(id, keepVisible): keepVisible = the image was promoted /// openViewer(id, keepVisible): keepVisible = the image was promoted
/// by a swipe commit — vimg ALREADY shows its thumbnail, so don't /// by a swipe commit — P ALREADY shows its thumbnail at the right
/// hide it or re-request the thumb (only full-res is wanted). /// 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) { function openViewer(id, keepVisible) {
viewerId = id; viewerId = id;
if (!keepVisible) navHide(); if (!keepVisible) navHide();
vFullDone = false; vFullDone = false;
vFullSeq = 0; vFullSeq = 0;
vScale = 1; vTx = 0; vTy = 0;
if (keepVisible) { if (keepVisible) {
vimg.style.visibility = "visible"; P.style.visibility = "visible";
vload.textContent = ""; vload.textContent = "";
} else { } else {
vScale = 1; vTx = 0; vTy = 0;
vload.textContent = "loading…"; vload.textContent = "loading…";
vimg.style.visibility = "hidden"; P.style.visibility = "hidden";
// 1) INSTANT: the grid thumb (Rust mem cache, ~1 ms). // 1) INSTANT: the grid thumb (Rust mem cache, ~1 ms).
tryShow(id, "http://gallery.localhost/thumb/" + id, false); tryShow(id, "http://gallery.localhost/thumb/" + id, false);
} }
@ -477,7 +486,7 @@ function openViewer(id, keepVisible) {
} }
if (++vFullSeq > 10) { if (++vFullSeq > 10) {
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; } if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
if (viewerId === id && vimg.naturalWidth <= 2) { if (viewerId === id && P.naturalWidth <= 2) {
vload.textContent = "image unavailable"; vload.textContent = "image unavailable";
} }
return; return;
@ -493,7 +502,8 @@ function closeViewer() {
navHide(); navHide();
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; } if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
viewer.classList.remove("open"); viewer.classList.remove("open");
vimg.src = ""; P.src = "";
S.src = ""; // release any parked full-res bitmap
} }
document.getElementById("vclose").addEventListener("click", closeViewer); document.getElementById("vclose").addEventListener("click", closeViewer);
addEventListener("keydown", (e) => { addEventListener("keydown", (e) => {
@ -520,7 +530,7 @@ function atFit() {
/// Raw transform write (no clamping) — used by the tween and swipes. /// Raw transform write (no clamping) — used by the tween and swipes.
function applyTransform() { function applyTransform() {
vimg.style.transform = P.style.transform =
"translate(" + (vTx - base.w * vScale / 2) + "px," + (vTy - base.h * vScale / 2) + "px) scale(" + vScale + ")"; "translate(" + (vTx - base.w * vScale / 2) + "px," + (vTy - base.h * vScale / 2) + "px) scale(" + vScale + ")";
} }
@ -595,28 +605,29 @@ function commitTo(ni, delta, vel) {
tween = { raf: requestAnimationFrame(step), commit: true, target: ni }; tween = { raf: requestAnimationFrame(step), commit: true, target: ni };
} }
/// Swap the elements' roles at the settled positions: vimg2 (the /// Settle the commit and swap the elements' roles. S (the preview)
/// incoming image) becomes primary at 0 with its own dimensions (see /// ALREADY shows the incoming image at its own size and scale — it
/// the aspect note there), vimg shows it next paint, the old image is /// is promoted to primary in place (moved to 0, brought to front),
/// gone. Called when the commit tween completes AND on mid-flight /// and the old primary is hidden immediately. No src copy, no async
/// takeover — a new touch/key during a commit finishes it instantly /// paint, no re-layout: the promoted pixels were on screen the whole
/// so the new gesture acts on the landed image (swipes chain). /// 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) { function finishCommit(ni) {
const id = ids[ni]; const id = ids[ni];
if (nav && nav.id === id && nav.ready) { if (nav && nav.id === id && nav.ready) {
const nw = nav.w, nh = nav.h, ns = nav.scale; base.w = nav.w; base.h = nav.h;
vimg.src = vimg2.src; // cached + decoded → paints immediately vScale = nav.scale;
base.w = nw; base.h = nh;
vScale = ns;
vimg.style.width = nw + "px";
vimg.style.height = nh + "px";
vTx = 0; vTy = 0; 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(); applyTransform();
openViewer(id, true); openViewer(id, true);
// Hide the preview only once the primary has the bytes.
vimg.onload = () => { if (viewerId === id) navHide(); };
} else { } else {
// Preview never made it (keyboard nav / slow thumb): plain open — // Preview never loaded (keyboard nav / slow thumb): plain open —
// the thumb round-trip sets the right dimensions. // the thumb round-trip sets the right dimensions.
openViewer(id, false); openViewer(id, false);
} }