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>
This commit is contained in:
yiekheng 2026-04-12 11:00:01 +08:00
parent 0c6425f0ff
commit 3745f1f316

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@ -84,6 +84,13 @@ export function PageReader({
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;
@ -93,6 +100,71 @@ export function PageReader({
}
}, [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];
@ -253,6 +325,15 @@ export function PageReader({
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;
@ -277,7 +358,7 @@ export function PageReader({
window.removeEventListener("scroll", onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, [pages, startChapterNumber]);
}, [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
@ -377,7 +458,7 @@ export function PageReader({
<img
src={page.imageUrl}
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}
/>
</div>