markdown 生成文章目录

marvin

marvin

markdown 生成文章目录

一般在看文章的时候如果有目录会很方便进行导航, 目录主要解决以下3个问题

  1. 显示整篇文章拥有的标题
  2. 根据当前的位置进行标题导航
  3. 点击目录能跳转到标题所在的内容

下面就如何解决以上问题分别进行突破

显示整篇文章拥有的标题

我们用 menu 存储所有的标题数据, data 存储标题元素相关的信息, 包括type, id, text, top, el, type 为标题的名称, 并且 type 会影响其在目录中的缩进样式, id 为我们给这个标题元素生成的唯一标识, text 为标题的内容, top 为元素相对于整个页面顶部的距离, el 是当前元素的一个引用,. cur 存储当前所在的标题的 id , 下面是 menu 的定义

const [menu, setMenu] = useState({
    data: [],
    cur: ''
});

解析 markdown 并生成 menu

当 markdown 的内容解析完成渲染到页面的时候, 便可以通过 html 生成 menu 数据

const mkdRef = useRef(null);

const uuid = () => {
  return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
    (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
  );
}

const setupData = () => {
  const nodeInfo = [];
  if(!mkdRef.current) {
    return;
  }
  setTimeout(() => {
    mkdRef.current.childNodes.forEach(item => {
      if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(item.nodeName)) {
        const id = uuid();
        item.setAttribute('id', id);
        nodeInfo.push({
          type: item.nodeName, 
          id, 
          text: item.textContent,
          top: item.getBoundingClientRect().top + document.documentElement.scrollTop,
          el: item, 
        });
      }
    });
    if(nodeInfo.length) {
      setMenu(menu => ({
        ...menu,
        data: nodeInfo,
        cur: menu.cur || nodeInfo[0].id,
      }))
    }
  }, 300);
}

useEffect(() => {
  setupData();
  window.onload = setupData;
}, [])

return (
  // ....
	<div className="papers" ref={mkdRef} style={{maxWidth: `calc(100% - ${48 + 12 * Math.max(...menu.data.map(item => item.text.length), 0)}px)`}}>
	  <ReactMarkdown plugins={[gfm]} children={singlePost.body} />
	</div>
  // ....
)

通过 mkdRef 引用父节点, 并在组件加载后或页面渲染完成后通过父节点的 childNodes 遍历每一个子节点, 通过 uuid 函数给子节点生成一个id标识, 将每一个子元素的信息放到 data 中以便在渲染的时候使用, cur 默认设置为文章第一个标题的 id, 为了解决 onload 事件触发的时候当前组件并没有加载的问题, 在组件加载完成之后立即解析了 menu

将 menu 渲染到页面上

有了 menu 数据, 接下来只需要遍历 data 中的内容

<section className="doc-aside">
  <div className="post-menu bg-black-800 rounded">
    {
      menu.data.map((item) => {
        return (
          <div 
            className={`${item.type}type post-menu-item`} 
            key={item.id}
          >
            <span 
              className={`text-red-100 font-bold cursor-pointer hover:underline hover:text-red-400 text-xs transition-all ${menu.cur === item.id ? 'text-red-300' : ''}`}
            >
              {item.text}
            </span>
          </div>
        );
      })
    }
  </div>
</section>

我们为每一个标题的 div 元素添加一个 type 前缀, 这样对于 H1type, H2type 这样的类型可以应用不同的样式, 表示目录的一种层级关系, 样式定义如下:


.H3type {
  text-indent: 12px;
}
.H4type {
  text-indent: 24px;
}
.H5type {
  text-indent: 36px;
}
.H6type {
  text-indent: 48px;
}

由于我写文章一般只会以 h1 作为文章标题, 文章内容最多会出现 h2, 所以没有定义 h2 的缩进,h2 默认就是一级标题缩进为0

有了目录, 接下来需要有交互

根据当前的位置进行标题导航

在页面滚动的时候需要监听当前所处的页面位置, 并实时更新当前所在的标题元素, 首先要获取目录元素的引用, 将渲染目录的部分修改一下:

const docAsideRef = useRef(null);

