51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

Vue3开发小应用:实现文章目录功能

Vue3开发应用:实现文章目录功能。一起往下了解下吧!

目录组件:

  • 自动高亮选中当前正在阅读的章节

  • 自动展开当前正在阅读的章节的子标题,并隐藏其他章节的子标题

  • 显示阅读进度

实现过程

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

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

效果图:

QQ截图20220315082506.jpg

代码如下:

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

        <div>             <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 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>


赞(4)
未经允许不得转载:工具盒子 » Vue3开发小应用:实现文章目录功能