fumadocs-core
Version:
The React.js library for building a documentation website
138 lines (134 loc) • 4.22 kB
JavaScript
'use client';
import { createContext, useContext, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from "react";
import { jsx } from "react/jsx-runtime";
import scrollIntoView from "scroll-into-view-if-needed";
//#region src/utils/merge-refs.ts
function mergeRefs(...refs) {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === "function") ref(value);
else if (ref != null) ref.current = value;
});
};
}
//#endregion
//#region src/toc.tsx
const ActiveAnchorContext = createContext([]);
const ScrollContext = createContext({ current: null });
/**
* The estimated active heading ID
*/
function useActiveAnchor() {
return useContext(ActiveAnchorContext)[0];
}
/**
* The id of visible anchors
*/
function useActiveAnchors() {
return useContext(ActiveAnchorContext);
}
function ScrollProvider({ containerRef, children }) {
return /* @__PURE__ */ jsx(ScrollContext.Provider, {
value: containerRef,
children
});
}
function AnchorProvider({ toc, single = false, children }) {
const headings = useMemo(() => {
return toc.map((item) => item.url.split("#")[1]);
}, [toc]);
return /* @__PURE__ */ jsx(ActiveAnchorContext.Provider, {
value: useAnchorObserver(headings, single),
children
});
}
function TOCItem({ ref, onActiveChange = () => null, ...props }) {
const containerRef = useContext(ScrollContext);
const anchorRef = useRef(null);
const activeOrder = useActiveAnchors().indexOf(props.href.slice(1));
const isActive = activeOrder !== -1;
const shouldScroll = activeOrder === 0;
const onActiveChangeEvent = useEffectEvent(onActiveChange);
useLayoutEffect(() => {
const anchor = anchorRef.current;
const container = containerRef.current;
if (container && anchor && shouldScroll) scrollIntoView(anchor, {
behavior: "smooth",
block: "center",
inline: "center",
scrollMode: "always",
boundary: container
});
}, [containerRef, shouldScroll]);
useEffect(() => {
return () => onActiveChangeEvent(isActive);
}, [isActive]);
return /* @__PURE__ */ jsx("a", {
ref: mergeRefs(anchorRef, ref),
"data-active": isActive,
...props,
children: props.children
});
}
/**
* Find the active heading of page
*
* It selects the top heading by default, and the last item when reached the bottom of page.
*
* @param watch - An array of element ids to watch
* @param single - only one active item at most
* @returns Active anchor
*/
function useAnchorObserver(watch, single) {
const observerRef = useRef(null);
const [activeAnchor, setActiveAnchor] = useState(() => []);
const stateRef = useRef(null);
const onChange = useEffectEvent((entries) => {
stateRef.current ??= { visible: /* @__PURE__ */ new Set() };
const state = stateRef.current;
for (const entry of entries) if (entry.isIntersecting) state.visible.add(entry.target.id);
else state.visible.delete(entry.target.id);
if (state.visible.size === 0) {
const viewTop = entries.length > 0 ? entries[0]?.rootBounds?.top ?? 0 : 0;
let fallback;
let min = -1;
for (const id of watch) {
const element = document.getElementById(id);
if (!element) continue;
const d = Math.abs(viewTop - element.getBoundingClientRect().top);
if (min === -1 || d < min) {
fallback = element;
min = d;
}
}
setActiveAnchor(fallback ? [fallback.id] : []);
} else {
const items = watch.filter((item) => state.visible.has(item));
setActiveAnchor(single ? items.slice(0, 1) : items);
}
});
useEffect(() => {
if (observerRef.current) return;
observerRef.current = new IntersectionObserver(onChange, {
rootMargin: "0px",
threshold: .98
});
return () => {
observerRef.current?.disconnect();
observerRef.current = null;
};
}, []);
useEffect(() => {
const observer = observerRef.current;
if (!observer) return;
const elements = watch.flatMap((heading) => document.getElementById(heading) ?? []);
for (const element of elements) observer.observe(element);
return () => {
for (const element of elements) observer.unobserve(element);
};
}, [watch]);
return activeAnchor;
}
//#endregion
export { AnchorProvider, ScrollProvider, TOCItem, useActiveAnchor, useActiveAnchors };
//# sourceMappingURL=toc.js.map