Files
afilmory/apps/docs/src/components/TableOfContents.tsx
Innei 77f16490e4 chore: adjust docs and add cjk font
- Created a new documentation site under `apps/docs` using Vite, React, and MDX.
- Added essential files including `index.html`, `package.json`, and various configuration files.
- Implemented a file-system based routing system with automatic route generation from markdown files.
- Included support for syntax highlighting, responsive design, and TypeScript.
- Established a structure for documentation content with initial deployment guides and storage provider information.

Signed-off-by: Innei <tukon479@gmail.com>
2025-11-14 21:52:54 +08:00

284 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ScrollArea } from '@radix-ui/react-scroll-area'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { TocItem } from '../toc-data'
import { getTocByPath } from '../toc-data'
// Custom hook to track active TOC item position for the thumb indicator
function useTocThumb(containerRef: React.RefObject<HTMLDivElement | null>, activeId: string | null) {
const [pos, setPos] = useState<[number, number]>([0, 0])
useLayoutEffect(() => {
if (!containerRef.current || !activeId) return
const container = containerRef.current
const activeElement = container.querySelector(`a[href="#${activeId}"]`) as HTMLElement
if (!activeElement) return
const top = activeElement.offsetTop
const height = activeElement.clientHeight
setPos([top, height])
}, [activeId, containerRef])
return pos
}
interface TableOfContentsProps {
currentPath: string
onItemClick?: () => void
handleScroll?: (top: number) => void
}
interface TocItemProps {
item: TocItem
activeId: string | null
level: number
onItemClick?: () => void
handleScroll?: (top: number) => void
}
// Helper functions for SVG indicator positioning
function getItemOffset(depth: number): number {
if (depth <= 2) return 16
if (depth === 3) return 32
return 48
}
function getLineOffset(depth: number): number {
return depth >= 3 ? 12 : 0
}
function TocItemComponent({ item, activeId, level, onItemClick, handleScroll }: TocItemProps) {
const isActive = activeId === item.id
const hasChildren = item.children && item.children.length > 0
return (
<li>
<a
href={`#${item.id}`}
className={`
relative block py-1.5 text-sm transition-colors
${isActive ? 'text-accent font-medium' : 'text-text-tertiary hover:text-text-primary'}
`}
style={{
paddingInlineStart: `${getItemOffset(level)}px`,
}}
onClick={(e) => {
e.preventDefault()
const element = document.querySelector(`#${item.id}`)
if (element && element instanceof HTMLElement) {
const elementTop = element.offsetTop
console.info('Navigating to:', element, 'Top:', elementTop)
handleScroll?.(elementTop - 74)
}
onItemClick?.()
}}
>
{item.text}
</a>
{hasChildren && (
<ul>
{item.children!.map((child) => (
<TocItemComponent
key={child.id}
item={child}
activeId={activeId}
level={level + 1}
onItemClick={onItemClick}
handleScroll={handleScroll}
/>
))}
</ul>
)}
</li>
)
}
export function TableOfContents({ currentPath, onItemClick, handleScroll }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const pos = useTocThumb(containerRef, activeId)
const [svg, setSvg] = useState<{
path: string
width: number
height: number
}>()
// 获取当前页面的 TOC 数据
const currentToc = getTocByPath(currentPath)
// 计算 SVG 路径和尺寸
useLayoutEffect(() => {
if (!containerRef.current || !currentToc || currentToc.length === 0) return
const container = containerRef.current
function calculateSvg(): void {
if (container.clientHeight === 0) return
let w = 0
let h = 0
const d: string[] = []
const getAllItems = (items: TocItem[], currentLevel = 1): Array<{ item: TocItem; level: number }> => {
const result: Array<{ item: TocItem; level: number }> = []
for (const item of items) {
result.push({ item, level: currentLevel })
if (item.children) {
result.push(...getAllItems(item.children, currentLevel + 1))
}
}
return result
}
const allItems = currentToc ? getAllItems(currentToc) : []
for (const [i, { item, level }] of allItems.entries()) {
const element = container.querySelector(`a[href="#${item.id}"]`) as HTMLElement
if (!element) continue
const styles = getComputedStyle(element)
const offset = getLineOffset(level) + 1
const top = element.offsetTop + Number.parseFloat(styles.paddingTop)
const bottom = element.offsetTop + element.clientHeight - Number.parseFloat(styles.paddingBottom)
w = Math.max(offset, w)
h = Math.max(h, bottom)
d.push(`${i === 0 ? 'M' : 'L'}${offset} ${top}`, `L${offset} ${bottom}`)
}
setSvg({
path: d.join(' '),
width: w + 1,
height: h,
})
}
const observer = new ResizeObserver(calculateSvg)
calculateSvg()
observer.observe(container)
return () => {
observer.disconnect()
}
}, [currentToc])
// 监听滚动,高亮当前标题
useEffect(() => {
if (!currentToc || currentToc.length === 0) return
const observer = new IntersectionObserver(
(entries) => {
// 找到可见的标题中最上面的一个
const visibleEntries = entries.filter((entry) => entry.isIntersecting)
if (visibleEntries.length > 0) {
visibleEntries.sort((a, b) => {
const aRect = a.boundingClientRect
const bRect = b.boundingClientRect
return aRect.top - bRect.top
})
setActiveId(visibleEntries[0].target.id)
}
},
{
rootMargin: '-20px 0px -80% 0px',
threshold: 0.1,
},
)
const getAllIds = (items: TocItem[]): string[] => {
const ids: string[] = []
for (const item of items) {
ids.push(item.id)
if (item.children) {
ids.push(...getAllIds(item.children))
}
}
return ids
}
const allIds = getAllIds(currentToc)
// 观察所有标题元素
allIds.forEach((id) => {
const element = document.querySelector(`#${id}`)
if (element) {
observer.observe(element)
}
})
return () => {
observer.disconnect()
}
}, [currentToc])
// 如果当前页面没有TOC数据不显示
if (!currentToc || currentToc.length === 0) {
return null
}
return (
<nav className="space-y-1">
<ScrollArea className="max-h-screen-safe flex flex-col">
<div className="relative min-h-0 text-sm" ref={containerRef}>
{/* SVG 指示器背景 */}
{svg && (
<>
{/* 半透明灰色背景线 */}
<div
className="absolute start-0 top-0"
style={{
width: svg.width,
height: svg.height,
maskImage: `url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svg.width} ${svg.height}"><path d="${svg.path}" stroke="black" stroke-width="1" fill="none" /></svg>`,
)}")`,
}}
>
<div className="bg-text-quaternary/30 h-full" />
</div>
{/* 高亮的活跃指示器 */}
<div
className="absolute start-0 top-0"
style={{
width: svg.width,
height: svg.height,
maskImage: `url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svg.width} ${svg.height}"><path d="${svg.path}" stroke="black" stroke-width="1" fill="none" /></svg>`,
)}")`,
}}
>
<div
className="bg-accent transition-all"
style={{
marginTop: pos[0],
height: pos[1],
}}
/>
</div>
</>
)}
<ul className="text-sm">
{currentToc.map((item) => (
<TocItemComponent
key={item.id}
item={item}
activeId={activeId}
level={1}
onItemClick={onItemClick}
handleScroll={handleScroll}
/>
))}
</ul>
</div>
</ScrollArea>
</nav>
)
}