给博客增加TOC(目录)功能
因为Notion不提供目录功能(notion提供了一个toc命令,但是不好用,不能固定在一个地方),所以我很早就想写一个toc了,但是一直没时间,最近做完了一个项目挤了一点时间来实现这个功能
效果如下:
有了菜单功能,应该是能够增加不少阅读体验
如何实现
下面来说说如何实现toc,实现过程中还是遇到了不少问题的
构建数据结构
第一步,菜单是一个树形的数据结构,但是为了方便渲染,我用一个扁平的结构来存储
代码:
class Toc {
title: string
id: string
level: number
children: Toc[]
constructor(id: string, title: string, level: number) {
this.id = id
this.title = title
this.level = level
this.children = []
}
append(id: string, title: string, level: number) {
this.children.push(new Toc(id, title, level))
}
}
export default Toc
在渲染Notion块对象的时候,只要遇到了heading block,就往toc里append
代码(完整代码请看github):
for (const block of blocks) {
...
if (root) {
if (block.type === 'heading_1') {
this.tocMap.get(id)!.append(block.id, block.heading_1.rich_text[0].plain_text, 1)
} else if (block.type === 'heading_2') {
this.tocMap.get(id)!.append(block.id, block.heading_2.rich_text[0].plain_text, 2)
} else if (block.type === 'heading_3') {
this.tocMap.get(id)!.append(block.id, block.heading_3.rich_text[0].plain_text, 3)
}
}
...
}
根据数据构建dom
第一步要根据Toc对象构建处一个a标签列表:
const render = () => {
return <div className={Styles.index}>
<a>{props.data.title}</a>
{props.data.children.map((child) => {
return <a key={child.id}
className={child.level == 2 ? Styles.h2 : child.level == 3 ? Styles.h3 : undefined}>{child.title}</a>
})}
</div>
}
点击a标签之后要滑动到锚点的位置(这里最好有30个px的偏移,要不然锚点的位置会贴着浏览器顶端,体验不是很好)
const scrollToTargetAdjusted = (id: string) => {
const element = document.getElementById(id);
const headerOffset = 30;
const elementPosition = element!.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: "smooth"
});
}
const render = () => {
return <div className={Styles.index}>
<a onClick={() => {
scrollToTargetAdjusted(props.data.id)
// 使用下面的方法不能实现偏移,例如我想滑到动目标节点的上面30px,这样标题就不会贴着浏览器顶部了
// document.getElementById(props.data.id)?.scrollIntoView({behavior: 'smooth', inline: 'start'})
}}>{props.data.title}</a>
{props.data.children.map((child) => {
return <a key={child.id}
onClick={() => {
scrollToTargetAdjusted(child.id)
}}
className={child.level == 2 ? Styles.h2 : child.level == 3 ? Styles.h3 : undefined}>{child.title}</a>
})}
</div>
}
根据不同的平台和浏览器显示和隐藏
这里会有一个问题,如果组件是在服务端渲染,那么服务端是不知道客户端是什么平台的,所以这里我做了一个处理,我用一个客户端组件把服务端组件包装了一层,客户端组件负责显示和隐藏服务端组件的内容。
例如:我想根据客户端是否是手机端来决定是否隐藏菜单,并且如果是pc端的话,浏览器宽度不够的情况下也隐藏菜单
React.useEffect(() => {
if (BrowserUtils.isMobile(navigator.userAgent) || document.body.clientWidth <= 1024) {
setHide(true)
} else {
setHide(false)
}
window.addEventListener('resize', () => {
if (BrowserUtils.isMobile(navigator.userAgent) || document.body.clientWidth <= 1024) {
setHide(true)
} else {
setHide(false)
}
})
}, [props.id]);
滚动时间定位
想给toc加一个滚动定位功能,就是在下拉或者上拉滚动条的时候,自动定位当前屏幕内容是什么
window.addEventListener('scroll', (event) => {
// 如果滑到了底部,就把最后一个id设置为active
const bottom = document.documentElement.scrollHeight - document.documentElement.clientHeight
if (window.scrollY >= bottom) {
setActiveId(props.data.children[props.data.children.length - 1].id)
}
const distances: { id: string, distance: number }[] = []
const headerOffset = 30;
props.data.children.forEach((child) => {
const header = document.getElementById(child.id)!
const headerTopPosition = header.getBoundingClientRect().top;
const offsetPosition = headerTopPosition + window.scrollY - headerOffset;
// console.log(`${child.title} ${offsetPosition} ${window.scrollY} ${window.scrollY - offsetPosition}`)
const distance = Math.abs(window.scrollY - offsetPosition)
distances.push({id: child.id, distance: distance})
if (distance <= 10) {
if (activeId !== child.id) {
setActiveId(child.id)
return
}
}
})
// 如果滑动过快,可能会监听不到当前真实的id,所以这里做一下排序,取距离最小的那个,非常增加丝滑度
distances.sort((a, b) => {
return a.distance - b.distance
})
setActiveId(distances[0].id)
})
完成的组件代码和样式
样式:
.index {
position: fixed;
width: 250px;
/*max-width: 250px;*/
padding: 1rem;
top: 20%;
right: 0;
display: flex;
align-items: start;
justify-content: start;
flex-direction: column;
font-size: x-small;
/*text-decoration: underline;*/
background-color: #ffffff;
/*color: blue;*/
border-top: 1px solid #eaeaea;
border-left: 1px solid #eaeaea;
border-bottom: 1px solid #eaeaea;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1) inset;
}
.index a {
cursor: pointer;
/*display: inline-block;*/
overflow-x: hidden;
white-space:nowrap;
width: 100%;
text-overflow:ellipsis;
}
.index .h2 {
padding-left: 15px;
}
.index .h3 {
padding-left: 30px;
}
.active {
color: #0070f3;
font-weight: bold;
font-size: 14px;
/*text-decoration: underline;*/
}
完整组件:
'use client'
import React from "react";
import BrowserUtils from "@/server/utils/browser-utils";
import Styles from "@/components/toc.module.css";
import TocData from "@/server/renderer/toc";
const Toc = (props: { id: string, data: TocData }) => {
const [hide, setHide] = React.useState<boolean>(true)
const [activeId, setActiveId] = React.useState<string>('')
React.useEffect(() => {
if (BrowserUtils.isMobile(navigator.userAgent) || document.body.clientWidth <= 1024) {
setHide(true)
} else {
setHide(false)
}
window.addEventListener('resize', () => {
if (BrowserUtils.isMobile(navigator.userAgent) || document.body.clientWidth <= 1024) {
setHide(true)
} else {
setHide(false)
}
})
window.addEventListener('scroll', (event) => {
// 如果滑到了底部,就把最后一个id设置为active
const bottom = document.documentElement.scrollHeight - document.documentElement.clientHeight
if (window.scrollY >= bottom) {
setActiveId(props.data.children[props.data.children.length - 1].id)
}
const distances: { id: string, distance: number }[] = []
const headerOffset = 30;
props.data.children.forEach((child) => {
const header = document.getElementById(child.id)!
const headerTopPosition = header.getBoundingClientRect().top;
const offsetPosition = headerTopPosition + window.scrollY - headerOffset;
// console.log(`${child.title} ${offsetPosition} ${window.scrollY} ${window.scrollY - offsetPosition}`)
const distance = Math.abs(window.scrollY - offsetPosition)
distances.push({id: child.id, distance: distance})
if (distance <= 10) {
if (activeId !== child.id) {
setActiveId(child.id)
return
}
}
})
// 如果滑动过快,可能会监听不到当前真实的id,所以这里做一下排序,取距离最小的那个,非常增加丝滑度
distances.sort((a, b) => {
return a.distance - b.distance
})
setActiveId(distances[0].id)
})
}, [props.id]);
const scrollToTargetAdjusted = (id: string) => {
const element = document.getElementById(id);
const headerOffset = 30;
const elementPosition = element!.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: "smooth"
});
// setActiveId(id) 让滚动时间自己触发,这样效果会丝滑很多
}
const render = () => {
return <div className={`${Styles.index}`}>
<h1 className={"text-[20px] mb-5 font-black text-black"}>目录</h1>
{props.data.children.map((child) => {
return <a key={child.id}
onClick={() => {
scrollToTargetAdjusted(child.id)
}}
className={`${child.level == 2 ? Styles.h2 : child.level == 3 ? Styles.h3 : undefined} ${activeId === child.id ? Styles.active : null}`}>{child.title}</a>
})}
</div>
}
return <>
<div hidden={hide}>
{render()}
</div>
</>
}
export default Toc