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:
parent
0c6425f0ff
commit
3745f1f316
@ -1,6 +1,6 @@
|
|||||||
"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 {
|
import {
|
||||||
@ -84,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;
|
||||||
@ -93,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];
|
||||||
@ -253,6 +325,15 @@ export function PageReader({
|
|||||||
hiddenByScrollRef.current = true;
|
hiddenByScrollRef.current = true;
|
||||||
setShowUI(false);
|
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
|
// Nothing loaded yet — don't overwrite the resumed-page state
|
||||||
if (pages.length === 0) return;
|
if (pages.length === 0) return;
|
||||||
let chapter = pages[0].chapterNumber;
|
let chapter = pages[0].chapterNumber;
|
||||||
@ -277,7 +358,7 @@ 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 progress as user scrolls. offsetRef has been pre-seeded above
|
// Persist progress as user scrolls. offsetRef has been pre-seeded above
|
||||||
// so the first fetched page IS the resumed page — no scroll restoration
|
// so the first fetched page IS the resumed page — no scroll restoration
|
||||||
@ -377,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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user