mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
New Patch for Reader
This commit is contained in:
@@ -192,6 +192,7 @@ export default function Reader() {
|
|||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const visibleChapterRef = useRef<number | null>(null); // single truth for visible chapter
|
const visibleChapterRef = useRef<number | null>(null); // single truth for visible chapter
|
||||||
const stripChaptersRef = useRef<StripChapter[]>([]);
|
const stripChaptersRef = useRef<StripChapter[]>([]);
|
||||||
|
const pageUrlsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -221,6 +222,7 @@ export default function Reader() {
|
|||||||
|
|
||||||
settingsRef.current = settings;
|
settingsRef.current = settings;
|
||||||
chapterListRef.current = activeChapterList;
|
chapterListRef.current = activeChapterList;
|
||||||
|
pageUrlsRef.current = pageUrls;
|
||||||
|
|
||||||
// ── UI autohide ──────────────────────────────────────────────────────────────
|
// ── UI autohide ──────────────────────────────────────────────────────────────
|
||||||
const showUi = useCallback(() => {
|
const showUi = useCallback(() => {
|
||||||
@@ -364,65 +366,68 @@ export default function Reader() {
|
|||||||
}, []); // no deps — reads everything from refs
|
}, []); // no deps — reads everything from refs
|
||||||
|
|
||||||
// ── Longstrip: IntersectionObserver for page number + visible chapter ────────
|
// ── Longstrip: IntersectionObserver for page number + visible chapter ────────
|
||||||
// Uses a MutationObserver to auto-observe newly added images so we never need
|
// ── Longstrip: scroll-driven page + chapter tracking + mark-as-read ──────────
|
||||||
// to recreate this observer when chapters are appended.
|
// Scroll listener queries the DOM directly on every scroll — no threshold gaps,
|
||||||
|
// no fast-scroll skipping, works equally scrolling up or down.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el || style !== "longstrip") return;
|
if (!el || style !== "longstrip") return;
|
||||||
|
|
||||||
const pageObs = new IntersectionObserver((entries) => {
|
const READ_LINE_PCT = 0.20; // 20% from top of container = "what the user is reading"
|
||||||
let topPage = Infinity;
|
|
||||||
let topChId: number | null = null;
|
|
||||||
let topY = Infinity;
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
const onScroll = () => {
|
||||||
if (!entry.isIntersecting) continue;
|
const containerTop = el.getBoundingClientRect().top;
|
||||||
const target = entry.target as HTMLElement;
|
const readLineY = containerTop + el.clientHeight * READ_LINE_PCT;
|
||||||
const p = Number(target.dataset.page);
|
const imgs = el.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||||
const ch = Number(target.dataset.chapter);
|
|
||||||
const y = entry.boundingClientRect.top;
|
|
||||||
// Track the topmost visible image by its position on screen
|
|
||||||
if (y < topY) { topY = y; topPage = p; topChId = ch; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topPage !== Infinity) setPageNumber(topPage);
|
let activeLocalPage: number | null = null;
|
||||||
|
let activeChId: number | null = null;
|
||||||
|
|
||||||
if (topChId && topChId !== visibleChapterRef.current) {
|
for (const img of imgs) {
|
||||||
const prev = visibleChapterRef.current;
|
const rect = img.getBoundingClientRect();
|
||||||
// Mark the chapter we just finished scrolling past
|
if (rect.top <= readLineY) {
|
||||||
if (settingsRef.current?.autoMarkRead && prev && !markedReadRef.current.has(prev)) {
|
activeLocalPage = Number(img.dataset.localPage);
|
||||||
markedReadRef.current.add(prev);
|
activeChId = Number(img.dataset.chapter);
|
||||||
gql(MARK_CHAPTER_READ, { id: prev, isRead: true }).catch(console.error);
|
} else {
|
||||||
|
break; // images are in DOM order top→bottom
|
||||||
}
|
}
|
||||||
visibleChapterRef.current = topChId;
|
|
||||||
setVisibleChapterId(topChId);
|
|
||||||
}
|
}
|
||||||
}, { root: el, threshold: 0.1 });
|
|
||||||
|
|
||||||
// Observe all existing images
|
// Fallback: nothing above read line yet — use first image
|
||||||
el.querySelectorAll<HTMLElement>("img[data-page]").forEach((img) => pageObs.observe(img));
|
if (activeLocalPage === null && imgs.length > 0) {
|
||||||
|
activeLocalPage = Number(imgs[0].dataset.localPage);
|
||||||
|
activeChId = Number(imgs[0].dataset.chapter);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-observe any images added later (new strip chunks)
|
if (activeLocalPage !== null) setPageNumber(activeLocalPage);
|
||||||
const mutObs = new MutationObserver((mutations) => {
|
|
||||||
for (const m of mutations) {
|
if (activeChId && activeChId !== visibleChapterRef.current) {
|
||||||
m.addedNodes.forEach((node) => {
|
visibleChapterRef.current = activeChId;
|
||||||
if (node instanceof HTMLElement) {
|
setVisibleChapterId(activeChId);
|
||||||
if (node.matches("img[data-page]")) {
|
}
|
||||||
pageObs.observe(node);
|
|
||||||
} else {
|
// Mark as read when active page reaches second-to-last or last page
|
||||||
node.querySelectorAll<HTMLElement>("img[data-page]").forEach((img) => pageObs.observe(img));
|
if (settingsRef.current?.autoMarkRead && activeLocalPage !== null && activeChId) {
|
||||||
}
|
const strip = stripChaptersRef.current;
|
||||||
|
const chunk = strip.find((c) => c.chapterId === activeChId);
|
||||||
|
// Fall back to pageUrls length when autoNext is off and strip is empty
|
||||||
|
const total = chunk ? chunk.urls.length : pageUrlsRef.current.length;
|
||||||
|
if (total > 0 && activeLocalPage >= total - 1) {
|
||||||
|
const ch = activeChId;
|
||||||
|
if (!markedReadRef.current.has(ch)) {
|
||||||
|
markedReadRef.current.add(ch);
|
||||||
|
gql(MARK_CHAPTER_READ, { id: ch, isRead: true }).catch((e) => {
|
||||||
|
markedReadRef.current.delete(ch);
|
||||||
|
console.error("MARK_CHAPTER_READ failed for chapter", ch, e);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
mutObs.observe(el, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
pageObs.disconnect();
|
|
||||||
mutObs.disconnect();
|
|
||||||
};
|
};
|
||||||
// Only recreate when style changes — MutationObserver handles new images
|
|
||||||
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
onScroll(); // fire once on mount to set initial state
|
||||||
|
return () => el.removeEventListener("scroll", onScroll);
|
||||||
}, [style]);
|
}, [style]);
|
||||||
|
|
||||||
// ── Longstrip: sentinel triggers append ──────────────────────────────────────
|
// ── Longstrip: sentinel triggers append ──────────────────────────────────────
|
||||||
@@ -451,7 +456,7 @@ export default function Reader() {
|
|||||||
return () => obs.disconnect();
|
return () => obs.disconnect();
|
||||||
// Re-run only when mode changes or first chunk arrives (0→N).
|
// Re-run only when mode changes or first chunk arrives (0→N).
|
||||||
// appendNextChapter is stable (no deps), so it won't cause extra re-runs.
|
// appendNextChapter is stable (no deps), so it won't cause extra re-runs.
|
||||||
}, [style, autoNext, stripChapters.length > 0, appendNextChapter]);
|
}, [style, autoNext, stripChapters.length, appendNextChapter]);
|
||||||
|
|
||||||
// ── Mark last chapter read when reaching the very bottom ─────────────────────
|
// ── Mark last chapter read when reaching the very bottom ─────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -511,17 +516,29 @@ export default function Reader() {
|
|||||||
if (behind) preloadImage(behind);
|
if (behind) preloadImage(behind);
|
||||||
}, [pageNumber, pageUrls, settings.preloadPages]);
|
}, [pageNumber, pageUrls, settings.preloadPages]);
|
||||||
|
|
||||||
|
// ── Derived display values ───────────────────────────────────────────────────
|
||||||
|
const lastPage = pageUrls.length;
|
||||||
|
|
||||||
|
// In longstrip+autoNext, show the chapter the user is actually looking at,
|
||||||
|
// not the one that was originally opened.
|
||||||
|
const displayChapter = useMemo(() => {
|
||||||
|
if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter;
|
||||||
|
return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter;
|
||||||
|
}, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]);
|
||||||
|
|
||||||
// ── Adjacent chapters + cache eviction ──────────────────────────────────────
|
// ── Adjacent chapters + cache eviction ──────────────────────────────────────
|
||||||
|
// Uses displayChapter so topbar prev/next navigate from what's visible on screen.
|
||||||
const adjacent = useMemo(() => {
|
const adjacent = useMemo(() => {
|
||||||
if (!activeChapter || !activeChapterList.length)
|
const ref = displayChapter ?? activeChapter;
|
||||||
|
if (!ref || !activeChapterList.length)
|
||||||
return { prev: null, next: null, remaining: [] };
|
return { prev: null, next: null, remaining: [] };
|
||||||
const idx = activeChapterList.findIndex((c) => c.id === activeChapter.id);
|
const idx = activeChapterList.findIndex((c) => c.id === ref.id);
|
||||||
return {
|
return {
|
||||||
prev: idx > 0 ? activeChapterList[idx - 1] : null,
|
prev: idx > 0 ? activeChapterList[idx - 1] : null,
|
||||||
next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
|
next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
|
||||||
remaining: activeChapterList.slice(idx + 1),
|
remaining: activeChapterList.slice(idx + 1),
|
||||||
};
|
};
|
||||||
}, [activeChapter, activeChapterList]);
|
}, [displayChapter, activeChapter, activeChapterList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pinned = new Set([activeChapter?.id, adjacent.next?.id, adjacent.prev?.id].filter(Boolean) as number[]);
|
const pinned = new Set([activeChapter?.id, adjacent.next?.id, adjacent.prev?.id].filter(Boolean) as number[]);
|
||||||
@@ -530,25 +547,15 @@ export default function Reader() {
|
|||||||
cacheEvict(pinned);
|
cacheEvict(pinned);
|
||||||
}, [adjacent.next?.id, adjacent.prev?.id]);
|
}, [adjacent.next?.id, adjacent.prev?.id]);
|
||||||
|
|
||||||
// ── Derived display values ───────────────────────────────────────────────────
|
|
||||||
const lastPage = pageUrls.length;
|
|
||||||
|
|
||||||
const displayChapter = useMemo(() => {
|
|
||||||
if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter;
|
|
||||||
return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter;
|
|
||||||
}, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]);
|
|
||||||
|
|
||||||
const visibleChunkLastPage = useMemo(() => {
|
const visibleChunkLastPage = useMemo(() => {
|
||||||
if (style !== "longstrip" || !autoNext) return lastPage;
|
if (style !== "longstrip" || !autoNext) return lastPage;
|
||||||
const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
|
const chId = visibleChapterId ?? activeChapter?.id;
|
||||||
|
const chunk = stripChapters.find((c) => c.chapterId === chId);
|
||||||
return chunk?.urls.length ?? lastPage;
|
return chunk?.urls.length ?? lastPage;
|
||||||
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]);
|
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]);
|
||||||
|
|
||||||
const visibleChunkPage = useMemo(() => {
|
// pageNumber is always local (per-chapter) — no offset math needed
|
||||||
if (style !== "longstrip" || !autoNext) return pageNumber;
|
const visibleChunkPage = pageNumber;
|
||||||
const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
|
|
||||||
return chunk ? Math.max(1, pageNumber - chunk.startGlobalIdx) : pageNumber;
|
|
||||||
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]);
|
|
||||||
|
|
||||||
// ── Auto-mark read + history (non-longstrip) ─────────────────────────────────
|
// ── Auto-mark read + history (non-longstrip) ─────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -827,16 +834,17 @@ export default function Reader() {
|
|||||||
<>
|
<>
|
||||||
{stripToRender.map((chunk) =>
|
{stripToRender.map((chunk) =>
|
||||||
chunk.urls.map((url, i) => {
|
chunk.urls.map((url, i) => {
|
||||||
const globalIdx = chunk.startGlobalIdx + i;
|
const localPage = i + 1;
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
key={`${chunk.chapterId}-${i}`}
|
key={`${chunk.chapterId}-${i}`}
|
||||||
src={url}
|
src={url}
|
||||||
alt={`${chunk.chapterName} – Page ${i + 1}`}
|
alt={`${chunk.chapterName} – Page ${localPage}`}
|
||||||
data-page={globalIdx + 1}
|
data-local-page={localPage}
|
||||||
data-chapter={chunk.chapterId}
|
data-chapter={chunk.chapterId}
|
||||||
|
data-total={chunk.urls.length}
|
||||||
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
|
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
|
||||||
loading={globalIdx < 3 ? "eager" : "lazy"}
|
loading={i < 3 ? "eager" : "lazy"}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
width={aspectCache.has(url) ? Math.round(aspectCache.get(url)! * 1000) : 720}
|
width={aspectCache.has(url) ? Math.round(aspectCache.get(url)! * 1000) : 720}
|
||||||
height={1000}
|
height={1000}
|
||||||
|
|||||||
Reference in New Issue
Block a user