mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core: improve TOC anchor detection
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': patch
|
||||
---
|
||||
|
||||
improve TOC anchor detection
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -73,6 +73,8 @@ export interface GlobalConfig {
|
||||
|
||||
/**
|
||||
* Configure global MDX options
|
||||
*
|
||||
* @remarks `GlobalConfigMDXOptions`
|
||||
*/
|
||||
mdxOptions?: GlobalConfigMDXOptions | (() => Promise<GlobalConfigMDXOptions>);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user