SectionProvider.jsx

  1import {
  2  createContext,
  3  useContext,
  4  useEffect,
  5  useLayoutEffect,
  6  useState,
  7} from 'react'
  8import { createStore, useStore } from 'zustand'
  9
 10import { remToPx } from '@/lib/remToPx'
 11
 12function createSectionStore(sections) {
 13  return createStore((set) => ({
 14    sections,
 15    visibleSections: [],
 16    setVisibleSections: (visibleSections) =>
 17      set((state) =>
 18        state.visibleSections.join() === visibleSections.join()
 19          ? {}
 20          : { visibleSections }
 21      ),
 22    registerHeading: ({ id, ref, offsetRem }) =>
 23      set((state) => {
 24        return {
 25          sections: state.sections.map((section) => {
 26            if (section.id === id) {
 27              return {
 28                ...section,
 29                headingRef: ref,
 30                offsetRem,
 31              }
 32            }
 33            return section
 34          }),
 35        }
 36      }),
 37  }))
 38}
 39
 40function useVisibleSections(sectionStore) {
 41  let setVisibleSections = useStore(sectionStore, (s) => s.setVisibleSections)
 42  let sections = useStore(sectionStore, (s) => s.sections)
 43
 44  useEffect(() => {
 45    function checkVisibleSections() {
 46      let { innerHeight, scrollY } = window
 47      let newVisibleSections = []
 48
 49      for (
 50        let sectionIndex = 0;
 51        sectionIndex < sections.length;
 52        sectionIndex++
 53      ) {
 54        let { id, headingRef, offsetRem } = sections[sectionIndex]
 55        let offset = remToPx(offsetRem)
 56        let top = headingRef.current.getBoundingClientRect().top + scrollY
 57
 58        if (sectionIndex === 0 && top - offset > scrollY) {
 59          newVisibleSections.push('_top')
 60        }
 61
 62        let nextSection = sections[sectionIndex + 1]
 63        let bottom =
 64          (nextSection?.headingRef.current.getBoundingClientRect().top ??
 65            Infinity) +
 66          scrollY -
 67          remToPx(nextSection?.offsetRem ?? 0)
 68
 69        if (
 70          (top > scrollY && top < scrollY + innerHeight) ||
 71          (bottom > scrollY && bottom < scrollY + innerHeight) ||
 72          (top <= scrollY && bottom >= scrollY + innerHeight)
 73        ) {
 74          newVisibleSections.push(id)
 75        }
 76      }
 77
 78      setVisibleSections(newVisibleSections)
 79    }
 80
 81    let raf = window.requestAnimationFrame(() => checkVisibleSections())
 82    window.addEventListener('scroll', checkVisibleSections, { passive: true })
 83    window.addEventListener('resize', checkVisibleSections)
 84
 85    return () => {
 86      window.cancelAnimationFrame(raf)
 87      window.removeEventListener('scroll', checkVisibleSections)
 88      window.removeEventListener('resize', checkVisibleSections)
 89    }
 90  }, [setVisibleSections, sections])
 91}
 92
 93const SectionStoreContext = createContext()
 94
 95const useIsomorphicLayoutEffect =
 96  typeof window === 'undefined' ? useEffect : useLayoutEffect
 97
 98export function SectionProvider({ sections, children }) {
 99  let [sectionStore] = useState(() => createSectionStore(sections))
100
101  useVisibleSections(sectionStore)
102
103  useIsomorphicLayoutEffect(() => {
104    sectionStore.setState({ sections })
105  }, [sectionStore, sections])
106
107  return (
108    <SectionStoreContext.Provider value={sectionStore}>
109      {children}
110    </SectionStoreContext.Provider>
111  )
112}
113
114export function useSectionStore(selector) {
115  let store = useContext(SectionStoreContext)
116  return useStore(store, selector)
117}