Files
kgva/assets/js/filebrowser.js
T
kgva CMS 37fd39f488 feat: peek column to the left of the file list, mirroring the info text on the right
replaces the last commits attempt (hover updating the main preview directly) with what was actually asked for: the main preview now stays on the locked file, untouched by hover; a separate 18rem-wide column to the left of the file list — same width and spot as .proj-info on the right — shows whichever OTHER image is under the mouse while something is locked, and disappears again once the mouse leaves the list or lands back on the locked row itself.\n\npositioned via plain absolute positioning against .filebrowser (not scroll-tracked — it scrolls together with the list naturally), sitting inside the padding .proj-layout already reserves to keep the file list anchored. only enabled above 1300px, with a safety margin over the ~1168px point where that reserved space stops being wide enough; caught and fixed a bug in the same pass where .proj-peek had no display:none fallback below that breakpoint and would otherwise render as a bare block in-flow.
2026-08-11 19:41:06 +00:00

192 lines
8.5 KiB
JavaScript

/* tui file browser — preview pane (image OR text panes), hover + lock +
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, locked = -1, container = null, player = null, pv = 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; }
// 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
}
// renders row i into the MAIN preview pane. driven by lock (click) and
// keyboard nav only — hovering never touches this, it drives the small
// peek column instead (see bind()), so a locked file stays put while you
// browse the others.
function render(i, silent, scroll) {
if (!rows.length) return;
if (i < 0) i = 0;
if (i >= rows.length) i = rows.length - 1;
cur = i;
var a = rows[i];
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);
}
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() {
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; locked = -1;
if (!rows.length) return;
// small side-by-side peek — sits to the left of the file list (mirroring
// the info text's spot on the right, same 18rem width) and shows
// whichever OTHER image is under the mouse while something is locked,
// without disturbing the main preview.
var peek = null, peekImg = null;
if (container) {
peek = document.createElement("div");
peek.className = "proj-peek";
peekImg = document.createElement("img");
peekImg.alt = "";
peek.appendChild(peekImg);
container.insertBefore(peek, container.firstChild);
}
function showPeek(a) {
var src = a.getAttribute("data-img");
if (!peek || !src) { if (peek) peek.classList.remove("is-visible"); return; }
peekImg.src = src;
peek.classList.add("is-visible");
}
function hidePeek() { if (peek) peek.classList.remove("is-visible"); }
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 locks it (no navigation); only a second
// click on the already-locked row, or the preview image itself,
// actually navigates.
var isProjectLink = a.hasAttribute("href") && a.hasAttribute("data-img");
a.addEventListener("mouseenter", function () {
if (locked >= 0 && i !== locked) showPeek(a); else hidePeek();
});
a.addEventListener("click", function (e) {
if (isProjectLink) {
if (locked === i) return; // already locked — let the link through
e.preventDefault();
lock(i);
render(i, true);
hidePeek();
return;
}
if (a.getAttribute("href")) return; // ".." navigates normally
e.preventDefault();
if (locked === i) { unlock(); ghostClear(); }
else { lock(i); render(i, true); }
hidePeek();
});
});
if (list) list.addEventListener("mouseleave", hidePeek);
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) {
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;
}
};
}
// 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 — a click to
// read or select a word shouldn't unlock the currently selected file.
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 (locked >= 0) { unlock(); 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(); render(cur + 1, false, true); }
else if (e.key === "ArrowUp") { e.preventDefault(); render(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);
})();