'use strict'; /* * Chapter reader. * - Modes: flow (all pages below each other, same width), page (one page), book (two facing pages; double pages alone). * - Page zoom ("zoom" below): 1 = the page (in flow: a normal single page) exactly fits the screen. * It is remembered per mode; flow for webtoons has its own value ("strip", 1 = strip as wide as the screen / its own width). * - Tap zones: left/right third turns pages following the reading direction (top/bottom third in flow), the middle toggles the bars. * - All pages are laid out with absolute positions computed from the image sizes the server knows, so zooming and * restoring positions never depend on images having loaded. */ (async function () { const P = new URLSearchParams(location.search); const stage = $('#stage'), canvas = $('#canvas'), slider = $('#slider'), ver = $('#ver'); let me, D; try { [me, D] = await Promise.all([api('api/me'), api('api/chapter?' + qsa({ m: P.get('m'), c: P.get('c'), v: P.get('v') }))]); } catch (e) { canvas.textContent = ''; canvas.append(h('p', { class: 'loading' }, e.message + ' · ', h('a', { href: './' }, 'Library'))); return; } const S = me.settings, rtl = D.direction === 'rtl', ttb = D.direction === 'ttb'; const GAP = ttb ? 0 : 8, END_H = 280; document.title = `${D.chapter.label} · ${D.manga.title}`; // ---------- pages ---------- const all = D.pages.map((p, i) => { const t = p.t || [], r = p.r || 0, unknown = !p.w || !p.h; const w = unknown ? (t.includes('double') ? 2000 : 1000) : p.w, hh = unknown ? 1414 : p.h; const dw = r % 180 ? hh : w, dh = r % 180 ? w : hh; // size as displayed (after rotation) return { i, u: p.u, r, w: dw, a: dw / dh, unknown, cover: t.includes('cover'), extra: t.includes('extra'), wide: t.includes('double') || dw > dh }; }); let list = all.filter(p => !(S.skipExtras && p.extra) && !(S.skipCovers && p.cover)); if (!list.length) list = all; const median = xs => (xs.length ? xs.sort((x, y) => x - y)[xs.length >> 1] : 0); const refA = median(list.filter(p => !p.wide).map(p => p.a)) || 0.7; // aspect ratio of a normal single page const refW = median(list.map(p => p.w)) || 800; // natural width of a webtoon strip const solo = p => p.wide || p.cover; // shown alone in book mode // ---------- state ---------- const compatible = ttb ? ['flow'] : ['flow', 'page', 'book']; const intended = S.startMode === 'last' ? S.lastMode : S.startMode; let mode = S.adapt && !compatible.includes(intended) ? compatible[0] : intended; if (!['flow', 'page', 'book'].includes(mode)) mode = compatible[0]; let cur = 0, atEnd = false, done = !!(D.progress && D.progress.done), bars = true, barsAt = 0; let spreads = [], spreadIdx = 0, shown = [], els = [], boxes = [], endEl = null, lastW = 0, lastH = 0; const shift = new Set(D.progress && D.progress.v === D.version ? D.progress.shift : []); const zkey = () => (mode === 'flow' && ttb ? 'strip' : mode); const zoom = () => S.zoom[zkey()] || 1; const pr = D.progress; if (pr && !(pr.done && pr.p >= pr.n - 1)) { const p = pr.v === D.version || !pr.n ? pr.p : Math.round((pr.p / pr.n) * all.length); const k = list.findIndex(x => x.i >= p); cur = k < 0 ? list.length - 1 : k; } // read.html?...&p= opens that page (jump links from Who's here). if (P.get('p') !== null && /^\d+$/.test(P.get('p'))) { const k = list.findIndex(x => x.i >= +P.get('p')); if (k >= 0) cur = k; } // Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover, // so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page. function computeSpreads() { const out = []; for (let i = 0; i < list.length;) { if (solo(list[i])) { out.push([i++]); continue; } let j = i, k = i; while (j < list.length && !solo(list[j])) j++; if (shift.has(list[i].i)) out.push([k++]); for (; k < j; k += 2) out.push(k + 1 < j ? [k, k + 1] : [k]); i = j; } return out; } const runStart = k => { while (k > 0 && !solo(list[k - 1])) k--; return k; }; // ---------- rendering ---------- const io = window.IntersectionObserver ? new IntersectionObserver(entries => { for (const e of entries) if (e.isIntersecting) { setSrc(e.target, e.target._p.u); io.unobserve(e.target); } }, { root: stage, rootMargin: '150% 50%' }) : null; // Page/book mode: preloaded images are downloaded *and decoded* ahead of time and then shown as the very same // elements, so a page turn swaps pictures within one frame instead of flashing an empty box. const pool = new Map(); function pooled(url, priority) { let img = pool.get(url); if (img) pool.delete(url); else { img = new Image(); img.decoding = 'async'; img.fetchPriority = priority; img.src = url; if (img.decode) img.decode().catch(() => {}); } pool.set(url, img); // most recently used last while (pool.size > 12) pool.delete(pool.keys().next().value); return img; } function preloadAround() { const ahead = mode === 'page' ? [cur + 1, cur + 2, cur + 3, cur - 1] : [].concat(spreads[spreadIdx + 1] || [], spreads[spreadIdx + 2] || [], spreads[spreadIdx - 1] || []); for (const k of ahead) if (list[k]) pooled(list[k].u, 'low'); } // A spinner shows (after a short delay, see CSS) while a page is still loading, a message if it failed. function watch(el) { const img = el.firstChild, p = el._p; const state = () => { el.classList.toggle('loading', !!img.getAttribute('src') && !img.complete); el.classList.toggle('failed', img.complete && !!img.getAttribute('src') && !img.naturalWidth); }; img.onload = () => { state(); if (p.unknown && img.naturalWidth) { const w = p.r % 180 ? img.naturalHeight : img.naturalWidth, hh = p.r % 180 ? img.naturalWidth : img.naturalHeight; Object.assign(p, { unknown: false, w, a: w / hh, wide: p.wide || w > hh }); relayout(); } }; img.onerror = state; state(); } function setSrc(el, url) { el.firstChild.src = url; watch(el); } function pageEl(p, lazy) { const img = lazy ? h('img', { decoding: 'async' }) : pooled(p.u, 'high'); img.alt = ''; img.draggable = false; img.style.cssText = p.r ? `transform: translate(-50%, -50%) rotate(${p.r}deg)` : ''; if (!lazy) img.decoding = 'sync'; // paint together with the page turn (it is decoded already when preloaded) const el = h('div', { class: 'pg' + (p.r ? ' rot' : '') }, img); el._p = p; if (!lazy) watch(el); else if (io) io.observe(el); else setSrc(el, p.u); return el; } function endCard() { const n = D.next; return h('div', { class: 'end' }, h('div', { class: 'endbox' }, h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')), n ? h('a', { class: 'btn primary', href: readUrl(D.manga.id, n.id), onclick: () => saveProgress(true) }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`) : h('div', {}, 'You are up to date.'), h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title))); } function build() { if (io) io.disconnect(); canvas.textContent = ''; canvas.className = mode + (ttb ? ' ttb' : ''); els = []; shown = []; endEl = null; if (mode === 'flow') { els = list.map(p => canvas.appendChild(pageEl(p, true))); endEl = canvas.appendChild(endCard()); } else if (atEnd) { endEl = canvas.appendChild(endCard()); } else { shown = mode === 'page' ? [cur] : spreads[spreadIdx]; els = shown.map(k => canvas.appendChild(pageEl(list[k]))); // The pages on screen get the bandwidth first, then the next ones are fetched. const token = ++buildToken, waiting = els.map(el => el.firstChild).filter(img => !img.complete); Promise.all(waiting.map(img => new Promise(done => { img.addEventListener('load', done, { once: true }); img.addEventListener('error', done, { once: true }); }))).then(() => { if (token === buildToken) preloadAround(); }); } layout(); } let buildToken = 0; // Scrollbars appear/disappear with the content size and change the viewport, so repeat until it is stable. function layout() { for (let pass = 0; pass < 3; pass++) { const vw = stage.clientWidth, vh = stage.clientHeight; layoutFor(vw, vh); if (stage.clientWidth === vw && stage.clientHeight === vh) break; } } function layoutFor(vw, vh) { const z = zoom(); let cw = vw, ch = vh; boxes = []; if (mode === 'flow') { // Every page gets the width of a normal single page; wide pages keep that height but never get wider than the screen. const W = (ttb ? Math.min(vw, refW) : Math.min(vw, vh * refA)) * z; const ws = list.map(p => (p.wide && !ttb ? Math.min((W * p.a) / refA, Math.max(vw, W)) : W)); cw = Math.max(vw, ...ws); let y = 0; ws.forEach((w, k) => { boxes.push({ x: (cw - w) / 2, y, w, h: w / list[k].a }); y += w / list[k].a + GAP; }); endEl.style.top = y + 'px'; endEl.style.height = END_H + 'px'; ch = y + END_H; } else if (shown.length) { const sum = shown.reduce((s, k) => s + list[k].a, 0), H = Math.min(vh, vw / sum) * z, W = H * sum; cw = Math.max(vw, W); ch = Math.max(vh, H); const y = (ch - H) / 2, order = shown.map((k, j) => j); if (rtl) order.reverse(); // right-to-left: the first page of a pair sits on the right let x = (cw - W) / 2; for (const j of order) { boxes[j] = { x, y, w: H * list[shown[j]].a, h: H }; x += boxes[j].w; } } canvas.style.width = cw + 'px'; canvas.style.height = ch + 'px'; els.forEach((el, j) => place(el, boxes[j])); stage.classList.toggle('pan', cw > vw + 1 || (mode === 'flow' ? z > 1.01 : ch > vh + 1)); lastW = vw; lastH = vh; } function place(el, b) { const s = el.style, x = Math.round(b.x), y = Math.round(b.y), w = Math.round(b.x + b.w) - x, hh = Math.round(b.y + b.h) - y; s.left = x + 'px'; s.top = y + 'px'; s.width = w + 'px'; s.height = hh + 'px'; if (el._p.r) { const im = el.firstChild.style, turned = el._p.r % 180; im.width = (turned ? hh : w) + 'px'; im.height = (turned ? w : hh) + 'px'; } } function flowIndexAt(y) { let lo = 0, hi = boxes.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (boxes[mid].y <= y) lo = mid; else hi = mid - 1; } return lo; } function union() { const x = Math.min(...boxes.map(b => b.x)); return { x, y: boxes[0].y, w: Math.max(...boxes.map(b => b.x + b.w)) - x, h: boxes[0].h }; } // Remember which point of which page is under (cx, cy) and put it back there after a re-layout. function anchor(cx, cy) { if (!boxes.length) return null; const X = stage.scrollLeft + cx, Y = stage.scrollTop + cy, k = mode === 'flow' ? flowIndexAt(Y) : -1, b = k < 0 ? union() : boxes[k]; return { k, fx: (X - b.x) / b.w, fy: (Y - b.y) / b.h }; } function restore(a, cx, cy) { if (!a || !boxes.length) return; const b = a.k < 0 ? union() : boxes[a.k]; stage.scrollLeft = b.x + a.fx * b.w - cx; stage.scrollTop = b.y + a.fy * b.h - cy; barsAt = stage.scrollTop; } function relayout() { const a = anchor(lastW / 2, lastH / 2); layout(); restore(a, lastW / 2, lastH / 2); } // ---------- zoom ---------- let zoomTimer = 0; function setZoom(z, cx = stage.clientWidth / 2, cy = stage.clientHeight / 2) { z = Math.min(6, Math.max(0.2, z)); if (Math.abs(z - 1) < 0.03) z = 1; // easy to get back to "fit" if (Math.abs(z - zoom()) < 0.001) return; const a = anchor(cx, cy); S.zoom[zkey()] = z; layout(); restore(a, cx, cy); toast(Math.round(z * 100) + '%'); updateUI(); clearTimeout(zoomTimer); zoomTimer = setTimeout(() => api('api/settings', { zoom: S.zoom }).catch(() => {}), 800); } // Ctrl + wheel (and touchpad pinch, which browsers report the same way) zooms the pages, not the page controls. stage.addEventListener('wheel', e => { if (!e.ctrlKey) return; e.preventDefault(); setZoom(zoom() * Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.05 : 0.0025)), e.clientX, e.clientY); }, { passive: false }); // Touch: two fingers zoom, a quick horizontal swipe turns the page (page/book mode, when not zoomed in). let pinch = null, swipe = null, pinchRaf = 0, pinchNext = null; const pinchApply = () => { pinchRaf = 0; if (pinchNext) setZoom(...pinchNext); pinchNext = null; }; const dist = t => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); stage.addEventListener('touchstart', e => { const t = e.touches; if (t.length === 2) { pinch = { d: dist(t), z: zoom() }; swipe = null; } else if (t.length === 1) swipe = { x: t[0].clientX, y: t[0].clientY, t: Date.now() }; }, { passive: true }); stage.addEventListener('touchmove', e => { if (!pinch || e.touches.length !== 2) return; e.preventDefault(); const t = e.touches; pinchNext = [pinch.z * dist(t) / pinch.d, (t[0].clientX + t[1].clientX) / 2, (t[0].clientY + t[1].clientY) / 2]; pinchRaf = pinchRaf || requestAnimationFrame(pinchApply); }, { passive: false }); stage.addEventListener('touchend', e => { if (e.touches.length < 2) { if (pinchNext) pinchApply(); pinch = null; } const s = swipe; swipe = null; if (!s || mode === 'flow' || e.touches.length) return; const t = e.changedTouches[0], dx = t.clientX - s.x, dy = t.clientY - s.y; if (Math.abs(dx) < 50 || Math.abs(dx) < 2 * Math.abs(dy) || Date.now() - s.t > 800) return; if (stage.scrollWidth > stage.clientWidth + 1) return; // zoomed in: the finger pans instead ((dx > 0) === rtl ? next : prev)(); }); // Safari reports pinches as gesture events; without this it would zoom the whole page. let gestureZoom = 1; stage.addEventListener('gesturestart', e => { e.preventDefault(); gestureZoom = zoom(); }); stage.addEventListener('gesturechange', e => { e.preventDefault(); if (!pinch) setZoom(gestureZoom * e.scale, e.clientX, e.clientY); }); // Mouse: drag to move a zoomed page around; a click without dragging uses the tap zones. let drag = null, dragged = false; stage.addEventListener('pointerdown', e => { if (e.pointerType === 'mouse' && e.button === 0) drag = { x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, moved: false }; }); addEventListener('pointermove', e => { if (!drag) return; const dx = e.clientX - drag.x, dy = e.clientY - drag.y; if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 6) return; drag.moved = true; stage.classList.add('drag'); stage.scrollLeft = drag.l - dx; stage.scrollTop = drag.t - dy; }); addEventListener('pointerup', () => { if (drag && drag.moved) { dragged = true; setTimeout(() => { dragged = false; }, 0); } drag = null; stage.classList.remove('drag'); }); // Outside the pages: toggle the controls. On a page: the outer 35 % of the visible page area turn pages // (top/bottom in flow), the middle toggles the controls (needed when a zoomed page fills the whole screen). stage.addEventListener('click', e => { if (dragged || e.target.closest('a, button, select, input')) return; const onPage = e.target.closest('.pg'), vw = stage.clientWidth; if (!onPage && !(atEnd && mode !== 'flow')) return toggleBars(); if (mode === 'flow') { const fy = e.clientY / stage.clientHeight; return fy < 0.35 ? prev() : fy > 0.65 ? next() : toggleBars(); } let left = 0, right = vw; if (!atEnd) { const rects = els.map(el => el.getBoundingClientRect()); left = Math.max(0, Math.min(...rects.map(r => r.left))); right = Math.min(vw, Math.max(...rects.map(r => r.right))); } const fx = (e.clientX - left) / (right - left); if (fx < 0.35) (rtl ? next : prev)(); else if (fx > 0.65) (rtl ? prev : next)(); else toggleBars(); }); addEventListener('keydown', e => { if ((e.target.closest && e.target.closest('input, select, textarea')) || e.ctrlKey || e.metaKey || e.altKey) return; const k = e.key; if (k === 'ArrowLeft' || k === 'ArrowRight') { e.preventDefault(); (k === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); } else if ((k === ' ' || k === 'PageDown') && mode !== 'flow') { e.preventDefault(); (e.shiftKey ? prev : next)(); } else if (k === 'PageUp' && mode !== 'flow') { e.preventDefault(); prev(); } else if (k === '+' || k === '=') setZoom(zoom() * 1.15); else if (k === '-') setZoom(zoom() / 1.15); else if (k === '0') setZoom(1); else if (k === 'Escape' && senpai && senpai.close()) return; else if (k === 'm' || k === 'Escape') toggleBars(); else if (k === 'f') toggleFullscreen(); else if (k === 'x' && senpai) senpai.toggle(); }); let scrollRaf = 0; stage.addEventListener('scroll', () => { if (mode !== 'flow' || scrollRaf) return; scrollRaf = requestAnimationFrame(() => { scrollRaf = 0; cur = flowIndexAt(stage.scrollTop + stage.clientHeight * 0.3); // Scrolling (up or down) only ever hides the bars; they come back with a tap in the middle. if (bars && Math.abs(stage.scrollTop - barsAt) > 100) autoHide(); moved(); }); }); let resizeRaf = 0; addEventListener('resize', () => { resizeRaf = resizeRaf || requestAnimationFrame(() => { resizeRaf = 0; relayout(); }); }); // ---------- navigation ---------- function next() { if (mode === 'flow') return flowStep(1); if (atEnd) return goChapter(D.next); if (mode === 'page' ? cur >= list.length - 1 : spreadIdx >= spreads.length - 1) atEnd = true; else if (mode === 'page') cur++; else cur = spreads[++spreadIdx][0]; turned(); } function prev() { if (mode === 'flow') return flowStep(-1); if (atEnd) atEnd = false; else if (mode === 'page' ? cur === 0 : spreadIdx === 0) return toast('First page'); else if (mode === 'page') cur--; else cur = spreads[--spreadIdx][0]; turned(); } function turned() { build(); toStart(); autoHide(); moved(); } function toStart() { stage.scrollTop = 0; stage.scrollLeft = rtl ? stage.scrollWidth : 0; barsAt = 0; } function goChapter(c) { if (!c) return toast('You are up to date'); saveProgress(true); location.href = readUrl(D.manga.id, c.id); } // Flow: jump to the top of the next page, but never further than ~one screen. function flowStep(dir) { const st = stage.scrollTop, vh = stage.clientHeight; if (dir > 0 && st + vh >= stage.scrollHeight - 2) return goChapter(D.next); let t; if (ttb) t = st + dir * vh * 0.85; // webtoon slices are arbitrary cuts: just scroll else if (dir > 0) { const b = boxes.find(b => b.y > st + 2); t = b ? Math.min(b.y, st + vh * 0.9) : st + vh * 0.9; } else { let b = null; for (const x of boxes) { if (x.y < st - 2) b = x; else break; } t = b ? Math.max(b.y, st - vh * 0.9) : 0; } stage.scrollTo({ top: t, behavior: 'smooth' }); } function goTo(k) { atEnd = false; if (mode === 'flow') { stage.scrollTop = boxes[k].y; barsAt = stage.scrollTop; cur = k; return moved(); } if (mode === 'book') { spreadIdx = spreads.findIndex(s => s.includes(k)); cur = spreads[spreadIdx][0]; } else cur = k; build(); toStart(); moved(); } function setMode(m, byUser) { mode = m; atEnd = false; if (mode === 'book') { spreads = computeSpreads(); spreadIdx = Math.max(0, spreads.findIndex(s => s.includes(cur))); cur = spreads[spreadIdx][0]; } build(); if (mode === 'flow') { stage.scrollTop = boxes[cur].y; stage.scrollLeft = (stage.scrollWidth - stage.clientWidth) / 2; barsAt = stage.scrollTop; } else toStart(); // Only a click changes the remembered mode, never an automatic switch for incompatible content. if (byUser) { S.lastMode = m; api('api/settings', { lastMode: m }).catch(() => {}); } moved(); } function toggleShift() { const k = shown[0]; if (k === undefined || solo(list[k])) return toast('This page is shown alone anyway'); const s = list[runStart(k)].i; if (shift.has(s)) shift.delete(s); else shift.add(s); spreads = computeSpreads(); spreadIdx = spreads.findIndex(sp => sp.includes(k)); cur = spreads[spreadIdx][0]; build(); toStart(); moved(); toast(shift.has(s) ? 'Pairs shifted by one page' : 'Pairs back to normal'); } // ---------- bars, progress ---------- function setBars(on) { // Stop a running smooth scroll first, otherwise it would hide the bars again right away. if (on) stage.scrollTo({ left: stage.scrollLeft, top: stage.scrollTop }); bars = on; document.body.classList.toggle('nobars', !on); barsAt = stage.scrollTop; } function toggleBars() { setBars(!bars); } // Reading hides the bars. The first time (per device and per kind of mode) say how to get them back. function autoHide() { if (!bars) return; setBars(false); const key = mode === 'flow' ? 'hint.flow' : 'hint.paged'; if (store.get(key)) return; store.set(key, '1'); toast(mode === 'flow' ? 'Controls hidden · tap the middle of the screen or beside the pages to show them' : 'Controls hidden · tap the middle of the page or beside it to show them', 3500); } let toastTimer = 0; function toast(msg, ms = 900) { const t = $('#toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), ms); } const modeBtns = [...document.querySelectorAll('#modes button')]; function updateUI() { const n = list.length; $('#pageno').textContent = atEnd ? 'end' : (shown.length === 2 ? `${cur + 1}-${cur + 2}` : cur + 1) + ' / ' + n; slider.max = n; slider.value = cur + 1; for (const b of modeBtns) { b.classList.toggle('on', b.dataset.mode === mode); b.classList.toggle('meh', !compatible.includes(b.dataset.mode)); } const sh = $('#shift'); sh.hidden = mode !== 'book'; sh.classList.toggle('on', shown.length > 0 && !solo(list[shown[0]]) && shift.has(list[runStart(shown[0])].i)); $('#zoom').textContent = Math.round(zoom() * 100) + '%'; } let saveTimer = 0, senpai = null; function moved() { if (mode === 'flow') { const b = boxes[boxes.length - 1]; if (b && stage.scrollTop + stage.clientHeight >= b.y + b.h - 4) { done = true; cur = list.length - 1; } } else if (atEnd || (mode === 'page' ? cur === list.length - 1 : spreadIdx === spreads.length - 1)) done = true; updateUI(); clearTimeout(saveTimer); saveTimer = setTimeout(saveProgress, 700); if (senpai) senpai.moved(); } function saveProgress(keepalive) { clearTimeout(saveTimer); const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift] }); return fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {}); } addEventListener('pagehide', () => saveProgress(true)); // ---------- controls ---------- $('#title').textContent = D.manga.title; // Chapter picker: every chapter of the manga, grouped by book when there is more than one. const chsel = $('#chsel'), groups = {}; for (const b of D.books) { groups[b.key] = D.books.length > 1 ? chsel.appendChild(h('optgroup', { label: b.label + (b.title ? ' · ' + b.title : '') })) : chsel; } for (const c of D.chapters) { groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`)); } chsel.value = D.chapter.id; chsel.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, chsel.value); }; $('#back').href = mangaUrl(D.manga.id); for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) { const a = $(id); if (c) { a.href = readUrl(D.manga.id, c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => saveProgress(true); } else a.classList.add('disabled'); } for (const v of D.versions) ver.append(h('option', { value: v.id }, (v.source || v.id) + (v.score ? ` (${v.score > 0 ? '+' : ''}${v.score})` : ''))); ver.value = D.version; ver.hidden = D.versions.length < 2; ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); }; for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); }; $('#shift').onclick = toggleShift; $('#zoom').onclick = () => setZoom(1); slider.dir = rtl ? 'rtl' : 'ltr'; slider.addEventListener('input', () => goTo(+slider.value - 1)); function toggleFullscreen() { if (!document.fullscreenEnabled) return; if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen().catch(() => {}); } $('#fs').hidden = !document.fullscreenEnabled; $('#fs').onclick = toggleFullscreen; // Keep keyboard focus on the pages so Space/arrows never "press" the last clicked button again. for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); }); // ---------- Who's here (Senpai, optional: only when the server has SENPAI_URL) ---------- // A side sheet in the .panel style with three tabs and a character card. Everything comes from // api/senpai/v1/..., which the server answers only up to this user's reading horizon. function whosHere() { const sheet = h('aside', { class: 'panel sheet', hidden: true, 'aria-label': "Who's here" }); const body = h('div', { class: 'sheet-body' }), sub = h('div', { class: 'muted small' }); const tabs = [['page', 'This page'], ['chapter', 'This chapter'], ['so_far', 'So far']]; const tabBtns = tabs.map(([id, label]) => h('button', { 'data-tab': id, onclick: () => { tab = id; store.set('senpai.tab', id); card = null; unmark(); render(); } }, label)); const option = (key, label, on) => h('label', { class: 'check small' }, h('input', { type: 'checkbox', checked: store.get(key) === on, onchange: e => { store.set(key, e.target.checked ? on : ''); card = null; refresh(); } }), h('span', {}, label)); sheet.append( h('div', { class: 'sheet-head' }, h('div', { class: 'grow' }, h('b', {}, "Who's here"), sub), h('button', { class: 'btn icon', title: 'Close (Esc)', 'aria-label': 'Close', onclick: () => close() }, '×')), h('div', { class: 'seg' }, tabBtns), body, h('div', { class: 'sheet-foot' }, option('senpai.asof', 'Only up to this page (for re-reading)', 'page'), option('senpai.proposed', 'Show unconfirmed (machine) findings', '1'))); document.body.append(sheet); let tab = ['page', 'chapter', 'so_far'].includes(store.get('senpai.tab')) ? store.get('senpai.tab') : 'page'; let data = null, card = null, loadToken = 0, movedTimer = 0, marks = []; const opts = extra => qsa({ m: D.manga.id, asof: store.get('senpai.asof') === 'page' ? 'page' : null, proposed: store.get('senpai.proposed') === '1' ? '1' : null, ...extra }); const pagesShown = () => (mode === 'flow' || atEnd || !shown.length ? [list[cur]] : shown.map(k => list[k])).filter(Boolean).map(p => p.i); const sp = path => 'api/senpai/' + path; const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; function unmark() { for (const m of marks) m.remove(); marks = []; } // Outline a character's figures on the visible page(s); boxes are in page pixels. function mark(item) { unmark(); if (!item.boxes || !item.page_size || !item.page_size[0]) return; const [w, hh] = item.page_size; for (const b of item.boxes) { for (const el of els) { if (el._p.i !== b.page || el._p.r) continue; const [x1, y1, x2, y2] = b.box; const box = h('div', { class: 'sp-box' }); box.style.cssText = `left:${(x1 / w) * 100}%;top:${(y1 / hh) * 100}%;width:${((x2 - x1) / w) * 100}%;height:${((y2 - y1) / hh) * 100}%`; el.append(box); marks.push(box); } } } function pic(item, cls) { const none = () => h('div', { class: (cls || 'pic') + ' none' }, item.name ? item.name.slice(0, 1).toUpperCase() : '?'); return item.portrait_url ? h('img', { class: cls || 'pic', src: sp(item.portrait_url), alt: '', loading: 'lazy', onerror: e => e.target.replaceWith(none()) }) : none(); } function who(item, detail) { return h('button', { class: 'who', onclick: () => { mark(item); openCard(item); }, onmouseenter: () => mark(item), onmouseleave: () => { if (!card) unmark(); } }, pic(item), h('div', { class: 'grow' }, h('div', { class: item.name ? 'nm' : 'nm unnamed' }, item.name || item.descriptor || 'unknown'), h('div', { class: 'muted small' }, detail)), item.prominence ? h('span', { class: 'sp-chip' }, item.prominence) : null, item.confirmed === false ? h('span', { class: 'sp-chip' }, 'unconfirmed') : null); } // Jump links stay inside the reader: same chapter and version -> go to the page, otherwise open it. function jumpLink(j, label, text) { const same = j.c === D.chapter.id && j.v === D.version; return h('a', { href: readUrl(j.m, j.c, j.v, j.p), onclick: e => { if (!same) { saveProgress(true); return; } e.preventDefault(); const k = list.findIndex(x => x.i >= j.p); if (k >= 0) goTo(k); } }, h('div', { class: 'muted small' }, label), text ? h('div', {}, text) : null); } function render() { for (const b of tabBtns) b.classList.toggle('on', b.dataset.tab === tab && !card); body.textContent = ''; if (card) return renderCard(); if (!data) return body.append(h('p', { class: 'muted' }, 'Loading…')); if (data.error) return body.append(h('p', { class: 'error' }, data.error)); sub.textContent = data.horizon && data.horizon.label ? 'What you know up to ' + data.horizon.label : 'Nothing read yet'; if (tab === 'page') { const named = data.page.filter(x => x.kind === 'character'), figures = data.page.filter(x => x.kind !== 'character'); if (data.beyond_horizon) body.append(h('p', { class: 'muted' }, "You haven't read this far yet.")); else if (!data.page.length) body.append(h('p', { class: 'muted' }, data.coverage.page_has_evidence ? 'Nobody on this page.' : 'Senpai has no notes for this page yet.')); for (const x of named) body.append(who(x, x.voice_only ? 'heard, not seen' : x.lines ? plural(x.lines, 'line') : 'seen')); if (figures.length) { const more = h('div', { hidden: true }, figures.map(x => who(x, x.lines ? plural(x.lines, 'line') : 'seen'))); body.append(h('button', { class: 'btn small sp-more', onclick: () => { more.hidden = !more.hidden; } }, `${plural(figures.length, 'unknown figure')} on this page`), more); } if (data.crowd_on_page) body.append(h('p', { class: 'muted small' }, 'and a crowd')); } else if (tab === 'chapter') { if (!data.chapter.length) body.append(h('p', { class: 'muted' }, 'Senpai has no notes for this chapter up to here.')); for (const x of data.chapter) body.append(who(x, `${plural(x.pages, 'page')} · ${plural(x.lines, 'line')} in this chapter`)); if (data.chapter_unnamed_figures) body.append(h('p', { class: 'muted small' }, `and ${plural(data.chapter_unnamed_figures, 'unnamed figure')}`)); } else { let any = false; for (const [key, label] of [['lead', 'Lead'], ['frequent', 'Frequent'], ['occasional', 'Occasional'], ['minor', 'Minor']]) { if (!data.so_far[key].length) continue; any = true; body.append(h('div', { class: 'tier' }, label)); for (const x of data.so_far[key]) body.append(who(x, `${plural(x.pages, 'page')} · ${plural(x.lines, 'line')} so far`)); } if (!any) body.append(h('p', { class: 'muted' }, 'Senpai has no notes up to here.')); body.append(h('p', { class: 'muted small' }, `Based on ${plural(data.coverage.pages_so_far, 'page')} with notes, up to where you are.`)); } } async function openCard(item) { const mine = (card = { item, info: null, time: null, error: null }); render(); try { const [info, time] = await Promise.all([api(sp(`v1/characters/${item.ref}?` + opts())), api(sp(`v1/characters/${item.ref}/timeline?` + opts()))]); Object.assign(mine, { info, time }); } catch (e) { mine.error = e.message; } if (card === mine) render(); } function renderCard() { const { item, info, time, error } = card, c = info || item; body.append(h('button', { class: 'btn small', onclick: () => { card = null; unmark(); render(); } }, '‹ Back'), h('div', { class: 'sp-card' }, pic(c, 'big'), h('div', { class: 'grow' }, h('h2', {}, c.name || c.descriptor || 'unknown'), c.prominence ? h('div', {}, h('span', { class: 'sp-chip' }, c.prominence)) : null, info ? h('div', { class: 'muted small' }, `${plural(info.counts.pages, 'page')} · ${plural(info.counts.lines, 'line')} so far`) : null))); if (error) return body.append(h('p', { class: 'error' }, error)); if (!info) return body.append(h('p', { class: 'muted' }, 'Loading…')); if (info.names_known.length > 1) { body.append(h('div', { class: 'tier' }, 'Names so far'), h('ul', { class: 'sp-names' }, info.names_known.map(n => h('li', {}, n.name, h('span', { class: 'muted small' }, ' · since ' + n.since))))); } if (info.first_seen) body.append(h('div', { class: 'tier' }, 'First seen'), h('div', { class: 'sp-time' }, jumpLink(info.first_seen.jump, info.first_seen.label))); if (time && time.items.length) body.append(h('div', { class: 'tier' }, 'Timeline'), h('div', { class: 'sp-time' }, time.items.map(t => jumpLink(t.jump, t.label, t.text)))); body.append(h('p', { class: 'muted small' }, 'Only what you have read, up to ' + info.horizon.label + '.')); } async function refresh() { const token = ++loadToken; await saveProgress(); // the server's horizon must include the page on screen let x; try { x = await api(sp('v1/xray?' + opts({ c: D.chapter.id, v: D.version, p: pagesShown().join(',') }))); } catch (e) { x = { error: e.message }; } if (token === loadToken) { data = x; render(); } } const btn = h('button', { id: 'who', class: 'btn', title: "Who's here (x)", 'aria-label': "Who's here", onclick: () => toggle() }, icon(ICONS.people), h('span', { class: 'wide-only' }, ' Who’s here')); function show() { sheet.hidden = false; btn.classList.add('on'); card = null; data = null; render(); refresh(); } function close() { if (sheet.hidden) return false; sheet.hidden = true; btn.classList.remove('on'); unmark(); stage.focus({ preventScroll: true }); return true; } function toggle() { if (sheet.hidden) show(); else close(); } $('#top').insertBefore(btn, $('#fs')); return { toggle, close, moved() { if (sheet.hidden) return; clearTimeout(movedTimer); unmark(); movedTimer = setTimeout(() => { if (!card) refresh(); }, 500); }, }; } if (me.senpai) senpai = whosHere(); setMode(mode, false); stage.focus({ preventScroll: true }); setBars(true); })();