hashmd: Hackable Markdown Editor and Viewer (github.com) 是掘金同款 Markdown 编辑器。
定义视图
如果当前视口全为当前章节内容,则定位到章节目录。
如果当前视图口包含多个章节内容,我们可以以顶部线为标准,认为与视口上边缘线交叉的内容则展示当前目录。
目录导航插件
新建文件 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>