/* 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 ` // 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); })();