@redocly/theme
Version:
Shared UI components lib
111 lines (92 loc) • 3.29 kB
text/typescript
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;
}