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