UI: Improve TOC detection logic

This commit is contained in:
Fuma Nama
2026-03-22 23:43:39 +08:00
parent b43672abd2
commit 55479b3b84
17 changed files with 405 additions and 304 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-ui': patch
'@fumadocs/base-ui': patch
'fumadocs-core': patch
---
Improve TOC detection logic
+64 -53
View File
@@ -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))'
}`,
}}
/>
);
+14 -12
View File
@@ -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
View File
@@ -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 -3
View File
@@ -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,
};
},
+2 -2
View File
@@ -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[];
};
}
+3 -3
View File
@@ -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[];
};
}
+64 -53
View File
@@ -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')} />