"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"; import { isPageReload } from "@/lib/progress"; import { LoadingLogo } from "@/components/LoadingLogo"; import { calcScrollRatio, scrollOffsetFromRatio, } from "@/lib/scroll-ratio"; type ChapterMeta = { id: string; number: number; title: string; totalPages: number; }; type PageMeta = { number: number; width: number; height: number }; type PageReaderProps = { mangaSlug: string; mangaTitle: string; startChapterNumber: number; chapters: ChapterMeta[]; initialChapterMeta: PageMeta[]; resume: boolean; }; const PREFETCH_NEXT_AT = 3; const IMAGE_CHUNK_SIZE = 5; const PREFETCH_LEAD = 3; const DOUBLE_TAP_MS = 280; const KEEP_PREV_CHAPTER_PAGES = 5; const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`; type IntersectingPage = { chNum: number; pNum: number; el: HTMLDivElement; }; export function PageReader({ mangaSlug, mangaTitle, startChapterNumber, chapters, initialChapterMeta, resume, }: PageReaderProps) { const [showUI, setShowUI] = useState(true); const [showDrawer, setShowDrawer] = useState(false); const [chapterMetas, setChapterMetas] = useState>({ [startChapterNumber]: initialChapterMeta, }); const [images, setImages] = useState>({}); const [visibleKeys, setVisibleKeys] = useState>(new Set()); const [currentChapterNum, setCurrentChapterNum] = useState(startChapterNumber); const [currentPageNum, setCurrentPageNum] = useState(() => { if (typeof window === "undefined") return 1; if (!resume && !isPageReload()) return 1; const p = readProgress(mangaSlug); if (p && p.chapter === startChapterNumber && p.page > 1) return p.page; return 1; }); const currentRatioRef = useRef(0); const currentChapterNumRef = useRef(startChapterNumber); const currentPageNumRef = useRef(1); const lastWriteAtRef = useRef(0); // Guards progress writes so an empty session (opened chapter, never // scrolled) doesn't overwrite prior bookmark for a different chapter. const hasScrolledRef = useRef(false); const [canHover, setCanHover] = useState(false); const [hoveringNav, setHoveringNav] = useState(false); useEffect(() => { if (window.matchMedia("(any-hover: hover)").matches) { setCanHover(true); return; } const onMove = () => { setCanHover(true); window.removeEventListener("mousemove", onMove); }; window.addEventListener("mousemove", onMove); return () => window.removeEventListener("mousemove", onMove); }, []); const imagesRef = useRef(images); const chapterMetasRef = useRef(chapterMetas); useEffect(() => { imagesRef.current = images; }, [images]); useEffect(() => { chapterMetasRef.current = chapterMetas; }, [chapterMetas]); const metaInflightRef = useRef>(new Set()); const imagesInflightRef = useRef>(new Set()); const forceInflightRef = useRef>(new Set()); const radiusAbortRef = useRef>(new Map()); const pageElRef = useRef>(new Map()); const observerRef = useRef(null); const viewportObserverRef = useRef(null); const hiddenByScrollRef = useRef(false); const drawerScrollRef = useRef(null); const drawerActiveRef = useRef(null); const intersectingPagesRef = useRef>(new Map()); const visibleKeysRef = useRef>(new Set()); 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(); for (const c of chapters) m.set(c.number, c); return m; }, [chapters]); const fetchChunkFrom = useCallback( async (chapterNum: number, startPage: number) => { const meta = chapterMetasRef.current[chapterNum]; const chapter = chapterByNumber.get(chapterNum); if (!meta || !chapter) return; const clamped = Math.max(1, startPage); const endPage = Math.min(meta.length, clamped + IMAGE_CHUNK_SIZE - 1); if (clamped > endPage) return; const toFetch: number[] = []; for (let p = clamped; p <= endPage; 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]; // One controller per live chapter — every fetch for this chapter // reuses the signal so chapter-unmount aborts them all in one shot. let controller = radiusAbortRef.current.get(chapterNum); if (!controller) { controller = new AbortController(); radiusAbortRef.current.set(chapterNum, controller); } try { const res = await fetch( `/api/pages?chapter=${chapter.id}&offset=${minP - 1}&limit=${ maxP - minP + 1 }`, { signal: controller.signal } ); if (!res.ok) return; const batch: { number: number; imageUrl: string }[] = await res.json(); if (!Array.isArray(batch)) return; setImages((prev) => { const next = { ...prev }; for (const item of batch) { next[pageKey(chapterNum, item.number)] = item.imageUrl; } return next; }); } catch { // aborted or failed — effect will re-fire when state changes } finally { for (const p of toFetch) imagesInflightRef.current.delete(pageKey(chapterNum, p)); } }, [chapterByNumber] ); const cachedPageBounds = useCallback( (chapterNum: number): { min: number; max: number } => { let min = Infinity; let max = 0; const prefix = `${chapterNum}-`; for (const k of Object.keys(imagesRef.current)) { if (!k.startsWith(prefix)) continue; const p = Number(k.slice(prefix.length)); if (p < min) min = p; if (p > max) max = p; } return { min: min === Infinity ? 0 : min, max }; }, [] ); // Tracked separately from imagesInflightRef so rapid taps dedup against // each other but don't block on a slow radius fetch already in flight. const forceFetchPage = useCallback( async (chapterNum: number, pageNum: number) => { const chapter = chapterByNumber.get(chapterNum); if (!chapter) return; const key = pageKey(chapterNum, pageNum); if (forceInflightRef.current.has(key)) return; forceInflightRef.current.add(key); try { const res = await fetch( `/api/pages?chapter=${chapter.id}&offset=${pageNum - 1}&limit=1` ); if (!res.ok) return; const batch: { number: number; imageUrl: string }[] = await res.json(); if (!Array.isArray(batch) || batch.length === 0) return; setImages((prev) => ({ ...prev, [pageKey(chapterNum, batch[0].number)]: batch[0].imageUrl, })); } catch { // user can tap again } finally { forceInflightRef.current.delete(key); } }, [chapterByNumber] ); 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`); if (!res.ok) return; const meta: PageMeta[] = await res.json(); if (!Array.isArray(meta)) return; 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 }); const chapter = chapterByNumber.get(chNum); if (chapter && pNum >= chapter.totalPages - PREFETCH_NEXT_AT) { prefetchNextChapterMeta(chNum); } } else { intersectingPagesRef.current.delete(key); } } }, { rootMargin: "1200px" } ); viewportObserverRef.current = new IntersectionObserver( (entries) => { let changed = false; 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) { if (!visibleKeysRef.current.has(key)) { visibleKeysRef.current.add(key); changed = true; } if ( !imagesRef.current[key] && !imagesInflightRef.current.has(key) ) { forceFetchPage(chNum, pNum); } } else if (visibleKeysRef.current.delete(key)) { changed = true; } } if (changed) setVisibleKeys(new Set(visibleKeysRef.current)); }, { rootMargin: "0px" } ); for (const el of pageElRef.current.values()) { observerRef.current.observe(el); viewportObserverRef.current.observe(el); } return () => { observerRef.current?.disconnect(); viewportObserverRef.current?.disconnect(); }; }, [forceFetchPage, prefetchNextChapterMeta, chapterByNumber]); // Chunk prefetch trigger — runs on current page/chapter change or when // images state changes. Maintains bidirectional sliding chunks of // IMAGE_CHUNK_SIZE pages each direction when user approaches within // PREFETCH_LEAD of the cached range edges. useEffect(() => { const ch = currentChapterNum; const meta = chapterMetas[ch]; if (!meta) return; const { min, max } = cachedPageBounds(ch); if (max === 0) { // Nothing cached yet for this chapter — seed chunk from current page fetchChunkFrom(ch, currentPageNum); return; } if (currentPageNum + PREFETCH_LEAD >= max && max < meta.length) { fetchChunkFrom(ch, max + 1); } if (currentPageNum - PREFETCH_LEAD <= min && min > 1) { fetchChunkFrom(ch, Math.max(1, min - IMAGE_CHUNK_SIZE)); } // Near chapter end — prefetch next chapter's first chunk of images // (meta is prefetched separately by the observer). Keeps the hand-off // seamless instead of waiting for chapter boundary to trigger a cold // fetch. if (currentPageNum + PREFETCH_LEAD >= meta.length) { const nextMeta = chapterMetas[ch + 1]; if (nextMeta) { const nextBounds = cachedPageBounds(ch + 1); if (nextBounds.max === 0) { fetchChunkFrom(ch + 1, 1); } } } }, [ currentPageNum, currentChapterNum, chapterMetas, images, fetchChunkFrom, cachedPageBounds, ]); const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => { const observer = observerRef.current; const viewportObserver = viewportObserverRef.current; const prev = pageElRef.current.get(key); if (prev) { observer?.unobserve(prev); viewportObserver?.unobserve(prev); } if (el) { pageElRef.current.set(key, el); observer?.observe(el); viewportObserver?.observe(el); } else { pageElRef.current.delete(key); } }, []); // All reader Links use scroll={false} to preserve scroll during in-reader // nav (natural scroll between chapters updates URL without remount). On // a fresh mount we position scroll: resume-to-saved if ?resume=1 (from // 继续阅读) OR a page reload (so browser refresh preserves position). // Plain chapter-link clicks from drawer / list go to top. const resumeDoneRef = useRef(false); useLayoutEffect(() => { if (resumeDoneRef.current) return; resumeDoneRef.current = true; // Disable browser auto scroll-restoration. On refresh while in an // auto-appended chapter, the stored scrollY references a taller // document than what reloads with only the URL's chapter — browser // would clamp to scrollHeight and land near the bottom. Manual mode // lets our own resume logic below be the source of truth. if ("scrollRestoration" in window.history) { window.history.scrollRestoration = "manual"; } const instantTop = (top: number) => window.scrollTo({ top, behavior: "instant" as ScrollBehavior }); const shouldResume = resume || isPageReload(); if (!shouldResume) { instantTop(0); return; } const p = readProgress(mangaSlug); if (!p || p.chapter !== startChapterNumber) { instantTop(0); return; } if (p.page <= 1 && p.ratio <= 0) { instantTop(0); return; } const scrollToResume = () => { const el = pageElRef.current.get(pageKey(startChapterNumber, p.page)); if (!el) return; const rect = el.getBoundingClientRect(); const docTop = rect.top + window.scrollY; instantTop(scrollOffsetFromRatio(docTop, rect.height, p.ratio)); }; scrollToResume(); requestAnimationFrame(scrollToResume); }, [mangaSlug, startChapterNumber, resume]); useEffect(() => { let rafId = 0; const tick = () => { rafId = 0; const y = window.scrollY; if (!hiddenByScrollRef.current && y > 50) { hiddenByScrollRef.current = true; setShowUI(false); } let bestCh = 0; let bestPg = 0; let bestTop = Number.NEGATIVE_INFINITY; let bestHeight = 0; for (const { chNum, pNum, el } of intersectingPagesRef.current.values()) { const rect = el.getBoundingClientRect(); const top = rect.top + y; if (top <= y + 80 && top > bestTop) { bestTop = top; bestHeight = rect.height; bestCh = chNum; bestPg = pNum; } } if (bestCh === 0) return; hasScrolledRef.current = true; currentRatioRef.current = calcScrollRatio(y, bestTop, bestHeight); currentChapterNumRef.current = bestCh; currentPageNumRef.current = bestPg; setCurrentChapterNum(bestCh); setCurrentPageNum(bestPg); const now = performance.now(); if (now - lastWriteAtRef.current >= 200) { lastWriteAtRef.current = now; writeProgress(mangaSlug, { chapter: bestCh, page: bestPg, ratio: currentRatioRef.current, }); } }; const onScroll = () => { if (rafId) return; rafId = requestAnimationFrame(tick); }; window.addEventListener("scroll", onScroll, { passive: true }); return () => { window.removeEventListener("scroll", onScroll); if (rafId) cancelAnimationFrame(rafId); }; }, [mangaSlug]); // Flush latest scroll position on pagehide / visibilitychange / unmount // — catches the 0-200ms window between the last throttled tick-write // and tab close / bfcache. useEffect(() => { const flush = () => { if (!hasScrolledRef.current) return; writeProgress(mangaSlug, { chapter: currentChapterNumRef.current, page: currentPageNumRef.current, ratio: currentRatioRef.current, }); }; const onVisibility = () => { if (document.visibilityState === "hidden") flush(); }; window.addEventListener("pagehide", flush); document.addEventListener("visibilitychange", onVisibility); return () => { flush(); window.removeEventListener("pagehide", flush); document.removeEventListener("visibilitychange", onVisibility); }; }, [mangaSlug]); // Aspect-ratio placeholders stay so layout is preserved; observer // re-fetches images on scrollback into an unmounted chapter. useEffect(() => { const keep = new Set([currentChapterNum]); if (currentPageNum <= KEEP_PREV_CHAPTER_PAGES) { keep.add(currentChapterNum - 1); } const curMeta = chapterMetasRef.current[currentChapterNum]; if ( curMeta && currentPageNum >= curMeta.length - KEEP_PREV_CHAPTER_PAGES + 1 ) { keep.add(currentChapterNum + 1); } for (const [ch, ctrl] of radiusAbortRef.current) { if (!keep.has(ch)) { ctrl.abort(); radiusAbortRef.current.delete(ch); } } setImages((prev) => { let changed = false; const next: Record = {}; for (const [k, v] of Object.entries(prev)) { const ch = Number(k.split("-")[0]); if (keep.has(ch)) { next[k] = v; } else { changed = true; } } return changed ? next : prev; }); }, [currentChapterNum, currentPageNum]); useEffect(() => { const url = `/manga/${mangaSlug}/${currentChapterNum}`; if (window.location.pathname === url) return; window.history.replaceState(window.history.state, "", url); }, [mangaSlug, currentChapterNum]); const { prevChapter, nextChapter } = useMemo(() => { const idx = chapters.findIndex((c) => c.number === currentChapterNum); return { prevChapter: idx > 0 ? chapters[idx - 1].number : null, nextChapter: idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1].number : null, }; }, [chapters, currentChapterNum]); const router = useRouter(); const touchMovedRef = useRef(false); const singleTapTimerRef = useRef | 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 (
{canHover && !showUI && !hoveringNav && (
setHoveringNav(true)} /> )}
setHoveringNav(true) : undefined} onMouseLeave={canHover ? () => setHoveringNav(false) : undefined} >

