我是如何在bytemd中实现自定义目录的

介绍

接着上文说完,实现了在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]);
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值