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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user