给博客增加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