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:
@@ -10,8 +10,10 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
### Added / 新增
|
### Added / 新增
|
||||||
|
|
||||||
- Text (.txt/.md) reader upgrades: paper / sepia / night reading themes and adjustable font size, with a compact prefs bar; choices persist across books (localStorage); body text is now serif with a readable measure and justified lines.
|
- Immersive reading for text & comics (微信读书/Mihon-style): tap the middle of the page to show/hide the reader header and control bar; controls now live in a slide-up bottom sheet — a progress slider jumps to any position (text) or page (CBZ), theme swatches (纸/米/夜) are WYSIWYG color dots, plus A−/A+ font size; body text is serif with a readable justified measure; all prefs persist (localStorage).
|
||||||
- 文本阅读器(.txt/.md)升级:新增 纸/米/夜 三种阅读主题与字号调节,工具条常驻;偏好跨书持久化(localStorage);正文改为衬线字体、收窄行宽并两端对齐,更利于长时间阅读。
|
- 文本与漫画进入沉浸阅读:点正文中央唤出/隐藏顶栏与工具条;控件收进底部滑出抽屉——进度/页码滑条直接跳到全书任意位置或任意页,主题改为所见即所得的色卡(纸/米/夜),保留 A−/A+ 字号;txt/md 正文为衬线、限宽两端对齐;偏好本地持久化。
|
||||||
|
- CBZ adds reading modes — 连读 (continuous scroll) / 整页 (snap to page top) / 适高 (one page per screen, full width) — cycle button in the bar, choice remembered; tapping the left/right edges turns a page/screen and ←/→/PageUp/PageDown work too.
|
||||||
|
- CBZ 新增三种阅读模式——连读 / 整页吸附 / 适高(一页一屏满宽),工具条一键循环切换并记忆;点按画面左右缘翻页(屏),并支持 ←/→/PageUp/PageDown 键。
|
||||||
- PDF reader: emoji skip icons replaced with 首页/末页 text buttons, framed page canvas, and an accessible label announcing arrow-key paging.
|
- PDF reader: emoji skip icons replaced with 首页/末页 text buttons, framed page canvas, and an accessible label announcing arrow-key paging.
|
||||||
- PDF 阅读器:以文字按钮(首页/末页)替换 emoji 图标,页面画布加边框投影,并补充可访问的「方向键翻页」说明。
|
- PDF 阅读器:以文字按钮(首页/末页)替换 emoji 图标,页面画布加边框投影,并补充可访问的「方向键翻页」说明。
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ body {
|
|||||||
.rd-btn-on { @apply font-semibold; background: color-mix(in oklab, currentColor 20%, transparent); }
|
.rd-btn-on { @apply font-semibold; background: color-mix(in oklab, currentColor 20%, transparent); }
|
||||||
.rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); }
|
.rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); }
|
||||||
|
|
||||||
|
/* 进度/页码滑条:主题色轨道 + 强调色拇指,触摸友好 */
|
||||||
|
.rd-range { @apply h-1.5 w-full cursor-pointer appearance-none rounded-full; background: color-mix(in oklab, currentColor 18%, transparent); }
|
||||||
|
.rd-range::-webkit-slider-thumb {
|
||||||
|
@apply h-4 w-4 appearance-none rounded-full ring-2 ring-black/10; background: var(--rd-link, #f59e0b);
|
||||||
|
}
|
||||||
|
.rd-range::-moz-range-thumb { @apply h-4 w-4 rounded-full border-0; background: var(--rd-link, #f59e0b); }
|
||||||
|
|
||||||
|
/* 沉浸态底部工具抽屉的滑入动效 */
|
||||||
|
@keyframes rd-rise { from { transform: translateY(100%); opacity: 0.4; } to { transform: translateY(0); opacity: 1; } }
|
||||||
|
.rd-sheet { animation: rd-rise 0.2s ease-out; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.rd-sheet { animation: none; }
|
||||||
|
.rd-rise-anim { transition: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
/* markdown 正文(仅 .md-body 作用域) */
|
/* markdown 正文(仅 .md-body 作用域) */
|
||||||
.md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; }
|
.md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; }
|
||||||
.md-body h2 { @apply mt-6 mb-2 text-xl font-semibold; }
|
.md-body h2 { @apply mt-6 mb-2 text-xl font-semibold; }
|
||||||
|
|||||||
@@ -47,3 +47,10 @@ export function splitChapters(text: string): TxtChapter[] {
|
|||||||
export function chapterText(text: string, chapters: TxtChapter[], i: number): string {
|
export function chapterText(text: string, chapters: TxtChapter[], i: number): string {
|
||||||
return text.slice(chapters[i].start, chapters[i + 1]?.start ?? text.length);
|
return text.slice(chapters[i].start, chapters[i + 1]?.start ?? text.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 全书进度分数(0..1)→ 章序号 + 章内分数。进度滑条用。 */
|
||||||
|
export function seekChapter(n: number, frac: number): { idx: number; intra: number } {
|
||||||
|
const p = Math.min(0.9999, Math.max(0, frac)) * n;
|
||||||
|
const idx = Math.min(n - 1, Math.floor(p));
|
||||||
|
return { idx, intra: p - idx };
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ export function useReaderPrefs() {
|
|||||||
return {
|
return {
|
||||||
prefs,
|
prefs,
|
||||||
fontSize: FONT_SIZES[prefs.sizeIdx],
|
fontSize: FONT_SIZES[prefs.sizeIdx],
|
||||||
cycleTheme: () =>
|
setTheme: (theme: RdTheme) => setPrefs((p) => ({ ...p, theme })),
|
||||||
setPrefs((p) => ({ ...p, theme: RD_THEMES[(RD_THEMES.indexOf(p.theme) + 1) % RD_THEMES.length] })),
|
|
||||||
bump: (d: number) =>
|
bump: (d: number) =>
|
||||||
setPrefs((p) => ({ ...p, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.sizeIdx + d)) })),
|
setPrefs((p) => ({ ...p, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.sizeIdx + d)) })),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ import { api } from "../api/client";
|
|||||||
import type { Book } from "../api/types";
|
import type { Book } from "../api/types";
|
||||||
import { createProgressSaver, type ProgressSaver } from "./progress";
|
import { createProgressSaver, type ProgressSaver } from "./progress";
|
||||||
|
|
||||||
|
export interface Chrome {
|
||||||
|
on: boolean;
|
||||||
|
toggle(): void;
|
||||||
|
show(): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReaderProps {
|
export interface ReaderProps {
|
||||||
book: Book;
|
book: Book;
|
||||||
initialLocator?: Record<string, unknown>;
|
initialLocator?: Record<string, unknown>;
|
||||||
|
chrome: Chrome;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProgressSaver(bookId: number): ProgressSaver {
|
export function useProgressSaver(bookId: number): ProgressSaver {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Suspense, lazy, type ComponentType, type LazyExoticComponent } from "react";
|
import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Format } from "../api/types";
|
import type { Format } from "../api/types";
|
||||||
@@ -22,6 +22,11 @@ export default function Reader() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
|
const [chromeOn, setChromeOn] = useState(true);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => setChromeOn((v) => !v), []);
|
||||||
|
const show = useCallback(() => setChromeOn(true), []);
|
||||||
|
const chrome = useMemo(() => ({ on: chromeOn, toggle, show }), [chromeOn, toggle, show]);
|
||||||
|
|
||||||
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
|
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
|
||||||
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
||||||
@@ -52,12 +57,17 @@ export default function Reader() {
|
|||||||
const R = READERS[book.format];
|
const R = READERS[book.format];
|
||||||
if (!R) return <Msg text={`暂不支持的格式:${book.format}`} />;
|
if (!R) return <Msg text={`暂不支持的格式:${book.format}`} />;
|
||||||
const row = (progQ.data ?? []).find((r) => r.library_id === book.library_id && r.path === book.path);
|
const row = (progQ.data ?? []).find((r) => r.library_id === book.library_id && r.path === book.path);
|
||||||
return <R book={book} initialLocator={row?.locator} />;
|
return <R book={book} initialLocator={row?.locator} chrome={chrome} />;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<header className="flex shrink-0 items-center gap-3 border-b border-stone-800/80 bg-stone-950/80 px-4 py-2 text-sm backdrop-blur">
|
<header
|
||||||
|
className={
|
||||||
|
"flex shrink-0 items-center gap-3 overflow-hidden border-b border-stone-800/80 bg-stone-950/80 px-4 text-sm backdrop-blur transition-all duration-300 " +
|
||||||
|
(chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0")
|
||||||
|
}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="shrink-0 rounded-md px-2 py-1 text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
|
className="shrink-0 rounded-md px-2 py-1 text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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 { api, formatPageUrl } from "../api/client";
|
||||||
import { btn } from "../components/ui";
|
import { btn } from "../components/ui";
|
||||||
import { fetchObjectUrl } from "../lib/authImage";
|
import { fetchObjectUrl } from "../lib/authImage";
|
||||||
@@ -9,7 +9,30 @@ import { PageHeights } from "../lib/virt";
|
|||||||
const MAX_W = 720;
|
const MAX_W = 720;
|
||||||
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
|
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 [src, setSrc] = useState("");
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
@@ -34,7 +57,11 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!src)
|
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 (
|
return (
|
||||||
<img
|
<img
|
||||||
src={src}
|
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({
|
const countQ = useQuery({
|
||||||
queryKey: ["pages", book.id],
|
queryKey: ["pages", book.id],
|
||||||
queryFn: () => api.pageCount(book.pages_url ?? ""),
|
queryFn: () => api.pageCount(book.pages_url ?? ""),
|
||||||
@@ -64,8 +91,13 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
|
|||||||
const [top, setTop] = useState(0);
|
const [top, setTop] = useState(0);
|
||||||
const [, bump] = useState(0);
|
const [, bump] = useState(0);
|
||||||
const restored = useRef(false);
|
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,阅读位置以当前滚动值为准)
|
// 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准)
|
||||||
useEffect(() => {
|
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 → 只有真正发起了请求才显示加载态
|
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
|
||||||
if (countQ.isPending && !!book.pages_url)
|
if (countQ.isPending && !!book.pages_url)
|
||||||
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
|
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 fmt: string = book.page_url_fmt;
|
||||||
const [a, b] = ph.range(top, vh, 2);
|
const [a, b] = ph.range(top, vh, 2);
|
||||||
const cur = ph.pageAt(top + vh / 2);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full">
|
<div
|
||||||
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto">
|
tabIndex={0}
|
||||||
<div className="relative mx-auto" style={{ height: ph.total(), maxWidth: MAX_W }}>
|
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) => {
|
{Array.from({ length: b - a }, (_, k) => {
|
||||||
const i = a + k;
|
const i = a + k;
|
||||||
return (
|
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
|
<PageImg
|
||||||
url={formatPageUrl(fmt, i)}
|
url={formatPageUrl(fmt, i)}
|
||||||
width={width}
|
width={width}
|
||||||
|
fitH={fitH}
|
||||||
onLoaded={(h) => {
|
onLoaded={(h) => {
|
||||||
|
if (fitH) return; // 适高模式定高一页一屏,无需量高
|
||||||
const el = boxRef.current;
|
const el = boxRef.current;
|
||||||
const topOfI = ph.offset(i);
|
const topOfI = ph.offset(i);
|
||||||
const d = ph.set(i, h);
|
const d = ph.set(i, h);
|
||||||
@@ -138,12 +210,40 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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>
|
<span>
|
||||||
{cur + 1}/{count}
|
{cur + 1}/{count}
|
||||||
</span>
|
</span>
|
||||||
<span className="opacity-60">{Math.round(((cur + 1) / count) * 100)}%</span>
|
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,22 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||||
import { apiRaw } from "../api/client";
|
import { apiRaw } from "../api/client";
|
||||||
import { btn } from "../components/ui";
|
import { btn } from "../components/ui";
|
||||||
import { chapterText, splitChapters } from "../lib/chapters";
|
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
|
||||||
import { decodeText } from "../lib/encoding";
|
import { decodeText } from "../lib/encoding";
|
||||||
import { FONT_SIZES, RD_THEME_LABEL, useReaderPrefs } from "../lib/readerPrefs";
|
import {
|
||||||
|
FONT_SIZES,
|
||||||
|
RD_THEMES,
|
||||||
|
RD_THEME_LABEL,
|
||||||
|
useReaderPrefs,
|
||||||
|
type RdTheme,
|
||||||
|
} from "../lib/readerPrefs";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
type Prefs = ReturnType<typeof useReaderPrefs>;
|
type Prefs = ReturnType<typeof useReaderPrefs>;
|
||||||
|
|
||||||
function PrefsBar({ pr }: { pr: Prefs }) {
|
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 text-sm" role="group" aria-label="阅读偏好">
|
|
||||||
<button className="rd-btn text-xs" onClick={pr.cycleTheme} aria-label="切换阅读主题">
|
|
||||||
{RD_THEME_LABEL[pr.prefs.theme]}·主题
|
|
||||||
</button>
|
|
||||||
<button className="rd-btn" onClick={() => pr.bump(-1)} disabled={pr.prefs.sizeIdx === 0} aria-label="减小字号">
|
|
||||||
A−
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rd-btn"
|
|
||||||
onClick={() => pr.bump(1)}
|
|
||||||
disabled={pr.prefs.sizeIdx === FONT_SIZES.length - 1}
|
|
||||||
aria-label="加大字号"
|
|
||||||
>
|
|
||||||
A+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReaderToolbar({ pr, children }: { pr: Prefs; children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="rd-divider flex shrink-0 items-center gap-2 border-b px-4 py-1.5">
|
|
||||||
{children}
|
|
||||||
<div className="ml-auto">
|
|
||||||
<PrefsBar pr={pr} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useFileText(url: string) {
|
function useFileText(url: string) {
|
||||||
const [text, setText] = useState<string | null>(null);
|
const [text, setText] = useState<string | null>(null);
|
||||||
@@ -77,15 +53,89 @@ function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 沉浸态底栏:进度滑条 + 主题色卡 + 字号,主题内配色。children 放左半区按钮(目录/翻页)。 */
|
||||||
|
function ReaderSheet({
|
||||||
|
pr,
|
||||||
|
frac,
|
||||||
|
onSeek,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
pr: Prefs;
|
||||||
|
frac: number;
|
||||||
|
onSeek: (f: number) => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rd-sheet rd-divider relative z-20 shrink-0 border-t px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
value={Math.round(frac * 1000)}
|
||||||
|
onChange={(e) => onSeek(Number(e.target.value) / 1000)}
|
||||||
|
className="rd-range"
|
||||||
|
aria-label="阅读进度"
|
||||||
|
/>
|
||||||
|
<span className="w-10 shrink-0 text-right text-xs tabular-nums opacity-70">
|
||||||
|
{Math.round(frac * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{children}
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
<div className="flex items-center gap-1.5" role="group" aria-label="阅读主题">
|
||||||
|
{RD_THEMES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
aria-label={`${RD_THEME_LABEL[t]}色主题`}
|
||||||
|
aria-pressed={pr.prefs.theme === t}
|
||||||
|
onClick={() => pr.setTheme(t)}
|
||||||
|
className="h-5 w-5 rounded-full transition-transform hover:scale-110"
|
||||||
|
style={{
|
||||||
|
background: SWATCH[t],
|
||||||
|
boxShadow:
|
||||||
|
pr.prefs.theme === t
|
||||||
|
? "0 0 0 2px var(--rd-link)"
|
||||||
|
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="rd-btn" onClick={() => pr.bump(-1)} disabled={pr.prefs.sizeIdx === 0} aria-label="减小字号">
|
||||||
|
A−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rd-btn"
|
||||||
|
onClick={() => pr.bump(1)}
|
||||||
|
disabled={pr.prefs.sizeIdx === FONT_SIZES.length - 1}
|
||||||
|
aria-label="加大字号"
|
||||||
|
>
|
||||||
|
A+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */
|
||||||
|
function bodyTogglesChrome(e: MouseEvent<HTMLElement>, toggle: () => void) {
|
||||||
|
if ((e.target as HTMLElement).closest("a,button,input,nav")) return;
|
||||||
|
if (window.getSelection()?.toString()) return;
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
|
||||||
export default function TextReader(props: ReaderProps) {
|
export default function TextReader(props: ReaderProps) {
|
||||||
return props.book.format === "md" ? <MdView {...props} /> : <TxtView {...props} />;
|
return props.book.format === "md" ? <MdView {...props} /> : <TxtView {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MdView({ book, initialLocator }: ReaderProps) {
|
function MdView({ book, initialLocator, chrome }: ReaderProps) {
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
const saver = useProgressSaver(book.id);
|
const saver = useProgressSaver(book.id);
|
||||||
const restored = useRef(false);
|
const restored = useRef(false);
|
||||||
const pr = useReaderPrefs();
|
const pr = useReaderPrefs();
|
||||||
|
const [frac, setFrac] = useState(0);
|
||||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||||
|
|
||||||
const html = useMemo(() => (text == null ? null : DOMPurify.sanitize(marked.parse(text, { async: false }))), [text]);
|
const html = useMemo(() => (text == null ? null : DOMPurify.sanitize(marked.parse(text, { async: false }))), [text]);
|
||||||
@@ -104,28 +154,40 @@ function MdView({ book, initialLocator }: ReaderProps) {
|
|||||||
const el = boxRef.current;
|
const el = boxRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const max = el.scrollHeight - el.clientHeight;
|
const max = el.scrollHeight - el.clientHeight;
|
||||||
const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
const f = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
||||||
saver.report({ scrollFraction: frac }, frac);
|
setFrac(f);
|
||||||
|
saver.report({ scrollFraction: f }, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(f: number) {
|
||||||
|
const el = boxRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = f * (el.scrollHeight - el.clientHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err) return <LoadState text={err} onRetry={retry} />;
|
if (err) return <LoadState text={err} onRetry={retry} />;
|
||||||
if (text == null) return <LoadState text="正文加载中…" />;
|
if (text == null) return <LoadState text="正文加载中…" />;
|
||||||
return (
|
return (
|
||||||
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}>
|
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||||
<ReaderToolbar pr={pr} />
|
<div
|
||||||
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
ref={boxRef}
|
||||||
|
onScroll={onScroll}
|
||||||
|
onClick={(e) => bodyTogglesChrome(e, chrome.toggle)}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
|
||||||
|
>
|
||||||
<article
|
<article
|
||||||
className="md-body mx-auto max-w-[46rem] leading-[1.85]"
|
className="md-body mx-auto max-w-[46rem] leading-[1.85]"
|
||||||
style={{ fontSize: pr.fontSize }}
|
style={{ fontSize: pr.fontSize }}
|
||||||
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{chrome.on && <ReaderSheet pr={pr} frac={frac} onSeek={seek} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
||||||
function TxtView({ book, initialLocator }: ReaderProps) {
|
function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
const saver = useProgressSaver(book.id);
|
const saver = useProgressSaver(book.id);
|
||||||
const inited = useRef(false);
|
const inited = useRef(false);
|
||||||
@@ -133,6 +195,7 @@ function TxtView({ book, initialLocator }: ReaderProps) {
|
|||||||
const targetCi = useRef(0);
|
const targetCi = useRef(0);
|
||||||
const activeRow = useRef<HTMLButtonElement>(null);
|
const activeRow = useRef<HTMLButtonElement>(null);
|
||||||
const [ci, setCi] = useState(0);
|
const [ci, setCi] = useState(0);
|
||||||
|
const [frac, setFrac] = useState(0);
|
||||||
const [toc, setToc] = useState(false);
|
const [toc, setToc] = useState(false);
|
||||||
const pr = useReaderPrefs();
|
const pr = useReaderPrefs();
|
||||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||||
@@ -145,18 +208,15 @@ function TxtView({ book, initialLocator }: ReaderProps) {
|
|||||||
const f = Number(initialLocator?.scrollFraction);
|
const f = Number(initialLocator?.scrollFraction);
|
||||||
const hasF = Number.isFinite(f) && f > 0 && f <= 1;
|
const hasF = Number.isFinite(f) && f > 0 && f <= 1;
|
||||||
const rawCh = Number(initialLocator?.ch);
|
const rawCh = Number(initialLocator?.ch);
|
||||||
let idx = 0;
|
|
||||||
if (Number.isFinite(rawCh) && rawCh >= 0 && rawCh < chapters.length) {
|
if (Number.isFinite(rawCh) && rawCh >= 0 && rawCh < chapters.length) {
|
||||||
idx = Math.floor(rawCh);
|
targetCi.current = Math.floor(rawCh);
|
||||||
|
setCi(Math.floor(rawCh));
|
||||||
if (hasF) restoreFrac.current = f;
|
if (hasF) restoreFrac.current = f;
|
||||||
} else if (hasF) {
|
} else if (hasF) {
|
||||||
const p = f * chapters.length; // 旧版全局分数 → 章节近似定位
|
const { idx, intra } = seekChapter(chapters.length, f); // 旧版全局分数 → 章节近似定位
|
||||||
idx = Math.min(chapters.length - 1, Math.floor(p));
|
|
||||||
restoreFrac.current = p - idx;
|
|
||||||
}
|
|
||||||
if (idx > 0 || restoreFrac.current != null) {
|
|
||||||
targetCi.current = idx;
|
targetCi.current = idx;
|
||||||
setCi(idx);
|
setCi(idx);
|
||||||
|
if (intra > 0) restoreFrac.current = intra;
|
||||||
}
|
}
|
||||||
}, [chapters, initialLocator]);
|
}, [chapters, initialLocator]);
|
||||||
|
|
||||||
@@ -169,43 +229,47 @@ function TxtView({ book, initialLocator }: ReaderProps) {
|
|||||||
} else el.scrollTop = 0;
|
} else el.scrollTop = 0;
|
||||||
}, [ci, chapters]);
|
}, [ci, chapters]);
|
||||||
|
|
||||||
function goChapter(i: number) {
|
|
||||||
if (!chapters) return;
|
|
||||||
restoreFrac.current = null;
|
|
||||||
setCi(i);
|
|
||||||
saver.report({ ch: i, scrollFraction: 0 }, i / chapters.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
|
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
|
||||||
}, [toc]);
|
}, [toc]);
|
||||||
|
|
||||||
|
function goChapter(i: number, intra = 0) {
|
||||||
|
if (!chapters) return;
|
||||||
|
restoreFrac.current = intra > 0 ? intra : null;
|
||||||
|
targetCi.current = i;
|
||||||
|
setCi(i);
|
||||||
|
setFrac(intra);
|
||||||
|
saver.report({ ch: i, scrollFraction: intra }, (i + intra) / chapters.length);
|
||||||
|
}
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
const el = boxRef.current;
|
const el = boxRef.current;
|
||||||
if (!el || !chapters) return;
|
if (!el || !chapters) return;
|
||||||
const max = el.scrollHeight - el.clientHeight;
|
const max = el.scrollHeight - el.clientHeight;
|
||||||
const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
const f = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
||||||
saver.report({ ch: ci, scrollFraction: frac }, (ci + frac) / chapters.length);
|
setFrac(f);
|
||||||
|
saver.report({ ch: ci, scrollFraction: f }, (ci + f) / chapters.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(f: number) {
|
||||||
|
if (!chapters) return;
|
||||||
|
const { idx, intra } = seekChapter(chapters.length, f);
|
||||||
|
goChapter(idx, intra);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err) return <LoadState text={err} onRetry={retry} />;
|
if (err) return <LoadState text={err} onRetry={retry} />;
|
||||||
if (text == null || !chapters) return <LoadState text="正文加载中…" />;
|
if (text == null || !chapters) return <LoadState text="正文加载中…" />;
|
||||||
|
|
||||||
|
const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
|
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||||
<ReaderToolbar pr={pr}>
|
<div
|
||||||
{chapters.length > 1 && (
|
ref={boxRef}
|
||||||
<>
|
onScroll={onScroll}
|
||||||
<button className="rd-btn" aria-expanded={toc} aria-controls="toc-panel" onClick={() => setToc((v) => !v)}>
|
onClick={(e) => bodyTogglesChrome(e, chrome.toggle)}
|
||||||
目录
|
className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
|
||||||
</button>
|
>
|
||||||
<span className="text-xs opacity-60 tabular-nums">
|
|
||||||
{ci + 1}/{chapters.length}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ReaderToolbar>
|
|
||||||
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6">
|
|
||||||
<pre
|
<pre
|
||||||
className="mx-auto max-w-[34em] break-words whitespace-pre-wrap"
|
className="mx-auto max-w-[34em] break-words whitespace-pre-wrap"
|
||||||
style={{ fontSize: pr.fontSize, lineHeight: 1.9, textAlign: "justify" }}
|
style={{ fontSize: pr.fontSize, lineHeight: 1.9, textAlign: "justify" }}
|
||||||
@@ -213,15 +277,30 @@ function TxtView({ book, initialLocator }: ReaderProps) {
|
|||||||
{chapterText(text, chapters, ci)}
|
{chapterText(text, chapters, ci)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
{chrome.on && (
|
||||||
|
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
|
||||||
{chapters.length > 1 && (
|
{chapters.length > 1 && (
|
||||||
<div className="rd-divider flex shrink-0 items-center justify-center gap-3 border-t px-4 py-2 text-sm">
|
<>
|
||||||
|
<button
|
||||||
|
className="rd-btn"
|
||||||
|
aria-expanded={toc}
|
||||||
|
aria-controls="toc-panel"
|
||||||
|
onClick={() => setToc((v) => !v)}
|
||||||
|
>
|
||||||
|
目录
|
||||||
|
</button>
|
||||||
|
<span className="text-xs opacity-60 tabular-nums">
|
||||||
|
{ci + 1}/{chapters.length}
|
||||||
|
</span>
|
||||||
<button className="rd-btn" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
|
<button className="rd-btn" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
|
||||||
← 上一章
|
← 上一章
|
||||||
</button>
|
</button>
|
||||||
<button className="rd-btn" disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
|
<button className="rd-btn" disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
|
||||||
下一章 →
|
下一章 →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</ReaderSheet>
|
||||||
)}
|
)}
|
||||||
{/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */}
|
{/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */}
|
||||||
{toc && chapters.length > 1 && (
|
{toc && chapters.length > 1 && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, it } from "vitest";
|
import { expect, it } from "vitest";
|
||||||
import { chapterText, splitChapters } from "../src/lib/chapters";
|
import { chapterText, seekChapter, splitChapters } from "../src/lib/chapters";
|
||||||
|
|
||||||
const pad = (n: number) => "字".repeat(n);
|
const pad = (n: number) => "字".repeat(n);
|
||||||
|
|
||||||
@@ -50,6 +50,15 @@ it("卷不感知:只按章切,卷题行留在正文里", () => {
|
|||||||
expect(chapterText(t, chs, 1)).toContain("第二卷 仙界"); // 卷题在第二章结尾,不进第三章开头
|
expect(chapterText(t, chs, 1)).toContain("第二卷 仙界"); // 卷题在第二章结尾,不进第三章开头
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("seekChapter:全局进度分数 → (章序号, 章内分数),端点与越界安全", () => {
|
||||||
|
expect(seekChapter(69, 0)).toEqual({ idx: 0, intra: 0 });
|
||||||
|
expect(seekChapter(69, 0.5)).toEqual({ idx: 34, intra: 0.5 }); // 69*0.5=34.5
|
||||||
|
expect(seekChapter(69, 1).idx).toBe(68); // 顶到 0.9999 防越界
|
||||||
|
expect(seekChapter(69, 1).intra).toBeGreaterThan(0.99);
|
||||||
|
expect(seekChapter(69, -0.2)).toEqual({ idx: 0, intra: 0 });
|
||||||
|
expect(seekChapter(1, 0.3)).toEqual({ idx: 0, intra: 0.3 });
|
||||||
|
});
|
||||||
|
|
||||||
it("只有卷没有章的书:按伪章节切", () => {
|
it("只有卷没有章的书:按伪章节切", () => {
|
||||||
const t = "第一卷 起\n" + pad(600) + "\n第二卷 承\n" + pad(600) + "\n第三卷 转\n" + pad(600);
|
const t = "第一卷 起\n" + pad(600) + "\n第二卷 承\n" + pad(600) + "\n第三卷 转\n" + pad(600);
|
||||||
const chs = splitChapters(t);
|
const chs = splitChapters(t);
|
||||||
|
|||||||
Reference in New Issue
Block a user