| 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 |