reader-view.tsx 10 KB

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