aboutsummaryrefslogtreecommitdiffstats
path: root/reader.html
diff options
context:
space:
mode:
Diffstat (limited to 'reader.html')
-rw-r--r--reader.html251
1 files changed, 251 insertions, 0 deletions
diff --git a/reader.html b/reader.html
new file mode 100644
index 0000000..5bc7057
--- /dev/null
+++ b/reader.html
@@ -0,0 +1,251 @@
+<!doctype html>
+<!--
+ book-reader: two-page image book viewer.
+ Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ SPDX-License-Identifier: GPL-2.0-only
+-->
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Reader</title>
+<style>
+:root { --ar: .707; --dur: 650ms; --accent: #e8b86a; }
+* { box-sizing: border-box; margin: 0; }
+html, body { height: 100%; overflow: hidden; }
+/* fit-width: pages span the screen, document scrolls vertically */
+html.wide { overflow-y: auto; scrollbar-width: none; }
+html.wide body { height: auto; min-height: 100%; overflow: visible; place-items: start center; }
+body {
+ background: #070607;
+ color: #ddd; font: 14px/1.4 system-ui, sans-serif; user-select: none;
+ display: grid; place-items: center;
+}
+/* own fixed layer: stays put while scrolling, no repaint (unlike background-attachment: fixed) */
+body::before {
+ content: ""; position: fixed; inset: 0; z-index: -1;
+ background: radial-gradient(ellipse at 50% 40%, #2a2522 0%, #121011 60%, #070607 100%);
+}
+#book {
+ position: relative; display: flex; perspective: 2800px;
+ filter: drop-shadow(0 30px 40px rgba(0,0,0,.6));
+}
+.page, #leaf { height: min(100vh, calc(50vw / var(--ar))); aspect-ratio: var(--ar); }
+.wide .page, .wide #leaf { height: calc(50vw / var(--ar)); }
+.page { position: relative; }
+.page img, .face img { width: 100%; height: 100%; object-fit: contain; display: block; }
+img:not([src]) { visibility: hidden; }
+/* spine shading */
+.page.left::after, .page.right::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
+.page.left::after { background: linear-gradient(to left, rgba(0,0,0,.35), transparent 6%); }
+.page.right::after { background: linear-gradient(to right, rgba(0,0,0,.35), transparent 6%); }
+#leaf {
+ position: absolute; top: 0; transform-style: preserve-3d; visibility: hidden;
+ will-change: transform;
+}
+.face { position: absolute; inset: 0; backface-visibility: hidden; }
+.back { transform: rotateY(180deg); }
+.shade { position: absolute; inset: 0; background: #000; opacity: 0; pointer-events: none; }
+
+#hud {
+ position: fixed; left: 50%; bottom: 14px; transform: translateX(-50%);
+ display: flex; gap: 14px; align-items: center; padding: 8px 14px;
+ background: rgba(20,18,18,.75); backdrop-filter: blur(8px); border-radius: 999px;
+ border: 1px solid rgba(255,255,255,.08); transition: opacity .4s;
+}
+body.idle #hud { opacity: 0; }
+body.idle { cursor: none; }
+#bar { width: min(30vw, 260px); height: 4px; background: rgba(255,255,255,.12); border-radius: 2px; overflow: hidden; }
+#fill { height: 100%; width: 0; background: var(--accent); transition: width .3s; }
+button { background: none; border: 0; color: inherit; font: inherit; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
+button:hover, button:focus-visible { background: rgba(255,255,255,.1); outline: none; }
+
+#shelf {
+ position: fixed; inset: 0; padding: 6vh 6vw; overflow-y: auto;
+ background: rgba(8,7,8,.88); backdrop-filter: blur(14px);
+ opacity: 0; pointer-events: none; transition: opacity .25s;
+}
+#shelf.open { opacity: 1; pointer-events: auto; }
+#shelf h1 { font-weight: 300; letter-spacing: .08em; margin-bottom: 24px; }
+#grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 28px; }
+.card { text-align: left; padding: 0; transition: transform .2s; }
+.card:hover, .card:focus-visible { transform: translateY(-4px); background: none; }
+.card img { width: 100%; aspect-ratio: var(--ar); object-fit: cover; border-radius: 4px; box-shadow: 0 10px 25px rgba(0,0,0,.6); }
+.card.current img { outline: 2px solid var(--accent); outline-offset: 3px; }
+.card b { display: block; margin-top: 8px; font-weight: 500; }
+.card small { color: #999; }
+.card .p { height: 3px; background: rgba(255,255,255,.12); margin-top: 6px; }
+.card .p i { display: block; height: 100%; background: var(--accent); }
+#help { color: #888; margin-top: 30px; }
+@media (prefers-reduced-motion: reduce) { :root { --dur: 0ms; } }
+</style>
+</head>
+<body>
+<div id="book">
+ <div class="page left"><img alt=""></div>
+ <div class="page right"><img alt=""></div>
+ <div id="leaf">
+ <div class="face front"><img alt=""><i class="shade"></i></div>
+ <div class="face back"><img alt=""><i class="shade"></i></div>
+ </div>
+</div>
+
+<div id="hud">
+ <button id="books" title="Books (B)">☰</button>
+ <span id="title"></span>
+ <div id="bar"><div id="fill"></div></div>
+ <span id="count"></span>
+ <button id="fit" title="Fit width / height (W)">↔</button>
+ <button id="fs" title="Fullscreen (F)">⛶</button>
+</div>
+
+<div id="shelf">
+ <h1>Library</h1>
+ <div id="grid"></div>
+ <p id="help">→ / Space / PgDn / wheel down: next · ← / PgUp / wheel up: previous · Home / End ·
+ W: fit width / height (wheel and ↑↓ scroll, flip at page end) · C: toggle cover offset · F: fullscreen · B: library · click left/right half to flip</p>
+</div>
+
+<script>
+const $ = s => document.querySelector(s);
+const IMG = /\.(jpe?g|png|webp|gif|avif)$/i;
+const coll = new Intl.Collator(undefined, { numeric: true });
+const dec = s => decodeURIComponent(s);
+
+let store = { pos: {}, last: null, cover: true, wide: false };
+try { Object.assign(store, JSON.parse(localStorage.getItem('reader')) || {}); } catch {}
+const save = () => { try { localStorage.setItem('reader', JSON.stringify(store)); } catch {} };
+
+let books = [], book = null, s = 0, cur = null;
+const L = $('.left img'), R = $('.right img'), leaf = $('#leaf');
+const F = $('.front img'), B = $('.back img'), shades = document.querySelectorAll('.shade');
+
+// Every directory holding images is a book, named after its folder. Needs a server that lists dirs (rc.book-reader).
+async function walk(path) {
+ const html = await (await fetch(path || './')).text();
+ const hrefs = [...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('a[href]')]
+ .map(a => a.getAttribute('href')).filter(h => !/^([?\/#]|\.\.|[a-z]+:)/i.test(h));
+ const pages = hrefs.filter(h => IMG.test(h)).sort((a, b) => coll.compare(dec(a), dec(b))).map(h => path + h);
+ const out = pages.length ? [{ id: path, name: dec(path.slice(0, -1)).split('/').pop(), pages }] : [];
+ for (const d of hrefs.filter(h => h.endsWith('/')).sort((a, b) => coll.compare(dec(a), dec(b))))
+ out.push(...await walk(path + d));
+ return out;
+}
+
+const align = i => i - ((i + (store.cover ? 1 : 0)) % 2); // left index of spread containing page i (may be -1)
+const src = (img, i) => book.pages[i] ? img.src = book.pages[i] : img.removeAttribute('src');
+const first = () => store.cover ? -1 : 0;
+
+function show() {
+ src(L, s); src(R, s + 1);
+ const n = book.pages.length, a = Math.max(s, 0) + 1, b = Math.min(s + 2, n);
+ $('#title').textContent = book.name;
+ $('#count').textContent = (a === b ? a : `${a}–${b}`) + ` / ${n}`;
+ $('#fill').style.width = (b / n * 100) + '%';
+ store.pos[book.id] = Math.max(s, 0); store.last = book.id; save();
+ for (let i = s - 2; i < s + 6; i++) if (book.pages[i]) new Image().src = book.pages[i]; // preload
+}
+
+function finish() { if (cur) { cur.forEach(a => a.cancel()); cur = null; leaf.style.visibility = 'hidden'; show(); } }
+
+function go(t) {
+ finish();
+ t = Math.max(first(), Math.min(align(t), align(book.pages.length - 1)));
+ if (t === s) return;
+ const fwd = t > s, old = s; s = t;
+ scrollTo(0, fwd ? 0 : document.documentElement.scrollHeight); // back: land at the bottom of the spread
+ // base shows the halves that stay put during the turn; the leaf carries the turning page
+ if (fwd) { src(R, t + 1); src(F, old + 1); src(B, t); }
+ else { src(L, t); src(F, old); src(B, t + 1); }
+ Object.assign(leaf.style, { left: fwd ? '50%' : '0', transformOrigin: fwd ? 'left' : 'right', visibility: 'visible' });
+ const opt = { duration: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--dur')) || 0,
+ easing: 'cubic-bezier(.645,.045,.355,1)', fill: 'forwards' };
+ cur = [
+ leaf.animate([{ transform: 'rotateY(0)' }, { transform: `rotateY(${fwd ? -180 : 180}deg)` }], opt),
+ shades[0].animate([{ opacity: 0 }, { opacity: .5 }], opt),
+ shades[1].animate([{ opacity: .5 }, { opacity: 0 }], opt),
+ ];
+ cur[0].onfinish = finish;
+}
+
+function open(b) {
+ finish(); book = b; $('#shelf').classList.remove('open');
+ const probe = new Image();
+ probe.onload = () => document.documentElement.style.setProperty('--ar', probe.naturalWidth / probe.naturalHeight);
+ probe.src = b.pages[0];
+ s = align(store.pos[b.id] ?? 0); show();
+}
+
+function shelf() {
+ const g = $('#grid'); g.replaceChildren();
+ for (const b of books) {
+ const c = document.createElement('button'), img = new Image(), name = document.createElement('b'),
+ sm = document.createElement('small'), p = document.createElement('div'), pi = document.createElement('i');
+ const at = Math.min((store.pos[b.id] ?? -1) + 2, b.pages.length);
+ c.className = 'card' + (b === book ? ' current' : '');
+ img.loading = 'lazy'; img.src = b.pages[0]; img.alt = '';
+ name.textContent = b.name;
+ sm.textContent = `${b.pages.length} pages` + (store.pos[b.id] != null ? ` · at ${Math.max(at, 1)}` : '');
+ p.className = 'p'; pi.style.width = (store.pos[b.id] != null ? at / b.pages.length * 100 : 0) + '%';
+ p.append(pi); c.append(img, name, sm, p); c.onclick = () => open(b); g.append(c);
+ }
+ $('#shelf').classList.add('open'); g.querySelector('.current')?.focus();
+}
+
+const toggleShelf = () => $('#shelf').classList.contains('open') ? book && $('#shelf').classList.remove('open') : shelf();
+const fullscreen = () => document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen();
+
+addEventListener('keydown', e => {
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
+ if ($('#shelf').classList.contains('open')) { if (e.key === 'Escape' || e.key === 'b') toggleShelf(); return; }
+ const k = e.key, act = {
+ ArrowRight: () => go(s + 2), PageDown: () => page(1), ' ': () => page(e.shiftKey ? -1 : 1), l: () => go(s + 2),
+ ArrowLeft: () => go(s - 2), PageUp: () => page(-1), Backspace: () => go(s - 2), h: () => go(s - 2),
+ Home: () => go(first()), End: () => go(book.pages.length - 1),
+ c: () => { finish(); store.cover = !store.cover; s = align(Math.max(s, 0)); show(); },
+ f: fullscreen, b: toggleShelf, w: fit,
+ }[k];
+ if (act) { e.preventDefault(); act(); }
+});
+
+const atEdge = d => d > 0 ? scrollY + innerHeight >= document.documentElement.scrollHeight - 2 : scrollY <= 0;
+// fit-width: scroll a screen, flip only once the end of the spread is on screen
+function page(d) {
+ if (store.wide && !atEdge(d)) scrollBy({ top: d * innerHeight * .85, behavior: 'smooth' });
+ else go(s + 2 * d);
+}
+function fit() {
+ store.wide = !store.wide; save();
+ document.documentElement.classList.toggle('wide', store.wide); scrollTo(0, 0);
+}
+document.documentElement.classList.toggle('wide', store.wide);
+
+let lastWheel = 0, lastScroll = 0;
+addEventListener('scroll', () => lastScroll = performance.now());
+addEventListener('wheel', e => {
+ if ($('#shelf').classList.contains('open') || !book) return;
+ const now = performance.now();
+ // fit-width: native scroll; flip needs a fresh push after the edge was reached (stops momentum from flipping)
+ if (store.wide && (!atEdge(e.deltaY) || now - lastScroll < 400)) return;
+ e.preventDefault();
+ if (now - lastWheel < 350 || Math.abs(e.deltaY) < 4) return; // ponytail: fixed throttle tames touchpad bursts
+ lastWheel = now; go(s + (e.deltaY > 0 ? 2 : -2));
+}, { passive: false });
+
+$('#book').onclick = e => go(s + (e.clientX > innerWidth / 2 ? 2 : -2));
+$('#books').onclick = toggleShelf; $('#fs').onclick = fullscreen; $('#fit').onclick = fit;
+
+let idle;
+addEventListener('mousemove', () => {
+ document.body.classList.remove('idle'); clearTimeout(idle);
+ idle = setTimeout(() => document.body.classList.add('idle'), 2500);
+});
+
+walk('').then(b => {
+ books = b;
+ if (!books.length) return $('#title').textContent = 'No images found under the library directory';
+ open(books.find(x => x.id === store.last) || books[0]);
+}).catch(() => $('#title').textContent = 'Directory listing failed: start rc.book-reader and open its URL, not file://');
+</script>
+</body>
+</html>