Files
MangaCircus/public/reader.js
T
Maashkinoozhe f3304732fb Optional Senpai integration: "Who's here" side sheet and /api/senpai proxy
- server.js: GET /api/senpai/v1/{locate,xray,characters/<ref>[/timeline],portraits/<ref>} forwarded to
  SENPAI_URL (plain http/https, no dependencies) with SENPAI_TOKEN, X-Senpai-User and the user's horizon
  (furthest chapter with progress, its version and furthest page); off when SENPAI_URL is unset
- progress records "top", the furthest page read in a version, so paging back does not lower the horizon
- /api/me reports senpai: true|false
- reader: Who's here button (key x) with This page / This chapter / So far, character card, timeline with
  jump links, figure outlines on the page; options "only up to this page" and "show unconfirmed"
- read.html?...&p=<index> opens a page; readUrl takes p
2026-10-17 16:41:46 +02:00

693 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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=<page index> 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);
})();