Click-to-enlarge lightbox for article figures

Clicking a figure image opens a full-viewport overlay: the image as
large as fits (100vw, flex-shrunk to leave exactly the caption's
height) with the caption below. Click or any key closes it; wheel and
touch scrolling stop at the overlay (overscroll-behavior: contain).

Styling lives in the base theme: blurred dark backdrop (theme-tunable
via --lightbox-bg / --lightbox-text), soft shadow, reduced-motion-aware
open animation.
This commit is contained in:
2026-08-30 02:08:06 +00:00
parent 8fc5dd7b9b
commit 7e8e0a2ea7
3 changed files with 117 additions and 1 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ Vue editor app entry, mounts the tabbed `EditorShell`. See `docs/editing.md` for
## `pagerite.js` ## `pagerite.js`
Public page entry; runs fetch-navigation (backed by an in-memory page cache: every visible internal link is fetched once at load and clicks are then served from JS with no fetch — the current page itself is not refetched, it enters the cache when navigated to — and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire). Editors can drop the entire cache with the `pagerite:drop-page-cache` event when site-wide or page changes (theme, headings, structure, banners, etc.) invalidate the cached HTML of other pages; `main.js` triggers a fresh `pagerite:preload-pages` pass when the editor panel closes so navigation is fast again. Navigation that starts while the editor is open bypasses the cache and fetches the target page on demand. Also runs scroll-reveal, a scroll-driven section hash (the location hash tracks the h1/h2 above the viewport middle via replaceState — removed above the first tagged heading and at the very top, never set on unscrollable pages), OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; native scroll APIs like `window.scrollTo` keep working; themed via the `--os-*` variables in pagerite.css), brand shrink-to-fit (the themed size is the maximum; JS reduces the font-size so a long brand or narrow viewport still fits one line), nav condense-to-fit (the top nav stays on one row: link gaps shrink first, then the side padding, then the font size; `flex-wrap: wrap` remains the no-JS fallback), code copy buttons, and the auth check. Public page entry; runs fetch-navigation (backed by an in-memory page cache: every visible internal link is fetched once at load and clicks are then served from JS with no fetch — the current page itself is not refetched, it enters the cache when navigated to — and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire). Editors can drop the entire cache with the `pagerite:drop-page-cache` event when site-wide or page changes (theme, headings, structure, banners, etc.) invalidate the cached HTML of other pages; `main.js` triggers a fresh `pagerite:preload-pages` pass when the editor panel closes so navigation is fast again. Navigation that starts while the editor is open bypasses the cache and fetches the target page on demand. Also runs scroll-reveal, a scroll-driven section hash (the location hash tracks the h1/h2 above the viewport middle via replaceState — removed above the first tagged heading and at the very top, never set on unscrollable pages), OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; native scroll APIs like `window.scrollTo` keep working; themed via the `--os-*` variables in pagerite.css), brand shrink-to-fit (the themed size is the maximum; JS reduces the font-size so a long brand or narrow viewport still fits one line), nav condense-to-fit (the top nav stays on one row: link gaps shrink first, then the side padding, then the font size; `flex-wrap: wrap` remains the no-JS fallback), code copy buttons, click-to-enlarge on article figure images (a full-viewport lightbox with the caption, closed by click or Esc), and the auth check.
It first probes `GET /auth/api/settings` to detect whether Paskia SSO is available, then `GET /_api/settings` to learn the current session's admin status. The same reverse proxy that gates `/_api` returns 401 for anonymous users, 403 for users without the admin permission, and 200 for admins. When Paskia is detected, a login link (anonymous) or profile link (logged in) is shown in the banner corner; both are plain `<a href="/auth/">` links (Paskia does not support being iframed, so we navigate normally), and a `pageshow` handler re-probes auth when history navigation restores a cached page. Admins also get the page/banner edit pens and a site-settings pen, plus a `modulepreload` warm-up of the editor bundle (the hashed asset is immutable, so it costs nothing). If no Paskia SSO is detected (dev/no proxy), editing is left open. Pages themselves render identically for everyone; the real gate is the auth proxy in front of all of `/_api`. It first probes `GET /auth/api/settings` to detect whether Paskia SSO is available, then `GET /_api/settings` to learn the current session's admin status. The same reverse proxy that gates `/_api` returns 401 for anonymous users, 403 for users without the admin permission, and 200 for admins. When Paskia is detected, a login link (anonymous) or profile link (logged in) is shown in the banner corner; both are plain `<a href="/auth/">` links (Paskia does not support being iframed, so we navigate normally), and a `pageshow` handler re-probes auth when history navigation restores a cached page. Admins also get the page/banner edit pens and a site-settings pen, plus a `modulepreload` warm-up of the editor bundle (the hashed asset is immutable, so it costs nothing). If no Paskia SSO is detected (dev/no proxy), editing is left open. Pages themselves render identically for everyone; the real gate is the auth proxy in front of all of `/_api`.
+74
View File
@@ -1346,6 +1346,80 @@ figure:has(.margin) {
margin: 0.3rem 1em 1rem 0; margin: 0.3rem 1em 1rem 0;
} }
/* Click-to-enlarge (pagerite.js): article figure images open in a
full-viewport lightbox — the image as large as fits with the caption
below; click or Esc closes. The backdrop keeps a hint of the page
behind it (blur + dark tint) rather than going solid black; themes
retune it via --lightbox-bg / --lightbox-text. */
article figure img {
cursor: zoom-in;
}
#lightbox {
--lightbox-bg: rgb(0 0 0 / 0.68);
--lightbox-text: #e8e8e8;
position: fixed;
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--lightbox-bg);
backdrop-filter: blur(1rem) saturate(0.85);
/* Wheel/touch scrolling stops at the overlay instead of chaining to
the page behind it. overscroll-behavior needs a scroll container,
hence overflow: auto — the content is clamped to fit, so it never
actually scrolls. */
overflow: auto;
overscroll-behavior: contain;
cursor: zoom-out;
}
#lightbox img {
max-width: 100vw;
/* Flex-shrink does the vertical fit: the image yields exactly the
space the caption needs, no fixed reservation. */
max-height: 100%;
flex: 0 1 auto;
min-height: 0;
object-fit: contain;
border-radius: 3px;
box-shadow: 0 1rem 4rem rgb(0 0 0 / 0.55);
}
#lightbox .caption {
max-width: 65ch;
padding: 0.8rem 1rem;
color: var(--lightbox-text);
font-size: 0.95rem;
text-align: center;
opacity: 0.85;
}
@media (prefers-reduced-motion: no-preference) {
#lightbox {
animation: lightbox-in 0.18s ease-out;
}
#lightbox img {
animation: lightbox-img 0.22s ease-out;
}
@keyframes lightbox-in {
from {
opacity: 0;
}
}
@keyframes lightbox-img {
from {
opacity: 0;
transform: scale(0.96);
}
}
}
/* Wide single-column pages: margin boxes lean into the vacant left /* Wide single-column pages: margin boxes lean into the vacant left
gutter instead (below 104rem the gutter cannot hold the box, and while gutter instead (below 104rem the gutter cannot hold the box, and while
editing the docked panel reshapes the gutters — in both they stay editing the docked panel reshapes the gutters — in both they stay
+42
View File
@@ -268,6 +268,41 @@ import "overlayscrollbars/overlayscrollbars.css";
} }
} }
// --- Figure lightbox (click to enlarge) --------------------------------
// Clicking an article figure's image opens it in a full-viewport box:
// the image as large as fits with its caption below; click or Esc
// closes. The zoomed <img> reuses the source URL — /_f/ is immutable
// and content-negotiated, so the large view comes from the browser
// cache at no extra cost.
let lightbox = null;
function closeLightbox() {
lightbox?.remove();
lightbox = null;
}
function openLightbox(figure) {
const img = figure.querySelector("img");
if (!img) return;
closeLightbox();
lightbox = document.createElement("div");
lightbox.id = "lightbox";
const big = document.createElement("img");
big.src = img.currentSrc || img.src;
big.alt = img.alt;
lightbox.append(big);
const cap = figure.querySelector("figcaption");
if (cap) {
const c = document.createElement("div");
c.className = "caption";
c.textContent = cap.textContent;
lightbox.append(c);
}
lightbox.addEventListener("click", closeLightbox);
document.body.append(lightbox);
}
// Any key dismisses the lightbox — Esc included, and the rest would
// only scroll the page behind it anyway.
addEventListener("keydown", () => closeLightbox());
// Tuck the article edit pen at the end of the first h1 (which may come // Tuck the article edit pen at the end of the first h1 (which may come
// from the markdown itself). Re-runs when the editor replaces the // from the markdown itself). Re-runs when the editor replaces the
// previewed article, since that wipes elements inside it. // previewed article, since that wipes elements inside it.
@@ -822,6 +857,13 @@ import "overlayscrollbars/overlayscrollbars.css";
.catch((e) => console.error("editor load failed:", e)); .catch((e) => console.error("editor load failed:", e));
return; return;
} }
// Article figure images enlarge into the lightbox.
const fig = ev.target.closest("#main article figure");
if (fig && ev.target.closest("img")) {
ev.preventDefault();
openLightbox(fig);
return;
}
const a = ev.target.closest("a[href]"); const a = ev.target.closest("a[href]");
if (!a || a.target || a.hasAttribute("download")) return; if (!a || a.target || a.hasAttribute("download")) return;
const url = new URL(a.href, location.href); const url = new URL(a.href, location.href);