MelonBlog

给博客增加TOC(目录)功能

因为Notion不提供目录功能(notion提供了一个toc命令,但是不好用,不能固定在一个地方),所以我很早就想写一个toc了,但是一直没时间,最近做完了一个项目挤了一点时间来实现这个功能

效果如下:

image

有了菜单功能,应该是能够增加不少阅读体验

如何实现

下面来说说如何实现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