UI: Improve TOC rendering for steps

This commit is contained in:
Fuma Nama
2026-03-27 17:04:28 +08:00
parent 301122bedb
commit 0f39a9f374
8 changed files with 215 additions and 120 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-ui': patch
'@fumadocs/base-ui': patch
---
Improve TOC rendering for steps
+79 -28
View File
@@ -1,15 +1,23 @@
'use client';
import * as Primitive from 'fumadocs-core/toc';
import { type ComponentProps, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import {
type ComponentProps,
type ReactNode,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import { cn } from '@/utils/cn';
import { TocThumb, useTOCItems } from '.';
import { mergeRefs } from '@/utils/merge-refs';
import { useI18n } from '@/contexts/i18n';
interface ComputedSVG {
d: string;
width: number;
height: number;
content: ReactNode;
}
export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
@@ -23,34 +31,63 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
let w = 0;
let h = 0;
let upperBottom = 0;
let upperX = 0;
let d = '';
const output: ReactNode[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const element: HTMLElement | null = container.querySelector(
`a[href="#${items[i].url.slice(1)}"]`,
`a[href="#${item.url.slice(1)}"]`,
);
if (!element) continue;
const styles = getComputedStyle(element);
const x = getLineOffset(items[i].depth) + 0.5,
const x = getLineOffset(item.depth) + 0.5,
top = element.offsetTop + parseFloat(styles.paddingTop),
bottom = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom);
w = Math.max(x + 0.5, w);
w = Math.max(x + 8, w);
h = Math.max(h, bottom);
if (i === 0) {
d += ` M${x} ${top} L${x} ${bottom}`;
} else {
const upperX = getLineOffset(items[i - 1].depth) + 0.5;
d += ` C ${upperX} ${top - 4} ${x} ${upperBottom + 4} ${x} ${top} L${x} ${bottom}`;
}
if (item._step !== undefined) {
output.push(
<circle
key={`${i}-circle`}
cx={x}
cy={(top + bottom) / 2}
r="8"
className="fill-fd-primary"
/>,
<text
key={`${i}-text`}
x={x}
y={(top + bottom) / 2}
textAnchor="middle"
alignmentBaseline="central"
dominantBaseline="middle"
className="fill-fd-primary-foreground font-medium text-xs leading-none font-mono"
>
{item._step}
</text>,
);
}
upperX = x;
upperBottom = bottom;
}
output.unshift(
<path key="path" d={d} className="stroke-fd-primary" strokeWidth="1" fill="none" />,
);
setSvg({
d,
content: output,
width: w,
height: h,
});
@@ -88,7 +125,7 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
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)))`,
}}
>
<path d={svg.d} className="stroke-fd-primary" strokeWidth="1" fill="none" />
{svg.content}
</svg>
<ThumbBox />
</TocThumb>
@@ -120,44 +157,48 @@ interface ThumbBoxInfo {
function ThumbBox() {
const items = Primitive.useItems();
const previousRef = useRef<ThumbBoxInfo>({
startIdx: -1,
endIdx: -1,
isUp: false,
});
const previousRef = useRef<ThumbBoxInfo>(null);
const startIdx = items.findIndex((item) => item.active);
const endIdx = items.findLastIndex((item) => item.active);
if (startIdx === -1) return;
const prev = previousRef.current;
const isUp =
prev.startIdx > startIdx ||
prev.endIdx > endIdx ||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
let isUp = false;
if (previousRef.current) {
const prev = previousRef.current;
isUp =
prev.startIdx > startIdx ||
prev.endIdx > endIdx ||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
}
previousRef.current = { startIdx, endIdx, isUp };
const original = items[isUp ? startIdx : endIdx].original;
return (
<div
className="absolute size-1 bg-fd-primary rounded-full transition-transform"
style={{
translate: `calc(${getLineOffset(items[isUp ? startIdx : endIdx].original.depth)}px - 1.5px) calc(${
translate: `${getLineOffset(original.depth) - 1.5}px calc(${
isUp ? 'var(--fd-top)' : 'var(--fd-top) + var(--fd-height)'
} - 1.5px)`,
scale: original._step !== undefined ? '0' : '1',
}}
/>
);
}
const a = 8;
function getItemOffset(depth: number): number {
if (depth <= 2) return 14;
if (depth === 3) return 26;
return 36;
if (depth <= 2) return 12 + a;
if (depth === 3) return 24 + a;
return 36 + a;
}
function getLineOffset(depth: number): number {
if (depth <= 2) return 2;
if (depth === 3) return 10;
return 20;
if (depth <= 2) return a;
if (depth === 3) return 8 + a;
return 16 + a;
}
export function TOCItem({
@@ -180,7 +221,7 @@ export function TOCItem({
href={item.url}
{...props}
className={cn(
'prose relative py-1.5 text-sm scroll-m-4 text-fd-muted-foreground hover:text-fd-accent-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
'group prose relative py-1.5 text-sm scroll-m-4 text-fd-muted-foreground hover:text-fd-accent-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
props.className,
)}
style={{
@@ -192,7 +233,7 @@ export function TOCItem({
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`${Math.min(offset, upperOffset)} 0 ${Math.abs(upperOffset - offset)} 12`}
className="absolute -top-1.5"
className="absolute -top-1.5 -z-1"
style={{
width: Math.abs(upperOffset - offset) + 1,
height: 12,
@@ -210,7 +251,7 @@ export function TOCItem({
)}
<div
className={cn(
'absolute inset-y-0 w-px bg-fd-foreground/10',
'absolute inset-y-0 w-px bg-fd-foreground/10 -z-1',
offset !== upperOffset && 'top-1.5',
offset !== lowerOffset && 'bottom-1.5',
)}
@@ -218,6 +259,16 @@ export function TOCItem({
insetInlineStart: offset,
}}
/>
{item._step !== undefined && (
<div
className="absolute flex items-center justify-center -translate-1/2 -z-1 top-[calc(50%-var(--t,0px)+var(--b,0px))] size-4 font-mono font-medium text-xs bg-fd-muted text-fd-muted-foreground rounded-full leading-none group-first:[--t:--spacing(0.75)] group-last:[--b:--spacing(0.75)]"
style={{
insetInlineStart: offset,
}}
>
{item._step}
</div>
)}
{item.title}
</Primitive.TOCItem>
);
+7 -14
View File
@@ -57,10 +57,9 @@ interface TocThumbInfo {
interface TocThumbProps extends ComponentProps<'div'> {
containerRef: RefObject<HTMLElement | null>;
align?: 'center' | 'end';
}
export function TocThumb({ containerRef, align = 'end', ...props }: TocThumbProps) {
export function TocThumb({ containerRef, ...props }: TocThumbProps) {
const thumbRef = useRef<HTMLDivElement>(null);
const active = useActiveAnchors();
@@ -85,19 +84,13 @@ export function TocThumb({ containerRef, align = 'end', ...props }: TocThumbProp
const element = container.querySelector<HTMLElement>(`a[href="#${item}"]`);
if (!element) continue;
if (align === 'center') {
const y = element.offsetTop + element.clientHeight / 2;
upper = Math.min(upper, y);
lower = Math.max(lower, y);
} else {
const styles = getComputedStyle(element);
const styles = getComputedStyle(element);
upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop));
lower = Math.max(
lower,
element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom),
);
}
upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop));
lower = Math.max(
lower,
element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom),
);
}
return {
@@ -63,9 +63,6 @@ export function remarkSteps({
value: steps,
},
],
data: {
_fd_step: true,
} as object,
children,
};
}
@@ -94,8 +91,7 @@ export function remarkSteps({
return (tree) => {
visit(tree, (parent) => {
if (!('children' in parent) || parent.type === 'heading') return;
if (parent.data && '_fd_step' in parent.data) return 'skip';
if (!('children' in parent) || parent.type === 'heading') return 'skip';
let startIdx = -1;
let i = 0;
@@ -114,12 +110,15 @@ export function remarkSteps({
for (; i < parent.children.length; i++) {
const node = parent.children[i];
if (node.type !== 'heading') continue;
if (node.type !== 'heading' || node.data?.hProperties?.['data-fd-step'] !== undefined)
continue;
if (startIdx !== -1) {
const startDepth = (parent.children[startIdx] as Heading).depth;
if (node.depth > startDepth) continue;
else if (node.depth < startDepth) onEnd();
if (node.depth !== startDepth) {
if (node.depth < startDepth) onEnd();
continue;
}
}
if (!handleHeadingStep(node)) {
+3 -3
View File
@@ -1,4 +1,4 @@
# Hello
# Hello [step]
## 1. First
@@ -20,10 +20,10 @@ content
content
# 1. Big: First
# World [step]
content
# Big: Second [step]
# Bye [step]
content
+27 -25
View File
@@ -1,42 +1,44 @@
# Hello
<div className="fd-steps">
<div className="fd-step">
## First
# Hello
content
<div className="fd-steps">
<div className="fd-step">
## First
### Little Tip
content
### Little Tip
content
</div>
<div className="fd-step">
## Second
content
</div>
<div className="fd-step">
## Third
content
</div>
</div>
## Ended
content
</div>
<div className="fd-step">
## Second
# World
content
</div>
<div className="fd-step">
## Third
content
</div>
</div>
## Ended
content
<div className="fd-steps">
<div className="fd-step">
# Big: First
content
</div>
<div className="fd-step">
# Big: Second
# Bye
content
</div>
+79 -28
View File
@@ -1,15 +1,23 @@
'use client';
import * as Primitive from 'fumadocs-core/toc';
import { type ComponentProps, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import {
type ComponentProps,
type ReactNode,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import { cn } from '@/utils/cn';
import { TocThumb, useTOCItems } from '.';
import { mergeRefs } from '@/utils/merge-refs';
import { useI18n } from '@/contexts/i18n';
interface ComputedSVG {
d: string;
width: number;
height: number;
content: ReactNode;
}
export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
@@ -23,34 +31,63 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
let w = 0;
let h = 0;
let upperBottom = 0;
let upperX = 0;
let d = '';
const output: ReactNode[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const element: HTMLElement | null = container.querySelector(
`a[href="#${items[i].url.slice(1)}"]`,
`a[href="#${item.url.slice(1)}"]`,
);
if (!element) continue;
const styles = getComputedStyle(element);
const x = getLineOffset(items[i].depth) + 0.5,
const x = getLineOffset(item.depth) + 0.5,
top = element.offsetTop + parseFloat(styles.paddingTop),
bottom = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom);
w = Math.max(x + 0.5, w);
w = Math.max(x + 8, w);
h = Math.max(h, bottom);
if (i === 0) {
d += ` M${x} ${top} L${x} ${bottom}`;
} else {
const upperX = getLineOffset(items[i - 1].depth) + 0.5;
d += ` C ${upperX} ${top - 4} ${x} ${upperBottom + 4} ${x} ${top} L${x} ${bottom}`;
}
if (item._step !== undefined) {
output.push(
<circle
key={`${i}-circle`}
cx={x}
cy={(top + bottom) / 2}
r="8"
className="fill-fd-primary"
/>,
<text
key={`${i}-text`}
x={x}
y={(top + bottom) / 2}
textAnchor="middle"
alignmentBaseline="central"
dominantBaseline="middle"
className="fill-fd-primary-foreground font-medium text-xs leading-none font-mono"
>
{item._step}
</text>,
);
}
upperX = x;
upperBottom = bottom;
}
output.unshift(
<path key="path" d={d} className="stroke-fd-primary" strokeWidth="1" fill="none" />,
);
setSvg({
d,
content: output,
width: w,
height: h,
});
@@ -88,7 +125,7 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
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)))`,
}}
>
<path d={svg.d} className="stroke-fd-primary" strokeWidth="1" fill="none" />
{svg.content}
</svg>
<ThumbBox />
</TocThumb>
@@ -120,44 +157,48 @@ interface ThumbBoxInfo {
function ThumbBox() {
const items = Primitive.useItems();
const previousRef = useRef<ThumbBoxInfo>({
startIdx: -1,
endIdx: -1,
isUp: false,
});
const previousRef = useRef<ThumbBoxInfo>(null);
const startIdx = items.findIndex((item) => item.active);
const endIdx = items.findLastIndex((item) => item.active);
if (startIdx === -1) return;
const prev = previousRef.current;
const isUp =
prev.startIdx > startIdx ||
prev.endIdx > endIdx ||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
let isUp = false;
if (previousRef.current) {
const prev = previousRef.current;
isUp =
prev.startIdx > startIdx ||
prev.endIdx > endIdx ||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
}
previousRef.current = { startIdx, endIdx, isUp };
const original = items[isUp ? startIdx : endIdx].original;
return (
<div
className="absolute size-1 bg-fd-primary rounded-full transition-transform"
style={{
translate: `calc(${getLineOffset(items[isUp ? startIdx : endIdx].original.depth)}px - 1.5px) calc(${
translate: `${getLineOffset(original.depth) - 1.5}px calc(${
isUp ? 'var(--fd-top)' : 'var(--fd-top) + var(--fd-height)'
} - 1.5px)`,
scale: original._step !== undefined ? '0' : '1',
}}
/>
);
}
const a = 8;
function getItemOffset(depth: number): number {
if (depth <= 2) return 14;
if (depth === 3) return 26;
return 36;
if (depth <= 2) return 12 + a;
if (depth === 3) return 24 + a;
return 36 + a;
}
function getLineOffset(depth: number): number {
if (depth <= 2) return 2;
if (depth === 3) return 10;
return 20;
if (depth <= 2) return a;
if (depth === 3) return 8 + a;
return 16 + a;
}
export function TOCItem({
@@ -180,7 +221,7 @@ export function TOCItem({
href={item.url}
{...props}
className={cn(
'prose relative py-1.5 text-sm scroll-m-4 text-fd-muted-foreground hover:text-fd-accent-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
'group prose relative py-1.5 text-sm scroll-m-4 text-fd-muted-foreground hover:text-fd-accent-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
props.className,
)}
style={{
@@ -192,7 +233,7 @@ export function TOCItem({
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`${Math.min(offset, upperOffset)} 0 ${Math.abs(upperOffset - offset)} 12`}
className="absolute -top-1.5"
className="absolute -top-1.5 -z-1"
style={{
width: Math.abs(upperOffset - offset) + 1,
height: 12,
@@ -210,7 +251,7 @@ export function TOCItem({
)}
<div
className={cn(
'absolute inset-y-0 w-px bg-fd-foreground/10',
'absolute inset-y-0 w-px bg-fd-foreground/10 -z-1',
offset !== upperOffset && 'top-1.5',
offset !== lowerOffset && 'bottom-1.5',
)}
@@ -218,6 +259,16 @@ export function TOCItem({
insetInlineStart: offset,
}}
/>
{item._step !== undefined && (
<div
className="absolute flex items-center justify-center -translate-1/2 -z-1 top-[calc(50%-var(--t,0px)+var(--b,0px))] size-4 font-mono font-medium text-xs bg-fd-muted text-fd-muted-foreground rounded-full leading-none group-first:[--t:--spacing(0.75)] group-last:[--b:--spacing(0.75)]"
style={{
insetInlineStart: offset,
}}
>
{item._step}
</div>
)}
{item.title}
</Primitive.TOCItem>
);
+7 -14
View File
@@ -57,10 +57,9 @@ interface TocThumbInfo {
interface TocThumbProps extends ComponentProps<'div'> {
containerRef: RefObject<HTMLElement | null>;
align?: 'center' | 'end';
}
export function TocThumb({ containerRef, align = 'end', ...props }: TocThumbProps) {
export function TocThumb({ containerRef, ...props }: TocThumbProps) {
const thumbRef = useRef<HTMLDivElement>(null);
const active = useActiveAnchors();
@@ -85,19 +84,13 @@ export function TocThumb({ containerRef, align = 'end', ...props }: TocThumbProp
const element = container.querySelector<HTMLElement>(`a[href="#${item}"]`);
if (!element) continue;
if (align === 'center') {
const y = element.offsetTop + element.clientHeight / 2;
upper = Math.min(upper, y);
lower = Math.max(lower, y);
} else {
const styles = getComputedStyle(element);
const styles = getComputedStyle(element);
upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop));
lower = Math.max(
lower,
element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom),
);
}
upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop));
lower = Math.max(
lower,
element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom),
);
}
return {