UNPKG

@redocly/theme

Version:

Shared UI components lib

282 lines (225 loc) 8.57 kB
import { useState, useEffect, useRef, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { enhancedSmoothstep } from '../utils'; type HeadingEntry = { [key: string]: IntersectionObserverEntry; }; export function useActiveHeading( contentElement: HTMLDivElement | null, displayedHeadings?: Array<{ id: string; depth: number } | null>, ): { heading: string | undefined; handleHeadingClick: (headingId: string) => void } { const [heading, setHeading] = useState<string | undefined>(undefined); const [headingElements, setHeadingElements] = useState<HTMLElement[]>([]); const headingElementsRef = useRef<HeadingEntry>({}); const displayedHeadingsRef = useRef(displayedHeadings); const lockObserver = useRef<boolean>(false); const location = useLocation(); useEffect(() => { setHeading(undefined); headingElementsRef.current = {}; }, [location.pathname]); useEffect(() => { displayedHeadingsRef.current = displayedHeadings; }, [displayedHeadings]); 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 isTopOfPage = () => window.scrollY <= 50; // Returns viewport ratio for intersection observer based on screen height const getViewportRatio = () => { const vh = window.innerHeight; if (vh >= 1400) return 0.4; if (vh >= 1000) return 0.3; return 0.25; }; const getIndexFromId = useCallback( (id: string) => { return headingElements.findIndex((item) => item.id === id); }, [headingElements], ); const findHeaders = (allContent: HTMLDivElement) => { const allHeaders = allContent.querySelectorAll<HTMLElement>('.heading-anchor'); const headers = Array.from(allHeaders); if (displayedHeadingsRef.current && displayedHeadingsRef.current.length > 0) { const displayedIds = displayedHeadingsRef.current.map((h) => h?.id).filter(Boolean); return headers.filter((header) => displayedIds.includes(header.id)); } return headers; }; const calculateVirtualPositions = useCallback((headers: HTMLElement[]) => { if (!headers.length) return {}; const navbarHeight = document.querySelector('nav')?.getBoundingClientRect().height || 0; const viewportHeight = window.innerHeight; const triggerOffset = navbarHeight + viewportHeight * 0.25; const lastHeader = headers[headers.length - 1]; const lastHeaderTop = lastHeader.offsetTop; const maxScrollableHeight = document.body.scrollHeight - viewportHeight; const requiredUplift = Math.max(0, lastHeaderTop - maxScrollableHeight + triggerOffset); const virtualPositions: Record<string, number> = {}; for (let i = 0; i < headers.length; i++) { const header = headers[i]; const headerTop = header.offsetTop; const normalizedPosition = headers.length === 1 ? 0 : i / (headers.length - 1); const adjustmentFactor = enhancedSmoothstep(normalizedPosition, 0.4); const uplift = requiredUplift * adjustmentFactor; const virtualTop = headerTop - uplift; virtualPositions[header.id] = virtualTop; } return virtualPositions; }, []); const getTriggerOffset = useCallback(() => { const navbarHeight = document.querySelector('nav')?.getBoundingClientRect().height || 0; return navbarHeight + window.innerHeight * getViewportRatio(); }, []); const findActiveHeaderByVirtualPosition = useCallback( (headers: HTMLElement[], scrollY: number, triggerOffset: number) => { const virtualPositions = calculateVirtualPositions(headers); let activeHeader = headers[0]; for (const header of headers) { const virtualTop = virtualPositions[header.id] || header.offsetTop; if (virtualTop <= scrollY + triggerOffset) { activeHeader = header; } else { break; } } return activeHeader; }, [calculateVirtualPositions], ); const intersectionCallback = useCallback( (entries: IntersectionObserverEntry[]) => { if (lockObserver.current) { return; } headingElementsRef.current = entries.reduce( (map: HeadingEntry, entry: IntersectionObserverEntry) => { map[entry.target.id] = entry; return map; }, headingElementsRef.current, ); if (isTopOfPage()) { const newHeading = headingElements[0]?.id || undefined; setHeading(newHeading); return; } const visibleHeadings = getVisibleHeadings(); if (!visibleHeadings.length) { const triggerOffset = getTriggerOffset(); if (isTopOfPage()) { setHeading(headingElements[0]?.id || undefined); return; } const totalHeight = window.scrollY + window.innerHeight; if (totalHeight >= document.body.scrollHeight - 10) { setHeading(headingElements[headingElements.length - 1]?.id || undefined); return; } const activeHeader = findActiveHeaderByVirtualPosition( headingElements, window.scrollY, triggerOffset, ); setHeading(activeHeader.id); return; } if (visibleHeadings.length === 1) { setHeading(visibleHeadings[0].target.id); return; } visibleHeadings.sort((a, b) => { return getIndexFromId(a.target.id) - getIndexFromId(b.target.id); }); const firstVisibleHeading = visibleHeadings[0]; if (getIndexFromId(firstVisibleHeading.target.id) === 0 && isTopOfPage()) { setHeading(firstVisibleHeading.target.id); return; } const totalHeight = scrollY + window.innerHeight; if (totalHeight >= document.body.scrollHeight - 10) { setHeading(visibleHeadings[visibleHeadings.length - 1].target.id); return; } setHeading(visibleHeadings[0].target.id); }, [getIndexFromId, headingElements, getTriggerOffset, findActiveHeaderByVirtualPosition], ); const handleHeadingClick = useCallback( (headingId: string) => { lockObserver.current = true; setHeading(headingId); const headingElement = headingElements.find((el) => el.id === headingId); if (headingElement && headingElement.scrollIntoView) { headingElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); } setTimeout(() => { headingElementsRef.current = {}; lockObserver.current = false; }, 300); }, [headingElements], ); useEffect(() => { if (!contentElement) { return; } const headers = findHeaders(contentElement); setHeadingElements(headers); if (headers.length > 0 && !heading) { if (isTopOfPage()) { setHeading(headers[0].id); } else { const totalHeight = scrollY + window.innerHeight; if (totalHeight >= document.body.scrollHeight - 10) { setHeading(headers[headers.length - 1]?.id || undefined); } else { const triggerOffset = getTriggerOffset(); const activeHeader = findActiveHeaderByVirtualPosition(headers, scrollY, triggerOffset); setHeading(activeHeader.id); } } } }, [ contentElement, location, heading, calculateVirtualPositions, getTriggerOffset, findActiveHeaderByVirtualPosition, ]); useEffect(() => { if (!headingElements?.length) { return; } headingElementsRef.current = {}; const triggerOffset = getTriggerOffset(); const observer = new IntersectionObserver(intersectionCallback, { rootMargin: `-${triggerOffset}px 0px -${window.innerHeight * getViewportRatio()}px 0px`, threshold: [0, 0.1, 0.3, 0.5, 0.7, 1], }); headingElements.forEach((element) => { observer.observe(element); }); const handleScroll = () => { if (lockObserver.current) return; if (isTopOfPage()) { setHeading(headingElements[0]?.id || undefined); } }; window.addEventListener('scroll', handleScroll, { passive: true }); return () => { observer.disconnect(); window.removeEventListener('scroll', handleScroll); }; }, [headingElements, intersectionCallback, getTriggerOffset]); return { heading, handleHeadingClick }; }