Files
kgva/assets/js/filebrowser.js
T
kgva CMS 113a954b16 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.
2026-08-11 19:12:43 +00:00

205 lines
9.5 KiB
JavaScript

/* tui file browser — preview pane (image OR text panes), hover + freeze +
keyboard. re-binds itself after pjax navigation (document listeners are added
once; rows/preview are re-queried per page). */
(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); 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
// preview-only leaves inside a project (images, info.md, video) are files → `cat`.
function ghostCmd(a) {
if (!a) return;
var name = a.getAttribute("data-name") || "";
var isDir = a.hasAttribute("href");
document.dispatchEvent(new CustomEvent("shell:ghost", { detail: (isDir ? "cd " : "cat ") + name }));
}
function ghostClear() { document.dispatchEvent(new CustomEvent("shell:ghost-clear")); }
function showImg(src) {
if (!img) return;
img.hidden = false;
if (img.getAttribute("src") === src) { img.style.opacity = "1"; return; }
img.style.opacity = "0";
img.onload = img.onerror = function () { img.style.opacity = "1"; };
img.src = src;
if (img.complete) img.style.opacity = "1"; // already cached → no fade
}
function select(i, silent, scroll) {
if (!rows.length) return;
if (i < 0) i = 0;
if (i >= rows.length) i = rows.length - 1;
cur = i;
for (var r = 0; r < rows.length; r++) rows[r].classList.remove("is-selected");
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" });
var src = a.getAttribute("data-img");
var tkey = a.getAttribute("data-text");
var vid = a.getAttribute("data-video");
if (src) { showImg(src); hideTexts(); hidePlayer(); }
else if (vid) { if (img) img.hidden = true; hideTexts(); showPlayer(); }
else if (tkey) { if (img) img.hidden = true; hidePlayer(); showText(tkey); }
// rows with no preview of their own (".." above all) leave whatever was
// last shown in place instead of blanking the pane out
if (cap) {
var n = a.querySelector(".tui__name");
var y = a.querySelector(".tui__date");
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
}
function bind() {
var list = document.querySelector(".filebrowser .tui__ls");
container = list ? list.closest(".filebrowser") : null;
rows = list ? Array.prototype.slice.call(list.querySelectorAll("a[data-name]")) : [];
img = document.querySelector(".tui__preview img");
texts = Array.prototype.slice.call(document.querySelectorAll(".tui__preview .tui__text"));
player = document.querySelector(".tui__preview .player");
pv = player ? player.querySelector("video") : null;
cap = document.querySelector(".tui__status");
cur = 0; frozen = false;
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 (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
// 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
var srcs = rows.map(function (a) { return a.getAttribute("data-img"); }).filter(Boolean);
if (srcs.length) {
var preload = function () { srcs.forEach(function (s) { var im = new Image(); im.src = s; }); };
if (window.requestIdleCallback) requestIdleCallback(preload); else setTimeout(preload, 300);
}
}
// 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) {
var t = e.target;
if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;
if (!rows.length) return;
if (e.key === "ArrowDown") { e.preventDefault(); select(cur + 1, false, true); }
else if (e.key === "ArrowUp") { e.preventDefault(); select(cur - 1, false, true); }
else if (e.key === "Enter") {
var href = rows[cur] && rows[cur].getAttribute("href");
if (href) { if (window.pjax) window.pjax(href); else window.location.href = href; }
}
});
// the always-open home menu lives in the persistent header (outside the
// filebrowser and untouched by pjax). hovering a folder ghost-types `cd <name>`
// so the shell echoes where a click would take you, until you type for real.
Array.prototype.forEach.call(document.querySelectorAll(".menu .tui__ls a[data-name]"), function (a) {
a.addEventListener("mouseenter", function () {
document.dispatchEvent(new CustomEvent("shell:ghost", { detail: "cd " + (a.getAttribute("data-name") || "") }));
});
});
bind();
document.addEventListener("pjax:load", bind);
})();