介绍
接着上文说完,实现了在markdown编辑器中插入视频的能力,接下来还需要继续优化 markdown文档的阅读体验,比如 再加个目录
熟悉markdown语法的朋友可能会说,直接在编辑时添加 @toc 标签,可以在文章顶部自动生成目录,但是这并不是我们想要的效果。我们想要什么效果呢,就和掘金这种效果一样(🤓️)。找了一圈没有看到 bytemd有自带的ToC组件,于是决定自行实现目录效果。
目录主要是展示的时候用,所以只需要处理查看页的相关逻辑。写之前也有参考bytemd自带预览视图的目录效果,不过不太好直接复用,因为实际上我们的目录还需要 - 1. 响应点击定位到具体的片段、2. 自定义样式效果 (其实主要原因是 项目开了es严格检查,直接copy过来的目录代码要改的东西太多。。。)
UI层
我们先实现目录的UI组件
export interface Heading {
id: string,
text: string,
level: number
}
interface TocProps {
hast: Heading[];
currentBlockIndex: number;
onTocClick: (clickIndex: number) => void;
}
const Toc: React.FC<TocProps> = ({ hast, currentBlockIndex, onTocClick}) => {
const [items, setItems] = useState<Heading[]>([]);
const [minLevel, setMinLevel] = useState(6);
const [currentHeadingIndex, setCurrentHeadingIndex] = useState(0);
useEffect(() => {
let newMinLevel = 6;
setCurrentHeadingIndex(currentBlockIndex);
setItems(hast);
hast.forEach((item, index) => {
newMinLevel = Math.min(newMinLevel, item.level);
})
setMinLevel(newMinLevel);
}, [hast, currentBlockIndex]);
const handleClick = (index: number) => {
onTocClick(index);
};
return (
<div className={`bytemd-toc`}>
<h2 style={{marginBottom: '0.5em', fontSize: '16px'}}>目录</h2>
<div className={styles.tocDivider}/>
<ul>
{items.map((item, index) => (
<li
key={index}
className={`bytemd-toc-${item.level} ${currentHeadingIndex === index ? 'bytemd-toc-active' : ''} ${item.level === minLevel ? 'bytemd-toc-first' : ''}`}
style={{paddingLeft: `${(item.level - minLevel) * 16 + 8}px`}}
onClick={() => handleClick(index)}
onKeyDown={(e) => {
if (['Enter', 'Space'].includes(e.code)) {
handleClick(index); // 监听目录项的点击
}
}}
tabIndex={0} // Make it focusable
>
{item.text}
</li>
))}
</ul>
</div>
);
};
export default Toc;
目录其实就是循环添加<li>
标签,当遇到level小一级的,就添加一个缩进;并处理目录项的选中与未选中的样式。
数据层
实现完目录的UI效果后,接下来就是获取目录数据了。因为文章内容是基于markdown语法编写的,所以渲染到页面上时,标题和正文会由不同的标签来区分,我们只需要将其中的<h>
标签过滤出来,就能获取到整个文章的目录结构了。
const extractHeadings = () => {
if (viewerRef && viewerRef.current) {
const headingElements = Array.from(viewerRef.current!.querySelectorAll('h1, h2, h3, h4, h5, h6'));
addIdsToHeadings(headingElements)
const headingData = headingElements.map((heading) => ({
id: heading.id,
text: heading.textContent || "",
level: parseInt(heading.tagName.replace('H', ''), 10),
}));
setHeadings(headingData);
}
};
function addIdsToHeadings(headingElements: Element[]) {
const ids = new Set(); // 用于存储已经生成的ID,确保唯一性
let count = 1;
headingElements.forEach(heading => {
let slug = generateSlug(heading.textContent);
let uniqueSlug = slug;
// 如果生成的ID已经存在,添加一个计数器来使其唯一
while (ids.has(uniqueSlug)) {
uniqueSlug = `${slug}-${count++}`;
}
ids.add(uniqueSlug);
heading.id = uniqueSlug;
});
}
交互层
然后再处理目录项的点击和滚动事件,点击某一项时页面要滚动到具体的位置(需要根据当前的内容高度动态计算);滚动到某一区域时对应的目录项也要展示被选中的状态
// 处理目录项点击事件
const handleTocClick = (index: number) => {
if (viewerRef.current && headings.length > index) {
const node = document.getElementById(headings[index].id)
if (node == null) {
return
}
// 获取元素当前的位置
const elementPosition = node.getBoundingClientRect().top;
// 获取当前视窗的滚动位置
const currentScrollPosition = scrollableDivRef.current?.scrollTop || 0;
// 计算目标位置
const targetScrollPosition = currentScrollPosition + elementPosition - OFFSET_TOP;
console.log("elementPosition ", elementPosition, "currentScrollPosition ", currentScrollPosition, "targetScrollPosition ", targetScrollPosition)
// 滚动到目标位置
scrollableDivRef.current?.scrollTo({
top: targetScrollPosition,
behavior: 'smooth' // 可选,平滑滚动
});
setTimeout(() => {
setCurrentBlockIndex(index)
}, 100)
}
};
const handleScroll = throttle(() => {
if (isFromClickRef.current) {
return;
}
if (viewerRef.current) {
const headings = viewerRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6');
let lastPassedHeadingIndex = 0;
for (let i = 0; i < headings.length; i++) {
const heading = headings[i];
const {top} = heading.getBoundingClientRect();
if (top < window.innerHeight * 0.3) {
lastPassedHeadingIndex = i;
} else {
break;
}
}
setCurrentBlockIndex(lastPassedHeadingIndex);
}
}, 100);
最后,在需要的位置添加ToC组件即可完成目录的展示啦
<Toc
hast={headings}
currentBlockIndex={currentBlockIndex}
onTocClick={handleTocClick}
/>
题外话
也许是由于初始选中组件的原因,整个markdown的开发过程并不算顺利,拓展能力几乎没有,需要自行添加。
同时也还遇到了 其中缩放组件 mediumZoom()
会跟随页面的渲染而重复初始化创建overlay层,导致预览失败。这里也提供一个常用的解决方案:使用useMemo
对组件进行处理,使其复用,避免了 mediumZoom()
的多次初始化
const viewerComponent = useMemo(() => {
return <div ref={viewerRef}>
<Viewer
plugins={plugins}
value={articleData.content}/>
</div>
}, [articleData]);