mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
[V1] Reader Simplification & Fixes
This commit is contained in:
@@ -3,6 +3,7 @@ Todo:
|
|||||||
4. Font Weird on Flatpak, Investigate and Fix
|
4. Font Weird on Flatpak, Investigate and Fix
|
||||||
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
|
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
|
||||||
|
|
||||||
|
|
||||||
Bugs:
|
Bugs:
|
||||||
|
|
||||||
- Add Back after Search & Clear on Search
|
- Add Back after Search & Clear on Search
|
||||||
@@ -10,15 +11,14 @@ Bugs:
|
|||||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
||||||
- Fix Storage Glitch (Currently uses Full Space Instead of Free Space)
|
- 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:
|
Features:
|
||||||
- Add PDF Textbook Support
|
- Add PDF Textbook Support
|
||||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
||||||
- Migration Features
|
- Migration Features
|
||||||
- Multi-Page Long Screenshot
|
- Multi-Page Long Screenshot
|
||||||
-
|
- Add Consumet Api (Anime & Light Novel Support)
|
||||||
|
|
||||||
|
|
||||||
Big Revisions:
|
Big Revisions:
|
||||||
@@ -31,16 +31,9 @@ Big Revisions:
|
|||||||
|
|
||||||
|
|
||||||
Testing:
|
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.
|
- 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)
|
||||||
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
- Fix the Mark as Read (Glitched)
|
||||||
- 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)
|
|
||||||
|
|
||||||
|
|
||||||
Completed:
|
Completed:
|
||||||
@@ -72,6 +65,16 @@ Completed:
|
|||||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
||||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
||||||
- Clean up Migrate Model to be more initutive
|
- 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+213
-184
@@ -40,8 +40,6 @@ function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]>
|
|||||||
|
|
||||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
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)) {
|
if (!inflight.has(chapterId)) {
|
||||||
const p = gql<{ fetchChapterPages: { pages: string[] } }>(
|
const p = gql<{ fetchChapterPages: { pages: string[] } }>(
|
||||||
FETCH_CHAPTER_PAGES, { chapterId },
|
FETCH_CHAPTER_PAGES, { chapterId },
|
||||||
@@ -56,10 +54,8 @@ function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]>
|
|||||||
|
|
||||||
const base = inflight.get(chapterId)!;
|
const base = inflight.get(chapterId)!;
|
||||||
|
|
||||||
// No abort signal — return the shared promise directly
|
|
||||||
if (!signal) return base;
|
if (!signal) return base;
|
||||||
|
|
||||||
// Wrap so this caller can abort their own wait without cancelling the network request
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||||
base.then(resolve, reject);
|
base.then(resolve, reject);
|
||||||
@@ -186,13 +182,16 @@ export default function Reader() {
|
|||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Live-value refs — updated every render, readable inside effects without stale closures
|
// Live-value refs — always current, safe to read inside effects/callbacks
|
||||||
const settingsRef = useRef<typeof settings | null>(null);
|
const settingsRef = useRef<typeof settings | null>(null);
|
||||||
const chapterListRef = useRef<typeof activeChapterList>([]);
|
const chapterListRef = useRef<typeof activeChapterList>([]);
|
||||||
const loadingIdRef = useRef<number | null>(null);
|
const loadingIdRef = useRef<number | null>(null);
|
||||||
const markedReadRef = useRef<Set<number>>(new Set());
|
const markedReadRef = useRef<Set<number>>(new Set());
|
||||||
const appendedRef = useRef<Set<number>>(new Set());
|
const appendedRef = useRef<Set<number>>(new Set());
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const appendingRef = useRef(false); // prevents concurrent appends
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const visibleChapterRef = useRef<number | null>(null); // single truth for visible chapter
|
||||||
|
const stripChaptersRef = useRef<StripChapter[]>([]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -203,7 +202,8 @@ export default function Reader() {
|
|||||||
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
||||||
const [stripChapters, setStripChapters] = useState<StripChapter[]>([]);
|
const [stripChapters, setStripChapters] = useState<StripChapter[]>([]);
|
||||||
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
||||||
const stripChaptersRef = useRef<StripChapter[]>([]);
|
|
||||||
|
// Keep refs in sync every render
|
||||||
stripChaptersRef.current = stripChapters;
|
stripChaptersRef.current = stripChapters;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -219,7 +219,6 @@ export default function Reader() {
|
|||||||
const maxW = settings.maxPageWidth ?? 900;
|
const maxW = settings.maxPageWidth ?? 900;
|
||||||
const autoNext = settings.autoNextChapter ?? false;
|
const autoNext = settings.autoNextChapter ?? false;
|
||||||
|
|
||||||
// Sync live refs every render
|
|
||||||
settingsRef.current = settings;
|
settingsRef.current = settings;
|
||||||
chapterListRef.current = activeChapterList;
|
chapterListRef.current = activeChapterList;
|
||||||
|
|
||||||
@@ -242,9 +241,11 @@ export default function Reader() {
|
|||||||
if (!activeChapter) {
|
if (!activeChapter) {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
appendedRef.current = new Set();
|
appendedRef.current = new Set();
|
||||||
|
appendingRef.current = false;
|
||||||
markedReadRef.current = new Set();
|
markedReadRef.current = new Set();
|
||||||
setStripChapters([]);
|
setStripChapters([]);
|
||||||
setVisibleChapterId(null);
|
setVisibleChapterId(null);
|
||||||
|
visibleChapterRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,14 +255,16 @@ export default function Reader() {
|
|||||||
|
|
||||||
const targetId = activeChapter.id;
|
const targetId = activeChapter.id;
|
||||||
loadingIdRef.current = targetId;
|
loadingIdRef.current = targetId;
|
||||||
appendedRef.current = new Set();
|
appendedRef.current = new Set([targetId]); // seed so we never double-append ch1
|
||||||
markedReadRef.current = new Set();
|
appendingRef.current = false;
|
||||||
|
markedReadRef.current = new Set();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setPageGroups([]);
|
setPageGroups([]);
|
||||||
setPageReady(false);
|
setPageReady(false);
|
||||||
setStripChapters([]);
|
setStripChapters([]);
|
||||||
setVisibleChapterId(null);
|
setVisibleChapterId(null);
|
||||||
|
visibleChapterRef.current = null;
|
||||||
|
|
||||||
fetchPages(targetId, ctrl.signal)
|
fetchPages(targetId, ctrl.signal)
|
||||||
.then(async (urls) => {
|
.then(async (urls) => {
|
||||||
@@ -271,46 +274,15 @@ export default function Reader() {
|
|||||||
setPageUrls(urls);
|
setPageUrls(urls);
|
||||||
setPageReady(true);
|
setPageReady(true);
|
||||||
if (style === "longstrip" && autoNext) {
|
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);
|
setVisibleChapterId(targetId);
|
||||||
// Manual kick: when stripChapters goes 0→1 the dep-array change
|
visibleChapterRef.current = targetId;
|
||||||
// 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); });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
@@ -321,147 +293,204 @@ export default function Reader() {
|
|||||||
});
|
});
|
||||||
}, [activeChapter?.id]);
|
}, [activeChapter?.id]);
|
||||||
|
|
||||||
// ── Longstrip: IntersectionObserver for page number + visible chapter ─────────
|
// ── Append next chapter to the strip ────────────────────────────────────────
|
||||||
// Watches every img[data-page]. Fires on scroll, keyboard jump, or any navigation.
|
// 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<HTMLElement>(
|
||||||
|
`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(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el || style !== "longstrip") return;
|
if (!el || style !== "longstrip") return;
|
||||||
|
|
||||||
const obs = new IntersectionObserver((entries) => {
|
const pageObs = new IntersectionObserver((entries) => {
|
||||||
let topPage = Infinity;
|
let topPage = Infinity;
|
||||||
let topChId: number | null = null;
|
let topChId: number | null = null;
|
||||||
|
let topY = Infinity;
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isIntersecting) continue;
|
if (!entry.isIntersecting) continue;
|
||||||
const p = Number((entry.target as HTMLElement).dataset.page);
|
const target = entry.target as HTMLElement;
|
||||||
const ch = Number((entry.target as HTMLElement).dataset.chapter);
|
const p = Number(target.dataset.page);
|
||||||
if (p < topPage) { topPage = p; topChId = ch; }
|
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 (topPage !== Infinity) setPageNumber(topPage);
|
||||||
if (topChId && topChId !== visibleChapterId) {
|
|
||||||
// Mark the chapter we just left as read
|
if (topChId && topChId !== visibleChapterRef.current) {
|
||||||
if (settingsRef.current?.autoMarkRead && visibleChapterId && !markedReadRef.current.has(visibleChapterId)) {
|
const prev = visibleChapterRef.current;
|
||||||
markedReadRef.current.add(visibleChapterId);
|
// Mark the chapter we just finished scrolling past
|
||||||
gql(MARK_CHAPTER_READ, { id: visibleChapterId, isRead: true }).catch(console.error);
|
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);
|
setVisibleChapterId(topChId);
|
||||||
}
|
}
|
||||||
}, { root: el, threshold: 0.1 });
|
}, { root: el, threshold: 0.1 });
|
||||||
|
|
||||||
el.querySelectorAll("img[data-page]").forEach((img) => obs.observe(img));
|
// Observe all existing images
|
||||||
return () => obs.disconnect();
|
el.querySelectorAll<HTMLElement>("img[data-page]").forEach((img) => pageObs.observe(img));
|
||||||
}, [style, stripChapters, pageUrls, visibleChapterId]);
|
|
||||||
|
|
||||||
// ── Longstrip: sentinel triggers append (or plain chapter advance) ────────────
|
// Auto-observe any images added later (new strip chunks)
|
||||||
// The sentinel div sits at the very bottom of the strip. When it enters the
|
const mutObs = new MutationObserver((mutations) => {
|
||||||
// viewport — via scroll, keyboard jump, or any other means — we fetch the
|
for (const m of mutations) {
|
||||||
// next chapter. rootMargin pre-fires 500px before it's actually visible.
|
m.addedNodes.forEach((node) => {
|
||||||
|
if (node instanceof HTMLElement) {
|
||||||
|
if (node.matches("img[data-page]")) {
|
||||||
|
pageObs.observe(node);
|
||||||
|
} else {
|
||||||
|
node.querySelectorAll<HTMLElement>("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(() => {
|
useEffect(() => {
|
||||||
const sentinel = sentinelRef.current;
|
const sentinel = sentinelRef.current;
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
// Gatekeeper: don't observe until chapter 1 is actually in the DOM.
|
if (!sentinel || !el || style !== "longstrip" || !autoNext) return;
|
||||||
// stripChapters is in the dep array, so this effect re-runs the moment
|
// Don't install until we have content — empty strip means sentinel is at
|
||||||
// content exists — and the new observer fires immediately for an already-
|
// the top and would fire immediately.
|
||||||
// visible sentinel.
|
if (stripChapters.length === 0) return;
|
||||||
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;
|
|
||||||
|
|
||||||
const obs = new IntersectionObserver(([entry]) => {
|
const obs = new IntersectionObserver(([entry]) => {
|
||||||
if (!entry.isIntersecting) return;
|
if (!entry.isIntersecting) return;
|
||||||
|
appendNextChapter();
|
||||||
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
|
|
||||||
});
|
|
||||||
}, { root: el, rootMargin: "0px 0px 500px 0px", threshold: 0 });
|
}, { root: el, rootMargin: "0px 0px 500px 0px", threshold: 0 });
|
||||||
|
|
||||||
obs.observe(sentinel);
|
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(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el || style !== "longstrip" || !autoNext) return;
|
if (!el || style !== "longstrip") return;
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (el.scrollTop + el.clientHeight < el.scrollHeight - 40) return;
|
if (el.scrollTop + el.clientHeight < el.scrollHeight - 40) return;
|
||||||
setStripChapters((cur) => {
|
const last = stripChaptersRef.current[stripChaptersRef.current.length - 1];
|
||||||
const last = cur[cur.length - 1];
|
if (!last) return;
|
||||||
if (last && settingsRef.current?.autoMarkRead && !markedReadRef.current.has(last.chapterId)) {
|
if (settingsRef.current?.autoMarkRead && !markedReadRef.current.has(last.chapterId)) {
|
||||||
markedReadRef.current.add(last.chapterId);
|
markedReadRef.current.add(last.chapterId);
|
||||||
gql(MARK_CHAPTER_READ, { id: last.chapterId, isRead: true }).catch(console.error);
|
gql(MARK_CHAPTER_READ, { id: last.chapterId, isRead: true }).catch(console.error);
|
||||||
}
|
}
|
||||||
return cur;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
el.addEventListener("scroll", onScroll, { passive: true });
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => el.removeEventListener("scroll", onScroll);
|
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
|
// Rebuild strip when autoNext is toggled while longstrip is active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (style !== "longstrip" || !pageUrls.length || !activeChapter) return;
|
if (style !== "longstrip" || !pageUrls.length || !activeChapter) return;
|
||||||
appendedRef.current = new Set();
|
appendedRef.current = new Set([activeChapter.id]);
|
||||||
|
appendingRef.current = false;
|
||||||
if (autoNext) {
|
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);
|
setVisibleChapterId(activeChapter.id);
|
||||||
|
visibleChapterRef.current = activeChapter.id;
|
||||||
} else {
|
} else {
|
||||||
setStripChapters([]);
|
setStripChapters([]);
|
||||||
setVisibleChapterId(null);
|
setVisibleChapterId(null);
|
||||||
|
visibleChapterRef.current = null;
|
||||||
}
|
}
|
||||||
if (containerRef.current) containerRef.current.scrollTop = 0;
|
if (containerRef.current) containerRef.current.scrollTop = 0;
|
||||||
}, [autoNext, style]);
|
}, [autoNext, style]);
|
||||||
@@ -471,32 +500,6 @@ export default function Reader() {
|
|||||||
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
||||||
}, [pageNumber, style]);
|
}, [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 ───────────────────────────────────────────────────
|
// ── Preload adjacent pages ───────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ahead = settings.preloadPages ?? 3;
|
const ahead = settings.preloadPages ?? 3;
|
||||||
@@ -547,7 +550,7 @@ export default function Reader() {
|
|||||||
return chunk ? Math.max(1, pageNumber - chunk.startGlobalIdx) : pageNumber;
|
return chunk ? Math.max(1, pageNumber - chunk.startGlobalIdx) : pageNumber;
|
||||||
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]);
|
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]);
|
||||||
|
|
||||||
// ── Auto-mark read + history ─────────────────────────────────────────────────
|
// ── Auto-mark read + history (non-longstrip) ─────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeChapter || !lastPage) return;
|
if (!activeChapter || !lastPage) return;
|
||||||
if (activeManga) {
|
if (activeManga) {
|
||||||
@@ -557,14 +560,40 @@ export default function Reader() {
|
|||||||
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
|
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 (settings.autoMarkRead && pageNumber === lastPage) {
|
||||||
if (!markedReadRef.current.has(activeChapter.id)) {
|
if (!markedReadRef.current.has(activeChapter.id)) {
|
||||||
markedReadRef.current.add(activeChapter.id);
|
markedReadRef.current.add(activeChapter.id);
|
||||||
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
|
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 ───────────────────────────────────────────────────────────────
|
// ── Navigation ───────────────────────────────────────────────────────────────
|
||||||
const advanceGroup = useCallback((forward: boolean) => {
|
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 */}
|
||||||
<div ref={sentinelRef} style={{ height: 1, flexShrink: 0, overflowAnchor: "none" }} />
|
<div ref={sentinelRef} style={{ height: 1, flexShrink: 0, overflowAnchor: "none" }} />
|
||||||
</>
|
</>
|
||||||
) : (pageReady && (
|
) : (pageReady && (
|
||||||
|
|||||||
Reference in New Issue
Block a user