[BETA] QOL Updates (Reader AutoScroll WIP)

This commit is contained in:
Youwes09
2026-02-21 21:50:36 -06:00
parent 9297743d52
commit b921b5eb99
57 changed files with 681 additions and 265 deletions
+245 -111
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
import {
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
Square, Columns, Rows, Download, ArrowsLeftRight,
Square, Rows, Download, ArrowsLeftRight,
ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
@@ -17,7 +17,6 @@ function preloadImage(url: string) {
const img = new Image(); img.src = url;
}
// Returns aspect ratio once image loads; wide (>1.2 w:h) = likely double spread
function measureAspect(url: string): Promise<number> {
return new Promise((res) => {
const img = new Image();
@@ -64,10 +63,15 @@ function DownloadModal({
Next chapters
<span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span>
</button>
<input type="number" className={s.dlInput} min={1}
max={remaining.length || 1} value={nextN}
onChange={(e) => setNextN(Math.max(1, Number(e.target.value)))}
onClick={(e) => e.stopPropagation()} />
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
<button className={s.dlStepBtn}
onClick={() => setNextN((n) => Math.max(1, n - 1))}
disabled={nextN <= 1}></button>
<span className={s.dlStepVal}>{nextN}</span>
<button className={s.dlStepBtn}
onClick={() => setNextN((n) => Math.min(remaining.length || 1, n + 1))}
disabled={nextN >= remaining.length}>+</button>
</div>
</div>
<button className={s.dlOption} disabled={busy || !remaining.length}
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
@@ -81,17 +85,61 @@ function DownloadModal({
);
}
// ── Zoom slider popover ───────────────────────────────────────────────────────
function ZoomPopover({
value,
onChange,
onReset,
onClose,
}: {
value: number;
onChange: (v: number) => void;
onReset: () => void;
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(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 (
<div className={s.zoomPopover} ref={ref}>
<input
type="range"
className={s.zoomSlider}
min={200}
max={2400}
step={50}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<button className={s.zoomResetBtn} onClick={onReset}>
{Math.round((value / 900) * 100)}%
</button>
</div>
);
}
// ── Reader ────────────────────────────────────────────────────────────────────
export default function Reader() {
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef(0);
const pageNumRef = useRef(1);
const pageCache = useRef<Map<number, string[]>>(new Map());
const aspectCache = useRef<Map<string, number>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef(0);
const pageNumRef = useRef(1);
const pageCache = useRef<Map<number, string[]>>(new Map());
const aspectCache = useRef<Map<string, number>>(new Map());
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const uiRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dlOpen, setDlOpen] = useState(false);
const [zoomOpen, setZoomOpen] = useState(false);
const [uiVisible, setUiVisible] = useState(true);
const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
const [pageGroups, setPageGroups] = useState<number[][]>([]);
@@ -102,14 +150,38 @@ export default function Reader() {
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 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]);
// ── Load pages ──────────────────────────────────────────────────────────────
useEffect(() => {
if (!activeChapter) return;
@@ -127,8 +199,8 @@ export default function Reader() {
}, [activeChapter?.id]);
// ── Double-page grouping ─────────────────────────────────────────────────────
// Rule: page 1 (cover) always solo. Wide pages (aspect>1.2) always solo.
// Normal portrait pages pair with next portrait page.
// 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;
@@ -145,18 +217,20 @@ export default function Reader() {
}
if (cancelled) return;
const groups: number[][] = [];
// Page 1 always solo (cover)
groups.push([1]);
let i = 2;
while (i <= pageUrls.length) {
const a = aspects[i - 1];
if (a > 1.2 || i === pageUrls.length) {
// Wide or last page — solo
if (a > 1.2) {
groups.push([i]); i++;
} else if (i === pageUrls.length) {
groups.push([i]); i++;
} else {
const next = aspects[i]; // aspects[i] = page i+1 (0-indexed)
if (next !== undefined && next <= 1.2) {
groups.push([i, i + 1]); i += 2;
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++;
}
@@ -165,12 +239,7 @@ export default function Reader() {
setPageGroups(groups);
})();
return () => { cancelled = true; };
}, [pageUrls, style, settings.offsetDoubleSpreads]);
const currentGroup = useMemo(() => {
if (style !== "double" || !pageGroups.length) return null;
return pageGroups.find((g) => g.includes(pageNumber)) ?? null;
}, [pageGroups, pageNumber, style]);
}, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);
// ── Preload ─────────────────────────────────────────────────────────────────
useEffect(() => {
@@ -230,7 +299,7 @@ export default function Reader() {
const gi = pageGroups.findIndex((g) => g.includes(pageNumber));
if (forward) {
if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]);
else if (adjacent.next) openReader(adjacent.next, activeChapterList);
else if (adjacent.next) { setPageNumber(1); openReader(adjacent.next, activeChapterList); }
else closeReader();
} else {
if (gi > 0) setPageNumber(pageGroups[gi - 1][0]);
@@ -240,9 +309,14 @@ export default function Reader() {
const goForward = useCallback(() => {
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
if (pageNumber < lastPage) setPageNumber(pageNumber + 1);
else if (adjacent.next) openReader(adjacent.next, activeChapterList);
else closeReader();
if (pageNumber < lastPage) {
setPageNumber(pageNumber + 1);
} else if (adjacent.next) {
setPageNumber(1);
openReader(adjacent.next, activeChapterList);
} else {
closeReader();
}
}, [pageNumber, lastPage, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
const goBack = useCallback(() => {
@@ -255,8 +329,9 @@ export default function Reader() {
const goPrev = rtl ? goForward : goBack;
function cycleStyle() {
const cycle = ["single", "double", "longstrip"] as const;
const next = cycle[(cycle.indexOf(style as any) + 1) % cycle.length];
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 });
}
@@ -265,7 +340,7 @@ export default function Reader() {
updateSettings({ fitMode: cycle[(cycle.indexOf(fit) + 1) % cycle.length] });
}
// Ctrl+scroll → zoom maxPageWidth
// ── Ctrl+scroll → zoom ───────────────────────────────────────────────────────
useEffect(() => {
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
@@ -277,54 +352,78 @@ export default function Reader() {
return () => window.removeEventListener("wheel", onWheel);
}, [maxW]);
// Keybinds
// ── Keybinds ─────────────────────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement).tagName === "INPUT") return;
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.exitReader)) { e.preventDefault(); closeReader(); }
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.openSettings)) { e.preventDefault(); openSettings(); }
if (e.key === "Escape") {
if (zoomOpen) { e.preventDefault(); setZoomOpen(false); return; }
if (dlOpen) { e.preventDefault(); setDlOpen(false); return; }
}
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.exitReader)) { e.preventDefault(); closeReader(); }
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.openSettings)) { e.preventDefault(); openSettings(); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList]);
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen]);
// Longstrip scroll — rAF throttled, no flushSync
// ── Longstrip scroll tracker ─────────────────────────────────────────────────
// Tracks current page number and auto-advances to next chapter at end of scroll
useEffect(() => {
const el = containerRef.current;
if (!el || style !== "longstrip") return;
const onScroll = () => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
if (!el) return;
const midY = el.scrollTop + el.clientHeight * 0.5;
let cumH = 0;
const children = Array.from(el.children) as HTMLElement[];
for (let i = 0; i < children.length; i++) {
cumH += children[i].clientHeight;
if (cumH >= midY) {
const n = i + 1;
if (n !== pageNumRef.current) setPageNumber(n);
break;
}
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);
// Auto-advance: within 80px of bottom and next chapter exists
if (autoNext && adjacent.next) {
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
if (nearBottom) openReader(adjacent.next, activeChapterList);
}
});
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(rafRef.current); };
}, [style]);
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
cancelAnimationFrame(rafRef.current);
};
}, [style, autoNext, adjacent.next?.id, activeChapterList]);
// 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
useEffect(() => {
if (style === "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
}, [activeChapter?.id, style]);
function handleTap(e: React.MouseEvent) {
if (style === "longstrip") return;
const x = e.clientX / window.innerWidth;
@@ -344,25 +443,6 @@ export default function Reader() {
settings.optimizeContrast && s.optimizeContrast,
].filter(Boolean).join(" ");
// ── Double page render ────────────────────────────────────────────────────────
function renderDouble() {
if (!currentGroup) {
return <img src={pageUrls[pageNumber - 1]} alt={`Page ${pageNumber}`} className={imgCls} decoding="async" />;
}
const ordered = rtl ? [...currentGroup].reverse() : currentGroup;
const [left, right] = ordered;
return (
<div className={s.doubleWrap}>
<img src={pageUrls[left - 1]} alt={`Page ${left}`}
className={[imgCls, s.pageHalf, settings.pageGap ? s.gapLeft : ""].join(" ")} decoding="async" />
{right && (
<img src={pageUrls[right - 1]} alt={`Page ${right}`}
className={[imgCls, s.pageHalf, settings.pageGap ? s.gapRight : ""].join(" ")} decoding="async" />
)}
</div>
);
}
// ── Icons ────────────────────────────────────────────────────────────────────
const fitIcon =
fit === "width" ? <ArrowsLeftRight size={14} weight="light" /> :
@@ -372,10 +452,7 @@ export default function Reader() {
const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit];
const styleIcon =
style === "single" ? <Square size={14} weight="light" /> :
style === "double" ? <Columns size={14} weight="light" /> :
<Rows size={14} weight="light" />;
const styleIcon = style === "single" ? <Square size={14} weight="light" /> : <Rows size={14} weight="light" />;
if (loading) return (
<div className={s.center}>
@@ -388,14 +465,28 @@ export default function Reader() {
);
return (
<div className={s.root}>
<div
className={s.root}
onMouseMove={(e) => {
const fromTop = e.clientY;
const fromBottom = window.innerHeight - e.clientY;
if (fromTop < 60 || fromBottom < 60) showUi();
}}
>
{/* ── Topbar ── */}
<div className={s.topbar}>
<div
ref={uiRef}
className={[s.topbar, uiVisible ? "" : s.uiHidden].join(" ")}
>
<button className={s.iconBtn} onClick={closeReader} title="Close reader">
<X size={15} weight="light" />
</button>
<button className={s.iconBtn} onClick={() => adjacent.prev && openReader(adjacent.prev, activeChapterList)}
disabled={!adjacent.prev} title="Previous chapter">
<button
className={s.iconBtn}
onClick={() => adjacent.prev && openReader(adjacent.prev, activeChapterList)}
disabled={!adjacent.prev}
title="Previous chapter"
>
<CaretLeft size={14} weight="light" />
</button>
<span className={s.chLabel}>
@@ -404,8 +495,12 @@ export default function Reader() {
<span>{activeChapter?.name}</span>
</span>
<span className={s.pageLabel}>{pageNumber} / {lastPage || "…"}</span>
<button className={s.iconBtn} onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
disabled={!adjacent.next} title="Next chapter">
<button
className={s.iconBtn}
onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
disabled={!adjacent.next}
title="Next chapter"
>
<CaretRight size={14} weight="light" />
</button>
@@ -417,17 +512,31 @@ export default function Reader() {
<span className={s.modeBtnLabel}>{fitLabel}</span>
</button>
{/* Zoom — click resets */}
<button className={s.zoomBtn} onClick={() => updateSettings({ maxPageWidth: 900 })}
title="Click to reset zoom (Ctrl+scroll to zoom)">
{Math.round((maxW / 900) * 100)}%
</button>
{/* Zoom */}
<div className={s.zoomWrap}>
<button
className={s.zoomBtn}
onClick={() => setZoomOpen((o) => !o)}
title="Zoom (click for slider, Ctrl+scroll)"
>
{Math.round((maxW / 900) * 100)}%
</button>
{zoomOpen && (
<ZoomPopover
value={maxW}
onChange={(v) => updateSettings({ maxPageWidth: v })}
onReset={() => updateSettings({ maxPageWidth: 900 })}
onClose={() => setZoomOpen(false)}
/>
)}
</div>
{/* RTL */}
<button
className={[s.modeBtn, rtl ? s.modeBtnActive : ""].join(" ")}
onClick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}
title={`Direction: ${rtl ? "RTL" : "LTR"}`}>
title={`Direction: ${rtl ? "RTL" : "LTR"}`}
>
<ArrowsLeftRight size={14} weight="light" />
<span className={s.modeBtnLabel}>{rtl ? "RTL" : "LTR"}</span>
</button>
@@ -438,16 +547,28 @@ export default function Reader() {
<span className={s.modeBtnLabel}>{style}</span>
</button>
{/* Page gap toggle — only meaningful in double/longstrip */}
{/* Page gap toggle */}
{style !== "single" && (
<button
className={[s.modeBtn, settings.pageGap ? s.modeBtnActive : ""].join(" ")}
onClick={() => updateSettings({ pageGap: !settings.pageGap })}
title="Toggle page gap">
title="Toggle page gap"
>
<span className={s.modeBtnLabel}>Gap</span>
</button>
)}
{/* Auto-next chapter */}
{style === "longstrip" && (
<button
className={[s.modeBtn, autoNext ? s.modeBtnActive : ""].join(" ")}
onClick={() => updateSettings({ autoNextChapter: !autoNext })}
title="Auto-advance to next chapter"
>
<span className={s.modeBtnLabel}>Auto</span>
</button>
)}
{/* Download */}
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
<Download size={14} weight="light" />
@@ -457,29 +578,42 @@ export default function Reader() {
{/* ── Viewer ── */}
<div
ref={containerRef}
className={[
s.viewer,
style === "longstrip" ? s.viewerStrip : "",
].join(" ")}
className={[s.viewer, style === "longstrip" ? s.viewerStrip : ""].join(" ")}
style={cssVars}
tabIndex={-1}
onClick={handleTap}
onKeyDown={(e) => {
if (e.key === " " && style === "longstrip") {
e.preventDefault();
containerRef.current?.scrollBy({ top: containerRef.current.clientHeight * 0.85, behavior: "smooth" });
}
}}
>
{style === "longstrip" ? (
pageUrls.map((url, i) => (
<img key={i} src={url} alt={`Page ${i + 1}`}
<img
key={`${activeChapter?.id}-${i}`}
src={url}
alt={`Page ${i + 1}`}
data-page={i + 1}
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
loading={i < 3 ? "eager" : "lazy"} decoding="async" />
loading={i < 3 ? "eager" : "lazy"}
decoding="async"
/>
))
) : style === "double" ? (
renderDouble()
) : (
<img key={pageNumber} src={pageUrls[pageNumber - 1]}
alt={`Page ${pageNumber}`} className={imgCls} decoding="async" />
<img
key={pageNumber}
src={pageUrls[pageNumber - 1]}
alt={`Page ${pageNumber}`}
className={imgCls}
decoding="async"
/>
)}
</div>
{/* ── Bottom nav ── */}
<div className={s.bottombar}>
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
<button className={s.navBtn} onClick={goPrev} disabled={pageNumber === 1 && !adjacent.prev}>
<ArrowLeft size={13} weight="light" />
</button>