Consolidate MangaCircus app, scraper, and Compose baseline

This commit is contained in:
Maashkinoozhe committed 2026-10-07 23:28:20 +02:00
1 parent cfb2d79103
commit eef4ffa07c
103 files changed
+9316 -172

No files matched your search

+81 -13
View File
@@ -16,7 +16,7 @@
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;
const GAP = ttb || S.flowPageGap === false ? 0 : 8, END_H = 280;
document.title = `${D.chapter.label} · ${D.manga.title}`;
// ---------- pages ----------
@@ -41,6 +41,7 @@
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;
@@ -112,8 +113,9 @@
Object.assign(p, { unknown: false, w, a: w / hh, wide: p.wide || w > hh });
relayout();
}
if (readingClock) readingClock.viewChanged();
};
img.onerror = state;
img.onerror = () => { state(); if (readingClock) readingClock.viewChanged(); };
state();
}
function setSrc(el, url) { el.firstChild.src = url; watch(el); }
@@ -162,6 +164,7 @@
}))).then(() => { if (token === buildToken) preloadAround(); });
}
layout();
if (readingClock) readingClock.viewChanged();
}
let buildToken = 0;
@@ -198,7 +201,11 @@
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));
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;
}
@@ -230,17 +237,18 @@
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.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(6, Math.max(0.2, z));
if (Math.abs(z - 1) < 0.03) z = 1; // easy to get back to "fit"
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;
@@ -252,11 +260,39 @@
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.05 : 0.0025)), e.clientX, e.clientY);
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).
@@ -266,10 +302,18 @@
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() };
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) return;
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];
@@ -293,7 +337,14 @@
// 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 };
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;
@@ -301,7 +352,7 @@
if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 6) return;
drag.moved = true;
stage.classList.add('drag');
stage.scrollLeft = drag.l - dx;
if (drag.panX) stage.scrollLeft = drag.l - dx;
stage.scrollTop = drag.t - dy;
});
addEventListener('pointerup', () => {
@@ -338,8 +389,8 @@
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 === '+' || k === '=') stepZoom(1);
else if (k === '-') stepZoom(-1);
else if (k === '0') setZoom(1);
else if (k === 'm' || k === 'Escape') toggleBars();
else if (k === 'f') toggleFullscreen();
@@ -347,6 +398,7 @@
let scrollRaf = 0;
stage.addEventListener('scroll', () => {
if (lockFlowX && stage.scrollLeft !== 0) stage.scrollLeft = 0;
if (mode !== 'flow' || scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0;
@@ -485,6 +537,7 @@
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);
}
@@ -519,7 +572,9 @@
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;
$('#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() {
@@ -533,6 +588,19 @@
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);
})();