diff --git a/package.json b/package.json index 7c9d978..7df9b38 100644 --- a/package.json +++ b/package.json @@ -80,5 +80,6 @@ "typescript": "^4.1.0", "webpack": "^5.78.0", "webpack-obfuscator": "^3.5.1" - } + }, + "packageManager": "pnpm@10.25.0+sha1.2cfb3ab644446565c127f58165cc76368c9c920b" } diff --git a/src/pages/book/index.scss b/src/pages/book/index.scss index f5d307c..206a12a 100644 --- a/src/pages/book/index.scss +++ b/src/pages/book/index.scss @@ -187,6 +187,11 @@ word-wrap: break-word; overflow-wrap: break-word; + /* 目录 scrollIntoView 时预留固定顶栏高度(与 .book_header_div 一致) */ + [data-chunk-index] { + scroll-margin-top: 88px; + } + h1 { font-size: 48px !important; font-weight: 800; @@ -574,6 +579,10 @@ max-width: 800PX; margin: 0 auto; + [data-chunk-index] { + scroll-margin-top: 56PX; + } + h1 { font-size: 28PX !important; margin: 36PX 0 14PX; diff --git a/src/pages/book/index.tsx b/src/pages/book/index.tsx index 5e7d301..2451ea8 100644 --- a/src/pages/book/index.tsx +++ b/src/pages/book/index.tsx @@ -3,7 +3,7 @@ import { View, Image } from "@tarojs/components"; import Taro, { useRouter } from "@tarojs/taro"; import "./index.scss"; import { AtButton, AtDrawer, AtIcon } from "taro-ui"; -import React, { useEffect, useState, useRef, useCallback } from "react"; +import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from "react"; import { chunks as infoChunks, headers as infoHeaders } from "./info.md"; import hackrobot from "../../images/hackrobot.jpg"; @@ -13,11 +13,15 @@ function getPb() { return PB ? new PB(PB_URL) : null; } -const LazyChunk = React.memo(({ html, estimatedHeight, forceVisible }) => { +const LazyChunk = React.memo(({ html, estimatedHeight, forceVisible, chunkIndex }) => { const [visible, setVisible] = useState(forceVisible); const ref = useRef(null); const show = forceVisible || visible; + useEffect(() => { + if (forceVisible) setVisible(true); + }, [forceVisible]); + useEffect(() => { if (forceVisible) return; if (visible) return; @@ -37,9 +41,23 @@ const LazyChunk = React.memo(({ html, estimatedHeight, forceVisible }) => { }, [forceVisible, visible]); if (show) { - return
; + const animate = !forceVisible; + return ( + + ); } - return ; + return ( + + ); }); const Book = () => { @@ -49,7 +67,11 @@ const Book = () => { const [paidType, setPaidType] = useState(null); const [userId, setUserId] = useState(Taro.getStorageSync("userId") || null); const [loading, setLoading] = useState(true); - const [forceShowAll, setForceShowAll] = useState(false); + /** 目录跳转时至少渲染到该索引的 chunk;null 表示仅首块 + 懒加载 */ + const [visibleUntilIndex, setVisibleUntilIndex] = useState(null); + /** 每次点击目录递增,驱动 useLayoutEffect 在 DOM 提交后滚动(不依赖 visibleUntilIndex 是否变化) */ + const [jumpSeq, setJumpSeq] = useState(0); + const pendingJumpIndexRef = useRef(null); const [readProgress, setReadProgress] = useState(0); const [showBackTop, setShowBackTop] = useState(false); const [bookData, setBookData] = useState(null); @@ -182,45 +204,94 @@ const Book = () => { if (e.target.tagName === "IMG") e.target.classList.add("loaded"); }; el.addEventListener("load", onImgLoad, true); + let raf = 0; + let pending = false; const checkCached = () => { + pending = false; el.querySelectorAll("img").forEach(img => { if (img.complete) img.classList.add("loaded"); }); }; - const mo = new MutationObserver(checkCached); + const scheduleCheck = () => { + if (pending) return; + pending = true; + raf = requestAnimationFrame(checkCached); + }; + const mo = new MutationObserver(() => scheduleCheck()); mo.observe(el, { childList: true, subtree: true }); - checkCached(); + scheduleCheck(); return () => { + cancelAnimationFrame(raf); el.removeEventListener("load", onImgLoad, true); mo.disconnect(); }; }, [contentLoading]); - const handleJump = useCallback((index) => { - setForceShowAll(true); - setshow(false); - - const run = () => { - const root = contentRef.current; - if (!root) return; - const h1s = root.querySelectorAll("h1"); - const el = h1s[index]; - 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); + const getMarkdownRoot = useCallback(() => { + const el = contentRef.current; + if (el && typeof el.querySelector === "function") return el; + if (typeof document !== "undefined") { + return document.querySelector(".Book .markdown-body"); + } + return null; }, []); + const scrollToChapterIndex = useCallback((index, attempt = 0) => { + const root = getMarkdownRoot(); + if (!root) return; + const el = root.querySelector(`[data-chunk-index="${index}"]`); + if (el?.classList.contains("chunk-placeholder") && attempt < 48) { + requestAnimationFrame(() => scrollToChapterIndex(index, attempt + 1)); + return; + } + const h1list = root.querySelectorAll("h1"); + const target = el || h1list[index]; + if (!target) return; + try { + target.scrollIntoView({ block: "start", behavior: "auto" }); + } catch (_) { + const rect = target.getBoundingClientRect(); + const st = window.pageYOffset || document.documentElement.scrollTop || 0; + const dest = Math.max(0, rect.top + st - 60); + try { + window.scrollTo({ top: dest, behavior: "auto" }); + } catch (__) { + window.scrollTo(0, dest); + } + } + }, [getMarkdownRoot]); + + const handleJump = useCallback((rawIndex) => { + const index = + typeof rawIndex === "number" && Number.isFinite(rawIndex) + ? rawIndex + : parseInt(String(rawIndex), 10); + if (!Number.isFinite(index) || index < 0) return; + pendingJumpIndexRef.current = index; + setVisibleUntilIndex(prev => Math.max(prev ?? -1, index)); + setshow(false); + setJumpSeq(s => s + 1); + }, []); + + useLayoutEffect(() => { + if (jumpSeq === 0) return; + const index = pendingJumpIndexRef.current; + if (index === null || index === undefined) return; + const run = () => scrollToChapterIndex(index); + run(); + const t1 = setTimeout(run, 50); + const t2 = setTimeout(run, 200); + const t3 = setTimeout(run, 450); + requestAnimationFrame(() => { + requestAnimationFrame(run); + }); + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; + }, [jumpSeq, scrollToChapterIndex]); + const submitPayForm = (xorpayUrl, xorpayParams) => { const form = document.createElement("form"); form.method = "POST"; @@ -301,16 +372,17 @@ const Book = () => {