Compare commits

...

2 Commits

Author SHA1 Message Date
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
0c6425f0ff Track reading progress at the page level and resume there
Upgrades the reader's last-read tracking from {chapter} to {chapter, page}.

- ReadingProgressButton: storage is now JSON {chapter, page}; legacy
  bare-number values are read back as {chapter, page: 1}. Button label
  is unchanged ("继续阅读 · #N title") — the extra precision lives in
  the reader's first-fetch offset, not the label.
- PageReader: on mount with saved progress, seed offsetRef to
  (page - 1) so the first /api/pages call starts AT the resumed page
  instead of the beginning of the chapter. currentPageNum state is
  initialized from storage too, so the first persist write is a no-op
  that matches the saved value.
- Scroll tracker now also tracks currentPageNum (last page whose top
  has crossed above viewport top+80), and persistence writes the
  {chapter, page} pair on each change.

Known limitation: earlier pages of the resumed chapter aren't loaded
yet — a follow-up commit adds scroll-up prefetch for those.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:59:40 +08:00
2 changed files with 156 additions and 18 deletions

View File

@ -1,9 +1,12 @@
"use client"; "use client";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { writeLastReadChapter } from "@/components/ReadingProgressButton"; import {
readProgress,
writeProgress,
} from "@/components/ReadingProgressButton";
type ChapterMeta = { type ChapterMeta = {
id: number; id: number;
@ -48,12 +51,30 @@ export function PageReader({
const [pages, setPages] = useState<LoadedPage[]>([]); const [pages, setPages] = useState<LoadedPage[]>([]);
const [currentChapterNum, setCurrentChapterNum] = const [currentChapterNum, setCurrentChapterNum] =
useState(startChapterNumber); 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 hiddenByScrollRef = useRef(false);
const fetchChapterIdxRef = useRef( const fetchChapterIdxRef = useRef(
chapters.findIndex((c) => c.number === startChapterNumber) 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 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 loadingRef = useRef(false);
const doneRef = useRef(false); const doneRef = useRef(false);
// Count of pages already loaded — tracked via ref so fetchBatch stays stable // Count of pages already loaded — tracked via ref so fetchBatch stays stable
@ -63,6 +84,13 @@ export function PageReader({
const observerRef = useRef<IntersectionObserver | null>(null); const observerRef = useRef<IntersectionObserver | null>(null);
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(new Map()); 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(() => { const advanceChapterOrFinish = useCallback(() => {
if (fetchChapterIdxRef.current + 1 < chapters.length) { if (fetchChapterIdxRef.current + 1 < chapters.length) {
fetchChapterIdxRef.current += 1; fetchChapterIdxRef.current += 1;
@ -72,6 +100,71 @@ export function PageReader({
} }
}, [chapters.length]); }, [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 () => { const fetchBatch = useCallback(async () => {
if (loadingRef.current || doneRef.current) return; if (loadingRef.current || doneRef.current) return;
const chapter = chapters[fetchChapterIdxRef.current]; const chapter = chapters[fetchChapterIdxRef.current];
@ -232,14 +325,29 @@ export function PageReader({
hiddenByScrollRef.current = true; hiddenByScrollRef.current = true;
setShowUI(false); setShowUI(false);
} }
let current = startChapterNumber; // 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++) { for (let i = 0; i < pages.length; i++) {
const el = pageRefsRef.current.get(i); const el = pageRefsRef.current.get(i);
if (!el) continue; if (!el) continue;
if (el.offsetTop <= y + 80) current = pages[i].chapterNumber; if (el.offsetTop <= y + 80) {
else break; chapter = pages[i].chapterNumber;
page = pages[i].pageNumber;
} else break;
} }
setCurrentChapterNum(current); setCurrentChapterNum(chapter);
setCurrentPageNum(page);
}; };
const onScroll = () => { const onScroll = () => {
if (rafId) return; if (rafId) return;
@ -250,12 +358,17 @@ export function PageReader({
window.removeEventListener("scroll", onScroll); window.removeEventListener("scroll", onScroll);
if (rafId) cancelAnimationFrame(rafId); if (rafId) cancelAnimationFrame(rafId);
}; };
}, [pages, startChapterNumber]); }, [pages, startChapterNumber, prependBatch]);
// Persist reading progress whenever the visible chapter changes // 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(() => { useEffect(() => {
writeLastReadChapter(mangaSlug, currentChapterNum); writeProgress(mangaSlug, {
}, [mangaSlug, currentChapterNum]); chapter: currentChapterNum,
page: currentPageNum,
});
}, [mangaSlug, currentChapterNum, currentPageNum]);
const currentChapter = const currentChapter =
chapters.find((c) => c.number === currentChapterNum) ?? chapters.find((c) => c.number === currentChapterNum) ??
@ -345,7 +458,7 @@ export function PageReader({
<img <img
src={page.imageUrl} src={page.imageUrl}
alt={`Page ${page.pageNumber}`} alt={`Page ${page.pageNumber}`}
className="w-full h-auto block align-bottom -mb-px [-webkit-touch-callout:none]" className="w-full h-auto block align-bottom -mb-px aspect-[3/4] [-webkit-touch-callout:none]"
draggable={false} draggable={false}
/> />
</div> </div>

View File

@ -13,34 +13,59 @@ type Props = {
chapters: ChapterLite[]; chapters: ChapterLite[];
}; };
export type ReadingProgress = {
chapter: number;
page: number;
};
function storageKey(slug: string) { function storageKey(slug: string) {
return `sunnymh:last-read:${slug}`; return `sunnymh:last-read:${slug}`;
} }
export function readLastReadChapter(slug: string): number | null { export function readProgress(slug: string): ReadingProgress | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const raw = window.localStorage.getItem(storageKey(slug)); const raw = window.localStorage.getItem(storageKey(slug));
if (!raw) return null; if (!raw) return null;
// New format: JSON { chapter, page }
if (raw.startsWith("{")) {
try {
const parsed = JSON.parse(raw) as ReadingProgress;
if (
typeof parsed.chapter === "number" &&
typeof parsed.page === "number" &&
parsed.chapter > 0 &&
parsed.page > 0
) {
return parsed;
}
} catch {
return null;
}
return null;
}
// Legacy format: bare chapter number
const n = Number(raw); const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : null; return Number.isFinite(n) && n > 0 ? { chapter: n, page: 1 } : null;
} }
export function writeLastReadChapter(slug: string, chapter: number) { export function writeProgress(slug: string, progress: ReadingProgress) {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
window.localStorage.setItem(storageKey(slug), String(chapter)); window.localStorage.setItem(storageKey(slug), JSON.stringify(progress));
} }
export function ReadingProgressButton({ mangaSlug, chapters }: Props) { export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
const [lastRead, setLastRead] = useState<number | null>(null); const [progress, setProgress] = useState<ReadingProgress | null>(null);
useEffect(() => { useEffect(() => {
setLastRead(readLastReadChapter(mangaSlug)); setProgress(readProgress(mangaSlug));
}, [mangaSlug]); }, [mangaSlug]);
if (chapters.length === 0) return null; if (chapters.length === 0) return null;
const first = chapters[0]; const first = chapters[0];
const resumeChapter = const resumeChapter =
lastRead !== null ? chapters.find((c) => c.number === lastRead) : null; progress !== null
? chapters.find((c) => c.number === progress.chapter)
: null;
const target = resumeChapter ?? first; const target = resumeChapter ?? first;
return ( return (