feat(bookmarks): shared useBookmarks panel + wired into all four readers
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { Book } from "../api/types";
|
||||||
|
import { toast } from "./Toaster";
|
||||||
|
|
||||||
|
type Locator = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function useBookmarks(opts: {
|
||||||
|
book: Book;
|
||||||
|
capture: () => { locator: Locator; percent: number };
|
||||||
|
seek: (locator: Locator) => void;
|
||||||
|
}): { btn: ReactNode; panel: ReactNode } {
|
||||||
|
const { book, capture, seek } = opts;
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const [editVal, setEditVal] = useState("");
|
||||||
|
const q = useQuery({ queryKey: ["bookmarks", book.id], queryFn: () => api.listBookmarks(book.id) });
|
||||||
|
const items = q.data ?? [];
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["bookmarks", book.id] });
|
||||||
|
const fail = (e: unknown) => toast("err", e instanceof Error ? e.message : "书签操作失败");
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const { locator, percent } = capture();
|
||||||
|
return api.createBookmark(book.id, locator, percent, note.trim());
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setNote("");
|
||||||
|
invalidate();
|
||||||
|
toast("ok", "已加书签");
|
||||||
|
},
|
||||||
|
onError: fail,
|
||||||
|
});
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: (v: { id: number; note: string }) => api.patchBookmark(v.id, v.note),
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditId(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: fail,
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteBookmark(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: fail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = (
|
||||||
|
<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">
|
||||||
|
<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"
|
||||||
|
placeholder="备注(可空)"
|
||||||
|
maxLength={500}
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !add.isPending) add.mutate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="rd-btn shrink-0" disabled={add.isPending} onClick={() => add.mutate()}>
|
||||||
|
加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||||
|
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
|
||||||
|
{!q.isPending && items.length === 0 && (
|
||||||
|
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
|
||||||
|
)}
|
||||||
|
{items.map((b) => (
|
||||||
|
<li key={b.id}>
|
||||||
|
{editId === b.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<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"
|
||||||
|
maxLength={500}
|
||||||
|
value={editVal}
|
||||||
|
onChange={(e) => setEditVal(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="rd-btn shrink-0"
|
||||||
|
disabled={patch.isPending}
|
||||||
|
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button className="rd-btn shrink-0" onClick={() => setEditId(null)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="group flex items-center">
|
||||||
|
<button
|
||||||
|
className="rd-row min-w-0 flex-1 truncate text-left"
|
||||||
|
title={b.note || undefined}
|
||||||
|
onClick={() => {
|
||||||
|
seek(b.locator);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
|
aria-label="改备注"
|
||||||
|
onClick={() => {
|
||||||
|
setEditId(b.id);
|
||||||
|
setEditVal(b.note);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
|
aria-label="删除书签"
|
||||||
|
onClick={() => del.mutate(b.id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return { btn, panel };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
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 { 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 } from "../lib/virt";
|
import { PageHeights } from "../lib/virt";
|
||||||
@@ -76,7 +77,7 @@ function PageImg({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) {
|
export default function CbzReader({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
||||||
const countQ = useQuery({
|
const countQ = useQuery({
|
||||||
queryKey: ["pages", book.id],
|
queryKey: ["pages", book.id],
|
||||||
queryFn: () => api.pageCount(book.pages_url ?? ""),
|
queryFn: () => api.pageCount(book.pages_url ?? ""),
|
||||||
@@ -87,7 +88,8 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
|
|||||||
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷)
|
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷)
|
||||||
const [toc, setToc] = useState(false);
|
const [toc, setToc] = useState(false);
|
||||||
const activeRow = useRef<HTMLButtonElement>(null);
|
const activeRow = useRef<HTMLButtonElement>(null);
|
||||||
const saver = useProgressSaver(book.id);
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
|
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => jump(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);
|
||||||
@@ -291,6 +293,7 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
|
|||||||
>
|
>
|
||||||
{MODE_LABEL[mode]}
|
{MODE_LABEL[mode]}
|
||||||
</button>
|
</button>
|
||||||
|
{bm.btn}
|
||||||
{chapters && (
|
{chapters && (
|
||||||
<>
|
<>
|
||||||
<button className="rd-btn" aria-expanded={toc} aria-controls="cbz-toc" onClick={() => setToc((v) => !v)}>
|
<button className="rd-btn" aria-expanded={toc} aria-controls="cbz-toc" onClick={() => setToc((v) => !v)}>
|
||||||
@@ -348,6 +351,7 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
|
|||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{bm.panel}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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 { btn } from "../components/ui";
|
import { btn } from "../components/ui";
|
||||||
|
import { toast } from "../components/Toaster";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
|
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
|
||||||
@@ -26,10 +28,19 @@ async function loadEpub(): Promise<(data: Uint8Array) => EpubBookLike> {
|
|||||||
return m.default as (data: Uint8Array) => EpubBookLike;
|
return m.default as (data: Uint8Array) => EpubBookLike;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EpubReader({ book, initialLocator }: ReaderProps) {
|
export default function EpubReader({ book, initialLocator, initialPercent }: ReaderProps) {
|
||||||
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);
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
|
const bm = useBookmarks({
|
||||||
|
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);
|
||||||
@@ -90,7 +101,7 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-rows-[1fr_auto]">
|
<div className="relative grid h-full grid-rows-[1fr_auto]">
|
||||||
<div className="relative mx-auto min-h-0 w-full max-w-4xl bg-white shadow-2xl ring-1 ring-stone-800">
|
<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" />
|
<div ref={hostRef} className="absolute inset-0" />
|
||||||
{!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>}
|
||||||
@@ -102,7 +113,9 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) {
|
|||||||
<button className={btn} onClick={() => rendRef.current?.next()}>
|
<button className={btn} onClick={() => rendRef.current?.next()}>
|
||||||
下一页
|
下一页
|
||||||
</button>
|
</button>
|
||||||
|
{bm.btn}
|
||||||
</div>
|
</div>
|
||||||
|
{bm.panel}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { btn } from "../components/ui";
|
import { btn } from "../components/ui";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
@@ -12,14 +13,22 @@ pdfjs.GlobalWorkerOptions.workerPort = new Worker(
|
|||||||
|
|
||||||
type RenderTask = { cancel(): void; promise: Promise<void> };
|
type RenderTask = { cancel(): void; promise: Promise<void> };
|
||||||
|
|
||||||
export default function PdfReader({ book, initialLocator }: ReaderProps) {
|
export default function PdfReader({ book, initialLocator, initialPercent }: ReaderProps) {
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
|
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
|
||||||
const docTaskRef = useRef<{ destroy(): Promise<void> } | null>(null);
|
const docTaskRef = useRef<{ destroy(): Promise<void> } | null>(null);
|
||||||
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);
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
|
const bm = useBookmarks({
|
||||||
|
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("");
|
||||||
@@ -130,7 +139,7 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={onKey}
|
onKeyDown={onKey}
|
||||||
aria-label="PDF 阅读器,可用左右方向键翻页"
|
aria-label="PDF 阅读器,可用左右方向键翻页"
|
||||||
className="grid h-full grid-rows-[1fr_auto] focus:outline-none"
|
className="relative 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">
|
<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" />
|
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
|
||||||
@@ -151,7 +160,9 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) {
|
|||||||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
|
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
|
||||||
末页
|
末页
|
||||||
</button>
|
</button>
|
||||||
|
{bm.btn}
|
||||||
</div>
|
</div>
|
||||||
|
{bm.panel}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import DOMPurify from "dompurify";
|
|||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
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 { 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";
|
||||||
@@ -130,9 +131,10 @@ export default function TextReader(props: ReaderProps) {
|
|||||||
return props.book.format === "md" ? <MdView {...props} /> : <TxtView {...props} />;
|
return props.book.format === "md" ? <MdView {...props} /> : <TxtView {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MdView({ book, initialLocator, chrome }: ReaderProps) {
|
function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
const saver = useProgressSaver(book.id);
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
|
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => seek(Number(l.scrollFraction) || 0) });
|
||||||
const restored = useRef(false);
|
const restored = useRef(false);
|
||||||
const pr = useReaderPrefs();
|
const pr = useReaderPrefs();
|
||||||
const [frac, setFrac] = useState(0);
|
const [frac, setFrac] = useState(0);
|
||||||
@@ -168,7 +170,7 @@ function MdView({ book, initialLocator, chrome }: ReaderProps) {
|
|||||||
if (err) return <LoadState text={err} onRetry={retry} />;
|
if (err) return <LoadState text={err} onRetry={retry} />;
|
||||||
if (text == null) return <LoadState text="正文加载中…" />;
|
if (text == null) return <LoadState text="正文加载中…" />;
|
||||||
return (
|
return (
|
||||||
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}>
|
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||||
<div
|
<div
|
||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
@@ -181,15 +183,16 @@ function MdView({ book, initialLocator, chrome }: ReaderProps) {
|
|||||||
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{chrome.on && <ReaderSheet pr={pr} frac={frac} onSeek={seek} />}
|
{chrome.on && <ReaderSheet pr={pr} frac={frac} onSeek={seek}>{bm.btn}</ReaderSheet>}
|
||||||
|
{bm.panel}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
||||||
function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
const saver = useProgressSaver(book.id);
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
const inited = useRef(false);
|
const inited = useRef(false);
|
||||||
const restoreFrac = useRef<number | null>(null);
|
const restoreFrac = useRef<number | null>(null);
|
||||||
const targetCi = useRef(0);
|
const targetCi = useRef(0);
|
||||||
@@ -199,6 +202,11 @@ function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
|||||||
const [toc, setToc] = useState(false);
|
const [toc, setToc] = useState(false);
|
||||||
const pr = useReaderPrefs();
|
const pr = useReaderPrefs();
|
||||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||||
|
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]);
|
||||||
|
|
||||||
@@ -279,6 +287,7 @@ function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
{chrome.on && (
|
{chrome.on && (
|
||||||
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
|
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
|
||||||
|
{bm.btn}
|
||||||
{chapters.length > 1 && (
|
{chapters.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -330,6 +339,7 @@ function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{bm.panel}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user