UNPKG

@redocly/theme

Version:

Shared UI components lib

111 lines (92 loc) 3.29 kB
import { useState, useEffect, useRef, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; export type UseActiveHeadingReturnType = string | undefined; type HeadingEntry = { [key: string]: IntersectionObserverEntry; }; export function useActiveHeading( contentElement: HTMLDivElement | null, displayedHeaders: Array<string | undefined>, ): UseActiveHeadingReturnType { const [heading, setHeading] = useState<string | undefined>( displayedHeaders.length > 1 ? displayedHeaders[0] : undefined, ); const [headingElements, setHeadingElements] = useState<HTMLElement[]>([]); const headingElementsRef = useRef<HeadingEntry>({}); const location = useLocation(); const getVisibleHeadings = () => { const visibleHeadings: IntersectionObserverEntry[] = []; for (const key in headingElementsRef.current) { const headingElement = headingElementsRef.current[key]; if (headingElement.isIntersecting) { visibleHeadings.push(headingElement); } } return visibleHeadings; }; const getIndexFromId = useCallback( (id: string) => { return headingElements.findIndex((item) => item.id === id); }, [headingElements], ); const findHeaders = (allContent: HTMLDivElement) => { const allHeaders = allContent.querySelectorAll<HTMLElement>('.heading-anchor'); return Array.from(allHeaders); }; const intersectionCallback = useCallback( (headings: IntersectionObserverEntry[]) => { headingElementsRef.current = headings.reduce( (map: HeadingEntry, headingElement: IntersectionObserverEntry) => { map[headingElement.target.id] = headingElement; return map; }, headingElementsRef.current, ); const totalHeight = window.scrollY + window.innerHeight; // handle bottom of the page if (totalHeight >= document.body.scrollHeight) { const newHeading = headingElements[headingElements?.length - 1]?.id || undefined; setHeading(newHeading); return; } const visibleHeadings = getVisibleHeadings(); if (!visibleHeadings.length) { return; } if (visibleHeadings.length === 1) { setHeading(visibleHeadings[0].target.id); return; } visibleHeadings.sort((a, b) => { return getIndexFromId(a.target.id) - getIndexFromId(b.target.id); }); setHeading(visibleHeadings[0].target.id); }, [getIndexFromId, headingElements], ); useEffect(() => { if (!contentElement) { return; } setHeadingElements(findHeaders(contentElement)); }, [contentElement, location]); useEffect(() => { if (!headingElements?.length) { return; } headingElementsRef.current = {}; // Bottom rootMargin -30% changes part of the view where IntersectionObserver starts to detect headers const observer = new IntersectionObserver(intersectionCallback, { rootMargin: '0px 0px -30% 0px', threshold: 1, }); headingElements?.forEach((element) => { if (displayedHeaders.includes(element.id)) { observer.observe(element); } }); return () => observer.disconnect(); }, [headingElements, displayedHeaders, intersectionCallback]); return heading; }