yiekheng 3745f1f316 Add backward prefetch for resumed mid-chapter reads
When the user resumes at a mid-chapter page, the reader previously
skipped the earlier pages entirely. This adds a scroll-up prefetch so
those earlier pages appear smoothly as the user scrolls toward the top.

- prependBatch() mirrors fetchBatch but decrements the offset cursor
  and prepends the new pages. prependExhaustedRef fires when the
  cursor hits 0 (start of chapter).
- Trigger: scrollY < 2500px fires prepend — well before the user
  reaches the top, so the DOM + images spawn ahead of the scroll
  position.
- Scroll preservation: aspect-[3/4] on <img> reserves vertical space
  before the image bytes arrive, so scrollHeight is accurate
  immediately after React commits. A single scrollBy(delta) keeps the
  previously-visible page visually anchored — no per-image jitter.
- Forward-fetch trigger indices + loadedCountRef are shifted by
  batch.length on each prepend so next-batch prefetch still fires at
  the correct page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:00:01 +08:00

553 lines
18 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
readProgress,
writeProgress,
} from "@/components/ReadingProgressButton";
type ChapterMeta = {
id: number;
number: number;
title: string;
totalPages: number;
};
type LoadedPage = {
chapterNumber: number;
pageNumber: number;
imageUrl: string;
};
type RawPage = {
number: number;
imageUrl: string;
};
type PageReaderProps = {
mangaSlug: string;
mangaTitle: string;
startChapterNumber: number;
prevChapter: number | null;
nextChapter: number | null;
chapters: ChapterMeta[];
};
const BATCH_SIZE = 7;
const PREFETCH_AT = 3;
export function PageReader({
mangaSlug,
mangaTitle,
startChapterNumber,
prevChapter,
nextChapter,
chapters,
}: PageReaderProps) {
const [showUI, setShowUI] = useState(true);
const [showDrawer, setShowDrawer] = useState(false);
const [pages, setPages] = useState<LoadedPage[]>([]);
const [currentChapterNum, setCurrentChapterNum] =
useState(startChapterNumber);
const [currentPageNum, setCurrentPageNum] = useState(() => {
if (typeof window === "undefined") return 1;
const p = readProgress(mangaSlug);
if (p && p.chapter === startChapterNumber && p.page > 1) return p.page;
return 1;
});
const hiddenByScrollRef = useRef(false);
const fetchChapterIdxRef = useRef(
chapters.findIndex((c) => c.number === startChapterNumber)
);
// Initialize offset from saved progress so the first fetch starts AT the
// user's last-read page — previous pages are skipped entirely
const offsetRef = useRef(0);
const initialPageRef = useRef(1);
const offsetInitedRef = useRef(false);
if (!offsetInitedRef.current && typeof window !== "undefined") {
offsetInitedRef.current = true;
const p = readProgress(mangaSlug);
if (p && p.chapter === startChapterNumber && p.page > 1) {
offsetRef.current = p.page - 1;
initialPageRef.current = p.page;
}
}
const loadingRef = useRef(false);
const doneRef = useRef(false);
// Count of pages already loaded — tracked via ref so fetchBatch stays stable
// (otherwise every batch re-creates fetchBatch and tears down the observer)
const loadedCountRef = useRef(0);
const triggerIndicesRef = useRef<Set<number>>(new Set());
const observerRef = useRef<IntersectionObserver | null>(null);
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(new Map());
// Reverse-fetch cursor: the offset of the first loaded page of the
// starting chapter. Used to prepend previous pages when the user scrolls
// up from a resumed mid-chapter position.
const prependOffsetRef = useRef(offsetRef.current);
const prependLoadingRef = useRef(false);
const prependExhaustedRef = useRef(offsetRef.current === 0);
const advanceChapterOrFinish = useCallback(() => {
if (fetchChapterIdxRef.current + 1 < chapters.length) {
fetchChapterIdxRef.current += 1;
offsetRef.current = 0;
} else {
doneRef.current = true;
}
}, [chapters.length]);
const startChapter = useMemo(
() => chapters.find((c) => c.number === startChapterNumber),
[chapters, startChapterNumber]
);
const prependBatch = useCallback(async () => {
if (
prependLoadingRef.current ||
prependExhaustedRef.current ||
!startChapter
)
return;
const currentOffset = prependOffsetRef.current;
const newOffset = Math.max(0, currentOffset - BATCH_SIZE);
const limit = currentOffset - newOffset;
if (limit <= 0) {
prependExhaustedRef.current = true;
return;
}
prependLoadingRef.current = true;
const beforeHeight = document.documentElement.scrollHeight;
try {
const res = await fetch(
`/api/pages?chapterId=${startChapter.id}&offset=${newOffset}&limit=${limit}`
);
const batch: RawPage[] = await res.json();
if (batch.length === 0) {
prependExhaustedRef.current = true;
return;
}
prependOffsetRef.current = newOffset;
if (newOffset === 0) prependExhaustedRef.current = true;
// Shift forward-fetch trigger indices + total count since every
// already-loaded page has moved right by batch.length
const shift = batch.length;
const shifted = new Set<number>();
for (const t of triggerIndicesRef.current) shifted.add(t + shift);
triggerIndicesRef.current = shifted;
loadedCountRef.current += shift;
setPages((prev) => [
...batch.map((p) => ({
chapterNumber: startChapter.number,
pageNumber: p.number,
imageUrl: p.imageUrl,
})),
...prev,
]);
// aspect-[3/4] on the prepended <img>s reserves height before their
// bytes arrive, so scrollHeight is accurate immediately after React
// commits. One scrollBy keeps the previously-visible page anchored.
requestAnimationFrame(() => {
const afterHeight = document.documentElement.scrollHeight;
const delta = afterHeight - beforeHeight;
if (delta > 0) window.scrollBy({ top: delta });
});
} catch {
// ignore — user can scroll-up again to retry
} finally {
prependLoadingRef.current = false;
}
}, [startChapter]);
const fetchBatch = useCallback(async () => {
if (loadingRef.current || doneRef.current) return;
const chapter = chapters[fetchChapterIdxRef.current];
if (!chapter) {
doneRef.current = true;
return;
}
loadingRef.current = true;
try {
const res = await fetch(
`/api/pages?chapterId=${chapter.id}&offset=${offsetRef.current}&limit=${BATCH_SIZE}`
);
const batch: RawPage[] = await res.json();
if (batch.length === 0) {
advanceChapterOrFinish();
return;
}
const baseIndex = loadedCountRef.current;
const willHaveMore =
offsetRef.current + batch.length < chapter.totalPages ||
fetchChapterIdxRef.current + 1 < chapters.length;
if (willHaveMore) {
const triggerIndex = baseIndex + PREFETCH_AT - 1;
triggerIndicesRef.current.add(triggerIndex);
const existing = pageRefsRef.current.get(triggerIndex);
if (existing && observerRef.current) {
observerRef.current.observe(existing);
}
}
loadedCountRef.current += batch.length;
setPages((prev) => [
...prev,
...batch.map((p) => ({
chapterNumber: chapter.number,
pageNumber: p.number,
imageUrl: p.imageUrl,
})),
]);
offsetRef.current += batch.length;
if (offsetRef.current >= chapter.totalPages) {
advanceChapterOrFinish();
}
} catch {
// retry on next intersection
} finally {
loadingRef.current = false;
}
}, [chapters, advanceChapterOrFinish]);
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const index = Number(
(entry.target as HTMLElement).dataset.pageIndex
);
if (triggerIndicesRef.current.has(index)) {
triggerIndicesRef.current.delete(index);
fetchBatch();
}
}
}
},
{ rootMargin: "400px" }
);
return () => observerRef.current?.disconnect();
}, [fetchBatch]);
const initialFetchRef = useRef(false);
useEffect(() => {
if (initialFetchRef.current) return;
initialFetchRef.current = true;
fetchBatch();
}, [fetchBatch]);
const setPageRef = useCallback(
(index: number, el: HTMLDivElement | null) => {
const observer = observerRef.current;
if (!observer) return;
const prev = pageRefsRef.current.get(index);
if (prev) observer.unobserve(prev);
if (el) {
pageRefsRef.current.set(index, el);
if (triggerIndicesRef.current.has(index)) {
observer.observe(el);
}
} else {
pageRefsRef.current.delete(index);
}
},
[]
);
const router = useRouter();
const touchMovedRef = useRef(false);
// Pending single-tap toggle, delayed so we can detect a double-tap first
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastTapAtRef = useRef(0);
const DOUBLE_TAP_MS = 280;
const onTouchStart = useCallback(() => {
touchMovedRef.current = false;
}, []);
const onTouchMove = useCallback(() => {
touchMovedRef.current = true;
}, []);
const onTap = useCallback(
(e: React.MouseEvent) => {
if (touchMovedRef.current) return;
const now = Date.now();
const isDoubleTap = now - lastTapAtRef.current < DOUBLE_TAP_MS;
if (isDoubleTap) {
// Cancel pending single-tap, navigate instead
if (singleTapTimerRef.current) {
clearTimeout(singleTapTimerRef.current);
singleTapTimerRef.current = null;
}
lastTapAtRef.current = 0;
const midX = window.innerWidth / 2;
if (e.clientX >= midX) {
if (nextChapter)
router.push(`/manga/${mangaSlug}/${nextChapter}`);
} else {
if (prevChapter)
router.push(`/manga/${mangaSlug}/${prevChapter}`);
}
return;
}
lastTapAtRef.current = now;
singleTapTimerRef.current = setTimeout(() => {
setShowUI((v) => !v);
singleTapTimerRef.current = null;
}, DOUBLE_TAP_MS);
},
[router, mangaSlug, nextChapter, prevChapter]
);
useEffect(
() => () => {
if (singleTapTimerRef.current) clearTimeout(singleTapTimerRef.current);
},
[]
);
useEffect(() => {
let rafId = 0;
const tick = () => {
rafId = 0;
const y = window.scrollY;
if (!hiddenByScrollRef.current && y > 50) {
hiddenByScrollRef.current = true;
setShowUI(false);
}
// Trigger backward prepend when user approaches the top — fire early
// so the DOM/pics are already spawned by the time the user scrolls there
if (
y < 2500 &&
!prependLoadingRef.current &&
!prependExhaustedRef.current
) {
prependBatch();
}
// Nothing loaded yet — don't overwrite the resumed-page state
if (pages.length === 0) return;
let chapter = pages[0].chapterNumber;
let page = pages[0].pageNumber;
for (let i = 0; i < pages.length; i++) {
const el = pageRefsRef.current.get(i);
if (!el) continue;
if (el.offsetTop <= y + 80) {
chapter = pages[i].chapterNumber;
page = pages[i].pageNumber;
} else break;
}
setCurrentChapterNum(chapter);
setCurrentPageNum(page);
};
const onScroll = () => {
if (rafId) return;
rafId = requestAnimationFrame(tick);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, [pages, startChapterNumber, prependBatch]);
// Persist progress as user scrolls. offsetRef has been pre-seeded above
// so the first fetched page IS the resumed page — no scroll restoration
// needed.
useEffect(() => {
writeProgress(mangaSlug, {
chapter: currentChapterNum,
page: currentPageNum,
});
}, [mangaSlug, currentChapterNum, currentPageNum]);
const currentChapter =
chapters.find((c) => c.number === currentChapterNum) ??
chapters.find((c) => c.number === startChapterNumber);
return (
<div className="min-h-dvh bg-background">
<div
className={`sticky top-0 z-50 bg-background backdrop-blur-sm shadow-sm transition-transform duration-300 pt-safe ${
showUI ? "translate-y-0" : "-translate-y-full"
}`}
>
<div className="flex items-center gap-3 px-4 h-12 max-w-4xl mx-auto">
<Link
href={`/manga/${mangaSlug}`}
className="text-foreground/80 hover:text-foreground transition-colors shrink-0"
aria-label="Back to manga"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className="w-6 h-6"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</Link>
<div className="min-w-0 flex-1">
<p className="text-foreground text-sm font-medium truncate">
{mangaTitle}
</p>
<p className="text-muted text-xs truncate">
Ch. {currentChapter?.number} {currentChapter?.title}
</p>
</div>
<button
onClick={() => setShowDrawer(true)}
className="text-foreground/80 hover:text-foreground transition-colors shrink-0"
aria-label="Chapter list"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className="w-6 h-6"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
</div>
</div>
<div
className="max-w-4xl mx-auto leading-[0] select-none"
onClick={onTap}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onContextMenu={(e) => e.preventDefault()}
>
{pages.map((page, i) => {
const isChapterStart =
i === 0 || pages[i - 1].chapterNumber !== page.chapterNumber;
return (
<div
key={`${page.chapterNumber}-${page.pageNumber}`}
className="relative leading-[0]"
data-page-index={i}
ref={(el) => setPageRef(i, el)}
>
{isChapterStart && i > 0 && (
<div className="bg-surface py-4 text-center leading-normal">
<p className="text-xs uppercase tracking-wider text-muted">
Chapter {page.chapterNumber}
</p>
<p className="text-sm font-semibold text-foreground">
{
chapters.find((c) => c.number === page.chapterNumber)
?.title
}
</p>
</div>
)}
<img
src={page.imageUrl}
alt={`Page ${page.pageNumber}`}
className="w-full h-auto block align-bottom -mb-px aspect-[3/4] [-webkit-touch-callout:none]"
draggable={false}
/>
</div>
);
})}
</div>
{doneRef.current && pages.length > 0 && (
<div className="min-h-dvh max-w-4xl mx-auto px-4 flex flex-col items-center justify-center text-center leading-normal gap-4">
<p className="text-xs uppercase tracking-wider text-muted">
End of Manga
</p>
<p className="text-base font-semibold">{mangaTitle}</p>
<Link
href="/"
className="px-5 py-2 rounded-lg bg-accent text-white text-sm font-semibold transition-colors hover:bg-accent-hover"
>
Back to Home
</Link>
</div>
)}
{/* Chapter drawer overlay (modal — fixed is necessary to cover viewport) */}
{showDrawer && (
<div
className="fixed inset-0 z-[60]"
onClick={() => setShowDrawer(false)}
>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className="absolute bottom-0 left-0 right-0 max-h-[75vh] bg-background rounded-t-3xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-background z-10 border-b border-border">
<div className="flex justify-center pt-2.5 pb-1.5">
<div className="w-10 h-1 rounded-full bg-muted/40" />
</div>
<div className="px-5 py-3 flex items-center justify-between">
<span className="text-foreground text-base font-bold">
Chapters
</span>
<span className="text-xs text-muted tabular-nums">
{chapters.length} total
</span>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(75vh-4rem)] pb-safe">
{chapters.map((ch) => {
const isActive = ch.number === currentChapterNum;
return (
<Link
key={ch.number}
href={`/manga/${mangaSlug}/${ch.number}`}
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors ${
isActive
? "bg-accent/10"
: "hover:bg-surface active:bg-surface-hover"
}`}
onClick={() => setShowDrawer(false)}
>
<span
className={`font-bold tabular-nums w-10 shrink-0 ${
isActive ? "text-accent" : "text-muted"
}`}
>
#{ch.number}
</span>
<span
className={`truncate ${
isActive
? "text-accent font-semibold"
: "text-foreground"
}`}
>
{ch.title}
</span>
{isActive && (
<span className="ml-auto shrink-0 text-[10px] uppercase tracking-wider font-bold text-accent">
Current
</span>
)}
</Link>
);
})}
</div>
</div>
</div>
)}
</div>
);
}