yiekheng cad59143a3 Center current chapter in drawer on open
Chapter drawer now opens with the active row pre-scrolled to the center
of the list instead of always starting at chapter #1. useLayoutEffect
measures via getBoundingClientRect so the scroll lands before paint —
no visible jump.

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

544 lines
18 KiB
TypeScript

"use client";
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} 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 PageMeta = { number: number; width: number; height: number };
type PageReaderProps = {
mangaSlug: string;
mangaTitle: string;
startChapterNumber: number;
prevChapter: number | null;
nextChapter: number | null;
chapters: ChapterMeta[];
initialChapterMeta: PageMeta[];
};
const PREFETCH_NEXT_AT = 3;
const IMAGE_BATCH_RADIUS = 3;
const DOUBLE_TAP_MS = 280;
const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`;
type IntersectingPage = {
chNum: number;
pNum: number;
el: HTMLDivElement;
};
export function PageReader({
mangaSlug,
mangaTitle,
startChapterNumber,
prevChapter,
nextChapter,
chapters,
initialChapterMeta,
}: PageReaderProps) {
const [showUI, setShowUI] = useState(true);
const [showDrawer, setShowDrawer] = useState(false);
const [chapterMetas, setChapterMetas] = useState<Record<number, PageMeta[]>>({
[startChapterNumber]: initialChapterMeta,
});
const [images, setImages] = useState<Record<string, string>>({});
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;
});
// Observer stays stable across state updates.
const imagesRef = useRef(images);
const chapterMetasRef = useRef(chapterMetas);
useEffect(() => {
imagesRef.current = images;
}, [images]);
useEffect(() => {
chapterMetasRef.current = chapterMetas;
}, [chapterMetas]);
const metaInflightRef = useRef<Set<number>>(new Set());
const imagesInflightRef = useRef<Set<string>>(new Set());
const pageElRef = useRef<Map<string, HTMLDivElement>>(new Map());
const observerRef = useRef<IntersectionObserver | null>(null);
const hiddenByScrollRef = useRef(false);
const drawerScrollRef = useRef<HTMLDivElement | null>(null);
const drawerActiveRef = useRef<HTMLAnchorElement | null>(null);
// Pages currently inside the observer's viewport margin. The scroll tick
// walks this small set instead of every loaded page.
const intersectingPagesRef = useRef<Map<string, IntersectingPage>>(new Map());
const loadedChapterNumbers = useMemo(() => {
return Object.keys(chapterMetas)
.map(Number)
.filter((n) => n >= startChapterNumber)
.sort((a, b) => a - b);
}, [chapterMetas, startChapterNumber]);
const chapterByNumber = useMemo(() => {
const m = new Map<number, ChapterMeta>();
for (const c of chapters) m.set(c.number, c);
return m;
}, [chapters]);
const fetchImagesAround = useCallback(
async (chapterNum: number, pageNum: number) => {
const meta = chapterMetasRef.current[chapterNum];
const chapter = chapterByNumber.get(chapterNum);
if (!meta || !chapter) return;
const start = Math.max(1, pageNum - IMAGE_BATCH_RADIUS);
const end = Math.min(meta.length, pageNum + IMAGE_BATCH_RADIUS);
const toFetch: number[] = [];
for (let p = start; p <= end; p++) {
const k = pageKey(chapterNum, p);
if (imagesRef.current[k] || imagesInflightRef.current.has(k)) continue;
imagesInflightRef.current.add(k);
toFetch.push(p);
}
if (toFetch.length === 0) return;
const minP = toFetch[0];
const maxP = toFetch[toFetch.length - 1];
try {
const res = await fetch(
`/api/pages?chapterId=${chapter.id}&offset=${minP - 1}&limit=${
maxP - minP + 1
}`
);
const batch: { number: number; imageUrl: string }[] = await res.json();
setImages((prev) => {
const next = { ...prev };
for (const item of batch) {
next[pageKey(chapterNum, item.number)] = item.imageUrl;
}
return next;
});
} catch {
// observer will re-trigger on next intersection
} finally {
for (const p of toFetch)
imagesInflightRef.current.delete(pageKey(chapterNum, p));
}
},
[chapters]
);
const prefetchNextChapterMeta = useCallback(
async (currentChapterNumArg: number) => {
const idx = chapters.findIndex((c) => c.number === currentChapterNumArg);
if (idx < 0 || idx >= chapters.length - 1) return;
const next = chapters[idx + 1];
if (chapterMetasRef.current[next.number]) return;
if (metaInflightRef.current.has(next.number)) return;
metaInflightRef.current.add(next.number);
try {
const res = await fetch(`/api/chapters/${next.id}/meta`);
const meta: PageMeta[] = await res.json();
setChapterMetas((prev) => ({ ...prev, [next.number]: meta }));
} catch {
// will retry next observer fire
} finally {
metaInflightRef.current.delete(next.number);
}
},
[chapters]
);
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
for (const e of entries) {
const el = e.target as HTMLDivElement;
const chNum = Number(el.dataset.chapter);
const pNum = Number(el.dataset.page);
if (!chNum || !pNum) continue;
const key = pageKey(chNum, pNum);
if (e.isIntersecting) {
intersectingPagesRef.current.set(key, { chNum, pNum, el });
fetchImagesAround(chNum, pNum);
const chapter = chapterByNumber.get(chNum);
if (chapter && pNum >= chapter.totalPages - PREFETCH_NEXT_AT) {
prefetchNextChapterMeta(chNum);
}
} else {
intersectingPagesRef.current.delete(key);
}
}
},
{ rootMargin: "1200px" }
);
for (const el of pageElRef.current.values()) {
observerRef.current.observe(el);
}
return () => observerRef.current?.disconnect();
}, [fetchImagesAround, prefetchNextChapterMeta, chapterByNumber]);
const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => {
const observer = observerRef.current;
const prev = pageElRef.current.get(key);
if (prev && observer) observer.unobserve(prev);
if (el) {
pageElRef.current.set(key, el);
if (observer) observer.observe(el);
} else {
pageElRef.current.delete(key);
}
}, []);
// Sync scroll + rAF re-scroll: defends against browser scroll-restoration
// on hard reload (the sync pass handles soft nav where Link scroll={false}).
const resumeDoneRef = useRef(false);
useLayoutEffect(() => {
if (resumeDoneRef.current) return;
resumeDoneRef.current = true;
const p = readProgress(mangaSlug);
if (!p || p.chapter !== startChapterNumber || p.page <= 1) return;
const scrollToResume = () => {
const el = pageElRef.current.get(pageKey(startChapterNumber, p.page));
if (!el) return;
window.scrollTo({
top: el.offsetTop,
behavior: "instant" as ScrollBehavior,
});
};
scrollToResume();
requestAnimationFrame(scrollToResume);
}, [mangaSlug, startChapterNumber]);
useEffect(() => {
let rafId = 0;
const tick = () => {
rafId = 0;
const y = window.scrollY;
if (!hiddenByScrollRef.current && y > 50) {
hiddenByScrollRef.current = true;
setShowUI(false);
}
// Walk only the pages currently inside the 1200px viewport margin
// (maintained by the observer) and pick the one with the greatest
// offsetTop still above y+80 — that's the topmost visible page.
let bestCh = currentChapterNum;
let bestPg = currentPageNum;
let bestTop = -1;
for (const { chNum, pNum, el } of intersectingPagesRef.current.values()) {
const top = el.offsetTop;
if (top <= y + 80 && top > bestTop) {
bestTop = top;
bestCh = chNum;
bestPg = pNum;
}
}
if (bestCh !== currentChapterNum) setCurrentChapterNum(bestCh);
if (bestPg !== currentPageNum) setCurrentPageNum(bestPg);
};
const onScroll = () => {
if (rafId) return;
rafId = requestAnimationFrame(tick);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, [currentChapterNum, currentPageNum]);
useEffect(() => {
writeProgress(mangaSlug, {
chapter: currentChapterNum,
page: currentPageNum,
});
}, [mangaSlug, currentChapterNum, currentPageNum]);
const router = useRouter();
const touchMovedRef = useRef(false);
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastTapAtRef = useRef(0);
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) {
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}`, {
scroll: false,
});
} else {
if (prevChapter)
router.push(`/manga/${mangaSlug}/${prevChapter}`, {
scroll: false,
});
}
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);
},
[]
);
useLayoutEffect(() => {
if (!showDrawer) return;
const scroll = drawerScrollRef.current;
const active = drawerActiveRef.current;
if (!scroll || !active) return;
const scrollRect = scroll.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
const delta =
activeRect.top -
scrollRect.top -
scroll.clientHeight / 2 +
active.clientHeight / 2;
scroll.scrollTop = Math.max(0, scroll.scrollTop + delta);
}, [showDrawer]);
const currentChapter =
chapters.find((c) => c.number === currentChapterNum) ??
chapters.find((c) => c.number === startChapterNumber);
const lastChapter = chapters[chapters.length - 1];
const atEnd =
currentChapterNum === lastChapter?.number &&
currentPageNum >= (chapterMetas[currentChapterNum]?.length ?? 0);
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()}
>
{loadedChapterNumbers.map((chNum, idx) => {
const meta = chapterMetas[chNum];
const chapter = chapters.find((c) => c.number === chNum);
return (
<Fragment key={chNum}>
{idx > 0 && (
<div className="bg-surface py-4 text-center leading-normal">
<p className="text-xs uppercase tracking-wider text-muted">
Chapter {chNum}
</p>
<p className="text-sm font-semibold text-foreground">
{chapter?.title}
</p>
</div>
)}
{meta.map((p) => {
const key = pageKey(chNum, p.number);
const url = images[key];
const aspect =
p.width > 0 && p.height > 0
? `${p.width} / ${p.height}`
: "3 / 4";
return (
<div
key={key}
ref={(el) => setPageRef(key, el)}
data-chapter={chNum}
data-page={p.number}
className="relative leading-[0] w-full"
style={{ aspectRatio: aspect }}
>
{url && (
<img
src={url}
alt={`Page ${p.number}`}
className="w-full h-auto block [-webkit-touch-callout:none]"
draggable={false}
/>
)}
</div>
);
})}
</Fragment>
);
})}
</div>
{atEnd && (
<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>
)}
{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
ref={drawerScrollRef}
className="overflow-y-auto max-h-[calc(75vh-4rem)] pb-safe"
>
{chapters.map((ch) => {
const isActive = ch.number === currentChapterNum;
return (
<Link
key={ch.number}
ref={isActive ? drawerActiveRef : undefined}
href={`/manga/${mangaSlug}/${ch.number}`}
scroll={false}
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>
);
}