7

如何使用 Vue3 实现文章目录功能

 2 years ago
source link: https://www.fly63.com/article/detial/11262
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
更新日期: 2022-03-14阅读量: 33标签: 功能分享

扫一扫分享

这一段时间一直在做一个博客项目 Kila Kila Blog,找了一圈发现没有特别满足自己需求的目录组件,所以决定自己动手,完成一个满足以下预期目标的目录组件:

  • 自动高亮选中当前正在阅读的章节
  • 自动展开当前正在阅读的章节的子标题,并隐藏其他章节的子标题
  • 显示阅读进度

由于标题之间有父子的关系,所以我们应该用树数据结构来解决这个问题。我们遍历文章容器中的所有标签,如果遇到 <h1>、<h2> 这类标签,就创建一个节点,将其放到列表中,之后使用 v-for 指令来生成目录就行了。下面分析一下每个节点需要有哪些属性。

一个树的节点,应该具有的属性包括:父节点的指针 parent、子节点的指针列表 children,因为一个节点代表一个标题,所以还要包含:标题的 ID号 id(用于 v-for 的 key),标题名 name(添加了标题的序号)、原始标题名 rawName 和标题的可见性 isVisible,当我们点击标题时,应该滚动到标题的位置,所以还要有 scrollTop 属性。在我们遍历文章容器中的所有标签时,需要判断当前遇到的标签和上一个标签之间的父子关系,所以要有一个 level 属性代表每一个节点的等级。

下面是具体实现代码

<template>
<div class="catalog-card" v-if="Object.keys(titles).length > 0">
<div class="catalog-card-header">
<div>
<span
><font-awesome-icon
:icon="['fas', 'bars-staggered']"
class="catalog-icon"
/></span>
<span>目录</span>
</div>
<span class="progress">{{ progress }}</span>
</div>

<div class="catalog-content">
<div
v-for="title in titles"
:key="title.id"
@click="scrollToView(title.scrollTop)"
:class="[
'catalog-item',
currentTitle.id == title.id ? 'active' : 'not-active',
]"
:style="{ marginLeft: title.level * 20 + 'px' }"
v-show="title.isVisible"
:title="title.rawName"
>
{{ title.name }}
</div>
</div>
</div>
</template>

<script>
import { reactive, ref } from "vue";

export default {
name: "KilaKilaCatalog",
setup(props) {
let titles = reactive(getTitles());
let currentTitle = reactive({});
let progress = ref(0);

// 获取目录的标题
function getTitles() {
let titles = [];
let levels = ["h1", "h2", "h3"];

let articleElement = document.querySelector(props.container);
if (!articleElement) {
return titles;
}

let elements = Array.from(articleElement.querySelectorAll("*"));

// 调整标签等级
let tagNames = new Set(
elements.map((el) => el.tagName.toLowerCase())
);
for (let i = levels.length - 1; i >= 0; i--) {
if (!tagNames.has(levels[i])) {
levels.splice(i, 1);
}
}

let serialNumbers = levels.map(() => 0);
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
let tagName = element.tagName.toLowerCase();
let level = levels.indexOf(tagName);
if (level == -1) continue;

let id = tagName + "-" + element.innerText + "-" + i;
let node = {
id,
level,
parent: null,
children: [],
rawName: element.innerText,
scrollTop: element.offsetTop,
};

if (titles.length > 0) {
let lastNode = titles.at(-1);

// 遇到子标题
if (lastNode.level < node.level) {
node.parent = lastNode;
lastNode.children.push(node);
}
// 遇到上一级标题
else if (lastNode.level > node.level) {
serialNumbers.fill(0, level + 1);
let parent = lastNode.parent;
while (parent) {
if (parent.level < node.level) {
parent.children.push(node);
node.parent = parent;
break;
}
parent = parent.parent;
}
}
// 遇到平级
else if (lastNode.parent) {
node.parent = lastNode.parent;
lastNode.parent.children.push(node);
}
}

serialNumbers[level] += 1;
let serialNumber = serialNumbers.slice(0, level + 1).join(".");

node.isVisible = node.parent == null;
node.name = serialNumber + ". " + element.innerText;
titles.push(node);
}

return titles;
}

// 监听滚动事件并更新样式
window.addEventListener("scroll", function () {
progress.value =
parseInt(
(window.scrollY / document.documentElement.scrollHeight) *
100
) + "%";

let visibleTitles = [];

for (let i = titles.length - 1; i >= 0; i--) {
const title = titles[i];
if (title.scrollTop <= window.scrollY) {
if (currentTitle.id === title.id) return;

Object.assign(currentTitle, title);

// 展开节点
setChildrenVisible(title, true);
visibleTitles.push(title);

// 展开父节点
let parent = title.parent;
while (parent) {
setChildrenVisible(parent, true);
visibleTitles.push(parent);
parent = parent.parent;
}

// 折叠其余节点
for (const t of titles) {
if (!visibleTitles.includes(t)) {
setChildrenVisible(t, false);
}
}

return;
}
}
});

// 设置子节点的可见性
function setChildrenVisible(title, isVisible) {
for (const child of title.children) {
child.isVisible = isVisible;
}
}

// 滚动到指定的位置
function scrollToView(scrollTop) {
window.scrollTo({ top: scrollTop, behavior: "smooth" });
}

return { titles, currentTitle, progress, scrollToView };
},
props: {
container: {
type: String,
default: ".post-body .article-content",
},
},
};
</script>

<style lang="less" scoped>
.catalog-card {
background: white;
border-radius: 8px;
box-shadow: 0 3px 8px 6px rgba(7, 17, 27, 0.05);
padding: 20px 24px;
width: 100%;
margin-top: 25px;
box-sizing: border-box;
}

.catalog-card-header {
text-align: left !important;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}

.catalog-icon {
font-size: 18px;
margin-right: 10px;
color: dodgerblue;
}

.catalog-card-header div > span {
font-size: 17px;
color: #4c4948;
}

.progress {
color: #a9a9a9;
font-style: italic;
font-size: 140%;
}

.catalog-content {
max-height: calc(100vh - 120px);
overflow: auto;
margin-right: -24px;
padding-right: 20px;
}

.catalog-item {
color: #666261;
margin: 5px 0;
line-height: 28px;
cursor: pointer;
transition: all 0.2s ease-in-out;
font-size: 14px;
padding: 2px 6px;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;

&:hover {
color: #1892ff;
}
}

.active {
background-color: #;
color: white;

&:hover {
background-color: #0c82e9;
color: white;
}
}
</style>

作者:之一Yo
出处:https://www.cnblogs.com/zhiyiYo/p/16003255.html

链接: https://www.fly63.com/article/detial/11262


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK