feat: sticky locked-image thumbnail while scrolling; two-click preview-then-open on the portfolio index

the locked image now gets a small pinned copy (18rem, same width as the text column) that appears once the real preview scrolls out of view and disappears again once the text runs out — fixed-position + scroll-driven rather than CSS sticky, since the filebrowser box is only ever as tall as its own content, not the much taller text next to it.\n\nalso scoped the existing click-outside-unfreezes-the-lock listener to clicks inside .filebrowser only, since with listings now starting locked, a click to read or select a word in the text would otherwise silently kill the sticky preview.\n\nportfolio index: rows there are real links (href + data-img together), so a single click used to jump straight into the project — the only way to preview a different one first was hovering, which does nothing for touch and is now also blocked while something is locked. first click on a different project now just previews it (like hover used to); a second click on the same row, or clicking the preview image itself, opens it.
This commit is contained in:
kgva CMS
2026-08-11 19:12:43 +00:00
parent 5c5b267a7f
commit 113a954b16
2 changed files with 115 additions and 3 deletions
+36
View File
@@ -172,6 +172,10 @@ body:not(.home) .menu { display: none; }
display: block;
transition: opacity 0.25s ease;
}
/* portfolio index only (not project pages): the big preview is itself a
second way into the already-previewed project, next to clicking its row
again */
.filebrowser:not(.tui--files) .tui__preview img { cursor: pointer; }
/* project page: image files (no date column), show full image (contain) */
.tui--files .tui__ls a { grid-template-columns: 11ch 1fr; }
@@ -247,6 +251,38 @@ body:not(.home) .menu { display: none; }
}
}
/* small pinned copy of the locked image — appears at the left edge (same
spot the real preview normally sits) once that preview has scrolled out
of view, and disappears again once the text next to it runs out. width
matches .proj-info so the two columns read as a pair. fixed, not sticky:
the file list's own box is only as tall as its own content, not the much
taller text next to it, so sticky positioning has nothing to be bounded
by — a scroll-driven fixed element sidesteps that entirely. */
.proj-sticky {
position: fixed;
top: 50%;
left: calc((100vw - var(--container-width)) / 2);
transform: translateY(-50%);
width: 18rem;
z-index: 5;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease;
pointer-events: none;
}
.proj-sticky.is-visible { opacity: 1; visibility: visible; }
.proj-sticky img {
width: 100%;
height: auto;
max-height: 60vh;
object-fit: contain;
display: block;
margin: 0;
}
@media (max-width: 1099.98px) {
.proj-sticky { display: none; }
}
/* prev/next as shell "cd" commands — same vocabulary as the rest of the site */
.proj-nav {
font-family: var(--font-family-mono);
+79 -3
View File
@@ -4,12 +4,54 @@
(function () {
"use strict";
var rows = [], img = null, texts = [], cap = null, cur = 0, frozen = false, container = null, player = null, pv = null;
var layout = null, mainPreview = null, sticky = null, stickyImg = null;
function hideTexts() { texts.forEach(function (t) { t.hidden = true; }); }
function showText(k) { texts.forEach(function (t) { t.hidden = (t.getAttribute("data-text") !== k); }); }
function hidePlayer() { if (player) { player.hidden = true; if (pv && !pv.paused) pv.pause(); } }
function showPlayer() { if (player) player.hidden = false; }
function setFrozen(v) { frozen = v; if (container) container.classList.toggle("is-frozen", v); }
function setFrozen(v) { frozen = v; if (container) container.classList.toggle("is-frozen", v); syncSticky(); }
// small pinned copy of the locked image, shown to the left of the text
// column once the real preview has scrolled out of view — mirrors the
// LOCKED row only (like the main preview already does once frozen), never
// the hover state, and only for as long as there's still text alongside it.
function ensureSticky() {
if (sticky) return;
sticky = document.createElement("div");
sticky.className = "proj-sticky";
stickyImg = document.createElement("img");
stickyImg.alt = "";
sticky.appendChild(stickyImg);
document.body.appendChild(sticky);
}
function syncSticky() {
if (!sticky) return;
var a = frozen ? rows[cur] : null;
var src = a ? a.getAttribute("data-img") : null;
if (src && layout && mainPreview) {
if (stickyImg.getAttribute("src") !== src) stickyImg.src = src;
updateStickyVisibility();
} else {
sticky.classList.remove("is-visible");
}
}
function updateStickyVisibility() {
if (!sticky || !stickyImg.getAttribute("src") || !layout || !mainPreview) return;
if (!window.matchMedia("(min-width: 1100px)").matches) { sticky.classList.remove("is-visible"); return; }
var pr = mainPreview.getBoundingClientRect();
var lr = layout.getBoundingClientRect();
var show = pr.bottom < 0 && lr.bottom > 0;
sticky.classList.toggle("is-visible", show);
}
var stickyTick = false;
function onScrollOrResize() {
if (stickyTick) return;
stickyTick = true;
requestAnimationFrame(function () { stickyTick = false; updateStickyVisibility(); });
}
window.addEventListener("scroll", onScrollOrResize, { passive: true });
window.addEventListener("resize", onScrollOrResize);
// tell the shell to "type" the matching command. navigable rows (those with an
// href: ".." and the portfolio project folders) are directories → `cd`; the
@@ -71,18 +113,47 @@
pv = player ? player.querySelector("video") : null;
cap = document.querySelector(".tui__status");
cur = 0; frozen = false;
if (!rows.length) return;
layout = document.querySelector(".proj-layout");
mainPreview = document.querySelector(".filebrowser .tui__preview");
ensureSticky();
sticky.classList.remove("is-visible");
if (!rows.length) { layout = null; return; }
rows.forEach(function (a, i) {
// portfolio-index rows are real links (data-img + href together) — a
// single click there used to jump straight into the project, which
// meant hovering was the only way to preview a different one first.
// now the first click just previews it (same as the old hover did);
// only a second click on the already-previewed row, or the preview
// image itself, actually navigates.
var isProjectLink = a.hasAttribute("href") && a.hasAttribute("data-img");
a.addEventListener("mouseenter", function () { if (!frozen) select(i); });
a.addEventListener("click", function (e) {
if (a.getAttribute("href")) return; // navigation rows behave normally
if (isProjectLink) {
if (frozen && cur === i) return; // already previewed — let the link through
e.preventDefault();
select(i);
setFrozen(true);
return;
}
if (a.getAttribute("href")) return; // ".." navigates normally
e.preventDefault();
if (frozen && cur === i) { setFrozen(false); ghostClear(); }
else { select(i); setFrozen(true); }
});
});
if (img) {
img.onclick = function () {
var a = rows[cur];
if (a && a.hasAttribute("href") && a.hasAttribute("data-img")) {
var href = a.getAttribute("href");
if (window.pjax) window.pjax(href); else window.location.href = href;
}
};
}
var start = rows.findIndex(function (a) { return a.classList.contains("is-selected"); });
select(start < 0 ? 0 : start, true); // initial selection: don't echo into the shell yet
// portfolio/about listings start pre-locked on their first file, same as a
@@ -98,8 +169,13 @@
}
// one-time document listeners (reference the current bound state)
// clicking elsewhere IN THE WIDGET (the preview image, blank space in the
// list) releases the lock; clicks out in the text/page don't — now that
// listings start locked, a click to read or select a word shouldn't kill
// the sticky preview underneath it.
document.addEventListener("click", function (e) {
if (e.target.closest && e.target.closest(".tui__ls a")) return;
if (!(e.target.closest && e.target.closest(".filebrowser"))) return;
if (frozen) { setFrozen(false); ghostClear(); }
});
document.addEventListener("keydown", function (e) {