Files
MaashkinoozheandClaude Opus 5.5 896726a51b Performance trace, load test and chapter read-ahead
A switchable trace (DATA/perf/trace.on or PERF_TRACE=1) records every request's
phases (stat, open, first read, disk vs network wait), browser resource timings,
page-turn waits and flow stalls, a cold storage probe, event-loop lag, scans and
scraper calls as JSONL. tools/perf-report.js says which phase slow loads spend
their time in; tools/perf-bench.js simulates readers.

READ_AHEAD (default on) reads an opened chapter's pages, then the next chapter's,
into the OS cache so a NAS's cold reads stop reaching the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:28:50 +01:00

767 lines
37 KiB
JavaScript
Raw Permalink 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 = await api('api/me');
const response = await fetch('api/chapter?' + qsa({ m: P.get('m'), c: P.get('c'), v: P.get('v') }));
if (response.status === 423) {
const access = await api('api/reading-access?' + qsa({ m: P.get('m') }));
canvas.textContent = '';
const card = h('section', { class: 'reading-gate' },
h('h1', {}, 'Reading is closed right now'),
h('p', {}, `The reading hours are set by this server's deployment (${access.timezone}). The library and your progress remain available.`),
h('p', {}, `Answer five questions drawn from manga you have already read to unlock ${access.unlockMinutes} minutes of reading. There is no answer timer or retry limit.`));
const message = h('p', { class: 'reading-gate-message', role: 'status', hidden: true });
const setMessage = (text, tone = 'info') => {
message.textContent = text;
message.dataset.tone = tone;
message.hidden = !text;
};
const question = h('div', { class: 'reading-gate-question' });
const start = h('button', { class: 'btn', type: 'button' }, 'Start five-question challenge');
const back = h('a', { class: 'btn', href: mangaUrl(P.get('m')) }, 'Back to manga');
card.append(message, question, h('div', { class: 'reading-gate-actions' }, start, back));
canvas.append(card);
let challenge = null;
const showQuestion = data => {
challenge = data.challenge;
question.textContent = '';
const input = h('input', { type: 'text', autocomplete: 'off', 'aria-label': 'Your answer', maxlength: '120', required: true });
const form = h('form', {}, h('p', {}, `Question ${data.number} of ${data.total}`),
h('p', { class: 'muted' }, data.mangaTitle), h('h2', {}, data.prompt), input, h('button', { class: 'btn', type: 'submit' }, 'Check answer'));
form.addEventListener('submit', async event => {
event.preventDefault();
try {
const result = await api('api/reading-quiz/answer', { m: P.get('m'), challenge, answer: input.value });
if (!result.correct) { setMessage('Not quite. Try another spelling or answer—there is no attempt limit.', 'retry'); input.select(); return; }
if (result.complete) { setMessage('Unlocked. Opening your chapter…', 'success'); location.reload(); return; }
setMessage('Correct.', 'success');
showQuestion(result);
} catch (error) {
setMessage(error.message, 'error');
if (/challenge expired/i.test(error.message)) { challenge = null; question.textContent = ''; start.hidden = false; }
}
});
question.append(form); input.focus();
};
start.addEventListener('click', async () => {
try { showQuestion(await api('api/reading-quiz/start', { m: P.get('m') })); start.hidden = true; setMessage(''); }
catch (error) { setMessage(error.message, 'error'); }
});
if (!access.quizAvailable) { start.hidden = true; setMessage('Fewer than five questions match your completed chapters. Please return during reading hours.', 'info'); }
window.addEventListener('pagehide', () => {
if (challenge) navigator.sendBeacon('api/reading-quiz/abandon', new Blob([JSON.stringify({ challenge })], { type: 'application/json' }));
});
window.addEventListener('pageshow', event => { if (event.persisted) location.reload(); });
setInterval(async () => {
try { if ((await api('api/reading-access?' + qsa({ m: P.get('m') }))).allowed) location.reload(); } catch { /* keep the gate visible */ }
}, 15000);
return;
}
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || response.statusText);
D = payload;
}
catch (e) { canvas.textContent = ''; canvas.append(h('p', { class: 'loading' }, e.message + ' · ', h('a', { href: './' }, 'Library'))); return; }
// Timings for the server's performance trace, only while an admin has it switched on.
const perf = D.perf && window.PerfTrace ? PerfTrace.start('read') : null;
let perfFlow = null;
// An already-open chapter also closes when a window ends or an unlock expires.
const checkReadingAccess = async () => {
try { if (!(await api('api/reading-access?' + qsa({ m: P.get('m') }))).allowed) location.reload(); } catch { /* transient check failure */ }
};
setInterval(checkReadingAccess, 15000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) checkReadingAccess(); });
const S = me.settings, rtl = D.direction === 'rtl', ttb = D.direction === 'ttb';
const GAP = ttb || S.flowPageGap === false ? 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;
let lockFlowX = false, readingClock = null;
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 tones = [
{ id: 'original', label: 'Original' },
{ id: 'inverted', label: 'Inverted' },
{ id: 'sepia', label: 'Sepia' },
{ id: 'dim', label: 'Dim' },
{ id: 'extra-dim', label: 'Extra dim' },
];
const toneButton = $('#page-tone');
const toneName = id => tones.find(tone => tone.id === id)?.label || 'Original';
function applyTone(id) {
S.pageTone = tones.some(tone => tone.id === id) ? id : 'original';
canvas.dataset.pageTone = S.pageTone;
toneButton.dataset.pageTone = S.pageTone;
toneButton.textContent = `Tone: ${toneName(S.pageTone)}`;
toneButton.title = `Page images: ${toneName(S.pageTone)}. Click for the next tone; controls and theme stay unchanged.`;
toneButton.setAttribute('aria-label', toneButton.title);
}
applyTone(S.pageTone);
let savedTone = S.pageTone, toneSave = Promise.resolve(), toneChange = 0;
function cycleTone() {
const next = tones[(tones.findIndex(tone => tone.id === S.pageTone) + 1) % tones.length];
applyTone(next.id);
const change = ++toneChange;
toneSave = toneSave.catch(() => {}).then(async () => {
await api('api/settings', { pageTone: next.id });
savedTone = next.id;
}).catch(() => {
if (change === toneChange) { applyTone(savedTone); toast('Could not save page tone'); }
});
}
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;
}
// A link to a page (e.g. "Open in reader" from a moment) wins over the saved position.
if (P.get('p') != null) { 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();
}
if (readingClock) readingClock.viewChanged();
};
img.onerror = () => { state(); if (readingClock) readingClock.viewChanged(); };
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());
if (perf) { if (perfFlow) perfFlow.disconnect(); perfFlow = perf.watchFlow(stage, els); }
} else if (atEnd) {
endEl = canvas.appendChild(endCard());
} else {
shown = mode === 'page' ? [cur] : spreads[spreadIdx];
els = shown.map(k => canvas.appendChild(pageEl(list[k])));
if (perf) perf.view(els.map(el => el.firstChild), { mode, page: list[shown[0]].i });
// 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();
if (readingClock) readingClock.viewChanged();
}
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]));
const overflowsX = cw > vw + 1;
lockFlowX = mode === 'flow' && !overflowsX;
stage.classList.toggle('pan', overflowsX || (mode !== 'flow' && ch > vh + 1));
stage.classList.toggle('lock-x', lockFlowX);
if (lockFlowX) stage.scrollLeft = 0;
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 = lockFlowX ? 0 : 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 ----------
const MIN_ZOOM = 0.2, MAX_ZOOM = 6, ZOOM_STEP = 0.05;
let zoomTimer = 0;
function setZoom(z, cx = stage.clientWidth / 2, cy = stage.clientHeight / 2) {
z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
if (Math.abs(z - 1) < 0.01) z = 1; // easy to get back to "fit" without swallowing fine adjustments
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);
}
// Pages exposed enough to count as something the reader could actually inspect.
// The clock handles decoding, duration and plausibility; this keeps layout knowledge here.
function readingView() {
const viewport = stage.getBoundingClientRect(), viewportArea = viewport.width * viewport.height, pages = [];
for (let j = 0; j < els.length; j++) {
const el = els[j], rect = el.getBoundingClientRect();
const w = Math.max(0, Math.min(rect.right, viewport.right) - Math.max(rect.left, viewport.left));
const hh = Math.max(0, Math.min(rect.bottom, viewport.bottom) - Math.max(rect.top, viewport.top));
const visibleArea = w * hh, pageArea = Math.max(1, rect.width * rect.height);
if (visibleArea < 0.10 * Math.min(pageArea, viewportArea)) continue;
const img = el.firstChild, page = mode === 'flow' ? list[j] : list[shown[j]];
pages.push({
index: page.i,
loaded: !!(img.complete && img.naturalWidth),
failed: !!(img.complete && img.getAttribute('src') && !img.naturalWidth),
});
}
const last = boxes[boxes.length - 1];
const flowEnd = mode === 'flow' && last && !pages.length && stage.scrollTop + stage.clientHeight >= last.y + last.h + 32;
return { pages, atEnd: mode === 'flow' ? !!flowEnd : atEnd };
}
function stepZoom(direction) {
const units = zoom() / ZOOM_STEP;
const stepped = (direction > 0 ? Math.floor(units + 1e-6) + 1 : Math.ceil(units - 1e-6) - 1) * ZOOM_STEP;
setZoom(Math.round(stepped * 100) / 100);
}
// 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.015 : 0.0005)), 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, top: stage.scrollTop, t: Date.now() };
}, { passive: true });
stage.addEventListener('touchmove', e => {
if (!pinch || e.touches.length !== 2) {
const t = e.touches[0], s = swipe;
if (t && s && lockFlowX && Math.abs(t.clientX - s.x) > Math.abs(t.clientY - s.y)) {
e.preventDefault();
stage.scrollLeft = 0;
stage.scrollTop = s.top - (t.clientY - s.y);
}
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,
panX: !lockFlowX && stage.scrollWidth > stage.clientWidth + 1,
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');
if (drag.panX) 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();
});
// Keys are looked up in READER_KEYS (keys.js), the same table Settings and "?" show.
const keyActions = {
turn: e => { e.preventDefault(); (e.key === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); },
forward: e => { if (mode === 'flow') return; e.preventDefault(); (e.shiftKey ? prev : next)(); },
back: e => { if (mode === 'flow') return; e.preventDefault(); prev(); },
zoomIn: () => stepZoom(1),
zoomOut: () => stepZoom(-1),
zoomFit: () => setZoom(1),
moment: e => { e.preventDefault(); saveMoment(); },
bars: () => toggleBars(),
fullscreen: () => toggleFullscreen(),
help: () => showKeys(),
};
addEventListener('keydown', e => {
if ((e.target.closest && e.target.closest('input, select, textarea')) || e.ctrlKey || e.metaKey || e.altKey) return;
if (document.querySelector('dialog[open]')) return; // an open dialog handles its own keys (Esc closes it)
const action = keyAction(READER_KEYS, e.key);
if (action) keyActions[action](e);
});
function showKeys() {
const dialog = openDialog('key-dialog', () => stage.focus({ preventScroll: true }));
dialog.append(
h('div', { class: 'moment-head' }, h('h2', {}, 'Keyboard shortcuts'),
h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.done() }, '×')),
keyTable([{ title: 'Reader', keys: READER_KEYS }]),
h('p', { class: 'muted small' }, 'Also listed in Settings → Keyboard shortcuts.'));
dialog.showModal();
}
// ---------- moments ----------
const momentPages = new Set((D.moments || []).flatMap(x => x.pages));
const momentPage = p => ({ i: p.i, u: p.u, w: D.pages[p.i].w, h: D.pages[p.i].h, r: p.r });
// Positions in `list` of the page(s) on screen, in reading order. In Flow the page at the reading line.
function currentRange() {
if (atEnd && mode !== 'flow') return null;
const ks = (mode === 'book' ? shown : [cur]).filter(k => list[k]);
return ks.length ? [Math.min(...ks), Math.max(...ks)] : null;
}
function currentPages() {
const range = currentRange();
return range ? list.slice(range[0], range[1] + 1).map(momentPage) : [];
}
let momentOpen = false;
async function saveMoment() {
const range = currentRange();
if (!range) return toast('No page here');
if (momentOpen) return;
momentOpen = true;
const chapter = D.chapters.find(c => c.id === D.chapter.id), book = D.books.length > 1 && chapter && D.books.find(b => b.key === chapter.book);
// A picture can run over several pages: the sheet can add neighbours, stacked in Flow, side by side in Page/Book.
const saved = await momentSheet({
heading: 'Save moment', sequence: list.map(momentPage), range, layout: mode === 'flow' ? 'column' : 'row', rtl,
context: [D.manga.title, [book && book.label, D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')].filter(Boolean).join(' · ')],
save: async v => (await api('api/moments', { m: D.manga.id, c: D.chapter.id, v: D.version, pages: v.pages.map(p => p.i), layout: v.layout,
region: v.region, cat: v.cat, note: v.note })).moment,
});
momentOpen = false;
stage.focus({ preventScroll: true });
if (!saved) return;
for (const p of saved.pages) momentPages.add(p.i);
updateUI();
toast(`Saved · ${momentCat(saved.cat).label} moment`, 1500);
}
let scrollRaf = 0;
stage.addEventListener('scroll', () => {
if (lockFlowX && stage.scrollLeft !== 0) stage.scrollLeft = 0;
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 (or press I) to show them'
: 'Controls hidden · tap the middle of the page or beside it (or press I) 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) + '%';
const hasMoment = currentPages().some(p => momentPages.has(p.i));
$('#moment').classList.toggle('on', hasMoment);
$('#moment').title = (hasMoment ? 'This page has a moment · save another' : 'Save a moment of this page') + ' (M)';
}
let saveTimer = 0;
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();
if (readingClock) readingClock.viewChanged();
clearTimeout(saveTimer);
saveTimer = setTimeout(saveProgress, 700);
}
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] });
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); };
toneButton.onclick = cycleTone;
$('#moment').onclick = saveMoment;
$('#shift').onclick = toggleShift;
$('#zoomout').onclick = () => stepZoom(-1);
$('#zoom').onclick = () => setZoom(1);
$('#zoomin').onclick = () => stepZoom(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 }); });
setMode(mode, false);
if (S.readingClock) readingClock = createReadingClock({
button: $('#readclock'),
panel: $('#clockpanel'),
identity: { m: D.manga.id, c: D.chapter.id, v: D.version },
initial: D.reading,
eligibleCount: list.length,
eligiblePages: list.map(page => page.i),
surface: stage,
view: readingView,
onComplete: result => toast(result.completedPasses > 1
? 'Reread time is now counted · 60% of pages viewed'
: 'Reading time is now counted · 60% of pages viewed', 3500),
});
stage.focus({ preventScroll: true });
setBars(true);
})();