Core: improve TOC anchor detection

This commit is contained in:
Fuma Nama
2025-10-29 18:17:43 +08:00
parent a0625a67e4
commit b95b0cfd42
7 changed files with 136 additions and 111 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
improve TOC anchor detection
+1 -5
View File
@@ -1,12 +1,8 @@
@import 'tailwindcss' source(none);
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@import 'fumadocs-twoslash/twoslash.css';
@import 'fumadocs-openapi/css/preset.css';
@source ".";
@source "../components";
@source "../content";
@plugin 'tailwindcss-animate';
@theme {
+1 -1
View File
@@ -42,7 +42,7 @@ export const logo = (
alt="Fumadocs"
src={Logo}
sizes="100px"
className="hidden w-20 md:w-24 [.uwu_&]:block"
className="hidden w-22 [.uwu_&]:block"
aria-label="Fumadocs"
/>
+123 -35
View File
@@ -1,10 +1,18 @@
'use client';
import type { AnchorHTMLAttributes, ReactNode, RefObject } from 'react';
import { createContext, forwardRef, useContext, useMemo, useRef } from 'react';
import {
type ComponentProps,
createContext,
type ReactNode,
type RefObject,
useContext,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import scrollIntoView from 'scroll-into-view-if-needed';
import { mergeRefs } from '@/utils/merge-refs';
import { useOnChange } from '@/utils/use-on-change';
import { useAnchorObserver } from './utils/use-anchor-observer';
export interface TOCItemType {
title: ReactNode;
@@ -24,7 +32,7 @@ const ScrollContext = createContext<RefObject<HTMLElement | null>>({
* The estimated active heading ID
*/
export function useActiveAnchor(): string | undefined {
return useContext(ActiveAnchorContext).at(-1);
return useContext(ActiveAnchorContext)[0];
}
/**
@@ -81,45 +89,125 @@ export function AnchorProvider({
);
}
export interface TOCItemProps
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
export interface TOCItemProps extends Omit<ComponentProps<'a'>, 'href'> {
href: string;
onActiveChange?: (v: boolean) => void;
}
export const TOCItem = forwardRef<HTMLAnchorElement, TOCItemProps>(
({ onActiveChange, ...props }, ref) => {
const containerRef = useContext(ScrollContext);
const anchors = useActiveAnchors();
const anchorRef = useRef<HTMLAnchorElement>(null);
const mergedRef = mergeRefs(anchorRef, ref);
export function TOCItem({
ref,
onActiveChange = () => null,
...props
}: TOCItemProps) {
const containerRef = useContext(ScrollContext);
const anchors = useActiveAnchors();
const anchorRef = useRef<HTMLAnchorElement>(null);
const activeOrder = anchors.indexOf(props.href.slice(1));
const isActive = activeOrder !== -1;
const shouldScroll = activeOrder === 0;
const onActiveChangeEvent = useEffectEvent(onActiveChange);
const isActive = anchors.includes(props.href.slice(1));
useEffect(() => {
if (shouldScroll && containerRef.current && anchorRef.current) {
scrollIntoView(anchorRef.current, {
behavior: 'smooth',
block: 'center',
inline: 'center',
scrollMode: 'always',
boundary: containerRef.current,
});
}
}, [containerRef, shouldScroll]);
useOnChange(isActive, (v) => {
const element = anchorRef.current;
if (!element) return;
useEffect(() => {
onActiveChangeEvent(isActive);
}, [isActive]);
if (v && containerRef.current) {
scrollIntoView(element, {
behavior: 'smooth',
block: 'center',
inline: 'center',
scrollMode: 'always',
boundary: containerRef.current,
});
return (
<a ref={mergeRefs(anchorRef, ref)} data-active={isActive} {...props}>
{props.children}
</a>
);
}
/**
* 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: string[], single: boolean): string[] {
const observerRef = useRef<IntersectionObserver>(null);
const [activeAnchor, setActiveAnchor] = useState<string[]>(() => []);
const stateRef = useRef<{
visible: Set<string>;
}>(null);
const onChange = useEffectEvent((entries: IntersectionObserverEntry[]) => {
stateRef.current ??= {
visible: 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[0].rootBounds!.top;
let fallback: Element | undefined;
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;
}
}
onActiveChange?.(v);
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: 0.98,
});
return (
<a ref={mergedRef} data-active={isActive} {...props}>
{props.children}
</a>
);
},
);
return () => {
observerRef.current?.disconnect();
observerRef.current = null;
};
}, []);
TOCItem.displayName = 'TOCItem';
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;
}
+4 -2
View File
@@ -1,11 +1,13 @@
import type * as React from 'react';
export function mergeRefs<T>(...refs: React.Ref<T>[]): React.RefCallback<T> {
export function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(value);
} else if (ref !== null) {
} else if (ref != null) {
ref.current = value;
}
});
@@ -1,68 +0,0 @@
import { useEffect, useState } from 'react';
/**
* 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
*/
export function useAnchorObserver(watch: string[], single: boolean): string[] {
const [activeAnchor, setActiveAnchor] = useState<string[]>([]);
useEffect(() => {
let visible: string[] = [];
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !visible.includes(entry.target.id)) {
visible = [...visible, entry.target.id];
} else if (
!entry.isIntersecting &&
visible.includes(entry.target.id)
) {
visible = visible.filter((v) => v !== entry.target.id);
}
}
if (visible.length > 0) setActiveAnchor(visible);
},
{
rootMargin: single ? '-80px 0% -70% 0%' : `-20px 0% -40% 0%`,
threshold: 1,
},
);
function onScroll(): void {
const element = document.scrollingElement;
if (!element) return;
const top = element.scrollTop;
if (top <= 0 && single) setActiveAnchor(watch.slice(0, 1));
else if (top + element.clientHeight >= element.scrollHeight - 6) {
setActiveAnchor((active) => {
return active.length > 0 && !single
? watch.slice(watch.indexOf(active[0]))
: watch.slice(-1);
});
}
}
for (const heading of watch) {
const element = document.getElementById(heading);
if (element) observer.observe(element);
}
onScroll();
window.addEventListener('scroll', onScroll);
return () => {
window.removeEventListener('scroll', onScroll);
observer.disconnect();
};
}, [single, watch]);
return single ? activeAnchor.slice(0, 1) : activeAnchor;
}
+2
View File
@@ -73,6 +73,8 @@ export interface GlobalConfig {
/**
* Configure global MDX options
*
* @remarks `GlobalConfigMDXOptions`
*/
mdxOptions?: GlobalConfigMDXOptions | (() => Promise<GlobalConfigMDXOptions>);