mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Improve TOC detection logic
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'fumadocs-ui': patch
|
||||
'@fumadocs/base-ui': patch
|
||||
'fumadocs-core': patch
|
||||
---
|
||||
|
||||
Improve TOC detection logic
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import * as Primitive from 'fumadocs-core/toc';
|
||||
import { type ComponentProps, useEffect, useRef, useState } from 'react';
|
||||
import { type ComponentProps, useEffect, useEffectEvent, useRef, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { TocThumb, useTOCItems } from '.';
|
||||
import { mergeRefs } from '@/utils/merge-refs';
|
||||
@@ -16,63 +16,58 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const items = useTOCItems();
|
||||
const { text } = useI18n();
|
||||
const svgRef = useRef<ComputedSVG | null>(null);
|
||||
const [svg, setSvg] = useState<ComputedSVG>();
|
||||
|
||||
const onResize = useEffectEvent(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || container.clientHeight === 0) return;
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let b0 = 0;
|
||||
let d = '';
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const element: HTMLElement | null = container.querySelector(
|
||||
`a[href="#${items[i].url.slice(1)}"]`,
|
||||
);
|
||||
if (!element) continue;
|
||||
|
||||
const styles = getComputedStyle(element);
|
||||
const offset = getLineOffset(items[i].depth) + 1,
|
||||
top = element.offsetTop + parseFloat(styles.paddingTop),
|
||||
bottom = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom);
|
||||
|
||||
w = Math.max(offset, w);
|
||||
h = Math.max(h, bottom);
|
||||
|
||||
if (i === 0) {
|
||||
d += ` M${offset} ${top} L${offset} ${bottom}`;
|
||||
} else {
|
||||
const pOffset = getLineOffset(items[i - 1].depth) + 1;
|
||||
d += ` C ${pOffset} ${top - 4} ${offset} ${b0! + 4} ${offset} ${top} L${offset} ${bottom}`;
|
||||
}
|
||||
|
||||
b0 = bottom;
|
||||
}
|
||||
|
||||
w += 1;
|
||||
setSvg({
|
||||
d,
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current;
|
||||
|
||||
function onResize(): void {
|
||||
if (container.clientHeight === 0) return;
|
||||
let w = 0,
|
||||
h = 0,
|
||||
b1 = 0,
|
||||
d = '';
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const element: HTMLElement | null = container.querySelector(
|
||||
`a[href="#${items[i].url.slice(1)}"]`,
|
||||
);
|
||||
if (!element) continue;
|
||||
|
||||
const styles = getComputedStyle(element);
|
||||
const offset = getLineOffset(items[i].depth) + 1,
|
||||
t2 = element.offsetTop + parseFloat(styles.paddingTop),
|
||||
b2 = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom);
|
||||
|
||||
w = Math.max(offset, w);
|
||||
h = Math.max(h, b2);
|
||||
|
||||
if (i === 0) {
|
||||
d += ` M${offset} ${t2} L${offset} ${b2}`;
|
||||
} else {
|
||||
const pOffset = getLineOffset(items[i - 1].depth) + 1;
|
||||
d += ` C ${pOffset} ${t2 - 4} ${offset} ${b1! + 4} ${offset} ${t2} L${offset} ${b2}`;
|
||||
}
|
||||
|
||||
b1 = b2;
|
||||
}
|
||||
|
||||
w += 1;
|
||||
if (!svgRef.current || svgRef.current.d !== d) {
|
||||
svgRef.current = {
|
||||
d,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
setSvg(svgRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(onResize);
|
||||
onResize();
|
||||
|
||||
observer.observe(container);
|
||||
observer.observe(containerRef.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [items]);
|
||||
}, []);
|
||||
|
||||
if (items.length === 0)
|
||||
return (
|
||||
@@ -122,17 +117,33 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
|
||||
}
|
||||
|
||||
function ThumbBox() {
|
||||
const items = useTOCItems();
|
||||
const ids = Primitive.useActiveAnchors();
|
||||
const itemInfos = Primitive.useItems();
|
||||
const startIdx = itemInfos.findIndex((info) => info.active);
|
||||
const endIdx = itemInfos.findLastIndex((info) => info.active);
|
||||
if (startIdx === -1) return;
|
||||
|
||||
const item = items.findLast((item) => ids.includes(item.url.slice(1)));
|
||||
if (!item) return;
|
||||
let isStart: boolean;
|
||||
if (startIdx === endIdx) {
|
||||
let lastInactiveIdx = -1;
|
||||
for (let i = 0; i < itemInfos.length; i++) {
|
||||
const item = itemInfos[i];
|
||||
if (item.active) continue;
|
||||
if (lastInactiveIdx === -1 || itemInfos[lastInactiveIdx].t < item.t) {
|
||||
lastInactiveIdx = i;
|
||||
}
|
||||
}
|
||||
isStart = startIdx < lastInactiveIdx;
|
||||
} else {
|
||||
isStart = itemInfos[startIdx].t > itemInfos[endIdx].t;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute size-1 bg-fd-primary rounded-full transition-transform"
|
||||
style={{
|
||||
translate: `calc(${getLineOffset(item.depth)}px - 1.25px) calc(var(--fd-top) + var(--fd-height))`,
|
||||
translate: `calc(${getLineOffset(itemInfos[isStart ? startIdx : endIdx].original.depth)}px - 1.25px) ${
|
||||
isStart ? 'var(--fd-top)' : 'calc(var(--fd-top) + var(--fd-height))'
|
||||
}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,21 +19,23 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={mergeRefs(ref, containerRef)}
|
||||
className={cn('relative flex flex-col border-s border-fd-foreground/10', className)}
|
||||
{...props}
|
||||
>
|
||||
<TocThumb
|
||||
containerRef={containerRef}
|
||||
className="absolute top-(--fd-top) inset-s-0 w-0.5 h-(--fd-height) rounded-e-full bg-fd-primary transition-[top,height]"
|
||||
className="absolute inset-y-0 inset-s-0 bg-fd-primary w-px transition-[clip-path]"
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(0 var(--fd-top), 100% var(--fd-top), 100% calc(var(--fd-top) + var(--fd-height)), 0 calc(var(--fd-top) + var(--fd-height)))',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={mergeRefs(ref, containerRef)}
|
||||
className={cn('flex flex-col border-s border-fd-foreground/10', className)}
|
||||
{...props}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TOCItem key={item.url} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
{items.map((item) => (
|
||||
<TOCItem key={item.url} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useTOCItems(): Primitive.TOCItemType[] {
|
||||
|
||||
export type TOCProviderProps = Primitive.AnchorProviderProps;
|
||||
|
||||
export const { useActiveAnchor, useActiveAnchors } = Primitive;
|
||||
export const { useActiveAnchor, useActiveAnchors, useItems } = Primitive;
|
||||
|
||||
export function TOCProvider({ toc, children, ...props }: TOCProviderProps) {
|
||||
return (
|
||||
|
||||
@@ -175,14 +175,10 @@ function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>)
|
||||
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
|
||||
const { text } = useI18n();
|
||||
const { open } = use(TocPopoverContext)!;
|
||||
const items = Base.useTOCItems();
|
||||
const active = Base.useActiveAnchor();
|
||||
const selected = useMemo(
|
||||
() => items.findIndex((item) => active === item.url.slice(1)),
|
||||
[items, active],
|
||||
);
|
||||
const items = Base.useItems();
|
||||
const selectedIdx = items.findIndex((item) => item.active);
|
||||
const path = useTreePath().at(-1);
|
||||
const showItem = selected !== -1 && !open;
|
||||
const showItem = selectedIdx !== -1 && !open;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
@@ -194,7 +190,7 @@ function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>
|
||||
{...props}
|
||||
>
|
||||
<ProgressCircle
|
||||
value={(selected + 1) / Math.max(1, items.length)}
|
||||
value={(selectedIdx + 1) / Math.max(1, items.length)}
|
||||
max={1}
|
||||
className={cn('shrink-0', open && 'text-fd-primary')}
|
||||
/>
|
||||
@@ -214,7 +210,7 @@ function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>
|
||||
!showItem && 'opacity-0 translate-y-full pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{items[selected]?.title}
|
||||
{items[selectedIdx]?.original.title}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useI18n } from '@/contexts/i18n';
|
||||
import { useTreePath } from '@/contexts/tree';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useActiveAnchor } from 'fumadocs-core/toc';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
type ComponentProps,
|
||||
@@ -133,12 +132,8 @@ function PageTOCPopoverPhysical({ className, children, ...rest }: ComponentProps
|
||||
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
|
||||
const { text } = useI18n();
|
||||
const { open } = use(TocPopoverContext)!;
|
||||
const items = Base.useTOCItems();
|
||||
const active = useActiveAnchor();
|
||||
const selected = useMemo(
|
||||
() => items.findIndex((item) => active === item.url.slice(1)),
|
||||
[items, active],
|
||||
);
|
||||
const items = Base.useItems();
|
||||
const selectedIdx = items.findIndex((item) => item.active);
|
||||
const path = useTreePath().at(-1);
|
||||
const spanProps = {
|
||||
transition: {
|
||||
@@ -169,14 +164,14 @@ function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>
|
||||
{...props}
|
||||
>
|
||||
<ProgressCircle
|
||||
value={(selected + 1) / Math.max(1, items.length)}
|
||||
value={(selectedIdx + 1) / Math.max(1, items.length)}
|
||||
max={1}
|
||||
className={cn('shrink-0', open && 'text-fd-primary')}
|
||||
/>
|
||||
<AnimatePresence mode="wait">
|
||||
{items[selected] && selected !== -1 && !open ? (
|
||||
<motion.span key={selected} {...spanProps}>
|
||||
{items[selected].title}
|
||||
{items[selectedIdx] && !open ? (
|
||||
<motion.span key={selectedIdx} {...spanProps}>
|
||||
{items[selectedIdx].original.title}
|
||||
</motion.span>
|
||||
) : path ? (
|
||||
<motion.span key={path.$id ?? ':pathId'} {...spanProps}>
|
||||
|
||||
@@ -175,14 +175,10 @@ function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>)
|
||||
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
|
||||
const { text } = useI18n();
|
||||
const { open } = use(TocPopoverContext)!;
|
||||
const items = Base.useTOCItems();
|
||||
const active = Base.useActiveAnchor();
|
||||
const selected = useMemo(
|
||||
() => items.findIndex((item) => active === item.url.slice(1)),
|
||||
[items, active],
|
||||
);
|
||||
const items = Base.useItems();
|
||||
const selectedIdx = items.findIndex((item) => item.active);
|
||||
const path = useTreePath().at(-1);
|
||||
const showItem = selected !== -1 && !open;
|
||||
const showItem = selectedIdx !== -1 && !open;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
@@ -194,7 +190,7 @@ function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>
|
||||
{...props}
|
||||
>
|
||||
<ProgressCircle
|
||||
value={(selected + 1) / Math.max(1, items.length)}
|
||||
value={(selectedIdx + 1) / Math.max(1, items.length)}
|
||||
max={1}
|
||||
className={cn('shrink-0', open && 'text-fd-primary')}
|
||||
/>
|
||||
@@ -214,7 +210,7 @@ function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>
|
||||
!showItem && 'opacity-0 translate-y-full pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{items[selected]?.title}
|
||||
{items[selectedIdx]?.original.title}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
|
||||
|
||||
+200
-106
@@ -4,9 +4,8 @@ import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useContext,
|
||||
use,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -23,28 +22,21 @@ export interface TOCItemType {
|
||||
|
||||
export type TableOfContents = TOCItemType[];
|
||||
|
||||
const ActiveAnchorContext = createContext<string[]>([]);
|
||||
|
||||
const ScrollContext = createContext<RefObject<HTMLElement | null>>({
|
||||
current: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* The estimated active heading ID
|
||||
*/
|
||||
export function useActiveAnchor(): string | undefined {
|
||||
return useContext(ActiveAnchorContext)[0];
|
||||
export interface TOCItemInfo {
|
||||
id: string;
|
||||
active: boolean;
|
||||
/** last time the item is updated */
|
||||
t: number;
|
||||
/** currently active but not intersecting in viewport */
|
||||
fallback: boolean;
|
||||
original: TOCItemType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of visible anchors
|
||||
*/
|
||||
export function useActiveAnchors(): string[] {
|
||||
return useContext(ActiveAnchorContext);
|
||||
}
|
||||
const ItemsContext = createContext<TOCItemInfo[] | null>(null);
|
||||
const ScrollContext = createContext<RefObject<HTMLElement | null> | null>(null);
|
||||
|
||||
export interface AnchorProviderProps {
|
||||
toc: TableOfContents;
|
||||
toc: TOCItemType[];
|
||||
/**
|
||||
* Only accept one active item at most
|
||||
*
|
||||
@@ -63,138 +55,240 @@ export interface ScrollProviderProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/** Optional: add auto-scroll to TOC items. */
|
||||
export function ScrollProvider({ containerRef, children }: ScrollProviderProps) {
|
||||
return <ScrollContext.Provider value={containerRef}>{children}</ScrollContext.Provider>;
|
||||
return <ScrollContext value={containerRef}>{children}</ScrollContext>;
|
||||
}
|
||||
|
||||
export function AnchorProvider({ toc, single = false, children }: AnchorProviderProps) {
|
||||
const headings = useMemo(() => {
|
||||
return toc.map((item) => item.url.split('#')[1]);
|
||||
}, [toc]);
|
||||
const observer = useMemo(() => new Observer(), []);
|
||||
const [items, setItems] = useState<TOCItemInfo[]>(observer.items);
|
||||
|
||||
return (
|
||||
<ActiveAnchorContext.Provider value={useAnchorObserver(headings, single)}>
|
||||
{children}
|
||||
</ActiveAnchorContext.Provider>
|
||||
);
|
||||
observer.single = single;
|
||||
useEffect(() => {
|
||||
observer.setItems(toc);
|
||||
}, [observer, toc]);
|
||||
|
||||
useEffect(() => {
|
||||
observer.watch({
|
||||
rootMargin: '0px',
|
||||
threshold: 0.98,
|
||||
});
|
||||
observer.onChange = () => setItems(observer.items);
|
||||
|
||||
return () => {
|
||||
observer.unwatch();
|
||||
};
|
||||
}, [observer]);
|
||||
|
||||
return <ItemsContext value={items}>{children}</ItemsContext>;
|
||||
}
|
||||
|
||||
export interface TOCItemProps extends Omit<ComponentProps<'a'>, 'href'> {
|
||||
href: string;
|
||||
export interface TOCItemProps extends ComponentProps<'a'> {
|
||||
onActiveChange?: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function TOCItem({ ref, onActiveChange = () => null, ...props }: TOCItemProps) {
|
||||
const containerRef = useContext(ScrollContext);
|
||||
const firstMountRef = useRef(true);
|
||||
const items = useItems();
|
||||
const containerRef = use(ScrollContext);
|
||||
const id = props.href ? getItemId(props.href) : null;
|
||||
const anchorRef = useRef<HTMLAnchorElement>(null);
|
||||
const activeOrder = useActiveAnchors().indexOf(props.href.slice(1));
|
||||
const isActive = activeOrder !== -1;
|
||||
const shouldScroll = activeOrder === 0;
|
||||
const [active, setActive] = useState(
|
||||
() => id && items.some((item) => item.active && item.id === id),
|
||||
);
|
||||
const initialShouldScroll = useMemo(() => {
|
||||
const lastActive = items.findLast((item) => item.active);
|
||||
return lastActive && lastActive.id === id;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useOnChange(items, () => {
|
||||
if (id === null) return;
|
||||
const currentItem = items.find((item) => item.id === id);
|
||||
if (!currentItem || currentItem.active === active) return;
|
||||
|
||||
useEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const container = containerRef.current;
|
||||
|
||||
if (container && anchor && shouldScroll)
|
||||
const container = containerRef?.current;
|
||||
const isLatestActive =
|
||||
currentItem.active && items.every((item) => !item.active || item.t <= currentItem.t);
|
||||
if (isLatestActive && container && anchor) {
|
||||
scrollIntoView(anchor, {
|
||||
behavior: firstMountRef.current ? 'instant' : 'smooth',
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
scrollMode: 'always',
|
||||
boundary: container,
|
||||
});
|
||||
firstMountRef.current = false;
|
||||
}, [containerRef, shouldScroll]);
|
||||
}
|
||||
|
||||
useOnChange(isActive, onActiveChange);
|
||||
setActive(currentItem.active);
|
||||
onActiveChange(currentItem.active);
|
||||
});
|
||||
|
||||
return (
|
||||
<a ref={mergeRefs(anchorRef, ref)} data-active={isActive} {...props}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
useEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const container = containerRef?.current;
|
||||
|
||||
if (initialShouldScroll && container && anchor) {
|
||||
scrollIntoView(anchor, {
|
||||
behavior: 'instant',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
scrollMode: 'always',
|
||||
boundary: container,
|
||||
});
|
||||
}
|
||||
}, [containerRef, initialShouldScroll]);
|
||||
|
||||
return <a ref={mergeRefs(anchorRef, ref)} data-active={active} {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* The estimated active heading ID
|
||||
*/
|
||||
function useAnchorObserver(watch: string[], single: boolean): string[] {
|
||||
const observerRef = useRef<IntersectionObserver>(null);
|
||||
const [activeAnchor, setActiveAnchor] = useState<string[]>(() => []);
|
||||
const stateRef = useRef<{
|
||||
visible: Set<string>;
|
||||
}>(null);
|
||||
export function useActiveAnchor(): string | undefined {
|
||||
const items = useItems();
|
||||
|
||||
const onChange = useEffectEvent((entries: IntersectionObserverEntry[]) => {
|
||||
stateRef.current ??= {
|
||||
visible: new Set(),
|
||||
};
|
||||
const state = stateRef.current;
|
||||
return useMemo(() => {
|
||||
let out: TOCItemInfo | undefined;
|
||||
for (const item of items) {
|
||||
if (!item.active) continue;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
state.visible.add(entry.target.id);
|
||||
} else {
|
||||
state.visible.delete(entry.target.id);
|
||||
if (!out || item.t > out.t) {
|
||||
out = item;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.visible.size === 0) {
|
||||
const viewTop = entries.length > 0 ? (entries[0]?.rootBounds?.top ?? 0) : 0;
|
||||
let fallback: Element | undefined;
|
||||
let min = -1;
|
||||
return out?.id;
|
||||
}, [items]);
|
||||
}
|
||||
|
||||
for (const id of watch) {
|
||||
const element = document.getElementById(id);
|
||||
/**
|
||||
* The id of visible anchors
|
||||
*/
|
||||
export function useActiveAnchors(): string[] {
|
||||
const items = useItems();
|
||||
return useMemo(() => {
|
||||
const out: string[] = [];
|
||||
for (const item of items) {
|
||||
if (item.active) out.push(item.id);
|
||||
}
|
||||
return out;
|
||||
}, [items]);
|
||||
}
|
||||
|
||||
export function useItems() {
|
||||
const ctx = use(ItemsContext);
|
||||
if (!ctx) throw new Error(`Component must be used under the <AnchorProvider /> component.`);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function getItemId(url: string) {
|
||||
if (url.startsWith('#')) return url.slice(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
class Observer {
|
||||
items: TOCItemInfo[] = [];
|
||||
single = false;
|
||||
private observer: IntersectionObserver | null = null;
|
||||
onChange?: () => void;
|
||||
|
||||
private callback(entries: IntersectionObserverEntry[]) {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
let hasActive = false;
|
||||
this.items = this.items.map((item) => {
|
||||
const entry = entries.find((entry) => entry.target.id === item.id);
|
||||
let active = entry ? entry.isIntersecting : item.active && !item.fallback;
|
||||
if (this.single && hasActive) active = false;
|
||||
|
||||
if (item.active !== active) {
|
||||
item = {
|
||||
...item,
|
||||
t: Date.now(),
|
||||
active,
|
||||
fallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (active) hasActive = true;
|
||||
return item;
|
||||
});
|
||||
|
||||
if (!hasActive && entries[0].rootBounds) {
|
||||
const viewTop = entries[0].rootBounds.top;
|
||||
let min = Number.MAX_VALUE;
|
||||
let fallbackIdx = -1;
|
||||
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const element = document.getElementById(this.items[i].id);
|
||||
if (!element) continue;
|
||||
|
||||
const d = Math.abs(viewTop - element.getBoundingClientRect().top);
|
||||
if (min === -1 || d < min) {
|
||||
fallback = element;
|
||||
if (d < min) {
|
||||
fallbackIdx = i;
|
||||
min = d;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveAnchor(fallback ? [fallback.id] : []);
|
||||
} else {
|
||||
const items = watch.filter((item) => state.visible.has(item));
|
||||
setActiveAnchor(single ? items.slice(0, 1) : items);
|
||||
if (fallbackIdx !== -1) {
|
||||
this.items[fallbackIdx] = {
|
||||
...this.items[fallbackIdx],
|
||||
active: true,
|
||||
fallback: true,
|
||||
t: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// disabled
|
||||
if (watch.length === 0) return;
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
if (!observerRef.current) {
|
||||
observerRef.current = new IntersectionObserver(onChange, {
|
||||
rootMargin: '0px',
|
||||
threshold: 0.98,
|
||||
setItems(newItems: TOCItemType[]) {
|
||||
const observer = this.observer;
|
||||
if (observer) {
|
||||
for (const item of this.items) {
|
||||
const element = document.getElementById(item.id);
|
||||
if (!element) continue;
|
||||
observer.unobserve(element);
|
||||
}
|
||||
}
|
||||
|
||||
this.items = [];
|
||||
for (const item of newItems) {
|
||||
const id = getItemId(item.url);
|
||||
if (!id) continue;
|
||||
|
||||
this.items.push({
|
||||
id,
|
||||
active: false,
|
||||
fallback: false,
|
||||
t: 0,
|
||||
original: item,
|
||||
});
|
||||
}
|
||||
this.watchItems();
|
||||
}
|
||||
|
||||
const observer = observerRef.current;
|
||||
const elements = watch.flatMap((heading) => document.getElementById(heading) ?? []);
|
||||
watch(options?: IntersectionObserverInit) {
|
||||
if (this.observer) return;
|
||||
|
||||
for (const element of elements) observer.observe(element);
|
||||
return () => {
|
||||
for (const element of elements) observer.unobserve(element);
|
||||
};
|
||||
}, [watch]);
|
||||
this.observer = new IntersectionObserver(this.callback.bind(this), options);
|
||||
this.watchItems();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
private watchItems() {
|
||||
if (!this.observer) return;
|
||||
for (const item of this.items) {
|
||||
const element = document.getElementById(item.id);
|
||||
if (!element) continue;
|
||||
this.observer.observe(element);
|
||||
}
|
||||
}
|
||||
|
||||
return activeAnchor;
|
||||
unwatch() {
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type CompileOptions, createProcessor } from '@mdx-js/mdx';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { parseFrontmatter, pluginOption, type ResolvePlugins } from './utils';
|
||||
import type { Compatible, VFile } from 'vfile';
|
||||
import type { TableOfContents } from 'fumadocs-core/toc';
|
||||
import type { TOCItemType } from 'fumadocs-core/toc';
|
||||
import { executeMdx, type MdxContent } from '@/render';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface CompileMDXOptions {
|
||||
export interface CompileMDXResult<TFrontmatter = Record<string, unknown>> {
|
||||
body: MdxContent;
|
||||
frontmatter: TFrontmatter;
|
||||
toc: TableOfContents;
|
||||
toc: TOCItemType[];
|
||||
vfile: VFile;
|
||||
|
||||
compiled: string;
|
||||
@@ -113,7 +113,7 @@ export function createCompiler(mdxOptions?: CompilerOptions) {
|
||||
components: { ...options.components, ...props.components },
|
||||
});
|
||||
},
|
||||
toc: exports?.toc ?? (file.data.toc as TableOfContents),
|
||||
toc: exports?.toc ?? file.data.toc!,
|
||||
exports,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TableOfContents } from 'fumadocs-core/toc';
|
||||
import type { TOCItemType } from 'fumadocs-core/toc';
|
||||
import type { MdxContent } from '@/render';
|
||||
|
||||
export * from './compile';
|
||||
@@ -31,6 +31,6 @@ export async function executeMdx(compiled: string, scope: object, baseUrl?: stri
|
||||
|
||||
return (await hydrateFn.apply(hydrateFn, values)) as {
|
||||
default: MdxContent;
|
||||
toc?: TableOfContents;
|
||||
toc?: TOCItemType[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import type { TableOfContents } from 'fumadocs-core/toc';
|
||||
import type { TOCItemType } from 'fumadocs-core/toc';
|
||||
import type { FC } from 'react';
|
||||
import jsxRuntimeDefault from 'react/jsx-runtime';
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function executeMdx(compiled: string, options: Options = {}) {
|
||||
const hydrateFn = new AsyncFunction(...Object.keys(fullScope), compiled);
|
||||
return (await hydrateFn.apply(hydrateFn, Object.values(fullScope))) as {
|
||||
default: MdxContent;
|
||||
toc?: TableOfContents;
|
||||
toc?: TOCItemType[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,6 @@ export function executeMdxSync(compiled: string, options: Options = {}) {
|
||||
|
||||
return hydrateFn.apply(hydrateFn, Object.values(fullScope)) as {
|
||||
default: MdxContent;
|
||||
toc?: TableOfContents;
|
||||
toc?: TOCItemType[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import * as Primitive from 'fumadocs-core/toc';
|
||||
import { type ComponentProps, useEffect, useRef, useState } from 'react';
|
||||
import { type ComponentProps, useEffect, useEffectEvent, useRef, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { TocThumb, useTOCItems } from '.';
|
||||
import { mergeRefs } from '@/utils/merge-refs';
|
||||
@@ -16,63 +16,58 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const items = useTOCItems();
|
||||
const { text } = useI18n();
|
||||
const svgRef = useRef<ComputedSVG | null>(null);
|
||||
const [svg, setSvg] = useState<ComputedSVG>();
|
||||
|
||||
const onResize = useEffectEvent(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || container.clientHeight === 0) return;
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let b0 = 0;
|
||||
let d = '';
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const element: HTMLElement | null = container.querySelector(
|
||||
`a[href="#${items[i].url.slice(1)}"]`,
|
||||
);
|
||||
if (!element) continue;
|
||||
|
||||
const styles = getComputedStyle(element);
|
||||
const offset = getLineOffset(items[i].depth) + 1,
|
||||
top = element.offsetTop + parseFloat(styles.paddingTop),
|
||||
bottom = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom);
|
||||
|
||||
w = Math.max(offset, w);
|
||||
h = Math.max(h, bottom);
|
||||
|
||||
if (i === 0) {
|
||||
d += ` M${offset} ${top} L${offset} ${bottom}`;
|
||||
} else {
|
||||
const pOffset = getLineOffset(items[i - 1].depth) + 1;
|
||||
d += ` C ${pOffset} ${top - 4} ${offset} ${b0! + 4} ${offset} ${top} L${offset} ${bottom}`;
|
||||
}
|
||||
|
||||
b0 = bottom;
|
||||
}
|
||||
|
||||
w += 1;
|
||||
setSvg({
|
||||
d,
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current;
|
||||
|
||||
function onResize(): void {
|
||||
if (container.clientHeight === 0) return;
|
||||
let w = 0,
|
||||
h = 0,
|
||||
b1 = 0,
|
||||
d = '';
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const element: HTMLElement | null = container.querySelector(
|
||||
`a[href="#${items[i].url.slice(1)}"]`,
|
||||
);
|
||||
if (!element) continue;
|
||||
|
||||
const styles = getComputedStyle(element);
|
||||
const offset = getLineOffset(items[i].depth) + 1,
|
||||
t2 = element.offsetTop + parseFloat(styles.paddingTop),
|
||||
b2 = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom);
|
||||
|
||||
w = Math.max(offset, w);
|
||||
h = Math.max(h, b2);
|
||||
|
||||
if (i === 0) {
|
||||
d += ` M${offset} ${t2} L${offset} ${b2}`;
|
||||
} else {
|
||||
const pOffset = getLineOffset(items[i - 1].depth) + 1;
|
||||
d += ` C ${pOffset} ${t2 - 4} ${offset} ${b1! + 4} ${offset} ${t2} L${offset} ${b2}`;
|
||||
}
|
||||
|
||||
b1 = b2;
|
||||
}
|
||||
|
||||
w += 1;
|
||||
if (!svgRef.current || svgRef.current.d !== d) {
|
||||
svgRef.current = {
|
||||
d,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
setSvg(svgRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(onResize);
|
||||
onResize();
|
||||
|
||||
observer.observe(container);
|
||||
observer.observe(containerRef.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [items]);
|
||||
}, []);
|
||||
|
||||
if (items.length === 0)
|
||||
return (
|
||||
@@ -122,17 +117,33 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
|
||||
}
|
||||
|
||||
function ThumbBox() {
|
||||
const items = useTOCItems();
|
||||
const ids = Primitive.useActiveAnchors();
|
||||
const itemInfos = Primitive.useItems();
|
||||
const startIdx = itemInfos.findIndex((info) => info.active);
|
||||
const endIdx = itemInfos.findLastIndex((info) => info.active);
|
||||
if (startIdx === -1) return;
|
||||
|
||||
const item = items.findLast((item) => ids.includes(item.url.slice(1)));
|
||||
if (!item) return;
|
||||
let isStart: boolean;
|
||||
if (startIdx === endIdx) {
|
||||
let lastInactiveIdx = -1;
|
||||
for (let i = 0; i < itemInfos.length; i++) {
|
||||
const item = itemInfos[i];
|
||||
if (item.active) continue;
|
||||
if (lastInactiveIdx === -1 || itemInfos[lastInactiveIdx].t < item.t) {
|
||||
lastInactiveIdx = i;
|
||||
}
|
||||
}
|
||||
isStart = startIdx < lastInactiveIdx;
|
||||
} else {
|
||||
isStart = itemInfos[startIdx].t > itemInfos[endIdx].t;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute size-1 bg-fd-primary rounded-full transition-transform"
|
||||
style={{
|
||||
translate: `calc(${getLineOffset(item.depth)}px - 1.25px) calc(var(--fd-top) + var(--fd-height))`,
|
||||
translate: `calc(${getLineOffset(itemInfos[isStart ? startIdx : endIdx].original.depth)}px - 1.25px) ${
|
||||
isStart ? 'var(--fd-top)' : 'calc(var(--fd-top) + var(--fd-height))'
|
||||
}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,22 +19,23 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={mergeRefs(ref, containerRef)}
|
||||
className={cn('relative flex flex-col border-s border-fd-foreground/10', className)}
|
||||
{...props}
|
||||
>
|
||||
<TocThumb
|
||||
containerRef={containerRef}
|
||||
className="absolute top-(--fd-top) inset-s-0 w-0.5 h-(--fd-height) rounded-e-full bg-fd-primary transition-[top,height]"
|
||||
className="absolute inset-y-0 inset-s-0 bg-fd-primary w-px transition-[clip-path]"
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(0 var(--fd-top), 100% var(--fd-top), 100% calc(var(--fd-top) + var(--fd-height)), 0 calc(var(--fd-top) + var(--fd-height)))',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={mergeRefs(ref, containerRef)}
|
||||
className={cn('flex flex-col border-s border-fd-foreground/10', className)}
|
||||
{...props}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TOCItem key={item.url} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
{items.map((item) => (
|
||||
<TOCItem key={item.url} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useTOCItems(): Primitive.TOCItemType[] {
|
||||
|
||||
export type TOCProviderProps = Primitive.AnchorProviderProps;
|
||||
|
||||
export const { useActiveAnchor, useActiveAnchors } = Primitive;
|
||||
export const { useActiveAnchor, useActiveAnchors, useItems } = Primitive;
|
||||
|
||||
export function TOCProvider({ toc, children, ...props }: TOCProviderProps) {
|
||||
return (
|
||||
|
||||
@@ -175,14 +175,10 @@ function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>)
|
||||
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
|
||||
const { text } = useI18n();
|
||||
const { open } = use(TocPopoverContext)!;
|
||||
const items = Base.useTOCItems();
|
||||
const active = Base.useActiveAnchor();
|
||||
const selected = useMemo(
|
||||
() => items.findIndex((item) => active === item.url.slice(1)),
|
||||
[items, active],
|
||||
);
|
||||
const items = Base.useItems();
|
||||
const selectedIdx = items.findIndex((item) => item.active);
|
||||
const path = useTreePath().at(-1);
|
||||
const showItem = selected !== -1 && !open;
|
||||
const showItem = selectedIdx !== -1 && !open;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
@@ -194,7 +190,7 @@ function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>
|
||||
{...props}
|
||||
>
|
||||
<ProgressCircle
|
||||
value={(selected + 1) / Math.max(1, items.length)}
|
||||
value={(selectedIdx + 1) / Math.max(1, items.length)}
|
||||
max={1}
|
||||
className={cn('shrink-0', open && 'text-fd-primary')}
|
||||
/>
|
||||
@@ -214,7 +210,7 @@ function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>
|
||||
!showItem && 'opacity-0 translate-y-full pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{items[selected]?.title}
|
||||
{items[selectedIdx]?.original.title}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
|
||||
|
||||
@@ -132,12 +132,8 @@ function PageTOCPopoverPhysical({ className, children, ...rest }: ComponentProps
|
||||
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
|
||||
const { text } = useI18n();
|
||||
const { open } = use(TocPopoverContext)!;
|
||||
const items = Base.useTOCItems();
|
||||
const active = Base.useActiveAnchor();
|
||||
const selected = useMemo(
|
||||
() => items.findIndex((item) => active === item.url.slice(1)),
|
||||
[items, active],
|
||||
);
|
||||
const items = Base.useItems();
|
||||
const selectedIdx = items.findIndex((item) => item.active);
|
||||
const path = useTreePath().at(-1);
|
||||
const spanProps = {
|
||||
transition: {
|
||||
@@ -168,14 +164,14 @@ function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>
|
||||
{...props}
|
||||
>
|
||||
<ProgressCircle
|
||||
value={(selected + 1) / Math.max(1, items.length)}
|
||||
value={(selectedIdx + 1) / Math.max(1, items.length)}
|
||||
max={1}
|
||||
className={cn('shrink-0', open && 'text-fd-primary')}
|
||||
/>
|
||||
<AnimatePresence mode="wait">
|
||||
{items[selected] && selected !== -1 && !open ? (
|
||||
<motion.span key={selected} {...spanProps}>
|
||||
{items[selected].title}
|
||||
{items[selectedIdx] && !open ? (
|
||||
<motion.span key={selectedIdx} {...spanProps}>
|
||||
{items[selectedIdx].original.title}
|
||||
</motion.span>
|
||||
) : path ? (
|
||||
<motion.span key={path.$id ?? ':pathId'} {...spanProps}>
|
||||
|
||||
@@ -175,14 +175,10 @@ function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>)
|
||||
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
|
||||
const { text } = useI18n();
|
||||
const { open } = use(TocPopoverContext)!;
|
||||
const items = Base.useTOCItems();
|
||||
const active = Base.useActiveAnchor();
|
||||
const selected = useMemo(
|
||||
() => items.findIndex((item) => active === item.url.slice(1)),
|
||||
[items, active],
|
||||
);
|
||||
const items = Base.useItems();
|
||||
const selectedIdx = items.findIndex((item) => item.active);
|
||||
const path = useTreePath().at(-1);
|
||||
const showItem = selected !== -1 && !open;
|
||||
const showItem = selectedIdx !== -1 && !open;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
@@ -194,7 +190,7 @@ function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>
|
||||
{...props}
|
||||
>
|
||||
<ProgressCircle
|
||||
value={(selected + 1) / Math.max(1, items.length)}
|
||||
value={(selectedIdx + 1) / Math.max(1, items.length)}
|
||||
max={1}
|
||||
className={cn('shrink-0', open && 'text-fd-primary')}
|
||||
/>
|
||||
@@ -214,7 +210,7 @@ function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>
|
||||
!showItem && 'opacity-0 translate-y-full pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{items[selected]?.title}
|
||||
{items[selectedIdx]?.original.title}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
|
||||
|
||||
Reference in New Issue
Block a user