[V1] Rebased Reader to 9a0afed + Improvements

This commit is contained in:
Youwes09
2026-02-28 18:30:00 -06:00
parent c9eba3da86
commit eb7360ee05
+69 -29
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useCallback, useState, useMemo } from "react"; import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from "react";
import { import {
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
Square, Rows, Download, ArrowsLeftRight, Square, Rows, Download, ArrowsLeftRight,
@@ -80,7 +80,12 @@ function measureAspect(url: string): Promise<number> {
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!); if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
return new Promise((res) => { return new Promise((res) => {
const img = new Image(); const img = new Image();
img.onload = () => { aspectCache.set(url, img.naturalWidth / img.naturalHeight); res(aspectCache.get(url)!); }; img.onload = () => {
// Guard against 0 dimensions (image not fully decoded yet) and NaN
const ratio = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
aspectCache.set(url, ratio);
res(ratio);
};
img.onerror = () => res(0.67); img.onerror = () => res(0.67);
img.src = url; img.src = url;
}); });
@@ -192,6 +197,8 @@ export default function Reader() {
const visibleChapterRef = useRef<number | null>(null); const visibleChapterRef = useRef<number | null>(null);
const stripChaptersRef = useRef<StripChapter[]>([]); const stripChaptersRef = useRef<StripChapter[]>([]);
const pageUrlsRef = useRef<string[]>([]); const pageUrlsRef = useRef<string[]>([]);
// Captured before a head-trim; useLayoutEffect restores scroll synchronously
const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -205,6 +212,21 @@ export default function Reader() {
stripChaptersRef.current = stripChapters; stripChaptersRef.current = stripChapters;
// Restore scroll position synchronously after a head-trim, before paint.
// This is the only reliable way to prevent the visible jump — rAF fires
// one frame too late and the user sees the incorrect position briefly.
useLayoutEffect(() => {
const anchor = scrollAnchorRef.current;
if (!anchor || !containerRef.current) return;
scrollAnchorRef.current = null;
const gained = containerRef.current.scrollHeight - anchor.scrollHeight;
// gained is negative when nodes were removed (scrollHeight shrank).
// Subtract the same amount from scrollTop so visible content stays put.
if (gained < 0) {
containerRef.current.scrollTop = Math.max(0, anchor.scrollTop + gained);
}
}, [stripChapters]);
const { const {
activeManga, activeChapter, activeChapterList, activeManga, activeChapter, activeChapterList,
pageUrls, pageNumber, settings, pageUrls, pageNumber, settings,
@@ -258,6 +280,9 @@ export default function Reader() {
appendedRef.current = new Set([targetId]); appendedRef.current = new Set([targetId]);
appendingRef.current = false; appendingRef.current = false;
markedReadRef.current = new Set(); markedReadRef.current = new Set();
// Clear stale aspect ratios — server URLs can return different images
// after a re-fetch, and a stale cached ratio renders as a black/collapsed img.
aspectCache.clear();
setLoading(true); setLoading(true);
setError(null); setError(null);
setPageGroups([]); setPageGroups([]);
@@ -315,6 +340,10 @@ export default function Reader() {
.then((urls) => { .then((urls) => {
// Kick off aspect measurement in background — don't block appending on it // Kick off aspect measurement in background — don't block appending on it
urls.forEach((url) => measureAspect(url).catch(() => {})); urls.forEach((url) => measureAspect(url).catch(() => {}));
// Ensure the first several images are already in the browser cache
// by the time React renders them — eliminates the blank-image flash
// that occurs when a freshly appended chapter hasn't been prefetched.
urls.slice(0, 6).forEach(preloadImage);
return urls; return urls;
}) })
.then((urls) => { .then((urls) => {
@@ -331,22 +360,14 @@ export default function Reader() {
}]; }];
if (updated.length > 3) { if (updated.length > 3) {
const container = containerRef.current; // Snapshot scroll position BEFORE React removes the nodes.
if (container) { // useLayoutEffect will restore it synchronously after the DOM
const removedChunk = updated[0]; // mutation, preventing any visible jump.
let removedHeight = 0; if (containerRef.current) {
container.querySelectorAll<HTMLElement>( scrollAnchorRef.current = {
`img[data-chapter="${removedChunk.chapterId}"]` scrollTop: containerRef.current.scrollTop,
).forEach((img) => { scrollHeight: containerRef.current.scrollHeight,
removedHeight += img.offsetHeight + (settingsRef.current?.pageGap ? 8 : 0); };
});
if (removedHeight > 0) {
requestAnimationFrame(() => {
if (containerRef.current) {
containerRef.current.scrollTop -= removedHeight;
}
});
}
} }
return updated.slice(-3); return updated.slice(-3);
} }
@@ -431,20 +452,37 @@ export default function Reader() {
if (!sentinel || !el || style !== "longstrip" || !autoNext) return; if (!sentinel || !el || style !== "longstrip" || !autoNext) return;
if (stripChapters.length === 0) return; if (stripChapters.length === 0) return;
// Trigger append when the user has scrolled through 80% of the current
// strip — early enough that the next chapter is ready before they reach
// the end. A fixed-pixel rootMargin can't express "80% of scrollHeight"
// so we use a scroll listener for the threshold check, and keep the
// IntersectionObserver only as a fallback for the absolute bottom.
const onScroll80 = () => {
const pct = (el.scrollTop + el.clientHeight) / el.scrollHeight;
if (pct >= 0.8) appendNextChapter();
};
el.addEventListener("scroll", onScroll80, { passive: true });
// IntersectionObserver as hard backstop at the very bottom
const obs = new IntersectionObserver(([entry]) => { const obs = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return; if (!entry.isIntersecting) return;
appendNextChapter(); appendNextChapter();
}, { root: el, rootMargin: "0px 0px 500px 0px", threshold: 0 }); }, { root: el, rootMargin: "0px", threshold: 0 });
obs.observe(sentinel); obs.observe(sentinel);
const sr = sentinel.getBoundingClientRect(); // Double-rAF ensures real image heights are committed before we measure.
const er = el.getBoundingClientRect(); // Fires the 80% check once on mount so short/cached chapters that never
if (sr.top < er.bottom + 500) { // produce a scroll event still trigger an append.
appendNextChapter(); requestAnimationFrame(() => {
} requestAnimationFrame(() => {
if (!containerRef.current) return;
const pct = (el.scrollTop + el.clientHeight) / el.scrollHeight;
if (pct >= 0.8) appendNextChapter();
});
});
return () => obs.disconnect(); return () => { obs.disconnect(); el.removeEventListener("scroll", onScroll80); };
}, [style, autoNext, stripChapters.length, activeChapter?.id, appendNextChapter]); }, [style, autoNext, stripChapters.length, activeChapter?.id, appendNextChapter]);
// ^^^^^^^^^^^^^^^^^ reinstall on manga switch // ^^^^^^^^^^^^^^^^^ reinstall on manga switch
@@ -544,9 +582,12 @@ export default function Reader() {
toPin.push(entry.id); toPin.push(entry.id);
fetchPages(entry.id) fetchPages(entry.id)
.then((urls) => { .then((urls) => {
// For the immediate next chapter, also preload the first few images // Preload the first several images of every prefetched chapter,
// so the browser has them decoded before the sentinel fires. // not just the immediate next one — chapters 23 ahead would
if (i === 1) urls.slice(0, 5).forEach(preloadImage); // otherwise start loading cold when appended, causing blank flashes.
// Fewer images for farther-ahead chapters to avoid wasting bandwidth.
const preloadCount = i === 1 ? 8 : i === 2 ? 4 : 2;
urls.slice(0, preloadCount).forEach(preloadImage);
}) })
.catch(() => {}); .catch(() => {});
} }
@@ -859,7 +900,6 @@ export default function Reader() {
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")} className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
loading={i < 3 ? "eager" : "lazy"} loading={i < 3 ? "eager" : "lazy"}
decoding="async" decoding="async"
width={aspectCache.has(url) ? Math.round(aspectCache.get(url)! * 1000) : 720}
height={1000} height={1000}
/> />
); );