import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from "react"; import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD, } from "../../lib/queries"; import { useStore, type FitMode } from "../../store"; import { matchesKeybind, toggleFullscreen } from "../../lib/keybinds"; import s from "./Reader.module.css"; // ── LRU image cache ─────────────────────────────────────────────────────────── // Keeps browser memory in check by revoking object-URLs for chapters that // have scrolled far away. We cache by chapterId (not URL) so that we can // drop a whole chapter at once. const MAX_CACHED_CHAPTERS = 6; // Track insertion order so we can evict the oldest chapter. const chapterCacheOrder: number[] = []; function touchChapterOrder(chapterId: number) { const idx = chapterCacheOrder.indexOf(chapterId); if (idx !== -1) chapterCacheOrder.splice(idx, 1); chapterCacheOrder.push(chapterId); } function evictOldestChapter( pageCache: React.MutableRefObject>, keepIds: Set, ): number | null { for (let i = 0; i < chapterCacheOrder.length; i++) { const id = chapterCacheOrder[i]; if (!keepIds.has(id)) { chapterCacheOrder.splice(i, 1); pageCache.current.delete(id); return id; } } return null; } /** Fire-and-forget: create an Image and let the browser cache it. */ function preloadImage(url: string) { const img = new Image(); img.src = url; } /** * Decode a single image fully before resolving. * Used to avoid showing a half-painted page. */ function decodeImage(url: string): Promise { return new Promise((resolve) => { const img = new Image(); img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); }; img.onerror = () => resolve(); // don't block on error img.src = url; }); } function measureAspect(url: string): Promise { return new Promise((res) => { const img = new Image(); img.onload = () => res(img.naturalWidth / img.naturalHeight); img.onerror = () => res(0.67); img.src = url; }); } // ── Download modal ──────────────────────────────────────────────────────────── function DownloadModal({ chapter, remaining, onClose, }: { chapter: { id: number; name: string }; remaining: { id: number }[]; onClose: () => void; }) { const [nextN, setNextN] = useState(5); const [busy, setBusy] = useState(false); const run = async (fn: () => Promise) => { setBusy(true); await fn().catch(console.error); setBusy(false); onClose(); }; return (
e.stopPropagation()}>

Download

