"use client"; import { useEffect, useState, useRef, useCallback } from "react"; import Link from "next/link"; import type { EbookData } from "@/lib/content/ebook"; import type { EbookConfig } from "@/lib/content/config"; import "github-markdown-css/github-markdown.css"; import styles from "./EbookViewer.module.css"; function LazyChunk({ html, estimatedHeight, forceVisible, }: { html: string; estimatedHeight: number; forceVisible: boolean; }) { const [visible, setVisible] = useState(forceVisible); const ref = useRef(null); const show = forceVisible || visible; useEffect(() => { if (forceVisible || visible) return; const el = ref.current; if (!el) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setVisible(true); observer.disconnect(); } }, { rootMargin: "1200px 0px" } ); observer.observe(el); return () => observer.disconnect(); }, [forceVisible, visible]); if (show) { return (
); } return (
); } type EbookViewerProps = { data: EbookData; config: EbookConfig; }; export function EbookViewer({ data, config }: EbookViewerProps) { const [showDrawer, setShowDrawer] = useState(false); const [readProgress, setReadProgress] = useState(0); const [showBackTop, setShowBackTop] = useState(false); const [forceShowAll, setForceShowAll] = useState(false); const [readingTimeLeft, setReadingTimeLeft] = useState(null); const contentRef = useRef(null); const { chunks, headers } = data; const estimatedMin = config.estimatedMinutes ?? null; useEffect(() => { let ticking = false; const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { const scrollTop = window.scrollY || document.documentElement.scrollTop; const docHeight = document.documentElement.scrollHeight - window.innerHeight; setReadProgress(docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0); setShowBackTop(scrollTop > 600); if (estimatedMin && docHeight > 0) { const remaining = Math.max(0, estimatedMin * (1 - scrollTop / docHeight)); setReadingTimeLeft(Math.ceil(remaining)); } ticking = false; }); }; window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, [estimatedMin]); const handleJump = useCallback((index: number) => { setForceShowAll(true); setShowDrawer(false); const run = () => { const root = contentRef.current; if (!root) return; const h1s = root.querySelectorAll("h1"); const h2s = root.querySelectorAll("h2"); const targets = h1s.length > 0 ? h1s : h2s; const el = targets[index] as HTMLElement; if (el) { try { el.scrollIntoView({ behavior: "smooth", block: "start" }); } catch { const top = el.getBoundingClientRect().top + (window.pageYOffset || document.documentElement.scrollTop) - 60; window.scrollTo({ top: Math.max(0, top), behavior: "smooth" }); } } }; run(); setTimeout(run, 100); setTimeout(run, 300); setTimeout(run, 600); }, []); return ( <>
{config.title} {readingTimeLeft !== null && readingTimeLeft > 0 && ( · 约 {readingTimeLeft} 分钟 )}
{chunks.map((chunk, i) => ( ))}
setShowDrawer(false)} aria-hidden="true" /> {showBackTop && ( )} ); }