Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3178c2802f | ||
|
|
472e58bec9 | ||
|
|
d2c852d6f0 | ||
|
|
9c2b7e2074 | ||
|
|
65e07a0890 | ||
|
|
48161ea1ff |
@@ -2,3 +2,4 @@
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
tasks/
|
tasks/
|
||||||
.playwright*/
|
.playwright*/
|
||||||
|
frontend/tsconfig.tsbuildinfo
|
||||||
|
|||||||
+10
-4
@@ -13,11 +13,11 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
- CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone.
|
- CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone.
|
||||||
- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
|
- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
|
||||||
|
|
||||||
- Bookmarks in all four readers: a 书签 button in the bar captures the current position (page / chapter+offset / EPUB CFI) with an optional note; the right-side panel lists them — click jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices.
|
- Bookmarks in all four readers: in the 书签 tab of the left navigation drawer, 加 captures the current position (page / chapter+offset / EPUB CFI) with an optional note; clicking a row jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices.
|
||||||
- 四类阅读器新增书签:工具条「书签」按钮把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;右侧书签面板点一下即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。
|
- 四类阅读器新增书签:在左侧导航抽屉的「书签」选项卡内点「加」把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;点条目即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。
|
||||||
|
|
||||||
- Folder-structured comics gain a 目录 sidebar in the CBZ reader: each archive folder (第N話…) becomes a chapter that jumps to its first page, with 上一章/下一章 buttons, a current-chapter counter, and the active row highlighted and scrolled into view — flat archives show no extra UI.
|
- Folder-structured comics gain a 目录 tab in the CBZ reader's navigation drawer: each archive folder (第N話…) becomes a chapter that jumps to its first page, with 上一章/下一章 buttons, a current-chapter counter, and the active row highlighted and scrolled into view — flat archives show no extra UI.
|
||||||
- 按目录组织的漫画在 CBZ 阅读器新增「目录」侧栏:压缩包内每个文件夹(第N話…)即一章,点击直达该话首页;工具条配上一章/下一章与当前章计数,目录内高亮当前章并自动居中;平铺无目录的包不显示多余控件。
|
- 按目录组织的漫画在 CBZ 阅读器导航抽屉内新增「目录」选项卡:压缩包内每个文件夹(第N話…)即一章,点击直达该话首页;工具条配上一章/下一章与当前章计数,目录内高亮当前章并自动居中;平铺无目录的包不显示多余控件。
|
||||||
|
|
||||||
- Library uploads over 16MB now auto-switch to the resumable chunked protocol (8MB parts): failed parts retry in place up to 3 times and already-sent parts are skipped, so a flaky upload continues instead of starting over; small files keep the original single request.
|
- Library uploads over 16MB now auto-switch to the resumable chunked protocol (8MB parts): failed parts retry in place up to 3 times and already-sent parts are skipped, so a flaky upload continues instead of starting over; small files keep the original single request.
|
||||||
- 书库上传超过 16MB 自动切换为可续传分片协议(每片 8MB):失败的分片原地重试至多 3 次,已传片自动跳过,中断后继续而不是从头再来;小文件仍走原单次上传。
|
- 书库上传超过 16MB 自动切换为可续传分片协议(每片 8MB):失败的分片原地重试至多 3 次,已传片自动跳过,中断后继续而不是从头再来;小文件仍走原单次上传。
|
||||||
@@ -31,6 +31,12 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
### Changed / 变更
|
### Changed / 变更
|
||||||
|
|
||||||
|
- Reader chrome is no longer text-only: lucide icons accompany 导航/上一章/下一章/翻页/抽屉选项卡/书签操作, while 连读 and 预读 (now a 0–3 slider) collapse into a 更多 menu in the CBZ bar.
|
||||||
|
- 阅读器工具条告别纯文字:导航/上一章/下一章/翻页/抽屉选项卡/书签操作均配 lucide 图标;连读与预读(改为 0–3 滑条)收进 CBZ 工具条的「更多」菜单。
|
||||||
|
|
||||||
|
- Reader navigation tightened into one left-side drawer opened by a single 导航 toolbar button: 目录 and 书签 are now tabs of a sidebar shared by all four readers, following the reader theme's colors; the button lands on 目录 when a TOC exists, otherwise straight on bookmarks (flat archives, EPUB, PDF show no tab bar); the separate right-side bookmark panel and the per-reader TOC popovers are gone.
|
||||||
|
- 阅读器导航收紧为统一左侧抽屉,工具条只留一个「导航」按钮:目录与书签成为四个阅读器共用侧边栏内的两个选项卡,配色跟随阅读器主题;有目录时默认落在「目录」,否则直达书签(平铺包、EPUB、PDF 不显示选项卡条);原右侧书签面板与各阅读器自绘目录浮层移除。
|
||||||
|
|
||||||
- The web UI was rebuilt on a shadcn/Radix design system: semantic light/dark theme tokens with a system/light/dark switcher (remembered), a persistent app shell (desktop sidebar, tablet icon rail, mobile bottom tabs), a shelf driven by URL-state search/sort/group controls with a flat-first grid and menu-based card actions (confirm dialogs replace native popups), and rebuilt login/admin pages. Readers keep their current chrome and every reading interaction this round; their restyle lands next.
|
- The web UI was rebuilt on a shadcn/Radix design system: semantic light/dark theme tokens with a system/light/dark switcher (remembered), a persistent app shell (desktop sidebar, tablet icon rail, mobile bottom tabs), a shelf driven by URL-state search/sort/group controls with a flat-first grid and menu-based card actions (confirm dialogs replace native popups), and rebuilt login/admin pages. Readers keep their current chrome and every reading interaction this round; their restyle lands next.
|
||||||
- 网页 UI 以 shadcn/Radix 设计系统重做:语义化明暗主题 token + 系统/浅色/深色切换(记忆);常驻应用壳(桌面侧栏、平板图标栏、手机底部标签);书架改为 URL 状态驱动的搜索/排序/分组工具条,默认平铺网格,卡片操作收进菜单(删除改为对话框确认);登录页与 admin 两页同步重做。阅读器保持现状,改版随下一期到达。
|
- 网页 UI 以 shadcn/Radix 设计系统重做:语义化明暗主题 token + 系统/浅色/深色切换(记忆);常驻应用壳(桌面侧栏、平板图标栏、手机底部标签);书架改为 URL 状态驱动的搜索/排序/分组工具条,默认平铺网格,卡片操作收进菜单(删除改为对话框确认);登录页与 admin 两页同步重做。阅读器保持现状,改版随下一期到达。
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState, type ReactNode } from "react";
|
import { Check, Pencil, Plus, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Book } from "../api/types";
|
import type { Book } from "../api/types";
|
||||||
import { toast } from "./Toaster";
|
import { toast } from "./Toaster";
|
||||||
|
|
||||||
type Locator = Record<string, unknown>;
|
type Locator = Record<string, unknown>;
|
||||||
|
|
||||||
export function useBookmarks(opts: {
|
/** 书签面板内容(供 ReaderNav 的「书签」选项卡承载);跳转后由调用方负责关抽屉. */
|
||||||
|
export function BookmarksTab({
|
||||||
|
book,
|
||||||
|
capture,
|
||||||
|
seek,
|
||||||
|
}: {
|
||||||
book: Book;
|
book: Book;
|
||||||
capture: () => { locator: Locator; percent: number };
|
capture: () => { locator: Locator; percent: number };
|
||||||
seek: (locator: Locator) => void;
|
seek: (locator: Locator) => void;
|
||||||
}): { btn: ReactNode; panel: ReactNode } {
|
}) {
|
||||||
const { book, capture, seek } = opts;
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [editId, setEditId] = useState<number | null>(null);
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
const [editVal, setEditVal] = useState("");
|
const [editVal, setEditVal] = useState("");
|
||||||
@@ -48,25 +52,8 @@ export function useBookmarks(opts: {
|
|||||||
onError: fail,
|
onError: fail,
|
||||||
});
|
});
|
||||||
|
|
||||||
const btn = (
|
return (
|
||||||
<button
|
|
||||||
className="rd-btn"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls="bm-panel"
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
>
|
|
||||||
书签{items.length ? ` ${items.length}` : ""}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const panel = open ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={() => setOpen(false)} />
|
|
||||||
<nav
|
|
||||||
id="bm-panel"
|
|
||||||
aria-label="书签"
|
|
||||||
className="rd-divider absolute inset-y-0 right-0 z-40 flex w-72 flex-col border-l bg-[var(--rd-bg)] p-2 shadow-2xl"
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
|
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
|
||||||
@@ -78,11 +65,16 @@ export function useBookmarks(opts: {
|
|||||||
if (e.key === "Enter" && !add.isPending) add.mutate();
|
if (e.key === "Enter" && !add.isPending) add.mutate();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="rd-btn shrink-0" disabled={add.isPending} onClick={() => add.mutate()}>
|
<button
|
||||||
加
|
className="rd-btn inline-flex shrink-0 items-center gap-1"
|
||||||
|
aria-label="加书签"
|
||||||
|
disabled={add.isPending}
|
||||||
|
onClick={() => add.mutate()}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> 加
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
<ul className="space-y-0.5">
|
||||||
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
|
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
|
||||||
{!q.isPending && items.length === 0 && (
|
{!q.isPending && items.length === 0 && (
|
||||||
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
|
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
|
||||||
@@ -101,14 +93,15 @@ export function useBookmarks(opts: {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="rd-btn shrink-0"
|
className="rd-btn inline-flex shrink-0 items-center"
|
||||||
|
aria-label="保存备注"
|
||||||
disabled={patch.isPending}
|
disabled={patch.isPending}
|
||||||
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
|
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
|
||||||
>
|
>
|
||||||
✓
|
<Check size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button className="rd-btn shrink-0" onClick={() => setEditId(null)}>
|
<button className="rd-btn inline-flex shrink-0 items-center" aria-label="取消编辑" onClick={() => setEditId(null)}>
|
||||||
✕
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -116,10 +109,7 @@ export function useBookmarks(opts: {
|
|||||||
<button
|
<button
|
||||||
className="rd-row min-w-0 flex-1 truncate text-left"
|
className="rd-row min-w-0 flex-1 truncate text-left"
|
||||||
title={b.note || undefined}
|
title={b.note || undefined}
|
||||||
onClick={() => {
|
onClick={() => seek(b.locator)}
|
||||||
seek(b.locator);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
|
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
|
||||||
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
|
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
|
||||||
@@ -132,23 +122,20 @@ export function useBookmarks(opts: {
|
|||||||
setEditVal(b.note);
|
setEditVal(b.note);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✎
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
aria-label="删除书签"
|
aria-label="删除书签"
|
||||||
onClick={() => del.mutate(b.id)}
|
onClick={() => del.mutate(b.id)}
|
||||||
>
|
>
|
||||||
✕
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
|
||||||
</>
|
</>
|
||||||
) : null;
|
);
|
||||||
|
|
||||||
return { btn, panel };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** 阅读器主题内的滑条配置项:说明 + 整数档位滑条 + 锚点刻度. */
|
||||||
|
export function RdSlider({
|
||||||
|
label,
|
||||||
|
ariaLabel,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-2.5 pb-1.5 pt-2">
|
||||||
|
<div className="mb-1 text-sm">{label}</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={1}
|
||||||
|
value={value}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="rd-range"
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between px-1 text-[10px] tabular-nums opacity-50" aria-hidden="true">
|
||||||
|
{Array.from({ length: max - min + 1 }, (_, i) => (
|
||||||
|
<span key={i}>{min + i}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Bookmark, List, X } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type NavTab = "toc" | "bm";
|
||||||
|
|
||||||
|
/** 阅读器左侧导航抽屉:目录/书签选项卡;不传 toc 则退化为纯书签面板. */
|
||||||
|
export function ReaderNav({
|
||||||
|
tab,
|
||||||
|
onTab,
|
||||||
|
onClose,
|
||||||
|
toc,
|
||||||
|
bookmarks,
|
||||||
|
}: {
|
||||||
|
tab: NavTab;
|
||||||
|
onTab?: (t: NavTab) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
toc?: ReactNode;
|
||||||
|
bookmarks: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={onClose} />
|
||||||
|
<nav
|
||||||
|
id="reader-nav"
|
||||||
|
aria-label="导航"
|
||||||
|
className="rd-divider absolute inset-y-0 left-0 z-40 flex w-72 flex-col border-r bg-[var(--rd-bg,#171512)] shadow-2xl"
|
||||||
|
style={{ color: "var(--rd-fg,#c6c0b6)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role={toc != null ? "tablist" : undefined}
|
||||||
|
aria-label="导航选项卡"
|
||||||
|
className="rd-divider flex shrink-0 items-center gap-1 border-b p-2"
|
||||||
|
>
|
||||||
|
{toc != null ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "toc"}
|
||||||
|
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "toc" ? " rd-btn-on" : "")}
|
||||||
|
onClick={() => onTab?.("toc")}
|
||||||
|
>
|
||||||
|
<List size={14} /> 目录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "bm"}
|
||||||
|
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "bm" ? " rd-btn-on" : "")}
|
||||||
|
onClick={() => onTab?.("bm")}
|
||||||
|
>
|
||||||
|
<Bookmark size={14} /> 书签
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="rd-btn rd-btn-on inline-flex items-center gap-1.5">
|
||||||
|
<Bookmark size={14} /> 书签
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="rd-btn ml-auto" aria-label="关闭导航" onClick={onClose}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tab === "toc" ? toc : bookmarks}</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -131,6 +131,9 @@ body {
|
|||||||
.rd-row { @apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors; }
|
.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-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-btn-on { @apply font-semibold; background: color-mix(in oklab, currentColor 20%, transparent); }
|
||||||
|
/* Radix 菜单项高亮(指针/键盘) */
|
||||||
|
.rd-highlight { outline: none; }
|
||||||
|
.rd-highlight[data-highlighted] { background: color-mix(in oklab, currentColor 16%, transparent); }
|
||||||
.rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); }
|
.rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); }
|
||||||
|
|
||||||
/* 进度/页码滑条:主题色轨道 + 强调色拇指,触摸友好 */
|
/* 进度/页码滑条:主题色轨道 + 强调色拇指,触摸友好 */
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BookOpenCheck, Check, ChevronLeft, ChevronRight, PanelLeft, Settings2 } from "lucide-react";
|
||||||
|
import { DropdownMenu } from "radix-ui";
|
||||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } 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 { useBookmarks } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
|
import { RdSlider } from "../components/rd-slider";
|
||||||
|
import { ReaderNav, type NavTab } from "../components/reader-nav";
|
||||||
import { fetchObjectUrl } from "../lib/authImage";
|
import { fetchObjectUrl } from "../lib/authImage";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
|
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
|
||||||
@@ -79,10 +83,9 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
});
|
});
|
||||||
const count = countQ.data?.count ?? 0;
|
const count = countQ.data?.count ?? 0;
|
||||||
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
|
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
|
||||||
const [toc, setToc] = useState(false);
|
const [nav, setNav] = useState<null | NavTab>(null);
|
||||||
const activeRow = useRef<HTMLButtonElement>(null);
|
const activeRow = useRef<HTMLButtonElement>(null);
|
||||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => goPage(Number(l.page) || 0) });
|
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
const [width, setWidth] = useState(480);
|
const [width, setWidth] = useState(480);
|
||||||
const [vh, setVh] = useState(600);
|
const [vh, setVh] = useState(600);
|
||||||
@@ -190,8 +193,8 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
|
const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
|
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
|
||||||
}, [toc]);
|
}, [nav]);
|
||||||
|
|
||||||
function scrollToPage(i: number, smooth: boolean) {
|
function scrollToPage(i: number, smooth: boolean) {
|
||||||
const el = boxRef.current;
|
const el = boxRef.current;
|
||||||
@@ -296,7 +299,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
<span className="text-sm text-stone-500">— 全书完 —</span>
|
<span className="text-sm text-stone-500">— 全书完 —</span>
|
||||||
) : (
|
) : (
|
||||||
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
|
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
|
||||||
本章完 · 下一章 →
|
本章完 · 下一章 <ChevronRight size={14} className="inline" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -340,45 +343,72 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
|
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
|
||||||
{chapters && chapters.length >= 2 && (
|
|
||||||
<button
|
<button
|
||||||
className={"rd-btn" + (continuous ? " rd-btn-on" : "")}
|
className="rd-btn inline-flex items-center gap-1.5"
|
||||||
aria-pressed={continuous}
|
aria-expanded={!!nav}
|
||||||
onClick={() => setContinuous(!continuous)}
|
aria-controls="reader-nav"
|
||||||
|
onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")}
|
||||||
>
|
>
|
||||||
连读{continuous ? "·开" : "·关"}
|
<PanelLeft size={14} /> 导航
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
{locked && (
|
|
||||||
<button
|
|
||||||
className="rd-btn"
|
|
||||||
aria-label="预读:锁定章节末尾时提前挂载并解码其后 N 章"
|
|
||||||
onClick={() => setPrefetch((p) => (p + 1) % 4)}
|
|
||||||
>
|
|
||||||
预读 {prefetch} 章
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{bm.btn}
|
|
||||||
{chapters && (
|
{chapters && (
|
||||||
<>
|
<>
|
||||||
<button className="rd-btn" aria-expanded={toc} aria-controls="cbz-toc" onClick={() => setToc((v) => !v)}>
|
|
||||||
目录
|
|
||||||
</button>
|
|
||||||
<span className="text-xs tabular-nums opacity-60">
|
<span className="text-xs tabular-nums opacity-60">
|
||||||
{ci + 1}/{chapters.length}
|
{ci + 1}/{chapters.length}
|
||||||
</span>
|
</span>
|
||||||
<button className="rd-btn" disabled={ci === 0} onClick={() => goPage(chapters[ci - 1].start)}>
|
<button
|
||||||
← 上一章
|
className="rd-btn inline-flex items-center gap-0.5"
|
||||||
|
disabled={ci === 0}
|
||||||
|
onClick={() => goPage(chapters[ci - 1].start)}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> 上一章
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rd-btn"
|
className="rd-btn inline-flex items-center gap-0.5"
|
||||||
disabled={ci === chapters.length - 1}
|
disabled={ci === chapters.length - 1}
|
||||||
onClick={() => goPage(chapters[ci + 1].start)}
|
onClick={() => goPage(chapters[ci + 1].start)}
|
||||||
>
|
>
|
||||||
下一章 →
|
下一章 <ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{chapters && chapters.length >= 2 && (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger className="rd-btn ml-auto inline-flex items-center gap-1.5" aria-label="更多阅读选项">
|
||||||
|
<Settings2 size={14} /> 更多
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
align="end"
|
||||||
|
side="top"
|
||||||
|
className="rd-divider z-50 min-w-44 rounded-md border bg-[var(--rd-bg,#171512)] p-1 shadow-2xl"
|
||||||
|
style={{ color: "var(--rd-fg,#c6c0b6)" }}
|
||||||
|
>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
className="rd-row rd-highlight cursor-pointer"
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||||
|
checked={continuous}
|
||||||
|
onCheckedChange={(v) => setContinuous(!!v)}
|
||||||
|
>
|
||||||
|
<BookOpenCheck size={14} /> 连读(滑动跨章)
|
||||||
|
<DropdownMenu.ItemIndicator style={{ marginLeft: "auto" }}>
|
||||||
|
<Check size={14} />
|
||||||
|
</DropdownMenu.ItemIndicator>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
{locked && (
|
||||||
|
<RdSlider
|
||||||
|
label="预读:锁章末尾时提前挂载其后 N 章"
|
||||||
|
ariaLabel="预读章数"
|
||||||
|
min={0}
|
||||||
|
max={3}
|
||||||
|
value={prefetch}
|
||||||
|
onChange={setPrefetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -389,14 +419,23 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
|
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{toc && chapters && (
|
{nav && (
|
||||||
<>
|
<ReaderNav
|
||||||
<div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
|
tab={chapters ? nav : "bm"}
|
||||||
<nav
|
onTab={setNav}
|
||||||
id="cbz-toc"
|
onClose={() => setNav(null)}
|
||||||
aria-label="章节目录"
|
bookmarks={
|
||||||
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"
|
<BookmarksTab
|
||||||
>
|
book={book}
|
||||||
|
capture={saver.capture}
|
||||||
|
seek={(l) => {
|
||||||
|
goPage(Number(l.page) || 0);
|
||||||
|
setNav(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
toc={
|
||||||
|
chapters && (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{chapters.map((c, i) => (
|
{chapters.map((c, i) => (
|
||||||
<li key={i}>
|
<li key={i}>
|
||||||
@@ -405,7 +444,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
goPage(c.start);
|
goPage(c.start);
|
||||||
setToc(false);
|
setNav(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i + 1}. {c.title}
|
{i + 1}. {c.title}
|
||||||
@@ -413,10 +452,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
)
|
||||||
</>
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{bm.panel}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { apiRaw } from "../api/client";
|
import { apiRaw } from "../api/client";
|
||||||
import { useBookmarks } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
|
import { ReaderNav } from "../components/reader-nav";
|
||||||
import { btn } from "../components/ui";
|
import { btn } from "../components/ui";
|
||||||
import { toast } from "../components/Toaster";
|
import { toast } from "../components/Toaster";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
@@ -32,15 +34,7 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
|
|||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const rendRef = useRef<EpubRendition | null>(null);
|
const rendRef = useRef<EpubRendition | null>(null);
|
||||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
const bm = useBookmarks({
|
const [nav, setNav] = useState(false);
|
||||||
book,
|
|
||||||
capture: saver.capture,
|
|
||||||
seek: (l) => {
|
|
||||||
const cfi = String(l.cfi ?? "");
|
|
||||||
if (!cfi) return;
|
|
||||||
Promise.resolve(rendRef.current?.display(cfi)).catch(() => toast("err", "书签定位失败"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
@@ -107,15 +101,39 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
|
|||||||
{!ready && !err && <div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>}
|
{!ready && !err && <div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-3 border-t border-stone-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 className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.prev()}>
|
||||||
上一页
|
<ChevronLeft size={14} /> 上一页
|
||||||
</button>
|
</button>
|
||||||
<button className={btn} onClick={() => rendRef.current?.next()}>
|
<button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.next()}>
|
||||||
下一页
|
下一页 <ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rd-btn inline-flex items-center gap-1.5"
|
||||||
|
aria-expanded={nav}
|
||||||
|
aria-controls="reader-nav"
|
||||||
|
onClick={() => setNav((v) => !v)}
|
||||||
|
>
|
||||||
|
<PanelLeft size={14} /> 导航
|
||||||
</button>
|
</button>
|
||||||
{bm.btn}
|
|
||||||
</div>
|
</div>
|
||||||
{bm.panel}
|
{nav && (
|
||||||
|
<ReaderNav
|
||||||
|
tab="bm"
|
||||||
|
onClose={() => setNav(false)}
|
||||||
|
bookmarks={
|
||||||
|
<BookmarksTab
|
||||||
|
book={book}
|
||||||
|
capture={saver.capture}
|
||||||
|
seek={(l) => {
|
||||||
|
const cfi = String(l.cfi ?? "");
|
||||||
|
setNav(false);
|
||||||
|
if (!cfi) return;
|
||||||
|
Promise.resolve(rendRef.current?.display(cfi)).catch(() => toast("err", "书签定位失败"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, PanelLeft } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
import { apiRaw } from "../api/client";
|
import { apiRaw } from "../api/client";
|
||||||
import { useBookmarks } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
|
import { ReaderNav } from "../components/reader-nav";
|
||||||
import { btn } from "../components/ui";
|
import { btn } from "../components/ui";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
@@ -21,14 +23,7 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
|
|||||||
const taskRef = useRef<RenderTask | null>(null);
|
const taskRef = useRef<RenderTask | null>(null);
|
||||||
const restored = useRef(false);
|
const restored = useRef(false);
|
||||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
const bm = useBookmarks({
|
const [nav, setNav] = useState(false);
|
||||||
book,
|
|
||||||
capture: saver.capture,
|
|
||||||
seek: (l) => {
|
|
||||||
const p = Number(l.page);
|
|
||||||
if (Number.isInteger(p) && p >= 0 && p < num) setPage(p);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [num, setNum] = useState(0);
|
const [num, setNum] = useState(0);
|
||||||
const [page, setPage] = useState(0); // 0 基
|
const [page, setPage] = useState(0); // 0 基
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
@@ -145,24 +140,47 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
|
|||||||
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
|
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-2 border-t border-stone-800/80 px-4 py-2 text-sm">
|
<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 className={btn + " inline-flex items-center gap-0.5"} disabled={page === 0} onClick={() => setPage(0)} aria-label="第一页">
|
||||||
首页
|
<ChevronsLeft size={14} /> 首页
|
||||||
</button>
|
</button>
|
||||||
<button className={btn} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
|
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
|
||||||
← 上一页
|
<ChevronLeft size={14} /> 上一页
|
||||||
</button>
|
</button>
|
||||||
<span className="min-w-16 text-center tabular-nums text-stone-400">
|
<span className="min-w-16 text-center tabular-nums text-stone-400">
|
||||||
{page + 1}/{num}
|
{page + 1}/{num}
|
||||||
</span>
|
</span>
|
||||||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
|
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
|
||||||
下一页 →
|
下一页 <ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
|
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
|
||||||
末页
|
<ChevronsRight size={14} /> 末页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rd-btn inline-flex items-center gap-1.5"
|
||||||
|
aria-expanded={nav}
|
||||||
|
aria-controls="reader-nav"
|
||||||
|
onClick={() => setNav((v) => !v)}
|
||||||
|
>
|
||||||
|
<PanelLeft size={14} /> 导航
|
||||||
</button>
|
</button>
|
||||||
{bm.btn}
|
|
||||||
</div>
|
</div>
|
||||||
{bm.panel}
|
{nav && (
|
||||||
|
<ReaderNav
|
||||||
|
tab="bm"
|
||||||
|
onClose={() => setNav(false)}
|
||||||
|
bookmarks={
|
||||||
|
<BookmarksTab
|
||||||
|
book={book}
|
||||||
|
capture={saver.capture}
|
||||||
|
seek={(l) => {
|
||||||
|
const p = Number(l.page);
|
||||||
|
setNav(false);
|
||||||
|
if (Number.isInteger(p) && p >= 0 && p < num) setPage(p);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
|
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState, type MouseEvent, 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 { useBookmarks } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
|
import { ReaderNav, type NavTab } from "../components/reader-nav";
|
||||||
import { btn } from "../components/ui";
|
import { btn } from "../components/ui";
|
||||||
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
|
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
|
||||||
import { decodeText } from "../lib/encoding";
|
import { decodeText } from "../lib/encoding";
|
||||||
@@ -134,7 +136,7 @@ export default function TextReader(props: ReaderProps) {
|
|||||||
function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => seek(Number(l.scrollFraction) || 0) });
|
const [nav, setNav] = useState(false);
|
||||||
const restored = useRef(false);
|
const restored = useRef(false);
|
||||||
const pr = useReaderPrefs();
|
const pr = useReaderPrefs();
|
||||||
const [frac, setFrac] = useState(0);
|
const [frac, setFrac] = useState(0);
|
||||||
@@ -183,8 +185,34 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
|||||||
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{chrome.on && <ReaderSheet pr={pr} frac={frac} onSeek={seek}>{bm.btn}</ReaderSheet>}
|
{chrome.on && (
|
||||||
{bm.panel}
|
<ReaderSheet pr={pr} frac={frac} onSeek={seek}>
|
||||||
|
<button
|
||||||
|
className="rd-btn inline-flex items-center gap-1.5"
|
||||||
|
aria-expanded={nav}
|
||||||
|
aria-controls="reader-nav"
|
||||||
|
onClick={() => setNav((v) => !v)}
|
||||||
|
>
|
||||||
|
<PanelLeft size={14} /> 导航
|
||||||
|
</button>
|
||||||
|
</ReaderSheet>
|
||||||
|
)}
|
||||||
|
{nav && (
|
||||||
|
<ReaderNav
|
||||||
|
tab="bm"
|
||||||
|
onClose={() => setNav(false)}
|
||||||
|
bookmarks={
|
||||||
|
<BookmarksTab
|
||||||
|
book={book}
|
||||||
|
capture={saver.capture}
|
||||||
|
seek={(l) => {
|
||||||
|
seek(Number(l.scrollFraction) || 0);
|
||||||
|
setNav(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,14 +227,9 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
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 [frac, setFrac] = useState(0);
|
||||||
const [toc, setToc] = useState(false);
|
const [nav, setNav] = useState<null | NavTab>(null);
|
||||||
const pr = useReaderPrefs();
|
const pr = useReaderPrefs();
|
||||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||||
const bm = useBookmarks({
|
|
||||||
book,
|
|
||||||
capture: saver.capture,
|
|
||||||
seek: (l) => goChapter(Number(l.ch) || 0, Number(l.scrollFraction) || 0),
|
|
||||||
});
|
|
||||||
|
|
||||||
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
|
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
|
||||||
|
|
||||||
@@ -238,8 +261,8 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
}, [ci, chapters]);
|
}, [ci, chapters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
|
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
|
||||||
}, [toc]);
|
}, [nav]);
|
||||||
|
|
||||||
function goChapter(i: number, intra = 0) {
|
function goChapter(i: number, intra = 0) {
|
||||||
if (!chapters) return;
|
if (!chapters) return;
|
||||||
@@ -287,39 +310,55 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
</div>
|
</div>
|
||||||
{chrome.on && (
|
{chrome.on && (
|
||||||
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
|
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
|
||||||
{bm.btn}
|
<button
|
||||||
|
className="rd-btn inline-flex items-center gap-1.5"
|
||||||
|
aria-expanded={!!nav}
|
||||||
|
aria-controls="reader-nav"
|
||||||
|
onClick={() => setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
|
||||||
|
>
|
||||||
|
<PanelLeft size={14} /> 导航
|
||||||
|
</button>
|
||||||
{chapters.length > 1 && (
|
{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">
|
<span className="text-xs opacity-60 tabular-nums">
|
||||||
{ci + 1}/{chapters.length}
|
{ci + 1}/{chapters.length}
|
||||||
</span>
|
</span>
|
||||||
<button className="rd-btn" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
|
<button
|
||||||
← 上一章
|
className="rd-btn inline-flex items-center gap-0.5"
|
||||||
|
disabled={ci === 0}
|
||||||
|
onClick={() => goChapter(ci - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> 上一章
|
||||||
</button>
|
</button>
|
||||||
<button className="rd-btn" disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
|
<button
|
||||||
下一章 →
|
className="rd-btn inline-flex items-center gap-0.5"
|
||||||
|
disabled={ci === chapters.length - 1}
|
||||||
|
onClick={() => goChapter(ci + 1)}
|
||||||
|
>
|
||||||
|
下一章 <ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ReaderSheet>
|
</ReaderSheet>
|
||||||
)}
|
)}
|
||||||
{/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */}
|
{nav && (
|
||||||
{toc && chapters.length > 1 && (
|
<ReaderNav
|
||||||
<>
|
tab={chapters.length > 1 ? nav : "bm"}
|
||||||
<div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
|
onTab={setNav}
|
||||||
<nav
|
onClose={() => setNav(null)}
|
||||||
id="toc-panel"
|
bookmarks={
|
||||||
aria-label="章节目录"
|
<BookmarksTab
|
||||||
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"
|
book={book}
|
||||||
>
|
capture={saver.capture}
|
||||||
|
seek={(l) => {
|
||||||
|
goChapter(Number(l.ch) || 0, Number(l.scrollFraction) || 0);
|
||||||
|
setNav(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
toc={
|
||||||
|
/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */
|
||||||
|
chapters.length > 1 && (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{chapters.map((c, i) => (
|
{chapters.map((c, i) => (
|
||||||
<li key={i}>
|
<li key={i}>
|
||||||
@@ -328,7 +367,7 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
goChapter(i);
|
goChapter(i);
|
||||||
setToc(false);
|
setNav(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i + 1}. {c.title}
|
{i + 1}. {c.title}
|
||||||
@@ -336,10 +375,10 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
)
|
||||||
</>
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{bm.panel}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user