diff --git a/assets/css/custom.css b/assets/css/custom.css index b6307df..61ac3d6 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -172,6 +172,10 @@ body:not(.home) .menu { display: none; } display: block; transition: opacity 0.25s ease; } +/* portfolio index only (not project pages): the big preview is itself a + second way into the already-previewed project, next to clicking its row + again */ +.filebrowser:not(.tui--files) .tui__preview img { cursor: pointer; } /* project page: image files (no date column), show full image (contain) */ .tui--files .tui__ls a { grid-template-columns: 11ch 1fr; } @@ -247,6 +251,38 @@ 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 1efcefa..10ce6c3 100644 --- a/assets/js/filebrowser.js +++ b/assets/js/filebrowser.js @@ -4,12 +4,54 @@ (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); } + 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 @@ -71,18 +113,47 @@ pv = player ? player.querySelector("video") : null; cap = document.querySelector(".tui__status"); cur = 0; frozen = false; - 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) { + // 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 (a.getAttribute("href")) return; // navigation rows behave normally + 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 @@ -98,8 +169,13 @@ } // 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) {