Skip to content

Commit 71e8c32

Browse files
committed
feat: Add Table of Contents component and enhance PageDetailArea layout
1 parent 03a262c commit 71e8c32

3 files changed

Lines changed: 377 additions & 20 deletions

File tree

app/client/src/components/PageDetailArea.tsx

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
2525
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
2626
import PageHighlightList from './highlights/PageHighlightList';
2727
import TextHighlighter from './highlights/TextHighlighter';
28+
import TableOfContents from './TableOfContents';
29+
import ListIcon from '@mui/icons-material/List';
2830

2931
// 引入 TurndownService
3032
import TurndownService from 'turndown';
@@ -47,6 +49,7 @@ const PageDetailArea = ({
4749
const [snackbarMessage, setSnackbarMessage] = useState("");
4850
const [hasAutoScrolled, setHasAutoScrolled] = useState(false);
4951
const [highlightMode, setHighlightMode] = useState(true);
52+
const [showToc, setShowToc] = useState(true);
5053

5154
// AI操作菜单状态
5255
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
@@ -428,23 +431,28 @@ const PageDetailArea = ({
428431
};
429432

430433
return (
431-
<div className="pl-2 pr-2 flex flex-col items-center">
434+
<div className="page-detail-layout">
432435
{isLoading && <Loading/>}
433436
{error && <p>error...</p>}
434437
{detail && (
435-
<Paper
436-
className={"page-detail-paper"}
437-
sx={{maxWidth: 800, minWidth: 800}}
438-
key={detail.page.id}
439-
elevation={2}
440-
>
441-
<div
442-
className={'bg-sky-50 pl-2 pr-2 pt-1 pb-1 mb-4 border-0 border-solid border-b-[2px] border-b-blue-100 sticky top-0 backdrop-blur-2xl bg-opacity-60 z-40'}>
443-
<Box sx={{}} className={"flex items-center justify-between"}>
444-
<div className={'flex items-center'}>
445-
<a href={detail.page.url} target={"_blank"} rel="noreferrer" className={'hover:underline'}>
446-
<div className={"flex items-center"}>
447-
{iconUrl &&
438+
<div className="page-detail-content-wrapper">
439+
{/* 左侧占位区域 - 用于居中,始终保留空间 */}
440+
<div className="page-detail-spacer hidden xl:block"></div>
441+
442+
{/* 主内容区域 */}
443+
<Paper
444+
className={"page-detail-paper"}
445+
sx={{maxWidth: 800, minWidth: 800}}
446+
key={detail.page.id}
447+
elevation={2}
448+
>
449+
<div
450+
className={'page-detail-header bg-sky-50 pl-2 pr-2 pt-1 pb-1 mb-4 border-0 border-solid border-b-[2px] border-b-blue-100 sticky top-0 backdrop-blur-2xl bg-opacity-60 z-40'}>
451+
<Box sx={{}} className={"flex items-center justify-between"}>
452+
<div className={'flex items-center'}>
453+
<a href={detail.page.url} target={"_blank"} rel="noreferrer" className={'hover:underline'}>
454+
<div className={"flex items-center"}>
455+
{iconUrl &&
448456
<span className={"mr-2"}>
449457
<CardMedia component={'img'} image={iconUrl}
450458
sx={{
@@ -494,6 +502,12 @@ const PageDetailArea = ({
494502
</IconButton>
495503
</Tooltip>
496504

505+
<Tooltip title={showToc ? 'Hide outline' : 'Show outline'} placement={"bottom"}>
506+
<IconButton onClick={() => setShowToc(!showToc)}>
507+
<ListIcon fontSize={"small"} sx={{ color: showToc ? "#3b82f6" : "#9e9e9e" }} />
508+
</IconButton>
509+
</Tooltip>
510+
497511
{shortcuts && shortcuts.length > 0 ? (
498512
<>
499513
<Tooltip title={'AI operations'} placement={"bottom"}>
@@ -672,6 +686,14 @@ const PageDetailArea = ({
672686
</article>
673687
</div>
674688
</Paper>
689+
690+
{/* 目录区域 - 显示在内容右侧,使用 visibility 而不是 display 保持占位 */}
691+
<div className={`page-detail-toc-area hidden xl:block ${showToc ? '' : 'invisible'}`}>
692+
{detail.page.content && (
693+
<TableOfContents content={detail.page.content} />
694+
)}
695+
</div>
696+
</div>
675697
)}
676698
<Snackbar
677699
open={snackbarOpen}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import React, { useEffect, useState, useCallback } from 'react';
2+
3+
interface TocItem {
4+
id: string;
5+
text: string;
6+
level: number;
7+
}
8+
9+
interface TableOfContentsProps {
10+
content: string;
11+
containerSelector?: string;
12+
}
13+
14+
const TableOfContents: React.FC<TableOfContentsProps> = ({
15+
content,
16+
containerSelector = '.page-content'
17+
}) => {
18+
const [activeId, setActiveId] = useState<string>('');
19+
const [tocItems, setTocItems] = useState<TocItem[]>([]);
20+
21+
// 从内容中提取标题
22+
const extractHeadings = useCallback((htmlContent: string): TocItem[] => {
23+
const parser = new DOMParser();
24+
const doc = parser.parseFromString(htmlContent, 'text/html');
25+
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
26+
27+
const items: TocItem[] = [];
28+
headings.forEach((heading, index) => {
29+
const level = parseInt(heading.tagName.substring(1));
30+
const text = heading.textContent?.trim() || '';
31+
if (text) {
32+
// 生成唯一 ID
33+
const id = `toc-heading-${index}`;
34+
items.push({ id, text, level });
35+
}
36+
});
37+
38+
return items;
39+
}, []);
40+
41+
// 初始化时提取标题
42+
useEffect(() => {
43+
const items = extractHeadings(content);
44+
setTocItems(items);
45+
}, [content, extractHeadings]);
46+
47+
// 为实际 DOM 中的标题元素添加 ID
48+
useEffect(() => {
49+
if (tocItems.length === 0) return;
50+
51+
// 等待 DOM 渲染完成
52+
const timer = setTimeout(() => {
53+
const container = document.querySelector(containerSelector);
54+
if (!container) return;
55+
56+
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
57+
headings.forEach((heading, index) => {
58+
if (index < tocItems.length) {
59+
heading.id = tocItems[index].id;
60+
}
61+
});
62+
}, 100);
63+
64+
return () => clearTimeout(timer);
65+
}, [tocItems, containerSelector]);
66+
67+
// 监听滚动事件,更新当前激活的标题
68+
useEffect(() => {
69+
if (tocItems.length === 0) return;
70+
71+
const handleScroll = () => {
72+
const container = document.querySelector(containerSelector);
73+
if (!container) return;
74+
75+
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
76+
77+
// 找到当前视口中最近的标题
78+
let currentActive = '';
79+
const offset = 120; // 固定偏移量,考虑顶部固定头部
80+
81+
for (let i = headings.length - 1; i >= 0; i--) {
82+
const heading = headings[i] as HTMLElement;
83+
const rect = heading.getBoundingClientRect();
84+
85+
if (rect.top <= offset) {
86+
currentActive = heading.id;
87+
break;
88+
}
89+
}
90+
91+
// 如果没有找到(在页面最顶部),激活第一个标题
92+
if (!currentActive && headings.length > 0) {
93+
const firstHeading = headings[0] as HTMLElement;
94+
const rect = firstHeading.getBoundingClientRect();
95+
if (rect.top < window.innerHeight) {
96+
currentActive = firstHeading.id;
97+
}
98+
}
99+
100+
if (currentActive !== activeId) {
101+
setActiveId(currentActive);
102+
}
103+
};
104+
105+
// 延迟初始调用确保 DOM 已渲染
106+
const initTimer = setTimeout(() => {
107+
handleScroll();
108+
}, 200);
109+
110+
// 监听页面滚动
111+
window.addEventListener('scroll', handleScroll, { passive: true });
112+
113+
// 监听 drawer 内的滚动(如果在 modal 中)
114+
const drawer = document.querySelector('.MuiDrawer-paper');
115+
if (drawer) {
116+
drawer.addEventListener('scroll', handleScroll, { passive: true });
117+
}
118+
119+
return () => {
120+
clearTimeout(initTimer);
121+
window.removeEventListener('scroll', handleScroll);
122+
if (drawer) {
123+
drawer.removeEventListener('scroll', handleScroll);
124+
}
125+
};
126+
}, [tocItems, activeId, containerSelector]);
127+
128+
// 点击目录项滚动到对应位置
129+
const handleClick = (e: React.MouseEvent, id: string) => {
130+
e.preventDefault();
131+
const element = document.getElementById(id);
132+
if (element) {
133+
// 获取 drawer 容器
134+
const drawer = document.querySelector('.MuiDrawer-paper');
135+
const offsetTop = 80; // 顶部固定栏的高度偏移
136+
137+
if (drawer) {
138+
const elementTop = element.getBoundingClientRect().top + drawer.scrollTop - drawer.getBoundingClientRect().top;
139+
drawer.scrollTo({
140+
top: elementTop - offsetTop,
141+
behavior: 'smooth'
142+
});
143+
} else {
144+
const elementTop = element.getBoundingClientRect().top + window.scrollY;
145+
window.scrollTo({
146+
top: elementTop - offsetTop,
147+
behavior: 'smooth'
148+
});
149+
}
150+
setActiveId(id);
151+
}
152+
};
153+
154+
// 如果标题数量少于等于 2 个,不显示目录
155+
if (tocItems.length <= 2) {
156+
return null;
157+
}
158+
159+
// 计算最小层级,用于调整缩进
160+
const minLevel = Math.min(...tocItems.map(item => item.level));
161+
162+
return (
163+
<div className="toc-wrapper">
164+
<nav className="toc-nav">
165+
<ul className="toc-list">
166+
{tocItems.map((item) => {
167+
const indent = (item.level - minLevel) * 14;
168+
const isActive = activeId === item.id;
169+
170+
return (
171+
<li
172+
key={item.id}
173+
style={{ paddingLeft: `${indent}px` }}
174+
className="toc-item"
175+
>
176+
<a
177+
href={`#${item.id}`}
178+
onClick={(e) => handleClick(e, item.id)}
179+
className={`toc-link ${isActive ? 'toc-link-active' : ''}`}
180+
title={item.text}
181+
>
182+
<span className="line-clamp-2">
183+
{item.text}
184+
</span>
185+
</a>
186+
</li>
187+
);
188+
})}
189+
</ul>
190+
</nav>
191+
</div>
192+
);
193+
};
194+
195+
export default TableOfContents;

0 commit comments

Comments
 (0)