Reader: bookmark button and M save the page (Flow: page at the reading line, Book: the spread) with an optional dragged region, exactly one category (Twist, Hot, Awesome, Cute, Funny, Feels) and a note. I now toggles the bars; ? lists all keys. Keys dispatch through public/keys.js, which also feeds the new Settings -> Keyboard shortcuts section. Manga page: "Your moments" strip of crops (story or recent order, category filters), viewer with step, full page, edit, delete and "Open in reader" (read.html ...&p=). Moments point at pages by file name and show "page moved" when the file is gone. Private per user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
760 lines
36 KiB
JavaScript
760 lines
36 KiB
JavaScript
'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; }
|
||
|
||
// 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());
|
||
} 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();
|
||
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 = h('dialog', { class: 'moment-dialog key-dialog' },
|
||
h('div', { class: 'moment-head' }, h('h2', {}, 'Keyboard shortcuts'),
|
||
h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.close() }, '×')),
|
||
keyTable([{ title: 'Reader', keys: READER_KEYS }]),
|
||
h('p', { class: 'muted small' }, 'Also listed in Settings → Keyboard shortcuts.'));
|
||
dialog.addEventListener('close', () => { dialog.remove(); stage.focus({ preventScroll: true }); });
|
||
document.body.append(dialog);
|
||
dialog.showModal();
|
||
}
|
||
|
||
// ---------- moments ----------
|
||
const momentPages = new Set((D.moments || []).flatMap(x => x.pages));
|
||
// The page(s) on screen, left to right as displayed. In Flow the page at the reading line.
|
||
function currentPages() {
|
||
if (atEnd && mode !== 'flow') return [];
|
||
const ks = mode === 'book' ? shown.slice() : [cur];
|
||
if (mode === 'book' && rtl) ks.reverse();
|
||
return ks.filter(k => list[k]).map(k => {
|
||
const p = list[k], raw = D.pages[p.i];
|
||
return { i: p.i, u: p.u, w: raw.w, h: raw.h, r: p.r };
|
||
});
|
||
}
|
||
let momentOpen = false;
|
||
async function saveMoment() {
|
||
const pages = currentPages();
|
||
if (!pages.length) 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);
|
||
const saved = await momentSheet({
|
||
heading: 'Save moment', pages,
|
||
context: [D.manga.title, [book && book.label, D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : ''), momentPageLabel(pages)].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), 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);
|
||
})();
|