Rewrite reader around known-dimension page placeholders
Replaces the prepend/flushSync/scrollBy gymnastics with placeholder divs
sized by each page's width/height. Document height is correct from the
first paint, so resume + backward scroll just work — no scroll
compensation, no gesture fights, no forced aspect ratio distorting images.
- New /api/chapters/[id]/meta returns the dim skeleton for any chapter.
- Chapter page pre-fetches the starting chapter's meta server-side and
parallelizes the two Prisma queries via Promise.all.
- Reader renders placeholders with aspectRatio: w/h, lazy-loads image
URLs in batches via IntersectionObserver, and prefetches the next
chapter's meta ~3 pages from the end.
- Scroll tracker walks only the intersecting-pages set (~3–5 elements)
instead of every loaded page per rAF.
- scroll={false} on all Links into the reader + { scroll: false } on
double-tap router.push, plus a belt-and-suspenders rAF re-scroll, so
resume survives soft navigation and browser scroll-restoration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0a1365a743
commit
43a2a6d3f8
19
app/api/chapters/[chapterId]/meta/route.ts
Normal file
19
app/api/chapters/[chapterId]/meta/route.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ chapterId: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: Params) {
|
||||||
|
const { chapterId: raw } = await params;
|
||||||
|
const chapterId = parseInt(raw, 10);
|
||||||
|
if (isNaN(chapterId)) {
|
||||||
|
return Response.json({ error: "Invalid chapterId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await prisma.page.findMany({
|
||||||
|
where: { chapterId },
|
||||||
|
orderBy: { number: "asc" },
|
||||||
|
select: { number: true, width: true, height: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(pages);
|
||||||
|
}
|
||||||
@ -26,7 +26,8 @@ export default async function ChapterReaderPage({ params }: Props) {
|
|||||||
const chapterNum = parseInt(chapter, 10);
|
const chapterNum = parseInt(chapter, 10);
|
||||||
if (isNaN(chapterNum)) notFound();
|
if (isNaN(chapterNum)) notFound();
|
||||||
|
|
||||||
const manga = await prisma.manga.findUnique({
|
const [manga, initialChapterMeta] = await Promise.all([
|
||||||
|
prisma.manga.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
include: {
|
include: {
|
||||||
chapters: {
|
chapters: {
|
||||||
@ -36,7 +37,13 @@ export default async function ChapterReaderPage({ params }: Props) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
prisma.page.findMany({
|
||||||
|
where: { chapter: { number: chapterNum, manga: { slug } } },
|
||||||
|
orderBy: { number: "asc" },
|
||||||
|
select: { number: true, width: true, height: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!manga) notFound();
|
if (!manga) notFound();
|
||||||
|
|
||||||
@ -68,6 +75,7 @@ export default async function ChapterReaderPage({ params }: Props) {
|
|||||||
prevChapter={prevChapter}
|
prevChapter={prevChapter}
|
||||||
nextChapter={nextChapter}
|
nextChapter={nextChapter}
|
||||||
chapters={allChapters}
|
chapters={allChapters}
|
||||||
|
initialChapterMeta={initialChapterMeta}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export function ChapterList({
|
|||||||
<Link
|
<Link
|
||||||
key={ch.id}
|
key={ch.id}
|
||||||
href={`/manga/${mangaSlug}/${ch.number}`}
|
href={`/manga/${mangaSlug}/${ch.number}`}
|
||||||
|
scroll={false}
|
||||||
className="flex items-center justify-between px-4 py-3 bg-surface rounded-xl hover:bg-surface-hover active:scale-[0.98] transition-all"
|
className="flex items-center justify-between px-4 py-3 bg-surface rounded-xl hover:bg-surface-hover active:scale-[0.98] transition-all"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
|||||||
@ -1,6 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import {
|
||||||
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@ -15,16 +23,7 @@ type ChapterMeta = {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LoadedPage = {
|
type PageMeta = { number: number; width: number; height: number };
|
||||||
chapterNumber: number;
|
|
||||||
pageNumber: number;
|
|
||||||
imageUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RawPage = {
|
|
||||||
number: number;
|
|
||||||
imageUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PageReaderProps = {
|
type PageReaderProps = {
|
||||||
mangaSlug: string;
|
mangaSlug: string;
|
||||||
@ -33,10 +32,20 @@ type PageReaderProps = {
|
|||||||
prevChapter: number | null;
|
prevChapter: number | null;
|
||||||
nextChapter: number | null;
|
nextChapter: number | null;
|
||||||
chapters: ChapterMeta[];
|
chapters: ChapterMeta[];
|
||||||
|
initialChapterMeta: PageMeta[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const BATCH_SIZE = 7;
|
const PREFETCH_NEXT_AT = 3;
|
||||||
const PREFETCH_AT = 3;
|
const IMAGE_BATCH_RADIUS = 3;
|
||||||
|
const DOUBLE_TAP_MS = 280;
|
||||||
|
|
||||||
|
const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`;
|
||||||
|
|
||||||
|
type IntersectingPage = {
|
||||||
|
chNum: number;
|
||||||
|
pNum: number;
|
||||||
|
el: HTMLDivElement;
|
||||||
|
};
|
||||||
|
|
||||||
export function PageReader({
|
export function PageReader({
|
||||||
mangaSlug,
|
mangaSlug,
|
||||||
@ -45,10 +54,14 @@ export function PageReader({
|
|||||||
prevChapter,
|
prevChapter,
|
||||||
nextChapter,
|
nextChapter,
|
||||||
chapters,
|
chapters,
|
||||||
|
initialChapterMeta,
|
||||||
}: PageReaderProps) {
|
}: PageReaderProps) {
|
||||||
const [showUI, setShowUI] = useState(true);
|
const [showUI, setShowUI] = useState(true);
|
||||||
const [showDrawer, setShowDrawer] = useState(false);
|
const [showDrawer, setShowDrawer] = useState(false);
|
||||||
const [pages, setPages] = useState<LoadedPage[]>([]);
|
const [chapterMetas, setChapterMetas] = useState<Record<number, PageMeta[]>>({
|
||||||
|
[startChapterNumber]: initialChapterMeta,
|
||||||
|
});
|
||||||
|
const [images, setImages] = useState<Record<string, string>>({});
|
||||||
const [currentChapterNum, setCurrentChapterNum] =
|
const [currentChapterNum, setCurrentChapterNum] =
|
||||||
useState(startChapterNumber);
|
useState(startChapterNumber);
|
||||||
const [currentPageNum, setCurrentPageNum] = useState(() => {
|
const [currentPageNum, setCurrentPageNum] = useState(() => {
|
||||||
@ -58,216 +71,209 @@ export function PageReader({
|
|||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hiddenByScrollRef = useRef(false);
|
// Observer stays stable across state updates.
|
||||||
const fetchChapterIdxRef = useRef(
|
const imagesRef = useRef(images);
|
||||||
chapters.findIndex((c) => c.number === startChapterNumber)
|
const chapterMetasRef = useRef(chapterMetas);
|
||||||
);
|
useEffect(() => {
|
||||||
// Initialize offset from saved progress so the first fetch starts AT the
|
imagesRef.current = images;
|
||||||
// user's last-read page — previous pages are skipped entirely
|
}, [images]);
|
||||||
const offsetRef = useRef(0);
|
useEffect(() => {
|
||||||
const initialPageRef = useRef(1);
|
chapterMetasRef.current = chapterMetas;
|
||||||
const offsetInitedRef = useRef(false);
|
}, [chapterMetas]);
|
||||||
if (!offsetInitedRef.current && typeof window !== "undefined") {
|
|
||||||
offsetInitedRef.current = true;
|
const metaInflightRef = useRef<Set<number>>(new Set());
|
||||||
const p = readProgress(mangaSlug);
|
const imagesInflightRef = useRef<Set<string>>(new Set());
|
||||||
if (p && p.chapter === startChapterNumber && p.page > 1) {
|
const pageElRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
offsetRef.current = p.page - 1;
|
|
||||||
initialPageRef.current = p.page;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const loadingRef = useRef(false);
|
|
||||||
const doneRef = useRef(false);
|
|
||||||
// Count of pages already loaded — tracked via ref so fetchBatch stays stable
|
|
||||||
// (otherwise every batch re-creates fetchBatch and tears down the observer)
|
|
||||||
const loadedCountRef = useRef(0);
|
|
||||||
const triggerIndicesRef = useRef<Set<number>>(new Set());
|
|
||||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(new Map());
|
const hiddenByScrollRef = useRef(false);
|
||||||
|
// Pages currently inside the observer's viewport margin. The scroll tick
|
||||||
|
// walks this small set instead of every loaded page.
|
||||||
|
const intersectingPagesRef = useRef<Map<string, IntersectingPage>>(new Map());
|
||||||
|
|
||||||
// Reverse-fetch cursor: the offset of the first loaded page of the
|
const loadedChapterNumbers = useMemo(() => {
|
||||||
// starting chapter. Used to prepend previous pages when the user scrolls
|
return Object.keys(chapterMetas)
|
||||||
// up from a resumed mid-chapter position.
|
.map(Number)
|
||||||
const prependOffsetRef = useRef(offsetRef.current);
|
.filter((n) => n >= startChapterNumber)
|
||||||
const prependLoadingRef = useRef(false);
|
.sort((a, b) => a - b);
|
||||||
const prependExhaustedRef = useRef(offsetRef.current === 0);
|
}, [chapterMetas, startChapterNumber]);
|
||||||
|
|
||||||
const advanceChapterOrFinish = useCallback(() => {
|
const chapterByNumber = useMemo(() => {
|
||||||
if (fetchChapterIdxRef.current + 1 < chapters.length) {
|
const m = new Map<number, ChapterMeta>();
|
||||||
fetchChapterIdxRef.current += 1;
|
for (const c of chapters) m.set(c.number, c);
|
||||||
offsetRef.current = 0;
|
return m;
|
||||||
} else {
|
}, [chapters]);
|
||||||
doneRef.current = true;
|
|
||||||
|
const fetchImagesAround = useCallback(
|
||||||
|
async (chapterNum: number, pageNum: number) => {
|
||||||
|
const meta = chapterMetasRef.current[chapterNum];
|
||||||
|
const chapter = chapterByNumber.get(chapterNum);
|
||||||
|
if (!meta || !chapter) return;
|
||||||
|
const start = Math.max(1, pageNum - IMAGE_BATCH_RADIUS);
|
||||||
|
const end = Math.min(meta.length, pageNum + IMAGE_BATCH_RADIUS);
|
||||||
|
const toFetch: number[] = [];
|
||||||
|
for (let p = start; p <= end; p++) {
|
||||||
|
const k = pageKey(chapterNum, p);
|
||||||
|
if (imagesRef.current[k] || imagesInflightRef.current.has(k)) continue;
|
||||||
|
imagesInflightRef.current.add(k);
|
||||||
|
toFetch.push(p);
|
||||||
}
|
}
|
||||||
}, [chapters.length]);
|
if (toFetch.length === 0) return;
|
||||||
|
const minP = toFetch[0];
|
||||||
const startChapter = useMemo(
|
const maxP = toFetch[toFetch.length - 1];
|
||||||
() => chapters.find((c) => c.number === startChapterNumber),
|
|
||||||
[chapters, startChapterNumber]
|
|
||||||
);
|
|
||||||
|
|
||||||
const prependBatch = useCallback(async () => {
|
|
||||||
if (
|
|
||||||
prependLoadingRef.current ||
|
|
||||||
prependExhaustedRef.current ||
|
|
||||||
!startChapter
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const currentOffset = prependOffsetRef.current;
|
|
||||||
const newOffset = Math.max(0, currentOffset - BATCH_SIZE);
|
|
||||||
const limit = currentOffset - newOffset;
|
|
||||||
if (limit <= 0) {
|
|
||||||
prependExhaustedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prependLoadingRef.current = true;
|
|
||||||
const beforeHeight = document.documentElement.scrollHeight;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/pages?chapterId=${startChapter.id}&offset=${newOffset}&limit=${limit}`
|
`/api/pages?chapterId=${chapter.id}&offset=${minP - 1}&limit=${
|
||||||
|
maxP - minP + 1
|
||||||
|
}`
|
||||||
);
|
);
|
||||||
const batch: RawPage[] = await res.json();
|
const batch: { number: number; imageUrl: string }[] = await res.json();
|
||||||
if (batch.length === 0) {
|
setImages((prev) => {
|
||||||
prependExhaustedRef.current = true;
|
const next = { ...prev };
|
||||||
return;
|
for (const item of batch) {
|
||||||
|
next[pageKey(chapterNum, item.number)] = item.imageUrl;
|
||||||
}
|
}
|
||||||
prependOffsetRef.current = newOffset;
|
return next;
|
||||||
if (newOffset === 0) prependExhaustedRef.current = true;
|
|
||||||
|
|
||||||
// Shift forward-fetch trigger indices + total count since every
|
|
||||||
// already-loaded page has moved right by batch.length
|
|
||||||
const shift = batch.length;
|
|
||||||
const shifted = new Set<number>();
|
|
||||||
for (const t of triggerIndicesRef.current) shifted.add(t + shift);
|
|
||||||
triggerIndicesRef.current = shifted;
|
|
||||||
loadedCountRef.current += shift;
|
|
||||||
|
|
||||||
setPages((prev) => [
|
|
||||||
...batch.map((p) => ({
|
|
||||||
chapterNumber: startChapter.number,
|
|
||||||
pageNumber: p.number,
|
|
||||||
imageUrl: p.imageUrl,
|
|
||||||
})),
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// aspect-[3/4] on the prepended <img>s reserves height before their
|
|
||||||
// bytes arrive, so scrollHeight is accurate immediately after React
|
|
||||||
// commits. One scrollBy keeps the previously-visible page anchored.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const afterHeight = document.documentElement.scrollHeight;
|
|
||||||
const delta = afterHeight - beforeHeight;
|
|
||||||
if (delta > 0) window.scrollBy({ top: delta });
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — user can scroll-up again to retry
|
// observer will re-trigger on next intersection
|
||||||
} finally {
|
} finally {
|
||||||
prependLoadingRef.current = false;
|
for (const p of toFetch)
|
||||||
|
imagesInflightRef.current.delete(pageKey(chapterNum, p));
|
||||||
}
|
}
|
||||||
}, [startChapter]);
|
},
|
||||||
|
[chapters]
|
||||||
const fetchBatch = useCallback(async () => {
|
|
||||||
if (loadingRef.current || doneRef.current) return;
|
|
||||||
const chapter = chapters[fetchChapterIdxRef.current];
|
|
||||||
if (!chapter) {
|
|
||||||
doneRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadingRef.current = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/pages?chapterId=${chapter.id}&offset=${offsetRef.current}&limit=${BATCH_SIZE}`
|
|
||||||
);
|
);
|
||||||
const batch: RawPage[] = await res.json();
|
|
||||||
if (batch.length === 0) {
|
|
||||||
advanceChapterOrFinish();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseIndex = loadedCountRef.current;
|
const prefetchNextChapterMeta = useCallback(
|
||||||
const willHaveMore =
|
async (currentChapterNumArg: number) => {
|
||||||
offsetRef.current + batch.length < chapter.totalPages ||
|
const idx = chapters.findIndex((c) => c.number === currentChapterNumArg);
|
||||||
fetchChapterIdxRef.current + 1 < chapters.length;
|
if (idx < 0 || idx >= chapters.length - 1) return;
|
||||||
if (willHaveMore) {
|
const next = chapters[idx + 1];
|
||||||
const triggerIndex = baseIndex + PREFETCH_AT - 1;
|
if (chapterMetasRef.current[next.number]) return;
|
||||||
triggerIndicesRef.current.add(triggerIndex);
|
if (metaInflightRef.current.has(next.number)) return;
|
||||||
const existing = pageRefsRef.current.get(triggerIndex);
|
metaInflightRef.current.add(next.number);
|
||||||
if (existing && observerRef.current) {
|
try {
|
||||||
observerRef.current.observe(existing);
|
const res = await fetch(`/api/chapters/${next.id}/meta`);
|
||||||
}
|
const meta: PageMeta[] = await res.json();
|
||||||
}
|
setChapterMetas((prev) => ({ ...prev, [next.number]: meta }));
|
||||||
|
|
||||||
loadedCountRef.current += batch.length;
|
|
||||||
setPages((prev) => [
|
|
||||||
...prev,
|
|
||||||
...batch.map((p) => ({
|
|
||||||
chapterNumber: chapter.number,
|
|
||||||
pageNumber: p.number,
|
|
||||||
imageUrl: p.imageUrl,
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
offsetRef.current += batch.length;
|
|
||||||
if (offsetRef.current >= chapter.totalPages) {
|
|
||||||
advanceChapterOrFinish();
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// retry on next intersection
|
// will retry next observer fire
|
||||||
} finally {
|
} finally {
|
||||||
loadingRef.current = false;
|
metaInflightRef.current.delete(next.number);
|
||||||
}
|
}
|
||||||
}, [chapters, advanceChapterOrFinish]);
|
},
|
||||||
|
[chapters]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
observerRef.current = new IntersectionObserver(
|
observerRef.current = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
for (const entry of entries) {
|
for (const e of entries) {
|
||||||
if (entry.isIntersecting) {
|
const el = e.target as HTMLDivElement;
|
||||||
const index = Number(
|
const chNum = Number(el.dataset.chapter);
|
||||||
(entry.target as HTMLElement).dataset.pageIndex
|
const pNum = Number(el.dataset.page);
|
||||||
);
|
if (!chNum || !pNum) continue;
|
||||||
if (triggerIndicesRef.current.has(index)) {
|
const key = pageKey(chNum, pNum);
|
||||||
triggerIndicesRef.current.delete(index);
|
if (e.isIntersecting) {
|
||||||
fetchBatch();
|
intersectingPagesRef.current.set(key, { chNum, pNum, el });
|
||||||
}
|
fetchImagesAround(chNum, pNum);
|
||||||
}
|
const chapter = chapterByNumber.get(chNum);
|
||||||
}
|
if (chapter && pNum >= chapter.totalPages - PREFETCH_NEXT_AT) {
|
||||||
},
|
prefetchNextChapterMeta(chNum);
|
||||||
{ rootMargin: "400px" }
|
|
||||||
);
|
|
||||||
return () => observerRef.current?.disconnect();
|
|
||||||
}, [fetchBatch]);
|
|
||||||
|
|
||||||
const initialFetchRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialFetchRef.current) return;
|
|
||||||
initialFetchRef.current = true;
|
|
||||||
fetchBatch();
|
|
||||||
}, [fetchBatch]);
|
|
||||||
|
|
||||||
const setPageRef = useCallback(
|
|
||||||
(index: number, el: HTMLDivElement | null) => {
|
|
||||||
const observer = observerRef.current;
|
|
||||||
if (!observer) return;
|
|
||||||
const prev = pageRefsRef.current.get(index);
|
|
||||||
if (prev) observer.unobserve(prev);
|
|
||||||
if (el) {
|
|
||||||
pageRefsRef.current.set(index, el);
|
|
||||||
if (triggerIndicesRef.current.has(index)) {
|
|
||||||
observer.observe(el);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pageRefsRef.current.delete(index);
|
intersectingPagesRef.current.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
{ rootMargin: "1200px" }
|
||||||
);
|
);
|
||||||
|
for (const el of pageElRef.current.values()) {
|
||||||
|
observerRef.current.observe(el);
|
||||||
|
}
|
||||||
|
return () => observerRef.current?.disconnect();
|
||||||
|
}, [fetchImagesAround, prefetchNextChapterMeta, chapterByNumber]);
|
||||||
|
|
||||||
|
const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => {
|
||||||
|
const observer = observerRef.current;
|
||||||
|
const prev = pageElRef.current.get(key);
|
||||||
|
if (prev && observer) observer.unobserve(prev);
|
||||||
|
if (el) {
|
||||||
|
pageElRef.current.set(key, el);
|
||||||
|
if (observer) observer.observe(el);
|
||||||
|
} else {
|
||||||
|
pageElRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync scroll + rAF re-scroll: defends against browser scroll-restoration
|
||||||
|
// on hard reload (the sync pass handles soft nav where Link scroll={false}).
|
||||||
|
const resumeDoneRef = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (resumeDoneRef.current) return;
|
||||||
|
resumeDoneRef.current = true;
|
||||||
|
const p = readProgress(mangaSlug);
|
||||||
|
if (!p || p.chapter !== startChapterNumber || p.page <= 1) return;
|
||||||
|
const scrollToResume = () => {
|
||||||
|
const el = pageElRef.current.get(pageKey(startChapterNumber, p.page));
|
||||||
|
if (!el) return;
|
||||||
|
window.scrollTo({
|
||||||
|
top: el.offsetTop,
|
||||||
|
behavior: "instant" as ScrollBehavior,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
scrollToResume();
|
||||||
|
requestAnimationFrame(scrollToResume);
|
||||||
|
}, [mangaSlug, startChapterNumber]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let rafId = 0;
|
||||||
|
const tick = () => {
|
||||||
|
rafId = 0;
|
||||||
|
const y = window.scrollY;
|
||||||
|
if (!hiddenByScrollRef.current && y > 50) {
|
||||||
|
hiddenByScrollRef.current = true;
|
||||||
|
setShowUI(false);
|
||||||
|
}
|
||||||
|
// Walk only the pages currently inside the 1200px viewport margin
|
||||||
|
// (maintained by the observer) and pick the one with the greatest
|
||||||
|
// offsetTop still above y+80 — that's the topmost visible page.
|
||||||
|
let bestCh = currentChapterNum;
|
||||||
|
let bestPg = currentPageNum;
|
||||||
|
let bestTop = -1;
|
||||||
|
for (const { chNum, pNum, el } of intersectingPagesRef.current.values()) {
|
||||||
|
const top = el.offsetTop;
|
||||||
|
if (top <= y + 80 && top > bestTop) {
|
||||||
|
bestTop = top;
|
||||||
|
bestCh = chNum;
|
||||||
|
bestPg = pNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestCh !== currentChapterNum) setCurrentChapterNum(bestCh);
|
||||||
|
if (bestPg !== currentPageNum) setCurrentPageNum(bestPg);
|
||||||
|
};
|
||||||
|
const onScroll = () => {
|
||||||
|
if (rafId) return;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [currentChapterNum, currentPageNum]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
writeProgress(mangaSlug, {
|
||||||
|
chapter: currentChapterNum,
|
||||||
|
page: currentPageNum,
|
||||||
|
});
|
||||||
|
}, [mangaSlug, currentChapterNum, currentPageNum]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const touchMovedRef = useRef(false);
|
const touchMovedRef = useRef(false);
|
||||||
// Pending single-tap toggle, delayed so we can detect a double-tap first
|
|
||||||
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastTapAtRef = useRef(0);
|
const lastTapAtRef = useRef(0);
|
||||||
const DOUBLE_TAP_MS = 280;
|
|
||||||
|
|
||||||
const onTouchStart = useCallback(() => {
|
const onTouchStart = useCallback(() => {
|
||||||
touchMovedRef.current = false;
|
touchMovedRef.current = false;
|
||||||
@ -275,15 +281,12 @@ export function PageReader({
|
|||||||
const onTouchMove = useCallback(() => {
|
const onTouchMove = useCallback(() => {
|
||||||
touchMovedRef.current = true;
|
touchMovedRef.current = true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onTap = useCallback(
|
const onTap = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (touchMovedRef.current) return;
|
if (touchMovedRef.current) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const isDoubleTap = now - lastTapAtRef.current < DOUBLE_TAP_MS;
|
const isDoubleTap = now - lastTapAtRef.current < DOUBLE_TAP_MS;
|
||||||
|
|
||||||
if (isDoubleTap) {
|
if (isDoubleTap) {
|
||||||
// Cancel pending single-tap, navigate instead
|
|
||||||
if (singleTapTimerRef.current) {
|
if (singleTapTimerRef.current) {
|
||||||
clearTimeout(singleTapTimerRef.current);
|
clearTimeout(singleTapTimerRef.current);
|
||||||
singleTapTimerRef.current = null;
|
singleTapTimerRef.current = null;
|
||||||
@ -292,14 +295,17 @@ export function PageReader({
|
|||||||
const midX = window.innerWidth / 2;
|
const midX = window.innerWidth / 2;
|
||||||
if (e.clientX >= midX) {
|
if (e.clientX >= midX) {
|
||||||
if (nextChapter)
|
if (nextChapter)
|
||||||
router.push(`/manga/${mangaSlug}/${nextChapter}`);
|
router.push(`/manga/${mangaSlug}/${nextChapter}`, {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (prevChapter)
|
if (prevChapter)
|
||||||
router.push(`/manga/${mangaSlug}/${prevChapter}`);
|
router.push(`/manga/${mangaSlug}/${prevChapter}`, {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastTapAtRef.current = now;
|
lastTapAtRef.current = now;
|
||||||
singleTapTimerRef.current = setTimeout(() => {
|
singleTapTimerRef.current = setTimeout(() => {
|
||||||
setShowUI((v) => !v);
|
setShowUI((v) => !v);
|
||||||
@ -316,64 +322,15 @@ export function PageReader({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let rafId = 0;
|
|
||||||
const tick = () => {
|
|
||||||
rafId = 0;
|
|
||||||
const y = window.scrollY;
|
|
||||||
if (!hiddenByScrollRef.current && y > 50) {
|
|
||||||
hiddenByScrollRef.current = true;
|
|
||||||
setShowUI(false);
|
|
||||||
}
|
|
||||||
// Trigger backward prepend when user approaches the top — fire early
|
|
||||||
// so the DOM/pics are already spawned by the time the user scrolls there
|
|
||||||
if (
|
|
||||||
y < 2500 &&
|
|
||||||
!prependLoadingRef.current &&
|
|
||||||
!prependExhaustedRef.current
|
|
||||||
) {
|
|
||||||
prependBatch();
|
|
||||||
}
|
|
||||||
// Nothing loaded yet — don't overwrite the resumed-page state
|
|
||||||
if (pages.length === 0) return;
|
|
||||||
let chapter = pages[0].chapterNumber;
|
|
||||||
let page = pages[0].pageNumber;
|
|
||||||
for (let i = 0; i < pages.length; i++) {
|
|
||||||
const el = pageRefsRef.current.get(i);
|
|
||||||
if (!el) continue;
|
|
||||||
if (el.offsetTop <= y + 80) {
|
|
||||||
chapter = pages[i].chapterNumber;
|
|
||||||
page = pages[i].pageNumber;
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
setCurrentChapterNum(chapter);
|
|
||||||
setCurrentPageNum(page);
|
|
||||||
};
|
|
||||||
const onScroll = () => {
|
|
||||||
if (rafId) return;
|
|
||||||
rafId = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", onScroll);
|
|
||||||
if (rafId) cancelAnimationFrame(rafId);
|
|
||||||
};
|
|
||||||
}, [pages, startChapterNumber, prependBatch]);
|
|
||||||
|
|
||||||
// Persist progress as user scrolls. offsetRef has been pre-seeded above
|
|
||||||
// so the first fetched page IS the resumed page — no scroll restoration
|
|
||||||
// needed.
|
|
||||||
useEffect(() => {
|
|
||||||
writeProgress(mangaSlug, {
|
|
||||||
chapter: currentChapterNum,
|
|
||||||
page: currentPageNum,
|
|
||||||
});
|
|
||||||
}, [mangaSlug, currentChapterNum, currentPageNum]);
|
|
||||||
|
|
||||||
const currentChapter =
|
const currentChapter =
|
||||||
chapters.find((c) => c.number === currentChapterNum) ??
|
chapters.find((c) => c.number === currentChapterNum) ??
|
||||||
chapters.find((c) => c.number === startChapterNumber);
|
chapters.find((c) => c.number === startChapterNumber);
|
||||||
|
|
||||||
|
const lastChapter = chapters[chapters.length - 1];
|
||||||
|
const atEnd =
|
||||||
|
currentChapterNum === lastChapter?.number &&
|
||||||
|
currentPageNum >= (chapterMetas[currentChapterNum]?.length ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-background">
|
<div className="min-h-dvh bg-background">
|
||||||
<div
|
<div
|
||||||
@ -432,41 +389,54 @@ export function PageReader({
|
|||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{pages.map((page, i) => {
|
{loadedChapterNumbers.map((chNum, idx) => {
|
||||||
const isChapterStart =
|
const meta = chapterMetas[chNum];
|
||||||
i === 0 || pages[i - 1].chapterNumber !== page.chapterNumber;
|
const chapter = chapters.find((c) => c.number === chNum);
|
||||||
return (
|
return (
|
||||||
<div
|
<Fragment key={chNum}>
|
||||||
key={`${page.chapterNumber}-${page.pageNumber}`}
|
{idx > 0 && (
|
||||||
className="relative leading-[0]"
|
|
||||||
data-page-index={i}
|
|
||||||
ref={(el) => setPageRef(i, el)}
|
|
||||||
>
|
|
||||||
{isChapterStart && i > 0 && (
|
|
||||||
<div className="bg-surface py-4 text-center leading-normal">
|
<div className="bg-surface py-4 text-center leading-normal">
|
||||||
<p className="text-xs uppercase tracking-wider text-muted">
|
<p className="text-xs uppercase tracking-wider text-muted">
|
||||||
Chapter {page.chapterNumber}
|
Chapter {chNum}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-semibold text-foreground">
|
||||||
{
|
{chapter?.title}
|
||||||
chapters.find((c) => c.number === page.chapterNumber)
|
|
||||||
?.title
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{meta.map((p) => {
|
||||||
|
const key = pageKey(chNum, p.number);
|
||||||
|
const url = images[key];
|
||||||
|
const aspect =
|
||||||
|
p.width > 0 && p.height > 0
|
||||||
|
? `${p.width} / ${p.height}`
|
||||||
|
: "3 / 4";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
ref={(el) => setPageRef(key, el)}
|
||||||
|
data-chapter={chNum}
|
||||||
|
data-page={p.number}
|
||||||
|
className="relative leading-[0] w-full"
|
||||||
|
style={{ aspectRatio: aspect }}
|
||||||
|
>
|
||||||
|
{url && (
|
||||||
<img
|
<img
|
||||||
src={page.imageUrl}
|
src={url}
|
||||||
alt={`Page ${page.pageNumber}`}
|
alt={`Page ${p.number}`}
|
||||||
className="w-full h-auto block align-bottom -mb-px aspect-[3/4] [-webkit-touch-callout:none]"
|
className="w-full h-auto block [-webkit-touch-callout:none]"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{doneRef.current && pages.length > 0 && (
|
{atEnd && (
|
||||||
<div className="min-h-dvh max-w-4xl mx-auto px-4 flex flex-col items-center justify-center text-center leading-normal gap-4">
|
<div className="min-h-dvh max-w-4xl mx-auto px-4 flex flex-col items-center justify-center text-center leading-normal gap-4">
|
||||||
<p className="text-xs uppercase tracking-wider text-muted">
|
<p className="text-xs uppercase tracking-wider text-muted">
|
||||||
End of Manga
|
End of Manga
|
||||||
@ -481,7 +451,6 @@ export function PageReader({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chapter drawer overlay (modal — fixed is necessary to cover viewport) */}
|
|
||||||
{showDrawer && (
|
{showDrawer && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60]"
|
className="fixed inset-0 z-[60]"
|
||||||
@ -512,6 +481,7 @@ export function PageReader({
|
|||||||
<Link
|
<Link
|
||||||
key={ch.number}
|
key={ch.number}
|
||||||
href={`/manga/${mangaSlug}/${ch.number}`}
|
href={`/manga/${mangaSlug}/${ch.number}`}
|
||||||
|
scroll={false}
|
||||||
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors ${
|
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent/10"
|
? "bg-accent/10"
|
||||||
|
|||||||
@ -71,6 +71,7 @@ export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/manga/${mangaSlug}/${target.number}`}
|
href={`/manga/${mangaSlug}/${target.number}`}
|
||||||
|
scroll={false}
|
||||||
className="flex items-center justify-center gap-3 w-full py-3 mb-6 px-4 text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl transition-colors active:scale-[0.98]"
|
className="flex items-center justify-center gap-3 w-full py-3 mb-6 px-4 text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl transition-colors active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
{resumeChapter ? (
|
{resumeChapter ? (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user