| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314 |
- "use client";
- import Link from "next/link";
- import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
- import { getBookEntries } from "@/lib/book-helpers";
- import { Book } from "@/types/book";
- type ReaderViewProps = {
- book: Book;
- chapterIndex: number;
- initialThemeKey?: string;
- initialFontKey?: string;
- initialWidthKey?: string;
- };
- const themeOptions = [
- { key: "warm", label: "暖米", page: "#f7f0e4", stage: "#d8d0c4", text: "#2b241e", catalog: "#e6ddd0" },
- { key: "mist", label: "浅灰", page: "#efede7", stage: "#d6d4cf", text: "#2a2723", catalog: "#dfddd7" },
- { key: "sepia", label: "茶褐", page: "#eee0cf", stage: "#d4c3b1", text: "#35281f", catalog: "#dfcdbb" }
- ] as const;
- const fontOptions = [
- { key: "sm", label: "小", size: "1.02rem", lineHeight: 2.05 },
- { key: "md", label: "中", size: "1.18rem", lineHeight: 2.28 },
- { key: "lg", label: "大", size: "1.34rem", lineHeight: 2.55 }
- ] as const;
- const widthOptions = [
- { key: "narrow", label: "窄", width: 760 },
- { key: "medium", label: "中", width: 920 },
- { key: "wide", label: "宽", width: 1080 }
- ] as const;
- export function ReaderView({
- book,
- chapterIndex,
- initialThemeKey,
- initialFontKey,
- initialWidthKey
- }: ReaderViewProps) {
- const entries = useMemo(() => getBookEntries(book), [book]);
- const entry = entries[chapterIndex];
- const prevChapterIndex = chapterIndex > 0 ? chapterIndex - 1 : null;
- const nextChapterIndex = chapterIndex < entries.length - 1 ? chapterIndex + 1 : null;
- const initialThemeIndex = Math.max(0, themeOptions.findIndex((item) => item.key === initialThemeKey));
- const initialFontIndex = Math.max(0, fontOptions.findIndex((item) => item.key === initialFontKey));
- const initialWidthIndex = Math.max(0, widthOptions.findIndex((item) => item.key === initialWidthKey));
- const [themeIndex, setThemeIndex] = useState(initialThemeKey ? initialThemeIndex : 0);
- const [fontIndex, setFontIndex] = useState(initialFontKey ? initialFontIndex : 1);
- const [widthIndex, setWidthIndex] = useState(initialWidthKey ? initialWidthIndex : 1);
- const [catalogOpen, setCatalogOpen] = useState(false);
- const [progress, setProgress] = useState(0);
- const stageRef = useRef<HTMLElement | null>(null);
- useEffect(() => {
- const pageColor = themeOptions[themeIndex].page;
- document.body.classList.add("reader-body");
- const previousBodyBackground = document.body.style.background;
- const previousHtmlBackground = document.documentElement.style.background;
- document.body.style.background = pageColor;
- document.documentElement.style.background = pageColor;
- return () => {
- document.body.classList.remove("reader-body");
- document.body.style.background = previousBodyBackground;
- document.documentElement.style.background = previousHtmlBackground;
- };
- }, [themeIndex]);
- useEffect(() => {
- const stage = stageRef.current;
- if (!stage) return;
- stage.scrollTo({ top: 0, behavior: "auto" });
- setCatalogOpen(false);
- const updateProgress = () => {
- const maxScroll = stage.scrollHeight - stage.clientHeight;
- const nextProgress = maxScroll <= 0 ? 0 : (stage.scrollTop / maxScroll) * 100;
- setProgress(nextProgress);
- };
- updateProgress();
- stage.addEventListener("scroll", updateProgress);
- window.addEventListener("resize", updateProgress);
- return () => {
- stage.removeEventListener("scroll", updateProgress);
- window.removeEventListener("resize", updateProgress);
- };
- }, [chapterIndex, widthIndex, fontIndex, themeIndex]);
- const currentTheme = themeOptions[themeIndex];
- const currentFont = fontOptions[fontIndex];
- const currentWidth = widthOptions[widthIndex];
- const entryWords = entry.content.join("").length;
- const isLoreEntry = entry.kind === "lore";
- const shellWidthStyle = useMemo(
- () =>
- ({
- width: "var(--reader-shell-width)"
- }) as CSSProperties,
- []
- );
- const cycleFont = () => setFontIndex((value) => (value + 1) % fontOptions.length);
- const cycleTheme = () => setThemeIndex((value) => (value + 1) % themeOptions.length);
- const cycleWidth = () => setWidthIndex((value) => (value + 1) % widthOptions.length);
- const catalogContent = (
- <>
- <div className="reader-catalog__header">
- <h2>目录</h2>
- <button type="button" className="reader-catalog__close" onClick={() => setCatalogOpen(false)}>
- 关闭
- </button>
- </div>
- <div className="reader-catalog-groups">
- {book.sections
- .slice()
- .sort((a, b) => a.order - b.order)
- .map((section) => {
- const sectionEntries = entries.filter((item) => item.sectionId === section.id);
- return (
- <section className="reader-catalog__section" key={section.id}>
- <div className="reader-catalog__section-title">
- <span className={`reader-catalog__kind reader-catalog__kind--${section.kind}`}>
- {section.kind === "lore" ? "资料" : "正文"}
- </span>
- <strong>{section.title}</strong>
- </div>
- <div className="reader-catalog__grid">
- {sectionEntries.map((item, index) => (
- <Link
- key={item.id}
- href={`/reader/${book.id}?entry=${item.flatIndex}`}
- className={`reader-catalog__item${item.flatIndex === chapterIndex ? " reader-catalog__item--active" : ""}`}
- onClick={() => setCatalogOpen(false)}
- >
- <span>
- {section.kind === "lore" ? `资料 ${index + 1}` : `第 ${index + 1} 章`}
- </span>
- <strong>{item.title}</strong>
- </Link>
- ))}
- </div>
- </section>
- );
- })}
- </div>
- </>
- );
- return (
- <main
- className="reader-stage"
- style={
- {
- background: currentTheme.stage,
- "--reader-page": currentTheme.page,
- "--reader-shell-width": `min(${currentWidth.width}px, calc(100vw - 280px))`
- } as CSSProperties
- }
- ref={stageRef}
- >
- <div className="reader-progress-rail" aria-hidden="true">
- <div className="reader-progress-rail__track">
- <div className="reader-progress-rail__fill" style={{ height: `${progress}%` }} />
- </div>
- </div>
- <div className="reader-mobile-bar reader-mobile-bar--top" style={{ background: currentTheme.page }}>
- <Link className="reader-mobile-bar__icon reader-mobile-bar__icon--back" href="/library" aria-label="返回书架">
- 返回
- </Link>
- <button className="reader-mobile-bar__icon" type="button" onClick={cycleTheme} aria-label="切换主题">
- 主题 {currentTheme.label}
- </button>
- <button className="reader-mobile-bar__icon" type="button" onClick={cycleFont} aria-label="切换字体">
- 字体 {currentFont.label}
- </button>
- </div>
- <div className="reader-mobile-bar reader-mobile-bar--bottom" style={{ background: currentTheme.page }}>
- <button className="reader-mobile-bar__action" type="button" onClick={() => setCatalogOpen(true)}>
- 目录
- </button>
- {prevChapterIndex !== null ? (
- <Link className="reader-mobile-bar__action" href={`/reader/${book.id}?entry=${prevChapterIndex}`}>
- 上一章
- </Link>
- ) : (
- <span className="reader-mobile-bar__action reader-mobile-bar__action--disabled">上一章</span>
- )}
- {nextChapterIndex !== null ? (
- <Link className="reader-mobile-bar__action" href={`/reader/${book.id}?entry=${nextChapterIndex}`}>
- 下一章
- </Link>
- ) : (
- <span className="reader-mobile-bar__action reader-mobile-bar__action--disabled">下一章</span>
- )}
- </div>
- <div className="reader-desktop-layout">
- <div className="reader-float reader-float--left" aria-label="阅读左侧工具">
- <Link className="reader-float__button" href="/library">
- <strong>返回</strong>
- <span>回到书架</span>
- </Link>
- <button className="reader-float__button" type="button" onClick={() => setCatalogOpen(true)}>
- <strong>目录</strong>
- <span>弹出目录</span>
- </button>
- </div>
- <section className="reader-qq-shell" style={shellWidthStyle}>
- <div className="reader-catalog-mobile-mask" hidden={!catalogOpen} onClick={() => setCatalogOpen(false)} />
- <div
- className={`reader-catalog-mobile-sheet${catalogOpen ? " is-open" : ""}`}
- style={{ background: currentTheme.catalog }}
- aria-hidden={!catalogOpen}
- >
- {catalogContent}
- </div>
- {catalogOpen ? (
- <div className="reader-catalog-inline" style={{ background: currentTheme.catalog }}>
- {catalogContent}
- </div>
- ) : null}
- <div
- className={`reader-qq-paper${catalogOpen ? " reader-qq-paper--desktop-hidden" : ""}`}
- style={{ background: currentTheme.page }}
- >
- <header className={`reader-qq-header${isLoreEntry ? " reader-qq-header--lore" : ""}`}>
- <div className="reader-entry-badges">
- <span className={`reader-entry-badge reader-entry-badge--${entry.kind}`}>
- {isLoreEntry ? "资料" : "正文"}
- </span>
- <span className="reader-entry-badge reader-entry-badge--section">{entry.sectionTitle}</span>
- </div>
- <h1>{entry.title}</h1>
- <div className="reader-qq-meta">
- <span>书名:{book.title}</span>
- <span>作者:{book.author}</span>
- <span>当前:{entry.sectionTitle}</span>
- <span>本节字数:{entryWords} 字</span>
- <span>总字数:{book.wordCount}</span>
- </div>
- </header>
- <article
- className={`reader-qq-content${isLoreEntry ? " reader-qq-content--lore" : ""}`}
- style={{ color: currentTheme.text }}
- >
- {entry.content.map((paragraph) => (
- <p key={paragraph} style={{ fontSize: currentFont.size, lineHeight: currentFont.lineHeight }}>
- {paragraph}
- </p>
- ))}
- </article>
- <footer className="reader-qq-footer">
- <button className="reader-qq-footer__ghost" type="button" onClick={() => setCatalogOpen(true)}>
- 目录
- </button>
- {prevChapterIndex !== null ? (
- <Link className="reader-qq-footer__button" href={`/reader/${book.id}?entry=${prevChapterIndex}`}>
- 上一章
- </Link>
- ) : (
- <span className="reader-qq-footer__button reader-qq-footer__button--disabled">已是第一节</span>
- )}
- {nextChapterIndex !== null ? (
- <Link className="reader-qq-footer__button" href={`/reader/${book.id}?entry=${nextChapterIndex}`}>
- 下一章
- </Link>
- ) : (
- <span className="reader-qq-footer__button reader-qq-footer__button--disabled">已是最后一节</span>
- )}
- </footer>
- </div>
- </section>
- <div className="reader-float reader-float--right" aria-label="阅读右侧工具">
- <button className="reader-float__button" type="button" onClick={cycleFont}>
- <strong>字号</strong>
- <span>当前{currentFont.label}</span>
- </button>
- <button className="reader-float__button" type="button" onClick={cycleTheme}>
- <strong>主题</strong>
- <span>当前{currentTheme.label}</span>
- </button>
- <button className="reader-float__button" type="button" onClick={cycleWidth}>
- <strong>版心</strong>
- <span>当前{currentWidth.label}</span>
- </button>
- </div>
- </div>
- </main>
- );
- }
|