From 81a492fc232687bf4715dad6c1d4638d179f4a25 Mon Sep 17 00:00:00 2001 From: ZeroZ_JQ Date: Thu, 17 Apr 2025 18:58:04 +0800 Subject: [PATCH 1/4] refactor: TOC scroll position logic --- web/app/(commonLayout)/datasets/Doc.tsx | 150 +++++++++++--- web/app/components/develop/doc.tsx | 262 ++++++++++++++++-------- 2 files changed, 289 insertions(+), 123 deletions(-) diff --git a/web/app/(commonLayout)/datasets/Doc.tsx b/web/app/(commonLayout)/datasets/Doc.tsx index 20264ce8ad..d49e50c800 100644 --- a/web/app/(commonLayout)/datasets/Doc.tsx +++ b/web/app/(commonLayout)/datasets/Doc.tsx @@ -12,57 +12,134 @@ import { LanguagesSupported } from '@/i18n/language' import useTheme from '@/hooks/use-theme' import { Theme } from '@/types/app' import cn from '@/utils/classnames' +import { throttle } from 'lodash-es' + +type TocItem = { + href: string; + text: string; + index: number; +} type DocProps = { apiBaseUrl: string } -const Doc = ({ apiBaseUrl }: DocProps) => { - const { locale } = useContext(I18n) - const { t } = useTranslation() - const [toc, setToc] = useState>([]) - const [isTocExpanded, setIsTocExpanded] = useState(false) - const { theme } = useTheme() +const useToc = (apiBaseUrl: string, locale: string) => { + const [toc, setToc] = useState([]) + const [headingElements, setHeadingElements] = useState([]) - // Set initial TOC expanded state based on screen width - useEffect(() => { - const mediaQuery = window.matchMedia('(min-width: 1280px)') - setIsTocExpanded(mediaQuery.matches) - }, []) - - // Extract TOC from article content useEffect(() => { const extractTOC = () => { const article = document.querySelector('article') if (article) { const headings = article.querySelectorAll('h2') - const tocItems = Array.from(headings).map((heading) => { - const anchor = heading.querySelector('a') - if (anchor) { - return { - href: anchor.getAttribute('href') || '', - text: anchor.textContent || '', - } - } - return null - }).filter((item): item is { href: string; text: string } => item !== null) + const headingElementsArray = Array.from(headings) as HTMLElement[] + setHeadingElements(headingElementsArray) + + const tocItems: TocItem[] = headingElementsArray.map((heading, index) => ({ + href: `#section-${index}`, + text: (heading.textContent || `章节 ${index + 1}`).trim(), + index, + })) + setToc(tocItems) } } - setTimeout(extractTOC, 0) - }, [locale]) + const timeoutId = setTimeout(extractTOC, 500) + return () => clearTimeout(timeoutId) + }, [locale, apiBaseUrl]) + + return { toc, headingElements } +} + +const useScrollPosition = (headingElements: HTMLElement[]) => { + const [activeIndex, setActiveIndex] = useState(null) + + useEffect(() => { + const scrollContainer = document.querySelector('.scroll-container') + if (!scrollContainer || headingElements.length === 0) return + + const handleScroll = () => { + const scrollContainerTop = scrollContainer.scrollTop + const scrollContainerHeight = scrollContainer.clientHeight + const scrollContainerBottom = scrollContainerTop + scrollContainerHeight + const totalScrollHeight = scrollContainer.scrollHeight + const offset = 110 + + let currentActiveIndex: number | null = null + + for (let i = headingElements.length - 1; i >= 0; i--) { + const heading = headingElements[i] + if (heading.offsetTop <= scrollContainerTop + offset) { + currentActiveIndex = i + break + } + } + + if (scrollContainerBottom >= totalScrollHeight - 20) { + currentActiveIndex = headingElements.length - 1 + } + else if (currentActiveIndex === null && headingElements.length > 0) { + const firstHeadingTop = headingElements[0].offsetTop + if (firstHeadingTop >= scrollContainerTop && firstHeadingTop < scrollContainerBottom) + currentActiveIndex = 0 + } + + if (currentActiveIndex !== activeIndex) + setActiveIndex(currentActiveIndex) + } + + const throttledScrollHandler = throttle(handleScroll, 100) + scrollContainer.addEventListener('scroll', throttledScrollHandler) + handleScroll() + + return () => { + scrollContainer.removeEventListener('scroll', throttledScrollHandler) + throttledScrollHandler.cancel() + } + }, [headingElements, activeIndex]) + + return activeIndex +} + +const useResponsiveToc = () => { + const [isTocExpanded, setIsTocExpanded] = useState(false) + + useEffect(() => { + const mediaQuery = window.matchMedia('(min-width: 1280px)') + setIsTocExpanded(mediaQuery.matches) + + const handleChange = (e: MediaQueryListEvent) => { + setIsTocExpanded(e.matches) + } + + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, []) + + return { isTocExpanded, setIsTocExpanded } +} + +const Doc = ({ apiBaseUrl }: DocProps) => { + const { locale } = useContext(I18n) + const { t } = useTranslation() + const { toc, headingElements } = useToc(apiBaseUrl, locale) + const { isTocExpanded, setIsTocExpanded } = useResponsiveToc() + const activeIndex = useScrollPosition(headingElements) + const { theme } = useTheme() // Handle TOC item click - const handleTocClick = (e: React.MouseEvent, item: { href: string; text: string }) => { + const handleTocClick = (e: React.MouseEvent, item: TocItem) => { e.preventDefault() - const targetId = item.href.replace('#', '') - const element = document.getElementById(targetId) - if (element) { + + const targetElement = headingElements[item.index] + + if (targetElement) { const scrollContainer = document.querySelector('.scroll-container') if (scrollContainer) { - const headerOffset = -40 - const elementTop = element.offsetTop - headerOffset + const headerOffset = 110 + const elementTop = targetElement.offsetTop - headerOffset scrollContainer.scrollTo({ top: elementTop, behavior: 'smooth', @@ -98,11 +175,16 @@ const Doc = ({ apiBaseUrl }: DocProps) => {