49 lines
1.8 KiB
TypeScript
49 lines
1.8 KiB
TypeScript
import * as React from "react";
|
|
|
|
function removeFirstHash(str: string): string {
|
|
return str.replace(/^#/, "");
|
|
}
|
|
|
|
/**
|
|
* React hook to highlight a heading a user is currently reading.
|
|
* @param headingList List of links to the headings (ex. "#title")
|
|
* @param options Options for the Intersectionobserver (ex. rootMargin)
|
|
* @returns The Id/Link to the heading, that is currently active.
|
|
*/
|
|
export function useActiveHeading(headingList: string[], options?: IntersectionObserverInit) {
|
|
const [activeId, setActiveId] = React.useState("");
|
|
|
|
React.useEffect(() => {
|
|
const callback: IntersectionObserverCallback = (headingEntries) => {
|
|
// Get all headings that are currently visible on the page
|
|
const visibleHeadings = headingEntries.filter((e) => e.isIntersecting);
|
|
|
|
if (visibleHeadings.length === 0) {
|
|
//Necessary if a user scrolls down and then reloads.
|
|
//In that case the IntersectionObserver didn't see the Heading onscreen
|
|
const element = headingEntries.reverse().find((e) => e.boundingClientRect.bottom < 150);
|
|
if (element) {
|
|
setActiveId(`#${element.target.id}`);
|
|
}
|
|
} else {
|
|
// If there is more than one visible heading,
|
|
// choose the one that is closest to the top of the page
|
|
// the entries are always sorted top to bottom.
|
|
setActiveId(`#${visibleHeadings[0].target.id}`);
|
|
}
|
|
};
|
|
|
|
const observer = new IntersectionObserver(callback, options);
|
|
|
|
//Observe all (non-null) elements
|
|
headingList
|
|
.map((heading) => document?.querySelector(`[id='${removeFirstHash(heading)}']`))
|
|
//Remove null elments
|
|
.flatMap((f) => (!!f ? [f] : []))
|
|
.forEach((element) => observer.observe(element));
|
|
|
|
return () => observer.disconnect();
|
|
}, [headingList, options]);
|
|
return activeId;
|
|
}
|