{mangaTitle}

Ch. {currentChapter?.number} — {currentChapter?.title}

e.preventDefault()} > {loadedChapterNumbers.map((chNum, idx) => { const meta = chapterMetas[chNum]; const chapter = chapters.find((c) => c.number === chNum); return ( {idx > 0 && (

Chapter {chNum}

{chapter?.title}

)} {meta.map((p) => { const key = pageKey(chNum, p.number); const url = images[key]; const isVisible = visibleKeys.has(key); const aspect = p.width > 0 && p.height > 0 ? `${p.width} / ${p.height}` : "3 / 4"; return (
setPageRef(key, el)} data-chapter={chNum} data-page={p.number} className="relative leading-[0] w-full" style={{ aspectRatio: aspect }} > {url ? ( {`Page { setImages((prev) => { if (!prev[key]) return prev; const next = { ...prev }; delete next[key]; return next; }); forceFetchPage(chNum, p.number); }} /> ) : ( forceFetchPage(chNum, p.number)} /> )}
); })}
); })}
{atEnd && (

End of Manga

{mangaTitle}

Back to Home
)} {showDrawer && (
setShowDrawer(false)} >
e.stopPropagation()} >
Chapters {chapters.length} total
{chapters.map((ch) => { const isActive = ch.number === currentChapterNum; return ( setShowDrawer(false)} > #{ch.number} {ch.title} {isActive && ( Current )} ); })}
)}
); }