e.stopPropagation()}> {nextN}
); } // ── Zoom slider popover ─────────────────────────────────────────────────────── function ZoomPopover({ value, onChange, onReset, onClose, }: { value: number; onChange: (v: number) => void; onReset: () => void; onClose: () => void; }) { const ref = useRef(null); useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [onClose]); return (
onChange(Number(e.target.value))} />
); } // ── Reader ──────────────────────────────────────────────────────────────────── /** One chapter's worth of pages in the infinite strip */ interface StripChapter { chapterId: number; chapterName: string; urls: string[]; /** Global page index offset for pages in this strip chunk */ startGlobalIdx: number; } export default function Reader() { const containerRef = useRef(null); const rafRef = useRef(0); const pageNumRef = useRef(1); const pageCache = useRef>(new Map()); const aspectCache = useRef>(new Map()); const hideTimerRef = useRef | null>(null); const uiRef = useRef(null); // Track which chapters are being fetched so we don't double-fire const fetchingRef = useRef>(new Set()); // Whether we've already appended the next chapter into the strip const appendedRef = useRef>(new Set()); // The chapter id whose pages are currently being loaded (prevents stale sets) const loadingChapterRef = useRef(null); // Mirror of stripChapters in a ref so the scroll handler never closes over stale state const stripChaptersRef = useRef([]); // Scroll anchor: captured just before a head-trim so useLayoutEffect can restore position const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dlOpen, setDlOpen] = useState(false); const [zoomOpen, setZoomOpen] = useState(false); const [uiVisible, setUiVisible] = useState(true); const [markedRead, setMarkedRead] = useState>(new Set()); const [pageGroups, setPageGroups] = useState([]); // True only after the first page of the new chapter has been decoded, // preventing any flash of the previous chapter's image. const [pageReady, setPageReady] = useState(false); /** * The infinite strip: an ordered list of chapter chunks. * In non-longstrip modes this is unused — only pageUrls matters. */ const [stripChapters, setStripChapters] = useState([]); /** * In longstrip autoNext mode, this tracks which chapter the user is * currently reading (for topbar display) without triggering a full reload. */ const [visibleChapterId, setVisibleChapterId] = useState(null); // Keep the ref mirror in sync so the scroll handler always sees current strip state useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]); // Restore scroll position synchronously after a head-trim, before the browser paints useLayoutEffect(() => { const anchor = scrollAnchorRef.current; if (!anchor || !containerRef.current) return; scrollAnchorRef.current = null; const gained = containerRef.current.scrollHeight - anchor.scrollHeight; // gained is negative when we removed nodes (scrollHeight shrank) // We want scrollTop to decrease by the same amount so the visible content stays put. // But since we removed nodes from the top, scrollHeight already shrank — // we just need to subtract the removed pixel height from scrollTop. if (gained < 0) { containerRef.current.scrollTop = Math.max(0, anchor.scrollTop + gained); } }, [stripChapters]); const { activeManga, activeChapter, activeChapterList, pageUrls, pageNumber, settings, setPageUrls, setPageNumber, closeReader, openReader, openSettings, updateSettings, addHistory, } = useStore(); const kb = settings.keybinds; const rtl = settings.readingDirection === "rtl"; const fit = settings.fitMode ?? "width"; const style = settings.pageStyle ?? "single"; const maxW = settings.maxPageWidth ?? 900; const autoNext = settings.autoNextChapter ?? false; useEffect(() => { pageNumRef.current = pageNumber; }, [pageNumber]); // ── UI autohide ────────────────────────────────────────────────────────────── const scheduleHide = useCallback(() => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000); }, []); const showUi = useCallback(() => { setUiVisible(true); scheduleHide(); }, [scheduleHide]); useEffect(() => { scheduleHide(); return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }; }, []); // ── Auto-focus viewer so spacebar/arrows work ─────────────────────────────── useEffect(() => { containerRef.current?.focus({ preventScroll: true }); }, [activeChapter?.id]); // ── Fetch helpers ──────────────────────────────────────────────────────────── const fetchPages = useCallback(async (chapterId: number): Promise => { const cached = pageCache.current.get(chapterId); if (cached) { touchChapterOrder(chapterId); return cached; } if (fetchingRef.current.has(chapterId)) { // Poll until another in-flight fetch resolves return new Promise((resolve) => { const interval = setInterval(() => { const c = pageCache.current.get(chapterId); if (c) { clearInterval(interval); resolve(c); } }, 50); }); } fetchingRef.current.add(chapterId); const d = await gql<{ fetchChapterPages: { pages: string[] } }>( FETCH_CHAPTER_PAGES, { chapterId } ); const urls = d.fetchChapterPages.pages.map(thumbUrl); pageCache.current.set(chapterId, urls); touchChapterOrder(chapterId); // Evict oldest chapters if we're over the limit, but always keep the // immediately adjacent chapters so navigation is instant. while (pageCache.current.size > MAX_CACHED_CHAPTERS) { evictOldestChapter(pageCache, new Set([chapterId])); } fetchingRef.current.delete(chapterId); return urls; }, []); // ── Load pages ────────────────────────────────────────────────────────────── useEffect(() => { if (!activeChapter) return; setLoading(true); setError(null); setPageGroups([]); setPageReady(false); // Reset strip state for new chapter navigation (non-scroll transitions) appendedRef.current = new Set(); const targetId = activeChapter.id; loadingChapterRef.current = targetId; fetchPages(targetId) .then(async (urls) => { // Discard result if the user has already navigated to a different chapter if (loadingChapterRef.current !== targetId) return; // Decode the first page before committing so no previous chapter flashes await decodeImage(urls[0]); if (loadingChapterRef.current !== targetId) return; setPageUrls(urls); setPageReady(true); if (style === "longstrip" && autoNext) { setStripChapters([{ chapterId: activeChapter.id, chapterName: activeChapter.name, urls, startGlobalIdx: 0, }]); setVisibleChapterId(activeChapter.id); } else { setStripChapters([]); setVisibleChapterId(null); } }) .catch((e) => setError(e instanceof Error ? e.message : String(e))) .finally(() => { if (loadingChapterRef.current === targetId) setLoading(false); }); }, [activeChapter?.id]); // ── Double-page grouping ───────────────────────────────────────────────────── // Page 1 (cover) always solo. Wide pages (aspect > 1.2) always solo. // Remaining portrait pages pair left-to-right: [2,3], [4,5], ... useEffect(() => { if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; } let cancelled = false; (async () => { const aspects: number[] = []; for (const url of pageUrls) { if (aspectCache.current.has(url)) { aspects.push(aspectCache.current.get(url)!); } else { const a = await measureAspect(url); aspectCache.current.set(url, a); aspects.push(a); } } if (cancelled) return; const groups: number[][] = []; groups.push([1]); let i = 2; while (i <= pageUrls.length) { const a = aspects[i - 1]; if (a > 1.2) { groups.push([i]); i++; } else if (i === pageUrls.length) { groups.push([i]); i++; } else { const nextA = aspects[i]; if (nextA !== undefined && nextA <= 1.2) { // Book order: left page is i, right page is i+1 groups.push(rtl ? [i + 1, i] : [i, i + 1]); i += 2; } else { groups.push([i]); i++; } } } setPageGroups(groups); })(); return () => { cancelled = true; }; }, [pageUrls, style, settings.offsetDoubleSpreads, rtl]); // ── Preload ───────────────────────────────────────────────────────────────── // Eagerly decode pages ahead; fire-and-forget preload for pages behind. useEffect(() => { const ahead = settings.preloadPages ?? 3; for (let i = 1; i <= ahead; i++) { const url = pageUrls[pageNumber - 1 + i]; if (url) decodeImage(url); // uses browser cache — no duplicate network request } // Also keep one page behind warm const behindUrl = pageUrls[pageNumber - 2]; if (behindUrl) preloadImage(behindUrl); }, [pageNumber, pageUrls, settings.preloadPages]); // ── Adjacent chapters ──────────────────────────────────────────────────────── const adjacent = useMemo(() => { if (!activeChapter || !activeChapterList.length) return { prev: null, next: null, remaining: [] }; const idx = activeChapterList.findIndex((c) => c.id === activeChapter.id); return { prev: idx > 0 ? activeChapterList[idx - 1] : null, next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null, remaining: activeChapterList.slice(idx + 1), }; }, [activeChapter, activeChapterList]); useEffect(() => { const pinned = new Set(); if (activeChapter) pinned.add(activeChapter.id); if (adjacent.next) pinned.add(adjacent.next.id); if (adjacent.prev) pinned.add(adjacent.prev.id); const preload = (id: number) => { fetchPages(id) .then((urls) => urls.slice(0, 3).forEach(preloadImage)) .catch(() => {}); }; if (adjacent.next) preload(adjacent.next.id); if (adjacent.prev) preload(adjacent.prev.id); // After preloads are kicked off, evict anything beyond MAX_CACHED_CHAPTERS // that isn't pinned as adjacent or current. while (pageCache.current.size > MAX_CACHED_CHAPTERS) { const evicted = evictOldestChapter(pageCache, pinned); if (evicted === null) break; // nothing left to evict } }, [adjacent.next?.id, adjacent.prev?.id]); const lastPage = pageUrls.length; /** * In infinite-strip mode, the topbar shows whichever chapter the user is * currently scrolled into rather than the "root" chapter we opened with. */ const displayChapter = useMemo(() => { if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter; return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter; }, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]); /** * In infinite-strip mode, the "last page" shown in the topbar is relative * to the currently visible chapter chunk. */ const visibleChunkLastPage = useMemo(() => { if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return lastPage; const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id)); return chunk ? chunk.urls.length : lastPage; }, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]); /** Page number within the currently visible chapter chunk (for topbar) */ const visibleChunkPage = useMemo(() => { if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return pageNumber; const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id)); if (!chunk) return pageNumber; return Math.max(1, pageNumber - chunk.startGlobalIdx); }, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]); // ── Auto-mark read + history ───────────────────────────────────────────────── useEffect(() => { if (!activeChapter || !lastPage) return; if (activeManga) { addHistory({ mangaId: activeManga.id, mangaTitle: activeManga.title, thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id, chapterName: activeChapter.name, pageNumber, readAt: Date.now(), }); } if (settings.autoMarkRead && pageNumber === lastPage && !markedRead.has(activeChapter.id)) { setMarkedRead((p) => new Set(p).add(activeChapter.id)); gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error); } }, [pageNumber, lastPage, activeChapter?.id]); // ── Navigation ────────────────────────────────────────────────────────────── const advanceGroup = useCallback((forward: boolean) => { if (!pageGroups.length) return; const gi = pageGroups.findIndex((g) => g.includes(pageNumber)); if (forward) { if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]); else if (adjacent.next) { setPageNumber(1); openReader(adjacent.next, activeChapterList); } else closeReader(); } else { if (gi > 0) setPageNumber(pageGroups[gi - 1][0]); else if (adjacent.prev) openReader(adjacent.prev, activeChapterList); } }, [pageGroups, pageNumber, adjacent, activeChapterList]); const goForward = useCallback(() => { if (style === "double" && pageGroups.length) { advanceGroup(true); return; } if (pageNumber < lastPage) { const nextUrl = pageUrls[pageNumber]; // pageNumber is 1-based, so index is pageNumber if (nextUrl) { decodeImage(nextUrl).then(() => setPageNumber(pageNumber + 1)); } else { setPageNumber(pageNumber + 1); } } else if (adjacent.next) { setPageNumber(1); openReader(adjacent.next, activeChapterList); } else { closeReader(); } }, [pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]); const goBack = useCallback(() => { if (style === "double" && pageGroups.length) { advanceGroup(false); return; } if (pageNumber > 1) { const prevUrl = pageUrls[pageNumber - 2]; // 0-based index of previous page if (prevUrl) { decodeImage(prevUrl).then(() => setPageNumber(pageNumber - 1)); } else { setPageNumber(pageNumber - 1); } } else if (adjacent.prev) { openReader(adjacent.prev, activeChapterList); } }, [pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]); const goNext = rtl ? goBack : goForward; const goPrev = rtl ? goForward : goBack; function cycleStyle() { const cycle = ["single", "longstrip"] as const; const cur = style === "double" ? "single" : style; const next = cycle[(cycle.indexOf(cur as any) + 1) % cycle.length]; updateSettings({ pageStyle: next }); } function cycleFit() { const cycle: FitMode[] = ["width", "height", "screen", "original"]; updateSettings({ fitMode: cycle[(cycle.indexOf(fit) + 1) % cycle.length] }); } // ── Ctrl+scroll → zoom ─────────────────────────────────────────────────────── useEffect(() => { const onWheel = (e: WheelEvent) => { if (!e.ctrlKey) return; e.preventDefault(); const delta = e.deltaY < 0 ? 50 : -50; updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + delta)) }); }; window.addEventListener("wheel", onWheel, { passive: false }); return () => window.removeEventListener("wheel", onWheel); }, [maxW]); // ── Keybinds ───────────────────────────────────────────────────────────────── useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.target as HTMLElement).tagName === "INPUT") return; // Escape: close overlays in priority order, then exit reader if (e.key === "Escape") { e.preventDefault(); if (zoomOpen) { setZoomOpen(false); return; } if (dlOpen) { setDlOpen(false); return; } closeReader(); return; } // Ctrl += / Ctrl + / Ctrl - / Ctrl 0 → zoom if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, maxW + 100) }); return; } if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, maxW - 100) }); return; } if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; } if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); } else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); } else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); } else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); } else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); } else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); } else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); } else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); } else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); } else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); } else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen, maxW]); // ── Longstrip scroll tracker ───────────────────────────────────────────────── // Tracks current page number. In autoNext mode, appends the next chapter's // pages directly into the strip (no re-render / scroll reset) so the flow // is one seamless ribbon of images. useEffect(() => { const el = containerRef.current; if (!el || style !== "longstrip") return; const onScroll = () => { cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(() => { if (!el) return; const imgs = Array.from(el.querySelectorAll("img[data-page]")) as HTMLElement[]; // Find the image whose center is closest to the viewport center const viewMid = el.scrollTop + el.clientHeight * 0.5; let closest = 0; let closestDist = Infinity; for (let i = 0; i < imgs.length; i++) { const imgMid = imgs[i].offsetTop + imgs[i].offsetHeight * 0.5; const dist = Math.abs(imgMid - viewMid); if (dist < closestDist) { closestDist = dist; closest = i; } } const n = closest + 1; if (n !== pageNumRef.current) setPageNumber(n); // ── Infinite append ────────────────────────────────────────────────── if (!autoNext) { const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80; if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList); return; } const strip = stripChaptersRef.current; // Silently update visibleChapterId as we scroll into each chunk for (const chunk of strip) { const chunkEnd = chunk.startGlobalIdx + chunk.urls.length; if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) { if (chunk.chapterId !== visibleChapterId) { setVisibleChapterId(chunk.chapterId); if (settings.autoMarkRead) { const prevChunk = strip[strip.indexOf(chunk) - 1]; if (prevChunk) { setMarkedRead((r) => { if (r.has(prevChunk.chapterId)) return r; gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error); return new Set(r).add(prevChunk.chapterId); }); } } } break; } } // Append next chapter when within 300px of the bottom const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300; if (!nearBottom) return; const lastChunk = strip[strip.length - 1]; if (!lastChunk) return; const lastChunkIdx = activeChapterList.findIndex((c) => c.id === lastChunk.chapterId); if (lastChunkIdx < 0 || lastChunkIdx >= activeChapterList.length - 1) return; const nextChEntry = activeChapterList[lastChunkIdx + 1]; if (!nextChEntry || appendedRef.current.has(nextChEntry.id)) return; appendedRef.current.add(nextChEntry.id); fetchPages(nextChEntry.id).then((urls) => { setStripChapters((prev) => { const lastInPrev = prev[prev.length - 1]; const newStart = lastInPrev ? lastInPrev.startGlobalIdx + lastInPrev.urls.length : 0; const next = [ ...prev, { chapterId: nextChEntry.id, chapterName: nextChEntry.name, urls, startGlobalIdx: newStart }, ]; const MAX_STRIP_CHAPTERS = 3; if (next.length > MAX_STRIP_CHAPTERS) { const toRemove = next.length - MAX_STRIP_CHAPTERS; // Snapshot scroll position now, inside the state updater, before React // removes the nodes. useLayoutEffect will restore it after the DOM mutation. scrollAnchorRef.current = { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight }; return next.slice(toRemove); } return next; }); }).catch(console.error); }); }; el.addEventListener("scroll", onScroll, { passive: true }); return () => { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(rafRef.current); }; }, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages, visibleChapterId]); // Reset scroll position when switching chapters in non-longstrip modes useEffect(() => { if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0; }, [pageNumber, style]); // When switching to longstrip, reset scroll to top and rebuild strip from current chapter useEffect(() => { if (style === "longstrip" && containerRef.current) { containerRef.current.scrollTop = 0; if (activeChapter && pageUrls.length > 0) { appendedRef.current = new Set(); if (autoNext) { setStripChapters([{ chapterId: activeChapter.id, chapterName: activeChapter.name, urls: pageUrls, startGlobalIdx: 0, }]); setVisibleChapterId(activeChapter.id); } else { // Plain longstrip — no multi-chapter strip setStripChapters([]); setVisibleChapterId(null); } } } else if (style !== "longstrip") { setStripChapters([]); setVisibleChapterId(null); } }, [activeChapter?.id, style, autoNext]); function handleTap(e: React.MouseEvent) { if (style === "longstrip") return; const x = e.clientX / window.innerWidth; if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); } else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); } } // ── CSS vars ───────────────────────────────────────────────────────────────── const cssVars = { "--max-page-width": `${maxW}px` } as React.CSSProperties; const imgCls = [ s.img, fit === "width" && s.fitWidth, fit === "height" && s.fitHeight, fit === "screen" && s.fitScreen, fit === "original" && s.fitOriginal, settings.optimizeContrast && s.optimizeContrast, ].filter(Boolean).join(" "); // ── Icons ──────────────────────────────────────────────────────────────────── const fitIcon = fit === "width" ? : fit === "height" ? : fit === "screen" ? : ; const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]; const styleIcon = style === "single" ? : ; if (loading) return (
); if (error) return (

{error}

); return (
{ const fromTop = e.clientY; const fromBottom = window.innerHeight - e.clientY; if (fromTop < 60 || fromBottom < 60) showUi(); }} > {/* ── Topbar ── */}
{activeManga?.title} / {displayChapter?.name} {visibleChunkPage} / {visibleChunkLastPage || "…"}
{/* Fit mode */} {/* Zoom */}
{zoomOpen && ( updateSettings({ maxPageWidth: v })} onReset={() => updateSettings({ maxPageWidth: 900 })} onClose={() => setZoomOpen(false)} /> )}
{/* RTL */} {/* Page style */} {/* Page gap toggle */} {style !== "single" && ( )} {/* Auto-next chapter */} {style === "longstrip" && ( )} {/* Download */}
{/* ── Viewer ── */}
{ if (e.key === " " && style === "longstrip") { e.preventDefault(); containerRef.current?.scrollBy({ top: containerRef.current.clientHeight * 0.85, behavior: "smooth" }); } }} > {style === "longstrip" ? ( <> {(autoNext && stripChapters.length > 0 ? stripChapters : [{ chapterId: activeChapter?.id ?? 0, chapterName: activeChapter?.name ?? "", urls: pageUrls, startGlobalIdx: 0, }]).map((chunk) => chunk.urls.map((url, i) => { const globalIdx = chunk.startGlobalIdx + i; return ( {`${chunk.chapterName} ); }) )} ) : ( pageReady && ( {`Page ) )}
{/* ── Bottom nav ── */}
{dlOpen && activeChapter && ( setDlOpen(false)} /> )}
); }