return (
	<section className="doc-aside">
	  <div className="post-menu bg-black-800 rounded" ref={docAsideRef}>
	    {
	      menu.data.map((item) => {
	        return (
	          <div 
	            className={`${item.type}type post-menu-item`} 
	            key={item.id}
	          >
	            <span 
	              className={`text-red-100 font-bold cursor-pointer hover:underline hover:text-red-400 text-xs transition-all ${menu.cur === item.id ? 'text-red-300' : ''}`}
	            >
	              {item.text}
	            </span>
	          </div>
	        );
	      })
	    }
	  </div>
	</section>
)
const isRedirect = useRef(false);
const docAsideTopRef = useRef(null);

const debounce = (fn, ms = 0) => {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), ms);
  };
};

const setMenuPosition = (scrollTop) => {
  if(!docAsideRef.current) {
    return;
  }
  let headerHeight = document.querySelector('#root > header').offsetHeight;
  if(!docAsideTopRef.current) {
    docAsideTopRef.current = docAsideRef.current.getBoundingClientRect().top + scrollTop;
  }
  if(docAsideTopRef.current - headerHeight < scrollTop) {
    docAsideRef.current.style.top = `${scrollTop + headerHeight - docAsideTopRef.current + 10}px`;
  } else {
    docAsideRef.current.style.top = '0px';
  }
}

const isPageBottom = () => {
  const scrollTop = document.documentElement.scrollTop;
  return document.documentElement.clientHeight + scrollTop >= document.documentElement.scrollHeight
}

const setCurrentMenu = useCallback((scrollTop) => {
	let cur = null;
	for(let item of menu.data) {
	  if (scrollTop >= item.top) {
	    cur = item.id;
	  } else {
	    break;
	  }
	}
	if (isPageBottom()) {
	  cur = menu.data[menu.data.length - 1].id;
	}
	if(cur) {
	  setMenu(menu => ({ ...menu, cur }))
	}
}, [menu.data]);

useEffect(() => {
  if(menu.data.length) {
    setMenuPosition(document.documentElement.scrollTop);
  }
  document.body.onscroll = debounce(() => {
    if(!menu.data.length) {
      return;
    }
    const scrollTop = document.documentElement.scrollTop;
    setMenuPosition(scrollTop);
    if(isRedirect.current) {
      return;
    }
    setCurrentMenu(scrollTop);
  }, 300);
}, [menu.data.length, setCurrentMenu]);

这里用 isRedirect 来保存我们当前是否处在"点击目录跳转到页面内容"的过程中, 如果是, 则不用更新当前所在标题的id值. 当 menu.data 的长度或 setCurrentMenu 发生变化都要设置当前目录的位置, 以及重新设置滚动的监听, 因为我们使用了 debouce 来减少触发滚动的频率, 里面缓存了 setCurrentMenu, 所以这里给 setCurrentMenu 添加 useCallback 减少重新设置监听的次数 , setCurrentMenu 所做的事情就是根据当前页面的滚动高度并结合每个标题相对页面顶部的高度, 判断当前位置所对应的标题

为了使目录紧随文章显示, 用 setMenuPosition 来对目录的位置进行更新, 逻辑是当目录距离页面顶部的高度小于导航栏与页面滚动高度之和, 则将其位置设置为两者距离的差值, 否则重置为0. 由于 getBoundingClientRect().top 的值是动态变化的, 要获取元素距离页面顶部的高度需要加上 scrollTop 值, 这个值一般不会变化, 所以只需要通过 docAsideTopRef 存储一次

有了滚动事件的监听, 接下来需要实现用户主动交互的逻辑

点击目录能跳转到标题所在的内容

为了在点击的时候能有反馈, 需要给每一个目录项添加点击事件, 代码如下:

const scrollPage = (item) => {
  isRedirect.current = true;
  const scrollTop = document.documentElement.scrollTop;
  const headerHeight = document.querySelector('#root > header').offsetHeight;
  const newTop = item.el.getBoundingClientRect().top + scrollTop;
  window.scrollTo(0, newTop);
  const fixedTop = (item.el.getBoundingClientRect().top < headerHeight) && (item.el.getBoundingClientRect().top > 0) ? newTop - headerHeight : newTop;
  window.scrollTo(0, fixedTop);
  setTimeout(() => {
    isRedirect.current = false;
  }, 1000);
}

