diff --git a/assets/css/custom.css b/assets/css/custom.css index 61ac3d6..7383b0b 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -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 */ .proj-nav { font-family: var(--font-family-mono); diff --git a/assets/js/filebrowser.js b/assets/js/filebrowser.js index 10ce6c3..2369aaa 100644 --- a/assets/js/filebrowser.js +++ b/assets/js/filebrowser.js @@ -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 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; + 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; } - 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 @@ -73,16 +30,15 @@ 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 (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"); @@ -100,9 +56,17 @@ 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() { var list = document.querySelector(".filebrowser .tui__ls"); container = list ? list.closest(".filebrowser") : null; @@ -112,38 +76,48 @@ 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; } + cur = 0; locked = -1; + if (!rows.length) 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. + // 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 (!frozen) select(i); }); + a.addEventListener("mouseenter", function () { render(i); }); a.addEventListener("click", function (e) { 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(); - select(i); - setFrozen(true); + lock(i); + render(i, true); return; } if (a.getAttribute("href")) return; // ".." navigates normally e.preventDefault(); - if (frozen && cur === i) { setFrozen(false); ghostClear(); } - else { select(i); setFrozen(true); } + if (locked === i) { unlock(); ghostClear(); } + 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) { img.onclick = function () { 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 var srcs = rows.map(function (a) { return a.getAttribute("data-img"); }).filter(Boolean); if (srcs.length) { @@ -170,20 +138,19 @@ // 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. + // 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 (frozen) { setFrozen(false); ghostClear(); } + 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(); select(cur + 1, false, true); } - else if (e.key === "ArrowUp") { 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(); 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; }