Merge branch 'feat/reader-warm-theme'
This commit is contained in:
@@ -8,8 +8,26 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added / 新增
|
||||
|
||||
- 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).
|
||||
- 文本与漫画进入沉浸阅读:点正文中央唤出/隐藏顶栏与工具条;控件收进底部滑出抽屉——进度/页码滑条直接跳到全书任意位置或任意页,主题改为所见即所得的色卡(纸/米/夜),保留 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 阅读器:以文字按钮(首页/末页)替换 emoji 图标,页面画布加边框投影,并补充可访问的「方向键翻页」说明。
|
||||
|
||||
### Changed / 变更
|
||||
|
||||
- .txt chapter splitting no longer recognizes volumes: 「第X卷/部」 lines stay plain body text and volume-only books fall back to pseudo-sections as before; chapter switching moves from the dropdown to a 目录 sidebar overlay that highlights the current chapter, scrolls it into view, and closes on click-outside or after picking a chapter.
|
||||
- .txt 章节切分不再感知卷:「第X卷/部」行只作为正文文本,只有卷的书和以前一样退回伪章节切分;换章从下拉框改为「目录」侧边栏浮层——当前章高亮并自动居中可见,点击空白处或选定章节即收起。
|
||||
- Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page.
|
||||
- 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。
|
||||
|
||||
### Fixed / 修复
|
||||
|
||||
- Dragging the comic scrollbar (or the page slider) is no longer gritty: scroll handling is coalesced to one update per frame instead of per event, prefetching waits until scrolling settles instead of firing mid-drag, height corrections are batched per frame, the virtual list re-estimates unseen pages from the first measured page size (scrollbar geometry stops morphing under your finger), and slider seeks land instantly instead of restarting a smooth-scroll animation per notch.
|
||||
- 漫画拖动滚动条(及页码滑条)不再发涩:滚动改为每帧合并处理而非逐事件响应,预取等滚动停稳再发(不再拖拽途中抢带宽),量高补偿按帧合并,虚拟列表用首个实测页高重估所有未加载页(滚动条几何不再拖拽中变形),滑条寻址即时落位(不再每格重启一段平滑动画)。
|
||||
- .txt/.md files encoded in GBK/GB2312/GB18030 or UTF-16 (typical web-novel exports) no longer render as mojibake: the reader strictly tries UTF-8 first and falls back to GB18030, recognizing UTF-16 by BOM.
|
||||
- GBK/GB2312/GB18030 或 UTF-16 编码的 .txt/.md(网文导出常见)不再乱码:阅读器先按 UTF-8 严格解码,失败自动回退 GB18030,UTF-16 靠 BOM 识别。
|
||||
- Reading a large .txt no longer freezes the browser: the text is split into chapters (「第X章」/Chapter X/序章… markers, pseudo-sections for unmarked files) and rendered one chapter at a time with a chapter selector and prev/next navigation; 「第X卷/部」 are volume groupings (dropdown optgroups), not chapter breaks, and books with only volumes split by volume; reading progress restores to the saved chapter.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="theme-color" content="#0c0a09" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<title>BookLib — 个人书库</title>
|
||||
</head>
|
||||
|
||||
@@ -77,7 +77,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { user, ready } = useAuth();
|
||||
const loc = useLocation();
|
||||
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
|
||||
if (!ready || !user) return <div className="grid h-full place-items-center text-zinc-500">加载中…</div>;
|
||||
if (!ready || !user) return <div className="grid h-full place-items-center text-stone-500">加载中…</div>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,12 @@ export function Cover({ book, className }: { book: Book; className?: string }) {
|
||||
const { src, failed, retry } = useAuthedImage(book.cover_url);
|
||||
if (failed)
|
||||
return (
|
||||
<div className={`grid place-items-center bg-zinc-900 ${className ?? ""}`}>
|
||||
<div className={`grid place-items-center bg-stone-900 ${className ?? ""}`}>
|
||||
<button className={btn} onClick={retry} aria-label="封面加载失败,重试">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
if (!src) return <div className={`animate-pulse bg-zinc-800 ${className ?? ""}`} />;
|
||||
return <img src={src} alt="" loading="lazy" className={`bg-zinc-900 object-cover ${className ?? ""}`} />;
|
||||
if (!src) return <div className={`animate-pulse bg-stone-800 ${className ?? ""}`} />;
|
||||
return <img src={src} alt="" loading="lazy" className={`bg-stone-900 object-cover ${className ?? ""}`} />;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err
|
||||
<div className="grid h-full place-items-center p-8 text-center">
|
||||
<div>
|
||||
<p className="mb-2 text-lg text-red-400">页面出错了</p>
|
||||
<p className="mb-4 max-w-md text-sm break-all text-zinc-500">{String(this.state.err)}</p>
|
||||
<button className="rounded-md bg-zinc-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
|
||||
<p className="mb-4 max-w-md text-sm break-all text-stone-500">{String(this.state.err)}</p>
|
||||
<button className="rounded-md bg-stone-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Toaster() {
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
className={`max-w-sm rounded-lg px-4 py-2 text-sm shadow-lg ${
|
||||
className={`max-w-sm rounded-lg px-4 py-2 text-sm shadow-lg ring-1 ring-white/10 ${
|
||||
t.kind === "ok" ? "bg-emerald-900 text-emerald-100" : "bg-red-900 text-red-100"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -6,23 +6,29 @@ import { btn } from "./ui";
|
||||
export function TopBar({ right }: { right?: ReactNode }) {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
return (
|
||||
<header className="flex shrink-0 items-center gap-4 border-b border-zinc-800 px-4 py-3">
|
||||
<Link to="/" className="font-semibold">
|
||||
BookLib
|
||||
<header className="flex shrink-0 items-center gap-2 border-b border-stone-800/80 px-4 py-3">
|
||||
<Link to="/" className="font-serif text-lg font-semibold tracking-wide">
|
||||
Book<span className="text-amber-500">Lib</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/users">
|
||||
<Link
|
||||
className="rounded-md px-2 py-1 text-sm text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
|
||||
to="/admin/users"
|
||||
>
|
||||
用户
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/libraries">
|
||||
<Link
|
||||
className="rounded-md px-2 py-1 text-sm text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
|
||||
to="/admin/libraries"
|
||||
>
|
||||
库管理
|
||||
</Link>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{right}
|
||||
<span className="text-sm text-zinc-400">
|
||||
<span className="text-sm text-stone-400">
|
||||
{user?.username}
|
||||
{isAdmin ? "(admin)" : ""}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const btn =
|
||||
"rounded-md bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
"rounded-lg bg-stone-800 px-3 py-1.5 text-sm transition-colors hover:bg-stone-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-stone-600 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
export const btnPrimary =
|
||||
"rounded-md bg-emerald-700 px-3 py-1.5 text-sm hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
"rounded-lg bg-amber-600 px-3 py-1.5 text-sm text-stone-950 transition-colors hover:bg-amber-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
export const input =
|
||||
"rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm outline-none focus:border-zinc-500";
|
||||
export const card = "rounded-xl border border-zinc-800 bg-zinc-900";
|
||||
"rounded-lg border border-stone-700 bg-stone-900 px-3 py-1.5 text-sm outline-none transition-colors placeholder:text-stone-500 focus:border-amber-500/70";
|
||||
export const card = "rounded-xl border border-stone-800 bg-stone-900";
|
||||
|
||||
+51
-8
@@ -6,14 +6,57 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
@apply bg-zinc-950 text-zinc-100 antialiased;
|
||||
@apply bg-stone-950 text-stone-100 antialiased;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply rounded bg-zinc-700;
|
||||
@apply rounded-full border-2 border-transparent bg-clip-padding bg-stone-700/70;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-stone-600;
|
||||
}
|
||||
|
||||
/* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */
|
||||
[data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; }
|
||||
[data-rd="sepia"] { --rd-bg: #f0e6d2; --rd-fg: #433a29; --rd-link: #92400e; }
|
||||
[data-rd="night"] { --rd-bg: #171512; --rd-fg: #c6c0b6; --rd-link: #e0a458; }
|
||||
.rd-surface {
|
||||
background: var(--rd-bg);
|
||||
color: var(--rd-fg);
|
||||
font-family: ui-serif, "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", SimSun, Georgia, serif;
|
||||
}
|
||||
.rd-surface ::selection {
|
||||
background: color-mix(in oklab, var(--rd-link) 30%, transparent);
|
||||
}
|
||||
/* 主题内的次级控件(章节条/翻页键),跟随当前阅读主题 */
|
||||
.rd-btn {
|
||||
@apply rounded-md px-2.5 py-1 text-sm transition-colors;
|
||||
background: color-mix(in oklab, currentColor 8%, transparent);
|
||||
}
|
||||
.rd-btn:hover { background: color-mix(in oklab, currentColor 16%, transparent); }
|
||||
.rd-btn:disabled { @apply cursor-not-allowed opacity-40; }
|
||||
/* 目录侧边栏行 */
|
||||
.rd-row { @apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors; }
|
||||
.rd-row:hover { background: color-mix(in oklab, currentColor 12%, 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-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 作用域) */
|
||||
@@ -23,12 +66,12 @@ body {
|
||||
.md-body p { @apply my-3; }
|
||||
.md-body ul { @apply my-3 list-disc space-y-1 pl-6; }
|
||||
.md-body ol { @apply my-3 list-decimal space-y-1 pl-6; }
|
||||
.md-body blockquote { @apply my-3 border-l-2 border-zinc-600 pl-4 text-zinc-400; }
|
||||
.md-body code { @apply rounded bg-zinc-800 px-1 py-0.5 text-[13px]; }
|
||||
.md-body pre { @apply my-3 overflow-x-auto rounded-lg bg-zinc-900 p-3; }
|
||||
.md-body blockquote { @apply my-3 border-l-2 pl-4; border-color: color-mix(in oklab, currentColor 30%, transparent); opacity: 0.75; }
|
||||
.md-body code { @apply rounded px-1 py-0.5 text-[0.85em]; background: color-mix(in oklab, currentColor 10%, transparent); }
|
||||
.md-body pre { @apply my-3 overflow-x-auto rounded-lg p-3; background: color-mix(in oklab, currentColor 6%, transparent); }
|
||||
.md-body pre code { @apply bg-transparent p-0; }
|
||||
.md-body a { @apply text-emerald-400 underline; }
|
||||
.md-body a { color: var(--rd-link, #f59e0b); @apply underline underline-offset-2; }
|
||||
.md-body img { @apply my-3 max-w-full rounded; }
|
||||
.md-body hr { @apply my-6 border-zinc-800; }
|
||||
.md-body hr { @apply my-6; border-color: color-mix(in oklab, currentColor 15%, transparent); }
|
||||
.md-body table { @apply my-3 w-full border-collapse text-sm; }
|
||||
.md-body th, .md-body td { @apply border border-zinc-700 px-2 py-1 text-left; }
|
||||
.md-body th, .md-body td { @apply border px-2 py-1 text-left; border-color: color-mix(in oklab, currentColor 22%, transparent); }
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// txt 章节切分:优先识别「第X章」类标题行;「第X卷/部」是层级不是章节边界,
|
||||
// 只做分组标签;整本没有章标记时才退而用卷作边界;再没有就按段落边界切
|
||||
// 伪章节,保证任何单块都能在浏览器里流畅渲染。纯逻辑,无 DOM,vitest 覆盖。
|
||||
// txt 章节切分:只识别「第X章」类标题行(卷/部不再感知,其行留在正文里);
|
||||
// 整本没有章标记时按段落边界切伪章节,保证任何单块都能在浏览器里流畅渲染。
|
||||
// 纯逻辑,无 DOM,vitest 覆盖。
|
||||
export interface TxtChapter {
|
||||
title: string;
|
||||
start: number; // 正文中的起始字符偏移(含标题行本身)
|
||||
vol?: string; // 所属卷标题(仅用于导航分组)
|
||||
}
|
||||
|
||||
const NUM = "[0-9〇零一二三四五六七八九十百千万两]+";
|
||||
@@ -12,7 +11,6 @@ const CH_RE = new RegExp(
|
||||
`^[ \\t\\u3000]*(?:第\\s*${NUM}\\s*[章节回篇话]|Chapter\\s+${NUM}|序[章言]|楔子|引子|尾声|终章|后记|番外)[^\\n]{0,60}[ \\t]*$`,
|
||||
"gim",
|
||||
);
|
||||
const VOL_RE = new RegExp(`^[ \\t\\u3000]*第\\s*${NUM}\\s*[卷部][^\\n]{0,60}[ \\t]*$`, "gm");
|
||||
|
||||
const MIN_GAP = 200; // 距上一保留标题不足此数视为目录行/正文提及,丢弃
|
||||
const CHUNK = 20_000; // 伪章节目标字数
|
||||
@@ -28,23 +26,9 @@ function markers(text: string, re: RegExp): TxtChapter[] {
|
||||
}
|
||||
|
||||
export function splitChapters(text: string): TxtChapter[] {
|
||||
const vols = markers(text, VOL_RE);
|
||||
let chs = markers(text, CH_RE);
|
||||
let volMarks = vols;
|
||||
if (chs.length < 2 && vols.length > 1) {
|
||||
chs = vols; // 只有卷的书:卷即章节
|
||||
volMarks = [];
|
||||
}
|
||||
const chs = markers(text, CH_RE);
|
||||
if (chs.length > 1) {
|
||||
if (chs[0].start > MIN_GAP) chs.unshift({ title: "开篇", start: 0 });
|
||||
for (const c of chs) {
|
||||
let v: TxtChapter | undefined;
|
||||
for (const m of volMarks) if (m.start < c.start) v = m;
|
||||
else break;
|
||||
if (!v) continue;
|
||||
c.vol = v.title;
|
||||
if (c.start - v.start < MIN_GAP) c.start = v.start; // 卷题行并入本章开头,不留在上一章尾部
|
||||
}
|
||||
return chs;
|
||||
}
|
||||
const out: TxtChapter[] = [];
|
||||
@@ -63,3 +47,10 @@ export function splitChapters(text: string): TxtChapter[] {
|
||||
export function chapterText(text: string, chapters: TxtChapter[], i: number): string {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const RD_THEMES = ["paper", "sepia", "night"] as const;
|
||||
export type RdTheme = (typeof RD_THEMES)[number];
|
||||
export const RD_THEME_LABEL: Record<RdTheme, string> = { paper: "纸", sepia: "米", night: "夜" };
|
||||
export const FONT_SIZES = [16, 18, 20, 22, 25, 28];
|
||||
|
||||
const KEY = "reader-prefs";
|
||||
|
||||
export interface ReaderPrefs {
|
||||
theme: RdTheme;
|
||||
sizeIdx: number;
|
||||
}
|
||||
|
||||
export function useReaderPrefs() {
|
||||
const [prefs, setPrefs] = useState<ReaderPrefs>(() => {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem(KEY) ?? "") as ReaderPrefs;
|
||||
if (RD_THEMES.includes(p.theme) && Number.isInteger(p.sizeIdx) && p.sizeIdx >= 0 && p.sizeIdx < FONT_SIZES.length)
|
||||
return p;
|
||||
} catch {
|
||||
/* 首次使用或存了坏值 */
|
||||
}
|
||||
return { theme: "sepia", sizeIdx: 2 };
|
||||
});
|
||||
useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]);
|
||||
|
||||
return {
|
||||
prefs,
|
||||
fontSize: FONT_SIZES[prefs.sizeIdx],
|
||||
setTheme: (theme: RdTheme) => setPrefs((p) => ({ ...p, theme })),
|
||||
bump: (d: number) =>
|
||||
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 { createProgressSaver, type ProgressSaver } from "./progress";
|
||||
|
||||
export interface Chrome {
|
||||
on: boolean;
|
||||
toggle(): void;
|
||||
show(): void;
|
||||
}
|
||||
|
||||
export interface ReaderProps {
|
||||
book: Book;
|
||||
initialLocator?: Record<string, unknown>;
|
||||
chrome: Chrome;
|
||||
}
|
||||
|
||||
export function useProgressSaver(bookId: number): ProgressSaver {
|
||||
|
||||
@@ -18,6 +18,21 @@ export class PageHeights {
|
||||
for (let i = from; i < this.n; i++) this.cum[i + 1] = this.cum[i] + (this.actual[i] ?? this.est);
|
||||
}
|
||||
|
||||
get estHeight(): number {
|
||||
return this.est;
|
||||
}
|
||||
|
||||
/** 重估默认页高(同开本漫画量出真实比例后,滚动条几何就不再逐页变了)。
|
||||
* 返回起始偏移 < y 的未量高页数——调用方可按 n×Δest 自行补偿 scrollTop。 */
|
||||
setEst(e: number, y: number): number {
|
||||
if (!Number.isFinite(e) || e <= 0 || e === this.est) return 0;
|
||||
let n = 0;
|
||||
for (let i = 0; i < this.n && this.cum[i] < y; i++) if (this.actual[i] === undefined) n++;
|
||||
this.est = e;
|
||||
this.rebuild(0);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 返回高度增量(相对旧值)。同值幂等返 0;越界/非法高度忽略。 */
|
||||
set(i: number, h: number): number {
|
||||
if (i < 0 || i >= this.n || !Number.isFinite(h) || h <= 0) return 0;
|
||||
|
||||
@@ -30,9 +30,9 @@ export default function Login() {
|
||||
<form onSubmit={submit} className={`${card} w-full max-w-sm space-y-4 p-6`}>
|
||||
<h1 className="text-lg font-semibold">BookLib 登录</h1>
|
||||
<input className={input + " w-full"} value={u} onChange={(e) => setU(e.target.value)}
|
||||
placeholder="用户名" autoFocus autoComplete="username" />
|
||||
aria-label="用户名" placeholder="用户名" autoFocus autoComplete="username" />
|
||||
<input className={input + " w-full"} type="password" value={p} onChange={(e) => setP(e.target.value)}
|
||||
placeholder="密码" autoComplete="current-password" />
|
||||
aria-label="密码" placeholder="密码" autoComplete="current-password" />
|
||||
<button className={btnPrimary + " w-full"} disabled={busy || !u || !p}>
|
||||
{busy ? "登录中…" : "登录"}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { api } from "../api/client";
|
||||
import type { Format } from "../api/types";
|
||||
@@ -22,6 +22,11 @@ export default function Reader() {
|
||||
const qc = useQueryClient();
|
||||
const nav = useNavigate();
|
||||
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 progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
||||
@@ -52,21 +57,31 @@ export default function Reader() {
|
||||
const R = READERS[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);
|
||||
return <R book={book} initialLocator={row?.locator} />;
|
||||
return <R book={book} initialLocator={row?.locator} chrome={chrome} />;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-zinc-800 px-4 py-2 text-sm">
|
||||
<Link to="/" className="text-zinc-400 hover:text-zinc-100">
|
||||
<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
|
||||
to="/"
|
||||
className="shrink-0 rounded-md px-2 py-1 text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
|
||||
>
|
||||
← 书架
|
||||
</Link>
|
||||
<span className="truncate font-medium">{book?.title ?? "…"}</span>
|
||||
<span className="text-zinc-500">
|
||||
{book?.library ? book.library + " · " : ""}
|
||||
{book?.format}
|
||||
{book ? ` · ${Math.round(book.percent * 100)}%` : ""}
|
||||
</span>
|
||||
{book && (
|
||||
<span className="hidden shrink-0 items-center gap-2 text-xs text-stone-500 sm:flex">
|
||||
{book.library && <span>{book.library}</span>}
|
||||
<span className="rounded bg-stone-800/80 px-1.5 py-0.5 uppercase">{book.format}</span>
|
||||
<span className="tabular-nums">{Math.round(book.percent * 100)}%</span>
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && book && (
|
||||
<button
|
||||
className={btn + " ml-auto text-red-300"}
|
||||
@@ -87,7 +102,7 @@ export default function Reader() {
|
||||
|
||||
function Msg({ text, retry }: { text: string; retry?: () => void }) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center p-8 text-center text-zinc-500">
|
||||
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
|
||||
<div>
|
||||
<p className="mb-3">{text}</p>
|
||||
{retry && (
|
||||
|
||||
@@ -23,9 +23,14 @@ function BookCard({
|
||||
return (
|
||||
<div className="group relative w-32 shrink-0">
|
||||
<button onClick={onOpen} className="block w-full text-left">
|
||||
<Cover book={book} className="aspect-[3/4] w-32 rounded-lg" />
|
||||
<p className="mt-1 truncate text-xs text-zinc-300">{book.title}</p>
|
||||
<p className="truncate text-[10px] text-zinc-500">
|
||||
<Cover
|
||||
book={book}
|
||||
className="aspect-[3/4] w-32 rounded-lg shadow-md ring-1 ring-stone-800 transition duration-200 group-hover:-translate-y-1 group-hover:shadow-xl group-hover:ring-stone-600"
|
||||
/>
|
||||
<p className="mt-1.5 truncate text-xs text-stone-200 transition-colors group-hover:text-amber-300">
|
||||
{book.title}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-stone-500">
|
||||
{book.format} · {book.path}
|
||||
</p>
|
||||
</button>
|
||||
@@ -38,13 +43,13 @@ function BookCard({
|
||||
</span>
|
||||
)}
|
||||
{book.percent > 0 && (
|
||||
<div className="mt-1 h-1 w-32 overflow-hidden rounded bg-zinc-800">
|
||||
<div className="h-full bg-emerald-600" style={{ width: `${Math.round(book.percent * 100)}%` }} />
|
||||
<div className="mt-1 h-1 w-32 overflow-hidden rounded-full bg-stone-800">
|
||||
<div className="h-full rounded-full bg-amber-500" style={{ width: `${Math.round(book.percent * 100)}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="absolute right-1 top-1 hidden rounded bg-zinc-900/80 px-1.5 text-xs text-red-300 group-hover:block"
|
||||
className="absolute right-1 top-1 hidden rounded bg-stone-900/80 px-1.5 text-xs text-red-300 group-hover:block"
|
||||
title="删除(连磁盘文件)"
|
||||
onClick={() => {
|
||||
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) onDelete();
|
||||
@@ -121,7 +126,7 @@ export default function Shelf() {
|
||||
)}
|
||||
<input
|
||||
className={input + " w-44"}
|
||||
placeholder="搜索标题…"
|
||||
aria-label="搜索标题" placeholder="搜索标题…"
|
||||
value={qRaw}
|
||||
onChange={(e) => setQRaw(e.target.value)}
|
||||
/>
|
||||
@@ -130,7 +135,7 @@ export default function Shelf() {
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{libs.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">还没有书库。admin 请到「库管理」建库并扫描。</p>
|
||||
<p className="text-sm text-stone-500">还没有书库。admin 请到「库管理」建库并扫描。</p>
|
||||
)}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<button className={lib === 0 ? btnPrimary : btn} onClick={() => setLib(0)}>
|
||||
@@ -145,13 +150,13 @@ export default function Shelf() {
|
||||
|
||||
{continueRows.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-2 text-sm font-semibold text-zinc-400">继续阅读</h2>
|
||||
<h2 className="mb-2 text-sm font-semibold text-stone-400">继续阅读</h2>
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
{continueRows.map((r) => {
|
||||
const b = booksByKey.get(`${r.library_id}/${r.path}`);
|
||||
if (!b)
|
||||
return (
|
||||
<div key={`${r.library_id}/${r.path}`} className={`w-28 shrink-0 p-2 text-xs text-zinc-600 ${card}`}>
|
||||
<div key={`${r.library_id}/${r.path}`} className={`w-28 shrink-0 p-2 text-xs text-stone-600 ${card}`}>
|
||||
<p className="truncate">{r.title || "已删除的书"}</p>
|
||||
<p>{Math.round(r.percent * 100)}%</p>
|
||||
</div>
|
||||
@@ -178,11 +183,11 @@ export default function Shelf() {
|
||||
</p>
|
||||
)}
|
||||
{!booksQ.isPending && groups.length === 0 && !booksQ.isError && (
|
||||
<p className="text-sm text-zinc-500">此库暂无书目(放入文件后等待扫描或点「扫描此库」)。</p>
|
||||
<p className="text-sm text-stone-500">此库暂无书目(放入文件后等待扫描或点「扫描此库」)。</p>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<section key={g.dir || "//"} className="mb-8">
|
||||
<h2 className="mb-2 text-sm font-semibold text-zinc-400">{g.dir || "根目录"}</h2>
|
||||
<h2 className="mb-2 text-sm font-semibold text-stone-400">{g.dir || "根目录"}</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{g.books.map((b) => (
|
||||
<BookCard key={b.id} book={b} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} />
|
||||
|
||||
@@ -67,9 +67,9 @@ export default function AdminLibraries() {
|
||||
<div key={l.id} className={`${card} p-4`}>
|
||||
<div className="mb-1 flex items-baseline gap-3">
|
||||
<span className="font-medium">{l.name}</span>
|
||||
<span className="text-xs text-zinc-500">#{l.id}</span>
|
||||
<span className="text-xs text-stone-500">#{l.id}</span>
|
||||
</div>
|
||||
<p className="mb-3 text-xs break-all text-zinc-500">{l.root_path}</p>
|
||||
<p className="mb-3 text-xs break-all text-stone-500">{l.root_path}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
|
||||
{scan.isPending ? "扫描中…" : "扫描"}
|
||||
@@ -90,12 +90,12 @@ export default function AdminLibraries() {
|
||||
<button className={btn} onClick={() => fileRefs.current[l.id]?.click()}>
|
||||
上传文件
|
||||
</button>
|
||||
<span className="text-xs text-zinc-600">白名单:cbz/zip/pdf/epub/txt/md</span>
|
||||
<span className="text-xs text-stone-600">白名单:cbz/zip/pdf/epub/txt/md</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
|
||||
<p className="text-sm text-zinc-500">还没有库,用上方表单创建。</p>
|
||||
<p className="text-sm text-stone-500">还没有库,用上方表单创建。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function AdminUsers() {
|
||||
</button>
|
||||
</form>
|
||||
<table className="w-full max-w-2xl text-sm">
|
||||
<thead className="text-left text-zinc-500">
|
||||
<thead className="text-left text-stone-500">
|
||||
<tr>
|
||||
<th className="py-1">ID</th>
|
||||
<th>用户名</th>
|
||||
@@ -66,11 +66,11 @@ export default function AdminUsers() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{(usersQ.data ?? []).map((x) => (
|
||||
<tr key={x.id} className="border-t border-zinc-800">
|
||||
<td className="py-1 text-zinc-500">{x.id}</td>
|
||||
<tr key={x.id} className="border-t border-stone-800">
|
||||
<td className="py-1 text-stone-500">{x.id}</td>
|
||||
<td>{x.username}</td>
|
||||
<td>{x.role}</td>
|
||||
<td className="text-zinc-500">{x.created_at.slice(0, 19).replace("T", " ")}</td>
|
||||
<td className="text-stone-500">{x.created_at.slice(0, 19).replace("T", " ")}</td>
|
||||
<td className="py-1 text-right">
|
||||
<button
|
||||
className={btn + " text-red-300"}
|
||||
|
||||
@@ -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);
|
||||
@@ -26,7 +49,7 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
|
||||
}, [url, nonce]);
|
||||
if (failed)
|
||||
return (
|
||||
<div className="grid place-items-center gap-2 bg-zinc-900 py-8 text-sm text-zinc-500" style={{ width }}>
|
||||
<div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}>
|
||||
本页加载失败
|
||||
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
|
||||
重试
|
||||
@@ -34,7 +57,11 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
|
||||
</div>
|
||||
);
|
||||
if (!src)
|
||||
return <div className="animate-pulse bg-zinc-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 ?? ""),
|
||||
@@ -62,10 +89,22 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
|
||||
const [width, setWidth] = useState(480);
|
||||
const [vh, setVh] = useState(600);
|
||||
const [top, setTop] = useState(0);
|
||||
const [scrub, setScrub] = useState<number | null>(null); // 页滑条拖拽中的临时值
|
||||
const [, bump] = useState(0);
|
||||
const restored = useRef(false);
|
||||
const scrollRaf = useRef(0);
|
||||
const idle = useRef<undefined | ReturnType<typeof setTimeout>>(undefined);
|
||||
const curRef = useRef(0);
|
||||
const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
|
||||
const shiftRaf = useRef(0);
|
||||
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
|
||||
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 * ratio.current),
|
||||
[count, width, vh, fitH],
|
||||
);
|
||||
|
||||
// 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准)
|
||||
useEffect(() => {
|
||||
@@ -87,49 +126,125 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
|
||||
if (Number.isInteger(p) && p > 0 && p < count) boxRef.current.scrollTop = ph.offset(p);
|
||||
}, [count, initialLocator, ph]);
|
||||
|
||||
// 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
|
||||
function onScroll() {
|
||||
if (scrollRaf.current) return;
|
||||
scrollRaf.current = requestAnimationFrame(() => {
|
||||
scrollRaf.current = 0;
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
const t = el.scrollTop;
|
||||
setTop(t);
|
||||
if (count) {
|
||||
curRef.current = ph.pageAt(t + vh / 2);
|
||||
saver.report({ page: curRef.current }, (curRef.current + 1) / count);
|
||||
}
|
||||
clearTimeout(idle.current);
|
||||
idle.current = setTimeout(() => {
|
||||
for (let j = curRef.current + 1; j <= Math.min(count - 1, curRef.current + 5); j++)
|
||||
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {});
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelAnimationFrame(scrollRaf.current);
|
||||
cancelAnimationFrame(shiftRaf.current);
|
||||
clearTimeout(idle.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
function applyShift(d: number) {
|
||||
shift.current += d;
|
||||
if (shiftRaf.current) return;
|
||||
shiftRaf.current = requestAnimationFrame(() => {
|
||||
shiftRaf.current = 0;
|
||||
const el = boxRef.current;
|
||||
if (el && shift.current) el.scrollTop += shift.current;
|
||||
shift.current = 0;
|
||||
});
|
||||
}
|
||||
|
||||
const cur = ph.pageAt(top + vh / 2);
|
||||
|
||||
function jump(i: number, smooth = true) {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
const t = el.scrollTop;
|
||||
setTop(t);
|
||||
if (count) {
|
||||
const cur = ph.pageAt(t + vh / 2);
|
||||
saver.report({ page: cur }, (cur + 1) / count);
|
||||
for (let j = cur + 1; j <= Math.min(count - 1, cur + 5); j++)
|
||||
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {}); // 预取下方 5 页
|
||||
}
|
||||
el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: smooth ? "smooth" : "auto" });
|
||||
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-zinc-500">页索引加载中…</div>;
|
||||
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
|
||||
if (countQ.isError || !book.page_url_fmt)
|
||||
return (
|
||||
<div className="grid h-full place-items-center p-8 text-center text-zinc-500">
|
||||
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
|
||||
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
|
||||
</div>
|
||||
);
|
||||
|
||||
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-proximity" : "")}
|
||||
>
|
||||
<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 topY = el ? el.scrollTop : 0;
|
||||
const oldEst = ph.estHeight;
|
||||
const topOfI = ph.offset(i);
|
||||
const d = ph.set(i, h);
|
||||
if (el && d !== 0 && topOfI < el.scrollTop) el.scrollTop += d; // 视口上方页高变化 → 补偿滚动
|
||||
if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动
|
||||
// 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架
|
||||
if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) {
|
||||
const n = ph.setEst(h, topY);
|
||||
if (el && n) applyShift(n * (h - oldEst));
|
||||
ratio.current = h / width;
|
||||
}
|
||||
bump((x) => x + 1);
|
||||
}}
|
||||
/>
|
||||
@@ -138,9 +253,47 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 rounded bg-zinc-900/80 px-2 py-1 text-xs text-zinc-300">
|
||||
{cur + 1}/{count}
|
||||
</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={scrub ?? cur}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setScrub(v);
|
||||
jump(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
|
||||
}}
|
||||
onPointerUp={() => setScrub(null)}
|
||||
onPointerCancel={() => setScrub(null)}
|
||||
onBlur={() => setScrub(null)}
|
||||
className="rd-range"
|
||||
aria-label="页码跳转"
|
||||
/>
|
||||
<span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70">
|
||||
{(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,11 +91,11 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) {
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[1fr_auto]">
|
||||
<div className="relative min-h-0 bg-white">
|
||||
<div className="relative mx-auto min-h-0 w-full max-w-4xl bg-white shadow-2xl ring-1 ring-stone-800">
|
||||
<div ref={hostRef} className="absolute inset-0" />
|
||||
{!ready && !err && <div className="grid h-full place-items-center bg-zinc-950 text-zinc-500">EPUB 加载中…</div>}
|
||||
{!ready && !err && <div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
|
||||
<div className="flex items-center justify-center gap-3 border-t border-stone-800 px-4 py-2 text-sm">
|
||||
<button className={btn} onClick={() => rendRef.current?.prev()}>
|
||||
上一页
|
||||
</button>
|
||||
|
||||
@@ -123,28 +123,33 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (num === 0) return <div className="grid h-full place-items-center text-zinc-500">PDF 加载中…</div>;
|
||||
if (num === 0) return <div className="grid h-full place-items-center text-stone-500">PDF 加载中…</div>;
|
||||
|
||||
return (
|
||||
<div tabIndex={0} onKeyDown={onKey} className="grid h-full grid-rows-[1fr_auto] focus:outline-none">
|
||||
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-zinc-900 p-4">
|
||||
<canvas ref={canvasRef} className="rounded shadow-lg" />
|
||||
<div
|
||||
tabIndex={0}
|
||||
onKeyDown={onKey}
|
||||
aria-label="PDF 阅读器,可用左右方向键翻页"
|
||||
className="grid h-full grid-rows-[1fr_auto] focus:outline-none"
|
||||
>
|
||||
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-stone-900/70 p-4">
|
||||
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
|
||||
<button className={btn} disabled={page === 0} onClick={() => setPage(0)}>
|
||||
⏮
|
||||
<div className="flex items-center justify-center gap-2 border-t border-stone-800/80 px-4 py-2 text-sm">
|
||||
<button className={btn} disabled={page === 0} onClick={() => setPage(0)} aria-label="第一页">
|
||||
首页
|
||||
</button>
|
||||
<button className={btn} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
|
||||
上一页
|
||||
← 上一页
|
||||
</button>
|
||||
<span className="text-zinc-400">
|
||||
<span className="min-w-16 text-center tabular-nums text-stone-400">
|
||||
{page + 1}/{num}
|
||||
</span>
|
||||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
|
||||
下一页
|
||||
下一页 →
|
||||
</button>
|
||||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)}>
|
||||
⏭
|
||||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
|
||||
末页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { apiRaw } from "../api/client";
|
||||
import { btn, input } from "../components/ui";
|
||||
import { chapterText, splitChapters } from "../lib/chapters";
|
||||
import { btn } from "../components/ui";
|
||||
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
|
||||
import { decodeText } from "../lib/encoding";
|
||||
import {
|
||||
FONT_SIZES,
|
||||
RD_THEMES,
|
||||
RD_THEME_LABEL,
|
||||
useReaderPrefs,
|
||||
type RdTheme,
|
||||
} from "../lib/readerPrefs";
|
||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||
|
||||
type Prefs = ReturnType<typeof useReaderPrefs>;
|
||||
|
||||
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
|
||||
|
||||
function useFileText(url: string) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
@@ -31,7 +42,7 @@ function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
|
||||
<div className="grid h-full place-items-center p-8 text-center">
|
||||
<div>
|
||||
{onRetry && <p className="mb-3 text-red-400">{text}</p>}
|
||||
{!onRetry && <p className="text-zinc-500">{text}</p>}
|
||||
{!onRetry && <p className="text-stone-500">{text}</p>}
|
||||
{onRetry && (
|
||||
<button className={btn} onClick={onRetry}>
|
||||
重试
|
||||
@@ -42,14 +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) {
|
||||
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 saver = useProgressSaver(book.id);
|
||||
const restored = useRef(false);
|
||||
const pr = useReaderPrefs();
|
||||
const [frac, setFrac] = useState(0);
|
||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||
|
||||
const html = useMemo(() => (text == null ? null : DOMPurify.sanitize(marked.parse(text, { async: false }))), [text]);
|
||||
@@ -68,27 +154,50 @@ function MdView({ book, initialLocator }: ReaderProps) {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
const max = el.scrollHeight - el.clientHeight;
|
||||
const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
||||
saver.report({ scrollFraction: frac }, frac);
|
||||
const f = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
||||
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 (text == null) return <LoadState text="正文加载中…" />;
|
||||
return (
|
||||
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto p-6">
|
||||
<article className="md-body mx-auto max-w-2xl text-[15px] leading-7" dangerouslySetInnerHTML={{ __html: html ?? "" }} />
|
||||
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||
<div
|
||||
ref={boxRef}
|
||||
onScroll={onScroll}
|
||||
onClick={(e) => bodyTogglesChrome(e, chrome.toggle)}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
|
||||
>
|
||||
<article
|
||||
className="md-body mx-auto max-w-[46rem] leading-[1.85]"
|
||||
style={{ fontSize: pr.fontSize }}
|
||||
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
||||
/>
|
||||
</div>
|
||||
{chrome.on && <ReaderSheet pr={pr} frac={frac} onSeek={seek} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
||||
function TxtView({ book, initialLocator }: ReaderProps) {
|
||||
function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const saver = useProgressSaver(book.id);
|
||||
const inited = useRef(false);
|
||||
const restoreFrac = useRef<number | null>(null);
|
||||
const targetCi = useRef(0);
|
||||
const activeRow = useRef<HTMLButtonElement>(null);
|
||||
const [ci, setCi] = useState(0);
|
||||
const [frac, setFrac] = useState(0);
|
||||
const [toc, setToc] = useState(false);
|
||||
const pr = useReaderPrefs();
|
||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||
|
||||
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
|
||||
@@ -99,18 +208,15 @@ function TxtView({ book, initialLocator }: ReaderProps) {
|
||||
const f = Number(initialLocator?.scrollFraction);
|
||||
const hasF = Number.isFinite(f) && f > 0 && f <= 1;
|
||||
const rawCh = Number(initialLocator?.ch);
|
||||
let idx = 0;
|
||||
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;
|
||||
} else if (hasF) {
|
||||
const p = f * chapters.length; // 旧版全局分数 → 章节近似定位
|
||||
idx = Math.min(chapters.length - 1, Math.floor(p));
|
||||
restoreFrac.current = p - idx;
|
||||
}
|
||||
if (idx > 0 || restoreFrac.current != null) {
|
||||
const { idx, intra } = seekChapter(chapters.length, f); // 旧版全局分数 → 章节近似定位
|
||||
targetCi.current = idx;
|
||||
setCi(idx);
|
||||
if (intra > 0) restoreFrac.current = intra;
|
||||
}
|
||||
}, [chapters, initialLocator]);
|
||||
|
||||
@@ -123,73 +229,106 @@ function TxtView({ book, initialLocator }: ReaderProps) {
|
||||
} else el.scrollTop = 0;
|
||||
}, [ci, chapters]);
|
||||
|
||||
function goChapter(i: number) {
|
||||
useEffect(() => {
|
||||
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
|
||||
}, [toc]);
|
||||
|
||||
function goChapter(i: number, intra = 0) {
|
||||
if (!chapters) return;
|
||||
restoreFrac.current = null;
|
||||
restoreFrac.current = intra > 0 ? intra : null;
|
||||
targetCi.current = i;
|
||||
setCi(i);
|
||||
saver.report({ ch: i, scrollFraction: 0 }, i / chapters.length);
|
||||
setFrac(intra);
|
||||
saver.report({ ch: i, scrollFraction: intra }, (i + intra) / chapters.length);
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const el = boxRef.current;
|
||||
if (!el || !chapters) return;
|
||||
const max = el.scrollHeight - el.clientHeight;
|
||||
const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
||||
saver.report({ ch: ci, scrollFraction: frac }, (ci + frac) / chapters.length);
|
||||
const f = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
||||
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 (text == null || !chapters) return <LoadState text="正文加载中…" />;
|
||||
|
||||
const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{chapters.length > 1 && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-zinc-800 px-4 py-2 text-sm">
|
||||
<select className={input + " max-w-64"} value={ci} onChange={(e) => goChapter(Number(e.target.value))}>
|
||||
{(() => {
|
||||
const groups: { vol: string | undefined; items: number[] }[] = [];
|
||||
chapters.forEach((c, i) => {
|
||||
const last = groups[groups.length - 1];
|
||||
if (!last || last.vol !== c.vol) groups.push({ vol: c.vol, items: [i] });
|
||||
else last.items.push(i);
|
||||
});
|
||||
const opt = (i: number) => (
|
||||
<option key={i} value={i}>
|
||||
{i + 1}. {chapters[i].title}
|
||||
</option>
|
||||
);
|
||||
return groups.length === 1 && !groups[0].vol
|
||||
? chapters.map((_, i) => opt(i))
|
||||
: groups.map((g, gi) =>
|
||||
g.vol ? (
|
||||
<optgroup key={gi} label={g.vol}>
|
||||
{g.items.map(opt)}
|
||||
</optgroup>
|
||||
) : (
|
||||
g.items.map(opt)
|
||||
),
|
||||
);
|
||||
})()}
|
||||
</select>
|
||||
<span className="ml-auto text-zinc-500">
|
||||
{ci + 1}/{chapters.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<pre className="mx-auto max-w-2xl font-sans text-[15px] leading-7 break-words whitespace-pre-wrap">
|
||||
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||
<div
|
||||
ref={boxRef}
|
||||
onScroll={onScroll}
|
||||
onClick={(e) => bodyTogglesChrome(e, chrome.toggle)}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
|
||||
>
|
||||
<pre
|
||||
className="mx-auto max-w-[34em] break-words whitespace-pre-wrap"
|
||||
style={{ fontSize: pr.fontSize, lineHeight: 1.9, textAlign: "justify" }}
|
||||
>
|
||||
{chapterText(text, chapters, ci)}
|
||||
</pre>
|
||||
</div>
|
||||
{chapters.length > 1 && (
|
||||
<div className="flex shrink-0 items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
|
||||
<button className={btn} disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
|
||||
上一章
|
||||
</button>
|
||||
<button className={btn} disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
|
||||
下一章
|
||||
</button>
|
||||
</div>
|
||||
{chrome.on && (
|
||||
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
|
||||
{chapters.length > 1 && (
|
||||
<>
|
||||
<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>
|
||||
<button className="rd-btn" disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
|
||||
下一章 →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</ReaderSheet>
|
||||
)}
|
||||
{/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */}
|
||||
{toc && chapters.length > 1 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
|
||||
<nav
|
||||
id="toc-panel"
|
||||
aria-label="章节目录"
|
||||
className="rd-divider absolute inset-y-0 left-0 z-20 w-64 overflow-y-auto border-r bg-[var(--rd-bg)] p-2 shadow-2xl"
|
||||
>
|
||||
<ul className="space-y-0.5">
|
||||
{chapters.map((c, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
ref={i === ci ? activeRow : undefined}
|
||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
||||
onClick={() => {
|
||||
goChapter(i);
|
||||
setToc(false);
|
||||
}}
|
||||
>
|
||||
{i + 1}. {c.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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);
|
||||
|
||||
@@ -39,20 +39,28 @@ it("短文无标记 → 单节,chapterText 即全文", () => {
|
||||
expect(chapterText(t, chs, 0)).toBe(t);
|
||||
});
|
||||
|
||||
it("卷不是章节边界:只按章切,卷题作分组标签并并入所属章开头", () => {
|
||||
it("卷不感知:只按章切,卷题行留在正文里", () => {
|
||||
const t =
|
||||
"第一卷 天云世界\n第一章 铜镜\n" + pad(500) + "\n第二章 修炼\n" + pad(500) +
|
||||
"\n\n\n第二卷 仙界\n第三章 飞升\n" + pad(300);
|
||||
const chs = splitChapters(t);
|
||||
expect(chs.map((c) => c.title)).toEqual(["第一章 铜镜", "第二章 修炼", "第三章 飞升"]);
|
||||
expect(chs.map((c) => c.vol)).toEqual(["第一卷 天云世界", "第一卷 天云世界", "第二卷 仙界"]);
|
||||
expect(chs[2].start).toBe(t.indexOf("第二卷 仙界")); // 卷题行从本章开头渲染,而非上一章结尾
|
||||
expect(chapterText(t, chs, 2)).toMatch(/^第二卷 仙界/);
|
||||
expect(chs[0].start).toBe(t.indexOf("第一章"));
|
||||
expect(chs[2].start).toBe(t.indexOf("第三章"));
|
||||
expect(chapterText(t, chs, 1)).toContain("第二卷 仙界"); // 卷题在第二章结尾,不进第三章开头
|
||||
});
|
||||
|
||||
it("只有卷没有章的书:卷即章节", () => {
|
||||
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("只有卷没有章的书:按伪章节切", () => {
|
||||
const t = "第一卷 起\n" + pad(600) + "\n第二卷 承\n" + pad(600) + "\n第三卷 转\n" + pad(600);
|
||||
const chs = splitChapters(t);
|
||||
expect(chs.map((c) => c.title)).toEqual(["第一卷 起", "第二卷 承", "第三卷 转"]);
|
||||
expect(chs.every((c) => c.vol === undefined)).toBe(true);
|
||||
expect(chs).toEqual([{ title: "第 1 节", start: 0 }]);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,14 @@ it("set 修正高度并平移后续 offset;返回增量;幂等", () => {
|
||||
expect(ph.set(2, 150)).toBe(0);
|
||||
});
|
||||
|
||||
it("setEst:重估默认页高,返回视口上方「未量高」页数,已量高页保留", () => {
|
||||
const ph = new PageHeights(5, 100); // cum 顶部: 0,100,200,300,400
|
||||
ph.set(0, 160); // 页0 实测 160 → cum 顶部: 0,160,260,360,460
|
||||
expect(ph.setEst(140, 260)).toBe(1); // 顶部<260 且未量高:页1(160)。页0 已量高排除,页2(260)不<260
|
||||
expect(ph.total()).toBe(160 + 4 * 140); // 页0 实测保留,其余按新估高
|
||||
expect(ph.setEst(140, 9999)).toBe(0); // 同值幂等:est 不变直接 0
|
||||
});
|
||||
|
||||
it("n=0 时一切归空", () => {
|
||||
const ph = new PageHeights(0, 100);
|
||||
expect(ph.total()).toBe(0);
|
||||
|
||||
@@ -14,8 +14,8 @@ export default defineConfig({
|
||||
name: "BookLib 个人书库",
|
||||
short_name: "BookLib",
|
||||
description: "个人书库/漫画库在线阅读",
|
||||
theme_color: "#09090b",
|
||||
background_color: "#09090b",
|
||||
theme_color: "#0c0a09",
|
||||
background_color: "#0c0a09",
|
||||
display: "standalone",
|
||||
icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user