return (
	<section className={`doc-aside ${menu.data.length ? '' : 'hidden'}`}>
		<div className="post-menu bg-black-800 rounded" ref={docAsideRef}>
		  {
		    menu.data.map((item) => {
		      return (
		        <div 
		          className={`${item.type}type post-menu-item`} 
		          key={item.id} 
		          onClick={(e) => {
		            e.stopPropagation();
		            setMenu(menu => ({ ...menu, cur: item.id }));
		            scrollPage(item);
		          }} 
		        >
		          <span 
		            className={`text-red-100 font-bold cursor-pointer hover:underline hover:text-red-400 text-xs transition-all ${menu.cur === item.id ? 'text-red-300' : ''}`}
		          >
		            {item.text}
		          </span>
		        </div>
		      );
		    })
		  }
		</div>
	</section>
)

这里在目录数据没有生成的时候添加一个 hidden 类用来隐藏当前目录元素, 防止这里变成一个黑点, 当点击当前目录项的时候需要完成两件事

  1. 设置当前目录的id为当前所在的标题id以高亮当前目录给用户视觉反馈.
  2. 页面滚动到与目录标题对应的内容位置

setMenu 即完成第一件事并对当前目录设置 text-red-300 这个高亮样式类, scrollPage 完成第二件事, 在滚动之前设置 isRedirect.current 为 true, 表示当前处在"点击目录跳转到页面内容"的过程中, 这样在滚动的过程中就不会产生当前所在标题id值被覆盖的问题

newTop 为当前标题元素相对于页面顶部的位置, 但在滚动到该位置后会出现一个问题, 顶部的导航栏会遮挡一部分内容, 所以设置了一个 fixedTop 变量, 再次修正滚动位置, 如果这个时候元素距离窗口顶部的距离(动态变化的值)小于导航栏且大于0表示该位置有一部分内容被遮挡住了, 那么在原来的基础上减去顶部导航栏的高度就是正确的位置

写在最后

markdown 文章目录的实现基本上就是解析目录并通过 DOM api 进行一些位置更新完成了, 还有一些小细节没有考虑到...

本文完整代码


import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import imageUrlBuilder from '@sanity/image-url';
import ReactMarkdown from 'react-markdown';
import gfm from 'remark-gfm';

import sanityClient from '../client';

import './SinglePost.css';

const debounce = (fn, ms = 0) => {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), ms);
  };
};

const builder = imageUrlBuilder(sanityClient);

const uuid = () => {
  return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
    (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
  );
}

function urlFor(source) {
  return builder.image(source);
}

