Reader: make position tracking accurate to sub-200ms

- Throttled write inside the scroll rAF tick (every 200ms during active
  scroll) captures mid-page ratio changes. Previously only page-boundary
  crossings triggered writes, so refreshing mid-page restored to top of
  that page instead of exact position.
- pagehide + visibilitychange + unmount flush captures the final 0-200ms
  before tab close / bfcache / nav.
- hasScrolledRef guards writes so opening a chapter without scrolling
  doesn't clobber a prior deep-progress bookmark for a different chapter.
- getBoundingClientRect replaces offsetTop/offsetHeight for subpixel
  precision and positioning-ancestor independence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-04-15 22:05:35 +08:00
parent b993de43bc
commit cd1fd6ad64

View File

@ -76,6 +76,12 @@ export function PageReader({
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 imagesRef = useRef(images);
const chapterMetasRef = useRef(chapterMetas);
@ -326,9 +332,9 @@ export function PageReader({
const scrollToResume = () => {
const el = pageElRef.current.get(pageKey(startChapterNumber, p.page));
if (!el) return;
instantTop(
scrollOffsetFromRatio(el.offsetTop, el.offsetHeight, p.ratio)
);
const rect = el.getBoundingClientRect();
const docTop = rect.top + window.scrollY;
instantTop(scrollOffsetFromRatio(docTop, rect.height, p.ratio));
};
scrollToResume();
requestAnimationFrame(scrollToResume);
@ -343,29 +349,37 @@ export function PageReader({
hiddenByScrollRef.current = true;
setShowUI(false);
}
// Pick the topmost page whose top edge is above y+80 (top edge of the
// content below the sticky header); walking the small intersecting set.
let bestCh = 0;
let bestPg = 0;
let bestTop = -1;
let bestEl: HTMLDivElement | null = null;
let bestTop = Number.NEGATIVE_INFINITY;
let bestHeight = 0;
for (const { chNum, pNum, el } of intersectingPagesRef.current.values()) {
const top = el.offsetTop;
const rect = el.getBoundingClientRect();
const top = rect.top + y;
if (top <= y + 80 && top > bestTop) {
bestTop = top;
bestHeight = rect.height;
bestCh = chNum;
bestPg = pNum;
bestEl = el;
}
}
if (!bestEl) return;
currentRatioRef.current = calcScrollRatio(
y,
bestTop,
bestEl.offsetHeight
);
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;
@ -376,15 +390,31 @@ export function PageReader({
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: currentChapterNum,
page: currentPageNum,
chapter: currentChapterNumRef.current,
page: currentPageNumRef.current,
ratio: currentRatioRef.current,
});
}, [mangaSlug, currentChapterNum, currentPageNum]);
};
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.