diff options
Diffstat (limited to 'reader.html')
| -rw-r--r-- | reader.html | 251 |
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> |
