mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Rebased Reader to 9a0afed + Improvements
This commit is contained in:
@@ -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 2–3 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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user