feat(reader): immersive chrome + WeChat-Read/Mihon-style bottom sheets — tap-center toggles header/toolbar, progress & page sliders seek anywhere, WYSIWYG theme swatches, A± font size; CBZ gains 连读/整页/适高 modes, edge-tap & arrow-key paging, persisted mode (seekChapter helper + test)

This commit is contained in:
2026-09-07 23:03:26 +08:00
parent 7e63c21902
commit 065fde5b56
9 changed files with 330 additions and 102 deletions
+116 -16
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { api, formatPageUrl } from "../api/client";
import { btn } from "../components/ui";
import { fetchObjectUrl } from "../lib/authImage";
@@ -9,7 +9,30 @@ import { PageHeights } from "../lib/virt";
const MAX_W = 720;
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
type PageMode = "scroll" | "snap" | "fit"; // 连读 / 整页(适宽) / 适高(一页一屏)
const MODES: PageMode[] = ["scroll", "snap", "fit"];
const MODE_LABEL: Record<PageMode, string> = { scroll: "连读", snap: "整页", fit: "适高" };
function useStoredMode() {
const [mode, setMode] = useState<PageMode>(() => {
const v = localStorage.getItem("cbz-mode");
return MODES.includes(v as PageMode) ? (v as PageMode) : "scroll";
});
useEffect(() => localStorage.setItem("cbz-mode", mode), [mode]);
return [mode, setMode] as const;
}
function PageImg({
url,
width,
fitH,
onLoaded,
}: {
url: string;
width: number;
fitH: boolean;
onLoaded: (h: number) => void;
}) {
const [src, setSrc] = useState("");
const [failed, setFailed] = useState(false);
const [nonce, setNonce] = useState(0);
@@ -34,7 +57,11 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
</div>
);
if (!src)
return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
return (
<div className="animate-pulse bg-stone-800" style={{ width, height: fitH ? "100%" : width * EST_RATIO }} />
);
if (fitH)
return <img src={src} alt="" className="mx-auto block h-full w-full object-contain" loading="eager" />;
return (
<img
src={src}
@@ -49,7 +76,7 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
);
}
export default function CbzReader({ book, initialLocator }: ReaderProps) {
export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) {
const countQ = useQuery({
queryKey: ["pages", book.id],
queryFn: () => api.pageCount(book.pages_url ?? ""),
@@ -64,8 +91,13 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
const [top, setTop] = useState(0);
const [, bump] = useState(0);
const restored = useRef(false);
const [mode, setMode] = useStoredMode();
const fitH = mode === "fit";
const ph = useMemo(() => new PageHeights(count, width * EST_RATIO), [count, width]);
const ph = useMemo(
() => new PageHeights(count, fitH ? vh : width * EST_RATIO),
[count, width, vh, fitH],
);
// 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准)
useEffect(() => {
@@ -100,6 +132,26 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
}
}
const cur = ph.pageAt(top + vh / 2);
function jump(i: number) {
const el = boxRef.current;
if (!el) return;
el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: "smooth" });
chrome.show();
}
// 点按两侧翻页/翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区)
function onZoneClick(e: MouseEvent<HTMLDivElement>) {
if ((e.target as HTMLElement).closest("button,input,nav,a")) return;
if (window.getSelection()?.toString()) return;
const r = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
if (x < 0.28) jump(cur - 1);
else if (x > 0.72) jump(cur + 1);
else chrome.toggle();
}
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
if (countQ.isPending && !!book.pages_url)
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
@@ -112,20 +164,40 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
const fmt: string = book.page_url_fmt;
const [a, b] = ph.range(top, vh, 2);
const cur = ph.pageAt(top + vh / 2);
return (
<div className="relative h-full">
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto">
<div className="relative mx-auto" style={{ height: ph.total(), maxWidth: MAX_W }}>
<div
tabIndex={0}
aria-label="漫画阅读器,点按左右两侧翻页,点按中间显示工具栏"
className="relative h-full"
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "PageDown") jump(cur + 1);
else if (e.key === "ArrowLeft" || e.key === "PageUp") jump(cur - 1);
else return;
e.preventDefault();
}}
>
<div
ref={boxRef}
onScroll={onScroll}
onClick={onZoneClick}
className={"h-full overflow-y-auto" + (mode !== "scroll" ? " snap-y snap-mandatory" : "")}
>
<div className="relative mx-auto" style={{ height: ph.total(), maxWidth: fitH ? "none" : MAX_W }}>
{Array.from({ length: b - a }, (_, k) => {
const i = a + k;
return (
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
<div
key={i}
className="snap-start absolute left-0 w-full"
style={{ top: ph.offset(i), ...(fitH ? { height: vh } : null) }}
>
<PageImg
url={formatPageUrl(fmt, i)}
width={width}
fitH={fitH}
onLoaded={(h) => {
if (fitH) return; // 适高模式定高一页一屏,无需量高
const el = boxRef.current;
const topOfI = ph.offset(i);
const d = ph.set(i, h);
@@ -138,12 +210,40 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
})}
</div>
</div>
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-stone-900/85 px-3 py-1.5 text-xs tabular-nums text-stone-200 shadow-lg ring-1 ring-stone-700/60 backdrop-blur">
<span>
{cur + 1}/{count}
</span>
<span className="opacity-60">{Math.round(((cur + 1) / count) * 100)}%</span>
</div>
{chrome.on ? (
<div className="rd-sheet absolute inset-x-0 bottom-0 z-20 border-t border-stone-700/60 bg-stone-900/85 px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 text-stone-200 backdrop-blur">
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={Math.max(0, count - 1)}
value={cur}
onChange={(e) => jump(Number(e.target.value))}
className="rd-range"
aria-label="页码跳转"
/>
<span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70">
{cur + 1}/{count} · {Math.round(((cur + 1) / Math.max(1, count)) * 100)}%
</span>
</div>
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
<button
className="rd-btn"
onClick={() => setMode(MODES[(MODES.indexOf(mode) + 1) % MODES.length])}
aria-label="切换阅读模式"
>
{MODE_LABEL[mode]}
</button>
</div>
</div>
) : (
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-stone-900/85 px-3 py-1.5 text-xs tabular-nums text-stone-200 shadow-lg ring-1 ring-stone-700/60 backdrop-blur">
<span>
{cur + 1}/{count}
</span>
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
</div>
)}
</div>
);
}