reader-view.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. "use client";
  2. import Link from "next/link";
  3. import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
  4. import { getBookEntries } from "@/lib/book-helpers";
  5. import { Book } from "@/types/book";
  6. type ReaderViewProps = {
  7. book: Book;
  8. chapterIndex: number;
  9. initialThemeKey?: string;
  10. initialFontKey?: string;
  11. initialWidthKey?: string;
  12. };
  13. const themeOptions = [
  14. { key: "warm", label: "暖米", page: "#f7f0e4", stage: "#d8d0c4", text: "#2b241e", catalog: "#e6ddd0" },
  15. { key: "mist", label: "浅灰", page: "#efede7", stage: "#d6d4cf", text: "#2a2723", catalog: "#dfddd7" },
  16. { key: "sepia", label: "茶褐", page: "#eee0cf", stage: "#d4c3b1", text: "#35281f", catalog: "#dfcdbb" }
  17. ] as const;
  18. const fontOptions = [
  19. { key: "sm", label: "小", size: "1.02rem", lineHeight: 2.05 },
  20. { key: "md", label: "中", size: "1.18rem", lineHeight: 2.28 },
  21. { key: "lg", label: "大", size: "1.34rem", lineHeight: 2.55 }
  22. ] as const;
  23. const widthOptions = [
  24. { key: "narrow", label: "窄", width: 760 },
  25. { key: "medium", label: "中", width: 920 },
  26. { key: "wide", label: "宽", width: 1080 }
  27. ] as const;
  28. export function ReaderView({
  29. book,
  30. chapterIndex,
  31. initialThemeKey,
  32. initialFontKey,
  33. initialWidthKey
  34. }: ReaderViewProps) {
  35. const entries = useMemo(() => getBookEntries(book), [book]);
  36. const entry = entries[chapterIndex];
  37. const prevChapterIndex = chapterIndex > 0 ? chapterIndex - 1 : null;
  38. const nextChapterIndex = chapterIndex < entries.length - 1 ? chapterIndex + 1 : null;
  39. const initialThemeIndex = Math.max(0, themeOptions.findIndex((item) => item.key === initialThemeKey));
  40. const initialFontIndex = Math.max(0, fontOptions.findIndex((item) => item.key === initialFontKey));
  41. const initialWidthIndex = Math.max(0, widthOptions.findIndex((item) => item.key === initialWidthKey));
  42. const [themeIndex, setThemeIndex] = useState(initialThemeKey ? initialThemeIndex : 0);
  43. const [fontIndex, setFontIndex] = useState(initialFontKey ? initialFontIndex : 1);
  44. const [widthIndex, setWidthIndex] = useState(initialWidthKey ? initialWidthIndex : 1);
  45. const [catalogOpen, setCatalogOpen] = useState(false);
  46. const [progress, setProgress] = useState(0);
  47. const stageRef = useRef<HTMLElement | null>(null);
  48. useEffect(() => {
  49. const { page: pageColor } = themeOptions[themeIndex];
  50. document.body.classList.add("reader-body");
  51. const previousBodyBackground = document.body.style.background;
  52. const previousHtmlBackground = document.documentElement.style.background;
  53. document.body.style.background = pageColor;
  54. document.documentElement.style.background = pageColor;
  55. // Use a dedicated reader-only meta tag (separate from React-managed one)
  56. const READER_THEME_ID = "reader-theme-color";
  57. let metaTheme = document.getElementById(READER_THEME_ID) as HTMLMetaElement | null;
  58. if (!metaTheme) {
  59. metaTheme = document.createElement("meta");
  60. metaTheme.name = "theme-color";
  61. metaTheme.id = READER_THEME_ID;
  62. document.head.appendChild(metaTheme);
  63. }
  64. metaTheme.content = pageColor;
  65. return () => {
  66. document.body.classList.remove("reader-body");
  67. document.body.style.background = previousBodyBackground;
  68. document.documentElement.style.background = previousHtmlBackground;
  69. metaTheme?.remove();
  70. };
  71. }, [themeIndex]);
  72. useEffect(() => {
  73. const stage = stageRef.current;
  74. if (!stage) return;
  75. stage.scrollTo({ top: 0, behavior: "auto" });
  76. setCatalogOpen(false);
  77. const updateProgress = () => {
  78. const maxScroll = stage.scrollHeight - stage.clientHeight;
  79. const nextProgress = maxScroll <= 0 ? 0 : (stage.scrollTop / maxScroll) * 100;
  80. setProgress(nextProgress);
  81. };
  82. updateProgress();
  83. stage.addEventListener("scroll", updateProgress);
  84. window.addEventListener("resize", updateProgress);
  85. return () => {
  86. stage.removeEventListener("scroll", updateProgress);
  87. window.removeEventListener("resize", updateProgress);
  88. };
  89. }, [chapterIndex]);
  90. useEffect(() => {
  91. const stage = stageRef.current;
  92. if (!stage) return;
  93. const maxScroll = stage.scrollHeight - stage.clientHeight;
  94. setProgress(maxScroll <= 0 ? 0 : (stage.scrollTop / maxScroll) * 100);
  95. }, [fontIndex, widthIndex, themeIndex]);
  96. const currentTheme = themeOptions[themeIndex];
  97. const currentFont = fontOptions[fontIndex];
  98. const currentWidth = widthOptions[widthIndex];
  99. const entryWords = entry.content.join("").length;
  100. const isLoreEntry = entry.kind === "lore";
  101. const shellWidthStyle = useMemo(
  102. () =>
  103. ({
  104. width: "var(--reader-shell-width)"
  105. }) as CSSProperties,
  106. []
  107. );
  108. const cycleFont = () => setFontIndex((value) => (value + 1) % fontOptions.length);
  109. const cycleTheme = () => setThemeIndex((value) => (value + 1) % themeOptions.length);
  110. const cycleWidth = () => setWidthIndex((value) => (value + 1) % widthOptions.length);
  111. const catalogContent = (
  112. <>
  113. <div className="reader-catalog__header">
  114. <h2>目录</h2>
  115. <button type="button" className="reader-catalog__close" onClick={() => setCatalogOpen(false)}>
  116. 关闭
  117. </button>
  118. </div>
  119. <div className="reader-catalog-groups">
  120. {book.sections
  121. .slice()
  122. .sort((a, b) => a.order - b.order)
  123. .map((section) => {
  124. const sectionEntries = entries.filter((item) => item.sectionId === section.id);
  125. return (
  126. <section className="reader-catalog__section" key={section.id}>
  127. <div className="reader-catalog__section-title">
  128. <span className={`reader-catalog__kind reader-catalog__kind--${section.kind}`}>
  129. {section.kind === "lore" ? "资料" : "正文"}
  130. </span>
  131. <strong>{section.title}</strong>
  132. </div>
  133. <div className="reader-catalog__grid">
  134. {sectionEntries.map((item, index) => (
  135. <Link
  136. key={item.id}
  137. href={`/reader/${book.id}?entry=${item.flatIndex}`}
  138. className={`reader-catalog__item${item.flatIndex === chapterIndex ? " reader-catalog__item--active" : ""}`}
  139. onClick={() => setCatalogOpen(false)}
  140. >
  141. <span>
  142. {section.kind === "lore" ? `资料 ${index + 1}` : `第 ${index + 1} 章`}
  143. </span>
  144. <strong>{item.title}</strong>
  145. </Link>
  146. ))}
  147. </div>
  148. </section>
  149. );
  150. })}
  151. </div>
  152. </>
  153. );
  154. return (
  155. <main
  156. className="reader-stage"
  157. style={
  158. {
  159. background: currentTheme.stage,
  160. "--reader-page": currentTheme.page,
  161. "--reader-stage-color": currentTheme.stage,
  162. "--reader-shell-width": `min(${currentWidth.width}px, calc(100vw - 280px))`
  163. } as CSSProperties
  164. }
  165. ref={stageRef}
  166. >
  167. <div className="reader-progress-rail" aria-hidden="true">
  168. <div className="reader-progress-rail__track">
  169. <div className="reader-progress-rail__fill" style={{ height: `${progress}%` }} />
  170. </div>
  171. </div>
  172. <div className="reader-mobile-bar reader-mobile-bar--top">
  173. <Link className="reader-mobile-bar__icon reader-mobile-bar__icon--back" href="/library" aria-label="返回书架">
  174. 返回
  175. </Link>
  176. <button className="reader-mobile-bar__icon" type="button" onClick={cycleTheme} aria-label="切换主题">
  177. 主题 {currentTheme.label}
  178. </button>
  179. <button className="reader-mobile-bar__icon" type="button" onClick={cycleFont} aria-label="切换字体">
  180. 字体 {currentFont.label}
  181. </button>
  182. </div>
  183. <div className="reader-mobile-bar reader-mobile-bar--bottom">
  184. <button className="reader-mobile-bar__action" type="button" onClick={() => setCatalogOpen(true)}>
  185. 目录
  186. </button>
  187. {prevChapterIndex !== null ? (
  188. <Link className="reader-mobile-bar__action" href={`/reader/${book.id}?entry=${prevChapterIndex}`}>
  189. 上一章
  190. </Link>
  191. ) : (
  192. <span className="reader-mobile-bar__action reader-mobile-bar__action--disabled">上一章</span>
  193. )}
  194. {nextChapterIndex !== null ? (
  195. <Link className="reader-mobile-bar__action" href={`/reader/${book.id}?entry=${nextChapterIndex}`}>
  196. 下一章
  197. </Link>
  198. ) : (
  199. <span className="reader-mobile-bar__action reader-mobile-bar__action--disabled">下一章</span>
  200. )}
  201. </div>
  202. <div className="reader-desktop-layout">
  203. <div className="reader-float reader-float--left" aria-label="阅读左侧工具">
  204. <Link className="reader-float__button" href="/library">
  205. <strong>返回</strong>
  206. <span>回到书架</span>
  207. </Link>
  208. <button className="reader-float__button" type="button" onClick={() => setCatalogOpen(true)}>
  209. <strong>目录</strong>
  210. <span>弹出目录</span>
  211. </button>
  212. </div>
  213. <section className="reader-qq-shell" style={shellWidthStyle}>
  214. <div className="reader-catalog-mobile-mask" hidden={!catalogOpen} onClick={() => setCatalogOpen(false)} />
  215. <div
  216. className={`reader-catalog-mobile-sheet${catalogOpen ? " is-open" : ""}`}
  217. style={{ background: currentTheme.catalog }}
  218. aria-hidden={!catalogOpen}
  219. >
  220. {catalogContent}
  221. </div>
  222. {catalogOpen ? (
  223. <div className="reader-catalog-inline" style={{ background: currentTheme.catalog }}>
  224. {catalogContent}
  225. </div>
  226. ) : null}
  227. <div
  228. className={`reader-qq-paper${catalogOpen ? " reader-qq-paper--desktop-hidden" : ""}`}
  229. style={{ background: currentTheme.page }}
  230. >
  231. <header className={`reader-qq-header${isLoreEntry ? " reader-qq-header--lore" : ""}`}>
  232. <div className="reader-entry-badges">
  233. <span className={`reader-entry-badge reader-entry-badge--${entry.kind}`}>
  234. {isLoreEntry ? "资料" : "正文"}
  235. </span>
  236. <span className="reader-entry-badge reader-entry-badge--section">{entry.sectionTitle}</span>
  237. </div>
  238. <h1>{entry.title}</h1>
  239. <div className="reader-qq-meta">
  240. <span>书名:{book.title}</span>
  241. <span>作者:{book.author}</span>
  242. <span>当前:{entry.sectionTitle}</span>
  243. <span>本节字数:{entryWords} 字</span>
  244. <span>总字数:{book.wordCount}</span>
  245. </div>
  246. </header>
  247. <article
  248. className={`reader-qq-content${isLoreEntry ? " reader-qq-content--lore" : ""}`}
  249. style={{ color: currentTheme.text }}
  250. >
  251. {entry.content.map((paragraph) => (
  252. <p key={paragraph} style={{ fontSize: currentFont.size, lineHeight: currentFont.lineHeight }}>
  253. {paragraph}
  254. </p>
  255. ))}
  256. </article>
  257. <footer className="reader-qq-footer">
  258. <button className="reader-qq-footer__ghost" type="button" onClick={() => setCatalogOpen(true)}>
  259. 目录
  260. </button>
  261. {prevChapterIndex !== null ? (
  262. <Link className="reader-qq-footer__button" href={`/reader/${book.id}?entry=${prevChapterIndex}`}>
  263. 上一章
  264. </Link>
  265. ) : (
  266. <span className="reader-qq-footer__button reader-qq-footer__button--disabled">已是第一节</span>
  267. )}
  268. {nextChapterIndex !== null ? (
  269. <Link className="reader-qq-footer__button" href={`/reader/${book.id}?entry=${nextChapterIndex}`}>
  270. 下一章
  271. </Link>
  272. ) : (
  273. <span className="reader-qq-footer__button reader-qq-footer__button--disabled">已是最后一节</span>
  274. )}
  275. </footer>
  276. </div>
  277. </section>
  278. <div className="reader-float reader-float--right" aria-label="阅读右侧工具">
  279. <button className="reader-float__button" type="button" onClick={cycleFont}>
  280. <strong>字号</strong>
  281. <span>当前{currentFont.label}</span>
  282. </button>
  283. <button className="reader-float__button" type="button" onClick={cycleTheme}>
  284. <strong>主题</strong>
  285. <span>当前{currentTheme.label}</span>
  286. </button>
  287. <button className="reader-float__button" type="button" onClick={cycleWidth}>
  288. <strong>版心</strong>
  289. <span>当前{currentWidth.label}</span>
  290. </button>
  291. </div>
  292. </div>
  293. </main>
  294. );
  295. }