返回 AiToEarn
useConfigSectionSpy.ts
根目录 / project / aitoearn-web / src / app / layout / ConfigManagerDialog / hooks / useConfigSectionSpy.ts
1 'use client'
2
3 import type { MutableRefObject } from 'react'
4 import type { ConfigSectionView } from '../types'
5 import { useCallback, useEffect, useRef, useState } from 'react'
6
7 function getSectionTop(container: HTMLElement, section: HTMLElement) {
8 const containerRect = container.getBoundingClientRect()
9 const sectionRect = section.getBoundingClientRect()
10 return sectionRect.top - containerRect.top + container.scrollTop
11 }
12
13 export function useConfigSectionSpy(
14 containerRef: MutableRefObject<HTMLDivElement | null>,
15 sections: ConfigSectionView[],
16 ) {
17 const [activeSectionId, setActiveSectionId] = useState(sections[0]?.id ?? '')
18 const rafRef = useRef<number | null>(null)
19 const lockTimeoutRef = useRef<number | null>(null)
20 const lockedSectionIdRef = useRef<string | null>(null)
21
22 useEffect(() => {
23 setActiveSectionId(sections[0]?.id ?? '')
24 }, [sections])
25
26 const updateActiveSection = useCallback(() => {
27 const container = containerRef.current
28 if (!container || lockedSectionIdRef.current)
29 return
30
31 const sectionElements = sections
32 .map(section => container.querySelector<HTMLElement>(`[data-config-section-id="${section.id}"]`))
33 .filter((element): element is HTMLElement => !!element)
34
35 if (sectionElements.length === 0)
36 return
37
38 const checkpoint = container.scrollTop + 32
39 const currentSection = sectionElements.reduce((current, element) => {
40 const top = getSectionTop(container, element)
41 if (top <= checkpoint)
42 return element
43 return current
44 }, sectionElements[0])
45
46 const sectionId = currentSection.dataset.configSectionId
47 if (sectionId)
48 setActiveSectionId(sectionId)
49 }, [containerRef, sections])
50
51 useEffect(() => {
52 const container = containerRef.current
53 if (!container)
54 return
55
56 const handleScroll = () => {
57 if (rafRef.current !== null)
58 cancelAnimationFrame(rafRef.current)
59 rafRef.current = requestAnimationFrame(updateActiveSection)
60 }
61
62 container.addEventListener('scroll', handleScroll, { passive: true })
63 updateActiveSection()
64
65 return () => {
66 container.removeEventListener('scroll', handleScroll)
67 if (rafRef.current !== null)
68 cancelAnimationFrame(rafRef.current)
69 if (lockTimeoutRef.current !== null)
70 window.clearTimeout(lockTimeoutRef.current)
71 }
72 }, [containerRef, updateActiveSection])
73
74 const scrollToSection = useCallback((sectionId: string) => {
75 const container = containerRef.current
76 const target = container?.querySelector<HTMLElement>(`[data-config-section-id="${sectionId}"]`)
77 if (!container || !target)
78 return
79
80 lockedSectionIdRef.current = sectionId
81 setActiveSectionId(sectionId)
82
83 if (lockTimeoutRef.current !== null)
84 window.clearTimeout(lockTimeoutRef.current)
85
86 container.scrollTo({
87 top: Math.max(0, getSectionTop(container, target) - 12),
88 behavior: 'smooth',
89 })
90
91 lockTimeoutRef.current = window.setTimeout(() => {
92 lockedSectionIdRef.current = null
93 updateActiveSection()
94 }, 550)
95 }, [containerRef, updateActiveSection])
96
97 return { activeSectionId, scrollToSection }
98 }
99
99 lines TYPESCRIPT