掘金同款编辑器:ByteMD 目录插件

449 阅读1分钟

hashmd: Hackable Markdown Editor and Viewer (github.com) 是掘金同款 Markdown 编辑器。

定义视图

如果当前视口全为当前章节内容,则定位到章节目录。

image.png

如果当前视图口包含多个章节内容,我们可以以顶部线为标准,认为与视口上边缘线交叉的内容则展示当前目录。

image.png

目录导航插件

新建文件 bytemd/plugin-topic/index.js :

/**
 * @file ByteMD 目录插件
 */

import { visit } from 'unist-util-visit';

function getScrollContainer(el, className) {
  const parentNode = el.parentNode;
  if (!parentNode || parentNode === document.documentElement) return parentNode;
  if (className ? parentNode.classList.contains('md-scroll-container') : window.getComputedStyle(parentNode).overflow === 'auto' || window.getComputedStyle(parentNode).overflow === 'overlay') {
    return parentNode;
  }
  return getScrollContainer(parentNode);
}

/**
 * @param {object} options 选项
 * @param {object} options.topic 目录
 * @param {number} options.topic.currentTopicIndex 当前索引
 * @param {object[]} options.topic.items 目录项,插件获取到的数据项会追加到末尾
 * @return {import('bytemd').BytemdPlugin}
 */
export default (options = {}) => {
  return {
    viewerEffect: () => {
      const markdown = document.querySelector('.markdown-body');
      const headings = markdown && markdown.querySelectorAll('h1,h2,h3,h4,h5,h6') || [];
      const scrollContainer = getScrollContainer(markdown);
      const observer = new IntersectionObserver((entries) => {
        entries.forEach((entry) => {
          const index = Number(entry.target.id.split('-')[1]);
          const isAbove = entry.boundingClientRect.top < entry.rootBounds.top;
          if (entry.isIntersecting) {
            if (!isAbove) { // 如果从上往里进入
              options.topic.currentTopicIndex = index - 1;
            }
          } else {
            if (isAbove) { // 如果从里往上出去
              options.topic.currentTopicIndex = index;
            }
          }
        });
      }, {
        threshold: 1,
        root: scrollContainer,
        rootMargin: `-120px 0px 0px 0px`
      });
      Array.from(headings).forEach((heading, index) => {
        heading.id = `heading-${index}`;
        observer.observe(heading);
      });
    },
    rehype: (processor) => {
      processor.use(() => {
        return (tree) => {
          const items = options.topic.items;
          if (tree && tree.children.length) {
            tree.children.filter((child) => {
              return child.type === 'element';
            }).forEach((node) => {
              if (node.tagName.charAt(0) === 'h' && !!node.children.length) {
                const i = Number(node.tagName.charAt(1));
                items.push({
                  level: i,
                  text: ((e) => {
                    let result = [];
                    visit(e, (node) => {
                      if (node.type === 'text') {
                        result.push(node.value);
                      }
                    });
                    return result.join('');
                  })(node)
                });
              }
            });
          }
          return tree;
        }
      });
      return processor;
    }
  };
}

使用插件

<script setup>
const topic = ref({
  currentTopicIndex: -1,
  items: []
});

const containerRef = ref(); // 文章的滚动容器
const scrollToHeading = (index) => {
  const heading = document.querySelector(`#heading-${index}`);
  containerRef.value && containerRef.value.scrollTo({
    top: heading.offsetTop,
    behavior: 'instant'
  });
  topic.value.currentTopicIndex = index;
};
</script>

<template>
  <BytemdViewer
    class="markdown-body max-w-full"
    :style="{ fontSize: articleBaseFontSize + 'px' }"
    :value="article.content"
    :plugins="[
      topicPlugin({
        topic
      })
    ]"
  />
  
  <div v-if="!!topic.items.length" class="sticky top-20 bg-$custom-content-bg w-full mt-4 p-4 rounded-sm box-border">
    <h5 class="text-lg">目录</h5>
    <hr />
    <ul class="text-sm mt-4">
      <li
        v-for="(item, index) in topic.items"
        :key="index"
        :title="item.text"
        class="text-ellipsis"
      >
        <a class="block hover:text-blue-500 py-1" :class="[ {'text-blue-500 before:content-empty before:absolute before:left-0 before:w-3px before:h-12px before:translate-y-4px before:bg-blue-500': topic.currentTopicIndex === index} ]" :style="{paddingLeft: `${(item.level-1)}em`}" href="javascript:void(0)" @click="scrollToHeading(index)">{{ item.text }}</a>
      </li>
    </ul>
  </div>
</template>