fix: hover now peeks the preview even while a file is locked; remove the scroll-triggered sticky thumbnail

the scroll-triggered mini-preview from the last commit solved the wrong problem — it never showed in practice (images are generally as tall or taller than the short project texts, so the gap it needed never opened), and wasnt what was actually wanted: the existing left-column preview pane should keep responding to hover even once a file is locked, only reverting to the locked file once the mouse leaves the list. lock now only gates the is-selected styling, the ● locked indicator, and the click-to-navigate semantics on the portfolio index — not what the pane displays.
This commit is contained in:
kgva CMS
2026-08-11 19:34:11 +00:00
parent 113a954b16
commit 0469e9dab5
2 changed files with 46 additions and 111 deletions
-32
View File
@@ -251,38 +251,6 @@ 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 */ /* prev/next as shell "cd" commands — same vocabulary as the rest of the site */
.proj-nav { .proj-nav {
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
+46 -79
View File
@@ -1,57 +1,14 @@
/* tui file browser — preview pane (image OR text panes), hover + freeze + /* tui file browser — preview pane (image OR text panes), hover + lock +
keyboard. re-binds itself after pjax navigation (document listeners are added keyboard. re-binds itself after pjax navigation (document listeners are added
once; rows/preview are re-queried per page). */ once; rows/preview are re-queried per page). */
(function () { (function () {
"use strict"; "use strict";
var rows = [], img = null, texts = [], cap = null, cur = 0, frozen = false, container = null, player = null, pv = null; var rows = [], img = null, texts = [], cap = null, cur = 0, locked = -1, 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 hideTexts() { texts.forEach(function (t) { t.hidden = true; }); }
function showText(k) { texts.forEach(function (t) { t.hidden = (t.getAttribute("data-text") !== k); }); } 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 hidePlayer() { if (player) { player.hidden = true; if (pv && !pv.paused) pv.pause(); } }
function showPlayer() { if (player) player.hidden = false; } function showPlayer() { if (player) player.hidden = false; }
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 // tell the shell to "type" the matching command. navigable rows (those with an
// href: ".." and the portfolio project folders) are directories → `cd`; the // href: ".." and the portfolio project folders) are directories → `cd`; the
@@ -73,16 +30,15 @@
if (img.complete) img.style.opacity = "1"; // already cached → no fade if (img.complete) img.style.opacity = "1"; // already cached → no fade
} }
function select(i, silent, scroll) { // renders row i into the preview pane. this runs on hover AND on lock —
// hovering always drives what the pane shows, lock only decides what it
// falls back to once the mouse leaves the list again.
function render(i, silent, scroll) {
if (!rows.length) return; if (!rows.length) return;
if (i < 0) i = 0; if (i < 0) i = 0;
if (i >= rows.length) i = rows.length - 1; if (i >= rows.length) i = rows.length - 1;
cur = i; cur = i;
for (var r = 0; r < rows.length; r++) rows[r].classList.remove("is-selected");
var a = rows[i]; var a = rows[i];
a.classList.add("is-selected");
// only scroll for keyboard nav — scrolling on hover nudges edge rows out from
// under the cursor, so the top/bottom row "sometimes" can't be clicked.
if (scroll && a.scrollIntoView) a.scrollIntoView({ block: "nearest" }); if (scroll && a.scrollIntoView) a.scrollIntoView({ block: "nearest" });
var src = a.getAttribute("data-img"); var src = a.getAttribute("data-img");
@@ -100,9 +56,17 @@
cap.textContent = (n ? n.textContent.trim() : a.getAttribute("data-name")) + (y ? " " + y.textContent.trim() : ""); cap.textContent = (n ? n.textContent.trim() : a.getAttribute("data-name")) + (y ? " " + y.textContent.trim() : "");
} }
if (!silent) ghostCmd(a); // echo into the shell, except on the initial load if (!silent) ghostCmd(a);
} }
function lock(i) {
locked = i;
for (var r = 0; r < rows.length; r++) rows[r].classList.remove("is-selected");
if (rows[i]) rows[i].classList.add("is-selected");
if (container) container.classList.toggle("is-frozen", locked >= 0);
}
function unlock() { lock(-1); }
function bind() { function bind() {
var list = document.querySelector(".filebrowser .tui__ls"); var list = document.querySelector(".filebrowser .tui__ls");
container = list ? list.closest(".filebrowser") : null; container = list ? list.closest(".filebrowser") : null;
@@ -112,38 +76,48 @@
player = document.querySelector(".tui__preview .player"); player = document.querySelector(".tui__preview .player");
pv = player ? player.querySelector("video") : null; pv = player ? player.querySelector("video") : null;
cap = document.querySelector(".tui__status"); cap = document.querySelector(".tui__status");
cur = 0; frozen = false; cur = 0; locked = -1;
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) { rows.forEach(function (a, i) {
// portfolio-index rows are real links (data-img + href together) — a // portfolio-index rows are real links (data-img + href together) — a
// single click there used to jump straight into the project, which // single click there used to jump straight into the project, which
// meant hovering was the only way to preview a different one first. // 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); // now the first click just locks it (no navigation); only a second
// only a second click on the already-previewed row, or the preview // click on the already-locked row, or the preview image itself,
// image itself, actually navigates. // actually navigates.
var isProjectLink = a.hasAttribute("href") && a.hasAttribute("data-img"); var isProjectLink = a.hasAttribute("href") && a.hasAttribute("data-img");
a.addEventListener("mouseenter", function () { if (!frozen) select(i); }); a.addEventListener("mouseenter", function () { render(i); });
a.addEventListener("click", function (e) { a.addEventListener("click", function (e) {
if (isProjectLink) { if (isProjectLink) {
if (frozen && cur === i) return; // already previewed — let the link through if (locked === i) return; // already locked — let the link through
e.preventDefault(); e.preventDefault();
select(i); lock(i);
setFrozen(true); render(i, true);
return; return;
} }
if (a.getAttribute("href")) return; // ".." navigates normally if (a.getAttribute("href")) return; // ".." navigates normally
e.preventDefault(); e.preventDefault();
if (frozen && cur === i) { setFrozen(false); ghostClear(); } if (locked === i) { unlock(); ghostClear(); }
else { select(i); setFrozen(true); } else { lock(i); render(i, true); }
}); });
}); });
// leaving the list reverts the preview back to whatever is locked (or
// to the initial file, if nothing is) — hover only ever peeks
if (list) {
list.addEventListener("mouseleave", function () {
render(locked >= 0 ? locked : 0, true);
});
}
var start = rows.findIndex(function (a) { return a.classList.contains("is-selected"); });
start = start < 0 ? 0 : start;
render(start, true); // initial preview: don't echo into the shell yet
// portfolio/about listings start locked on their first file — games stay
// unlocked, there's nothing else to browse there
if (container && !container.classList.contains("game-embed")) lock(start);
if (img) { if (img) {
img.onclick = function () { img.onclick = function () {
var a = rows[cur]; var a = rows[cur];
@@ -154,12 +128,6 @@
}; };
} }
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
// manual click — games stay hover-driven, there's nothing else to browse there
if (container && !container.classList.contains("game-embed")) setFrozen(true);
// warm the cache so switching between gallery images is instant // warm the cache so switching between gallery images is instant
var srcs = rows.map(function (a) { return a.getAttribute("data-img"); }).filter(Boolean); var srcs = rows.map(function (a) { return a.getAttribute("data-img"); }).filter(Boolean);
if (srcs.length) { if (srcs.length) {
@@ -170,20 +138,19 @@
// one-time document listeners (reference the current bound state) // one-time document listeners (reference the current bound state)
// clicking elsewhere IN THE WIDGET (the preview image, blank space in the // 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 // list) releases the lock; clicks out in the text/page don't — a click to
// listings start locked, a click to read or select a word shouldn't kill // read or select a word shouldn't unlock the currently selected file.
// the sticky preview underneath it.
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
if (e.target.closest && e.target.closest(".tui__ls a")) return; if (e.target.closest && e.target.closest(".tui__ls a")) return;
if (!(e.target.closest && e.target.closest(".filebrowser"))) return; if (!(e.target.closest && e.target.closest(".filebrowser"))) return;
if (frozen) { setFrozen(false); ghostClear(); } if (locked >= 0) { unlock(); ghostClear(); }
}); });
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
var t = e.target; var t = e.target;
if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return; if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;
if (!rows.length) return; if (!rows.length) return;
if (e.key === "ArrowDown") { e.preventDefault(); select(cur + 1, false, true); } if (e.key === "ArrowDown") { e.preventDefault(); render(cur + 1, false, true); }
else if (e.key === "ArrowUp") { e.preventDefault(); select(cur - 1, false, true); } else if (e.key === "ArrowUp") { e.preventDefault(); render(cur - 1, false, true); }
else if (e.key === "Enter") { else if (e.key === "Enter") {
var href = rows[cur] && rows[cur].getAttribute("href"); var href = rows[cur] && rows[cur].getAttribute("href");
if (href) { if (window.pjax) window.pjax(href); else window.location.href = href; } if (href) { if (window.pjax) window.pjax(href); else window.location.href = href; }