From 8c38330143aeeda55db6a418efbeb301d96beba8 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Thu, 26 Feb 2026 23:31:01 -0600 Subject: [PATCH] [V1] Reader Simplification & Fixes --- Todo | 29 +-- src/components/pages/Reader.tsx | 397 +++++++++++++++++--------------- 2 files changed, 229 insertions(+), 197 deletions(-) diff --git a/Todo b/Todo index ca0deaa..db600bb 100644 --- a/Todo +++ b/Todo @@ -3,6 +3,7 @@ Todo: 4. Font Weird on Flatpak, Investigate and Fix 5. Investigate "egl:failed to create dri2 screen" & more GPU Issues + Bugs: - Add Back after Search & Clear on Search @@ -10,15 +11,14 @@ Bugs: - Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks - Fix Storage Glitch (Currently uses Full Space Instead of Free Space) -- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled) -- Fix the Mark as Read (Glitched) + Features: - Add PDF Textbook Support - Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm) - Migration Features - Multi-Page Long Screenshot -- +- Add Consumet Api (Anime & Light Novel Support) Big Revisions: @@ -31,16 +31,9 @@ Big Revisions: Testing: -6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip -5. Lock reader on valid chapters to avoid bugs, etc. -1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load -- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail) -- Properly Kill Tachidesk-Server -- Fix Reader Marking As Read. -- Fix scaling on splash screen -- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out -- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization -- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug) + +- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled) +- Fix the Mark as Read (Glitched) Completed: @@ -72,6 +65,16 @@ Completed: 16. Contrast Adjustment Option in Settings for Users (UI FOCUSED) - Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break) - Clean up Migrate Model to be more initutive +6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip +5. Lock reader on valid chapters to avoid bugs, etc. +1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load +- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail) +- Properly Kill Tachidesk-Server +- Fix scaling on splash screen +- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out +- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization +- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug) + diff --git a/src/components/pages/Reader.tsx b/src/components/pages/Reader.tsx index f995c7a..3cc26ee 100644 --- a/src/components/pages/Reader.tsx +++ b/src/components/pages/Reader.tsx @@ -40,8 +40,6 @@ function fetchPages(chapterId: number, signal?: AbortSignal): Promise if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); - // The inflight promise is shared — never pass a caller's signal into it, - // because one caller aborting would kill the fetch for everyone else waiting. if (!inflight.has(chapterId)) { const p = gql<{ fetchChapterPages: { pages: string[] } }>( FETCH_CHAPTER_PAGES, { chapterId }, @@ -56,10 +54,8 @@ function fetchPages(chapterId: number, signal?: AbortSignal): Promise const base = inflight.get(chapterId)!; - // No abort signal — return the shared promise directly if (!signal) return base; - // Wrap so this caller can abort their own wait without cancelling the network request return new Promise((resolve, reject) => { signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); base.then(resolve, reject); @@ -186,13 +182,16 @@ export default function Reader() { const sentinelRef = useRef(null); const hideTimerRef = useRef | null>(null); - // Live-value refs — updated every render, readable inside effects without stale closures - const settingsRef = useRef(null); - const chapterListRef = useRef([]); - const loadingIdRef = useRef(null); - const markedReadRef = useRef>(new Set()); - const appendedRef = useRef>(new Set()); - const abortRef = useRef(null); + // Live-value refs — always current, safe to read inside effects/callbacks + const settingsRef = useRef(null); + const chapterListRef = useRef([]); + const loadingIdRef = useRef(null); + const markedReadRef = useRef>(new Set()); + const appendedRef = useRef>(new Set()); + const appendingRef = useRef(false); // prevents concurrent appends + const abortRef = useRef(null); + const visibleChapterRef = useRef(null); // single truth for visible chapter + const stripChaptersRef = useRef([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -203,7 +202,8 @@ export default function Reader() { const [pageGroups, setPageGroups] = useState([]); const [stripChapters, setStripChapters] = useState([]); const [visibleChapterId, setVisibleChapterId] = useState(null); - const stripChaptersRef = useRef([]); + + // Keep refs in sync every render stripChaptersRef.current = stripChapters; const { @@ -219,7 +219,6 @@ export default function Reader() { const maxW = settings.maxPageWidth ?? 900; const autoNext = settings.autoNextChapter ?? false; - // Sync live refs every render settingsRef.current = settings; chapterListRef.current = activeChapterList; @@ -242,9 +241,11 @@ export default function Reader() { if (!activeChapter) { abortRef.current?.abort(); appendedRef.current = new Set(); + appendingRef.current = false; markedReadRef.current = new Set(); setStripChapters([]); setVisibleChapterId(null); + visibleChapterRef.current = null; return; } @@ -254,14 +255,16 @@ export default function Reader() { const targetId = activeChapter.id; loadingIdRef.current = targetId; - appendedRef.current = new Set(); - markedReadRef.current = new Set(); + appendedRef.current = new Set([targetId]); // seed so we never double-append ch1 + appendingRef.current = false; + markedReadRef.current = new Set(); setLoading(true); setError(null); setPageGroups([]); setPageReady(false); setStripChapters([]); setVisibleChapterId(null); + visibleChapterRef.current = null; fetchPages(targetId, ctrl.signal) .then(async (urls) => { @@ -271,46 +274,15 @@ export default function Reader() { setPageUrls(urls); setPageReady(true); if (style === "longstrip" && autoNext) { - setStripChapters([{ chapterId: targetId, chapterName: activeChapter.name, urls, startGlobalIdx: 0 }]); + const firstChunk: StripChapter = { + chapterId: targetId, + chapterName: activeChapter.name, + urls, + startGlobalIdx: 0, + }; + setStripChapters([firstChunk]); setVisibleChapterId(targetId); - // Manual kick: when stripChapters goes 0→1 the dep-array change - // re-creates the observer. If the sentinel is already in the viewport - // at that moment the new observer fires immediately and handles the - // append itself. But if getBoundingClientRect shows it's already - // visible before the observer has a chance to fire, kick directly. - const _sentinel = sentinelRef.current; - const _el = containerRef.current; - if (_sentinel && _el) { - const sr = _sentinel.getBoundingClientRect(); - const er = _el.getBoundingClientRect(); - if (sr.top < er.bottom + 500) { - const list = chapterListRef.current; - const idx = list.findIndex((c) => c.id === targetId); - const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null; - if (next && !appendedRef.current.has(next.id)) { - appendedRef.current.add(next.id); - fetchPages(next.id) - .then((u) => Promise.all(u.map(measureAspect)).then(() => u)) - .then((u) => setStripChapters((cur) => { - if (cur.some((c) => c.chapterId === next.id)) return cur; - const last = cur[cur.length - 1]; - const newStart = last ? last.startGlobalIdx + last.urls.length : 0; - const updated = [...cur, { chapterId: next.id, chapterName: next.name, urls: u, startGlobalIdx: newStart }]; - if (updated.length > 3) { - const container = containerRef.current; - if (container) { - const firstKeptImg = container.querySelector( - `img[data-chapter="${updated[1].chapterId}"]` - ) as HTMLElement | null; - if (firstKeptImg) container.scrollTop -= firstKeptImg.offsetTop; - } - return updated.slice(-3); - } - return updated; - })).catch((err) => { console.error(err); appendedRef.current.delete(next.id); }); - } - } - } + visibleChapterRef.current = targetId; } setLoading(false); }) @@ -321,147 +293,204 @@ export default function Reader() { }); }, [activeChapter?.id]); - // ── Longstrip: IntersectionObserver for page number + visible chapter ───────── - // Watches every img[data-page]. Fires on scroll, keyboard jump, or any navigation. + // ── Append next chapter to the strip ──────────────────────────────────────── + // Extracted so both the sentinel observer and the "already in viewport" check + // use exactly the same code path. + const appendNextChapter = useCallback(() => { + if (appendingRef.current) return; + + const strip = stripChaptersRef.current; + const lastChunk = strip[strip.length - 1]; + if (!lastChunk) return; + + const list = chapterListRef.current; + const lastIdx = list.findIndex((c) => c.id === lastChunk.chapterId); + if (lastIdx < 0 || lastIdx >= list.length - 1) return; + + const nextEntry = list[lastIdx + 1]; + if (!nextEntry || appendedRef.current.has(nextEntry.id)) return; + + appendedRef.current.add(nextEntry.id); + appendingRef.current = true; + + fetchPages(nextEntry.id) + .then((urls) => Promise.all(urls.map(measureAspect)).then(() => urls)) + .then((urls) => { + setStripChapters((cur) => { + if (cur.some((c) => c.chapterId === nextEntry.id)) return cur; + + const last = cur[cur.length - 1]; + const newStart = last ? last.startGlobalIdx + last.urls.length : 0; + const updated = [...cur, { + chapterId: nextEntry.id, + chapterName: nextEntry.name, + urls, + startGlobalIdx: newStart, + }]; + + // Trim to 3 chunks max. Fix scroll compensation: subtract the height + // of the removed chunk (sum of its images), not the kept element's offsetTop. + if (updated.length > 3) { + const container = containerRef.current; + if (container) { + const removedChunk = updated[0]; + let removedHeight = 0; + container.querySelectorAll( + `img[data-chapter="${removedChunk.chapterId}"]` + ).forEach((img) => { + removedHeight += img.offsetHeight + (settingsRef.current?.pageGap ? 8 : 0); + }); + if (removedHeight > 0) { + // Use requestAnimationFrame so the DOM has updated before we adjust + requestAnimationFrame(() => { + if (containerRef.current) { + containerRef.current.scrollTop -= removedHeight; + } + }); + } + } + return updated.slice(-3); + } + + return updated; + }); + appendingRef.current = false; + }) + .catch((err) => { + console.error("appendNextChapter failed:", err); + appendedRef.current.delete(nextEntry.id); // allow retry + appendingRef.current = false; + }); + }, []); // no deps — reads everything from refs + + // ── Longstrip: IntersectionObserver for page number + visible chapter ──────── + // Uses a MutationObserver to auto-observe newly added images so we never need + // to recreate this observer when chapters are appended. useEffect(() => { const el = containerRef.current; if (!el || style !== "longstrip") return; - const obs = new IntersectionObserver((entries) => { - let topPage = Infinity; + const pageObs = new IntersectionObserver((entries) => { + let topPage = Infinity; let topChId: number | null = null; + let topY = Infinity; + for (const entry of entries) { if (!entry.isIntersecting) continue; - const p = Number((entry.target as HTMLElement).dataset.page); - const ch = Number((entry.target as HTMLElement).dataset.chapter); - if (p < topPage) { topPage = p; topChId = ch; } + const target = entry.target as HTMLElement; + const p = Number(target.dataset.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); - if (topChId && topChId !== visibleChapterId) { - // Mark the chapter we just left as read - if (settingsRef.current?.autoMarkRead && visibleChapterId && !markedReadRef.current.has(visibleChapterId)) { - markedReadRef.current.add(visibleChapterId); - gql(MARK_CHAPTER_READ, { id: visibleChapterId, isRead: true }).catch(console.error); + + if (topChId && topChId !== visibleChapterRef.current) { + const prev = visibleChapterRef.current; + // Mark the chapter we just finished scrolling past + if (settingsRef.current?.autoMarkRead && prev && !markedReadRef.current.has(prev)) { + markedReadRef.current.add(prev); + gql(MARK_CHAPTER_READ, { id: prev, isRead: true }).catch(console.error); } + visibleChapterRef.current = topChId; setVisibleChapterId(topChId); } }, { root: el, threshold: 0.1 }); - el.querySelectorAll("img[data-page]").forEach((img) => obs.observe(img)); - return () => obs.disconnect(); - }, [style, stripChapters, pageUrls, visibleChapterId]); + // Observe all existing images + el.querySelectorAll("img[data-page]").forEach((img) => pageObs.observe(img)); - // ── Longstrip: sentinel triggers append (or plain chapter advance) ──────────── - // The sentinel div sits at the very bottom of the strip. When it enters the - // viewport — via scroll, keyboard jump, or any other means — we fetch the - // next chapter. rootMargin pre-fires 500px before it's actually visible. + // Auto-observe any images added later (new strip chunks) + const mutObs = new MutationObserver((mutations) => { + for (const m of mutations) { + m.addedNodes.forEach((node) => { + if (node instanceof HTMLElement) { + if (node.matches("img[data-page]")) { + pageObs.observe(node); + } else { + node.querySelectorAll("img[data-page]").forEach((img) => pageObs.observe(img)); + } + } + }); + } + }); + mutObs.observe(el, { childList: true, subtree: true }); + + return () => { + pageObs.disconnect(); + mutObs.disconnect(); + }; + // Only recreate when style changes — MutationObserver handles new images + }, [style]); + + // ── Longstrip: sentinel triggers append ────────────────────────────────────── useEffect(() => { const sentinel = sentinelRef.current; const el = containerRef.current; - // Gatekeeper: don't observe until chapter 1 is actually in the DOM. - // stripChapters is in the dep array, so this effect re-runs the moment - // content exists — and the new observer fires immediately for an already- - // visible sentinel. - if (!sentinel || !el || style !== "longstrip") return; - // Gatekeeper for autoNext: don't create the observer until the strip has - // content. On mount the sentinel is at the top of an empty container — - // it fires immediately, sees no strip, and goes permanently idle. - // stripChapters is a dep so this effect re-runs the moment ch1 is seeded, - // creating a fresh observer that accurately checks the sentinel position. - // Non-autoNext uses loadingIdRef not stripChapters — no gate needed there. - if (autoNext && stripChapters.length === 0) return; + if (!sentinel || !el || style !== "longstrip" || !autoNext) return; + // Don't install until we have content — empty strip means sentinel is at + // the top and would fire immediately. + if (stripChapters.length === 0) return; const obs = new IntersectionObserver(([entry]) => { if (!entry.isIntersecting) return; - - if (!autoNext) { - const list = chapterListRef.current; - const idx = list.findIndex((c) => c.id === loadingIdRef.current); - const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null; - if (next) openReader(next, list); - return; - } - - // Read from ref — always current, no stale closure, no updater needed - const strip = stripChaptersRef.current; - const lastChunk = strip[strip.length - 1]; - if (!lastChunk) return; - - const list = chapterListRef.current; - const lastIdx = list.findIndex((c) => c.id === lastChunk.chapterId); - if (lastIdx < 0 || lastIdx >= list.length - 1) return; - - const nextEntry = list[lastIdx + 1]; - if (!nextEntry || appendedRef.current.has(nextEntry.id)) return; - - // Lock immediately and synchronously — before any async work - appendedRef.current.add(nextEntry.id); - - fetchPages(nextEntry.id) - .then((urls) => Promise.all(urls.map(measureAspect)).then(() => urls)) - .then((urls) => { - setStripChapters((cur) => { - if (cur.some((c) => c.chapterId === nextEntry.id)) return cur; - const last = cur[cur.length - 1]; - const newStart = last ? last.startGlobalIdx + last.urls.length : 0; - const next = [...cur, { chapterId: nextEntry.id, chapterName: nextEntry.name, urls, startGlobalIdx: newStart }]; - if (next.length > 3) { - // Compensate scroll so the viewport doesn't jump when the - // first chunk is trimmed off the top of the DOM. - const container = containerRef.current; - if (container) { - const firstKeptImg = container.querySelector( - `img[data-chapter="${next[1].chapterId}"]` - ) as HTMLElement | null; - if (firstKeptImg) { - container.scrollTop -= firstKeptImg.offsetTop; - } - } - return next.slice(-3); - } - return next; - }); - }).catch((err) => { - console.error(err); - appendedRef.current.delete(nextEntry.id); // allow retry on failure - }); + appendNextChapter(); }, { root: el, rootMargin: "0px 0px 500px 0px", threshold: 0 }); obs.observe(sentinel); - return () => obs.disconnect(); - // stripChapters.length as dep: observer re-mounts exactly once when ch1 - // arrives (0→1), not on every subsequent append. - }, [style, autoNext, stripChapters.length, openReader]); - // Mark last chunk read when reaching the very bottom of the strip + // If sentinel is already visible right now, kick immediately + const sr = sentinel.getBoundingClientRect(); + const er = el.getBoundingClientRect(); + if (sr.top < er.bottom + 500) { + appendNextChapter(); + } + + return () => obs.disconnect(); + // 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. + }, [style, autoNext, stripChapters.length > 0, appendNextChapter]); + + // ── Mark last chapter read when reaching the very bottom ───────────────────── useEffect(() => { const el = containerRef.current; - if (!el || style !== "longstrip" || !autoNext) return; + if (!el || style !== "longstrip") return; + const onScroll = () => { if (el.scrollTop + el.clientHeight < el.scrollHeight - 40) return; - setStripChapters((cur) => { - const last = cur[cur.length - 1]; - if (last && settingsRef.current?.autoMarkRead && !markedReadRef.current.has(last.chapterId)) { - markedReadRef.current.add(last.chapterId); - gql(MARK_CHAPTER_READ, { id: last.chapterId, isRead: true }).catch(console.error); - } - return cur; - }); + const last = stripChaptersRef.current[stripChaptersRef.current.length - 1]; + if (!last) return; + if (settingsRef.current?.autoMarkRead && !markedReadRef.current.has(last.chapterId)) { + markedReadRef.current.add(last.chapterId); + gql(MARK_CHAPTER_READ, { id: last.chapterId, isRead: true }).catch(console.error); + } }; + el.addEventListener("scroll", onScroll, { passive: true }); return () => el.removeEventListener("scroll", onScroll); - }, [style, autoNext, stripChapters]); + // Only depends on style — reads strip from ref, so no stale closure issues + }, [style]); // Rebuild strip when autoNext is toggled while longstrip is active useEffect(() => { if (style !== "longstrip" || !pageUrls.length || !activeChapter) return; - appendedRef.current = new Set(); + appendedRef.current = new Set([activeChapter.id]); + appendingRef.current = false; if (autoNext) { - setStripChapters([{ chapterId: activeChapter.id, chapterName: activeChapter.name, urls: pageUrls, startGlobalIdx: 0 }]); + setStripChapters([{ + chapterId: activeChapter.id, + chapterName: activeChapter.name, + urls: pageUrls, + startGlobalIdx: 0, + }]); setVisibleChapterId(activeChapter.id); + visibleChapterRef.current = activeChapter.id; } else { setStripChapters([]); setVisibleChapterId(null); + visibleChapterRef.current = null; } if (containerRef.current) containerRef.current.scrollTop = 0; }, [autoNext, style]); @@ -471,32 +500,6 @@ export default function Reader() { if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0; }, [pageNumber, style]); - // ── Double-page grouping ───────────────────────────────────────────────────── - useEffect(() => { - if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; } - let cancelled = false; - const snap = pageUrls; - Promise.all(snap.map(measureAspect)).then((aspects) => { - if (cancelled || snap !== pageUrls) return; - const offset = settings.offsetDoubleSpreads; - const groups: number[][] = [[1]]; - if (offset) groups.push([2]); - let i = offset ? 3 : 2; - while (i <= snap.length) { - const a = aspects[i - 1]; - const nextA = aspects[i] ?? 0; - if (a > 1.2 || i === snap.length || nextA > 1.2) { - groups.push([i++]); - } else { - groups.push(rtl ? [i + 1, i] : [i, i + 1]); - i += 2; - } - } - setPageGroups(groups); - }); - return () => { cancelled = true; }; - }, [pageUrls, style, settings.offsetDoubleSpreads, rtl]); - // ── Preload adjacent pages ─────────────────────────────────────────────────── useEffect(() => { const ahead = settings.preloadPages ?? 3; @@ -547,7 +550,7 @@ export default function Reader() { return chunk ? Math.max(1, pageNumber - chunk.startGlobalIdx) : pageNumber; }, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]); - // ── Auto-mark read + history ───────────────────────────────────────────────── + // ── Auto-mark read + history (non-longstrip) ───────────────────────────────── useEffect(() => { if (!activeChapter || !lastPage) return; if (activeManga) { @@ -557,14 +560,40 @@ export default function Reader() { chapterName: activeChapter.name, pageNumber, readAt: Date.now(), }); } - if (style === "longstrip" && autoNext) return; // handled by IntersectionObserver + if (style === "longstrip") return; // handled by scroll/intersection observers if (settings.autoMarkRead && pageNumber === lastPage) { if (!markedReadRef.current.has(activeChapter.id)) { markedReadRef.current.add(activeChapter.id); gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error); } } - }, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead, style, autoNext]); + }, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead, style]); + + // ── Double-page grouping ───────────────────────────────────────────────────── + useEffect(() => { + if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; } + let cancelled = false; + const snap = pageUrls; + Promise.all(snap.map(measureAspect)).then((aspects) => { + if (cancelled || snap !== pageUrls) return; + const offset = settings.offsetDoubleSpreads; + const groups: number[][] = [[1]]; + if (offset) groups.push([2]); + let i = offset ? 3 : 2; + while (i <= snap.length) { + const a = aspects[i - 1]; + const nextA = aspects[i] ?? 0; + if (a > 1.2 || i === snap.length || nextA > 1.2) { + groups.push([i++]); + } else { + groups.push(rtl ? [i + 1, i] : [i, i + 1]); + i += 2; + } + } + setPageGroups(groups); + }); + return () => { cancelled = true; }; + }, [pageUrls, style, settings.offsetDoubleSpreads, rtl]); // ── Navigation ─────────────────────────────────────────────────────────────── const advanceGroup = useCallback((forward: boolean) => { @@ -815,7 +844,7 @@ export default function Reader() { ); }) )} - {/* Entering viewport (or within 500px of it) triggers next-chapter fetch */} + {/* Sentinel: entering viewport triggers next-chapter fetch */}
) : (pageReady && (