export default function SinglePost() {
  const [singlePost, setSinglePost] = useState({});
  const [menu, setMenu] = useState({
    data: [],
    cur: ''
  });
  const mkdRef = useRef(null);
  const docAsideRef = useRef(null);
  const docAsideTopRef = useRef(null);
  const isRedirect = useRef(false);
  const { slug } = useParams();

  useEffect(() => {
    sanityClient.fetch(`*[slug.current == "${slug}"]{
      title,
      _id,
      slug,
      mainImage{
        asset->{
          _id,
          url,
        },
      },
      body,
      "name": author->name,
      "authorImage": author->image
    }`).then((data) => setSinglePost(data[0]))
    .catch(console.error)
  }, [slug]);

  const setupData = () => {
    const nodeInfo = [];
    if(!mkdRef.current) {
      return;
    }
    setTimeout(() => {
      mkdRef.current.childNodes.forEach(item => {
        if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(item.nodeName)) {
          const id = uuid();
          item.setAttribute('id', id);
          nodeInfo.push({
            type: item.nodeName, 
            id, 
            text: item.textContent,
            top: item.getBoundingClientRect().top + document.documentElement.scrollTop,
            el: item, 
          });
        }
      });
      if(nodeInfo.length) {
        setMenu(menu => ({
          ...menu,
          data: nodeInfo,
          cur: menu.cur || nodeInfo[0].id,
        }))
      }
    }, 300);
  }

  useEffect(() => {
    setupData();
    window.onload = setupData;
  }, [])

  const setMenuPosition = (scrollTop) => {
    if(!docAsideRef.current) {
      return;
    }
    let headerHeight = document.querySelector('#root > header').offsetHeight;
    if(!docAsideTopRef.current) {
      docAsideTopRef.current = docAsideRef.current.getBoundingClientRect().top + scrollTop;
    }
    if(docAsideTopRef.current - headerHeight < scrollTop) {
      docAsideRef.current.style.top = `${scrollTop + headerHeight - docAsideTopRef.current + 10}px`;
    } else {
      docAsideRef.current.style.top = '0px';
    }
  }

  const isPageBottom = () => {
    const scrollTop = document.documentElement.scrollTop;
    return document.documentElement.clientHeight + scrollTop >= document.documentElement.scrollHeight
  }

  const setCurrentMenu = useCallback((scrollTop) => {
    let cur = null;
    for(let item of menu.data) {
      if (scrollTop >= item.top) {
        cur = item.id;
      } else {
        break;
      }
    }
    if (isPageBottom()) {
      cur = menu.data[menu.data.length - 1].id;
    }
    if(cur) {
      setMenu(menu => ({ ...menu, cur }))
    }
  }, [menu.data]);

  const scrollPage = (item) => {
    isRedirect.current = true;
    const scrollTop = document.documentElement.scrollTop;
    const headerHeight = document.querySelector('#root > header').offsetHeight;
    const newTop = item.el.getBoundingClientRect().top + scrollTop;
    window.scrollTo(0, newTop);
    const fixedTop = (item.el.getBoundingClientRect().top < headerHeight) && (item.el.getBoundingClientRect().top > 0) ? newTop - headerHeight : newTop;
    window.scrollTo(0, fixedTop);
    setTimeout(() => {
      isRedirect.current = false;
    }, 1000);
  }

 useEffect(() => {
    if(menu.data.length) {
      setMenuPosition(document.documentElement.scrollTop);
    }
    document.body.onscroll = debounce(() => {
      if(!menu.data.length) {
        return;
      }
      const scrollTop = document.documentElement.scrollTop;
      setMenuPosition(scrollTop);
      if(isRedirect.current) {
        return;
      }
      setCurrentMenu(scrollTop);
    }, 300);
  }, [menu.data.length, setCurrentMenu]);

  return (
    <>
      <main className="bg-gray-200 min-h-screen p-12 relative z-0 single-post">
        <article className="container shadow-lg mx-auto bg-green-100 rounded-lg">
          {singlePost && (
            <>
              <header className="relative">
                <div className="absolute h-full w-full flex items-center justify-center p-8">
                  <div className="bg-white bg-opacity-75 rounded p-12">
                    <h1 
                      className="cursive text-3xl lg:text-6xl mb-4" 
                      style={{textShadow: '0 0 10px #000'}}
                    >
                      {singlePost.title}
                    </h1>
                    <div className="flex justify-center text-gray-800">
                      <img 
                        src={urlFor(singlePost.authorImage).url()} 
                        alt={singlePost.name}
                        className="w-10 h-10 rounded-full"
                      />
                      <p className="cursive flex items-center pl-2 text-2xl">{singlePost.name}</p>
                    </div>
                  </div>
                </div>
                <img 
                  src={singlePost.mainImage ? singlePost.mainImage.asset.url : ''}
                  alt={singlePost.title}
                  className="w-full object-cover rounded-t"
                  style={{height: 400}}
                />
              </header>
              <div 
                className="flex flex-row overflow-hidden px-10 lg:px-18 py-6 prose lg:prose-xl max-w-full justify-between"
              >
                <div className="papers" ref={mkdRef} style={{maxWidth: `calc(100% - ${48 + 12 * Math.max(...menu.data.map(item => item.text.length), 0)}px)`}}>
                  <ReactMarkdown plugins={[gfm]} children={singlePost.body} />
                </div>
                <section className={`doc-aside ${menu.data.length ? '' : 'hidden'}`}>
                  <div className="post-menu bg-black-800 rounded" ref={docAsideRef}>
                    {
                      menu.data.map((item) => {
                        return (
                          <div 
                            className={`${item.type}type post-menu-item`} 
                            key={item.id} 
                            onClick={(e) => {
                              e.stopPropagation();
                              setMenu(menu => ({ ...menu, cur: item.id }));
                              scrollPage(item);
                            }} 
                          >
                            <span 
                              className={`text-red-100 font-bold cursor-pointer hover:underline hover:text-red-400 text-xs transition-all ${menu.cur === item.id ? 'text-red-300' : ''}`}
                            >
                              {item.text}
                            </span>
                          </div>
                        );
                      })
                    }
                  </div>
                </section>
              </div>
            </>
          )}
          {!singlePost && '加载中...'}
        </article>
      </main>
    </>
  )
}
显示整篇文章拥有的标题
解析 markdown 并生成 menu
将 menu 渲染到页面上
根据当前的位置进行标题导航
点击目录能跳转到标题所在的内容
写在最后
本文完整代码