v-md-editor使用 & 生成el-tree文章目录滚动跟随高亮

官网文档

v-md-editor官方文档介绍的很详细

滚动条样式修改

效果

实现的有:图片上传、添加表情、添加行号、一键复制、代码高亮。
在这里插入图片描述

代码

package.json

{
  "name": "vue-router-test",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build"
  },
  "dependencies": {
    "@kangc/v-md-editor": "^1.7.11",
    "animate.css": "^4.1.1",
    "axios": "^1.3.4",
    "babel-plugin-prismjs": "^2.1.0",
    "core-js": "^3.8.3",
    "element-ui": "^2.15.13",
    "sass": "^1.60.0",
    "sass-loader": "^13.2.2",
    "vue": "^2.6.14",
    "vue-router": "^3.5.1"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~5.0.0",
    "@vue/cli-plugin-router": "~5.0.0",
    "@vue/cli-service": "~5.0.0",
    "vue-template-compiler": "^2.6.14"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

babel.config.js

const components = require('prismjs/components');
const allLanguages = Object.keys(components.languages).filter((item) => item !== 'meta');
module.exports = {
  presets: [
    '@vue/cli-plugin-babel/preset'
  ],
  plugins: [
    [
      'prismjs',
      {
        languages: allLanguages,
      },
    ],
  ],
}

request.js

import axios from 'axios'
import router from '@/router'


const instance = axios.create({
    baseURL: 'http://localhost:8083',
    timeout: 60000,
    withCredentials: true /* 需要设置这个选项,axios发送请求时,才会携带cookie, 否则不会携带 */
})

// Add a request interceptor
instance.interceptors.request.use(function (config) {
    // Do something before request is sent

    return config;
  }, function (error) {
    // Do something with request error
    return Promise.reject(error);
  });

// Add a response interceptor
instance.interceptors.response.use(function (response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    console.log('收到响应',response);

    if(response.data.code == 401) {
        router.push('/login')
    }

    return response.data.data;
  }, function (error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    return Promise.reject(error);
  });

export default instance

main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'

import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';

import 'animate.css'
import '@/assets/base.scss'

import VueMarkdownEditor from '@kangc/v-md-editor';
import '@kangc/v-md-editor/lib/style/base-editor.css';
import vuepressTheme from '@kangc/v-md-editor/lib/theme/vuepress.js';
import '@kangc/v-md-editor/lib/theme/style/vuepress.css';

// 代码高亮
import Prism from 'prismjs';

// Emoji 表情插件
import createEmojiPlugin from '@kangc/v-md-editor/lib/plugins/emoji/index';
import '@kangc/v-md-editor/lib/plugins/emoji/emoji.css';

// 代码行号插件
import createLineNumbertPlugin from '@kangc/v-md-editor/lib/plugins/line-number/index';

// 高亮代码行插件
import createHighlightLinesPlugin from '@kangc/v-md-editor/lib/plugins/highlight-lines/index';
import '@kangc/v-md-editor/lib/plugins/highlight-lines/highlight-lines.css';

// 快捷复制插件
import createCopyCodePlugin from '@kangc/v-md-editor/lib/plugins/copy-code/index';
import '@kangc/v-md-editor/lib/plugins/copy-code/copy-code.css';

// 快捷插入提示(带样式)
import createTipPlugin from '@kangc/v-md-editor/lib/plugins/tip/index';
import '@kangc/v-md-editor/lib/plugins/tip/tip.css';

// md预览组件
import VMdPreview from '@kangc/v-md-editor/lib/preview';
import '@kangc/v-md-editor/lib/style/preview.css';

import VMdPreviewHtml from '@kangc/v-md-editor/lib/preview-html';
import '@kangc/v-md-editor/lib/style/preview-html.css';

VMdPreview.use(vuepressTheme, {
  Prism,
})
VMdPreview.use(createLineNumbertPlugin());
VMdPreview.use(createHighlightLinesPlugin());
VMdPreview.use(createCopyCodePlugin());

VueMarkdownEditor.use(vuepressTheme, {
  Prism,
});
VueMarkdownEditor.use(createEmojiPlugin());
VueMarkdownEditor.use(createLineNumbertPlugin());
VueMarkdownEditor.use(createHighlightLinesPlugin());
VueMarkdownEditor.use(createCopyCodePlugin());
VueMarkdownEditor.use(createTipPlugin());


Vue.use(VueMarkdownEditor);
Vue.use(VMdPreview);
Vue.use(VMdPreviewHtml);



Vue.config.productionTip = false



Vue.use(ElementUI);


new Vue({
  router,
  render: h => h(App)
}).$mount('#app')


Article.vue

<style lang="scss">
/* 整个滚动条 */
::-webkit-scrollbar {
    width: 10px;
    height: 10px;
}

/* 滚动条上的滚动滑块,参考: 滚动条样式修改->https://blog.csdn.net/coder_jxd/article/details/124213962 */
::-webkit-scrollbar-thumb {
    background-color: #49b1f5;
    /* 关键代码 */
    background-image: -webkit-linear-gradient(45deg,
            rgba(255, 255, 255, 0.4) 25%,
            transparent 25%,
            transparent 50%,
            rgba(255, 255, 255, 0.4) 50%,
            rgba(255, 255, 255, 0.4) 75%,
            transparent 75%,
            transparent);
    border-radius: 32px;
}

/* 添加行号插件后,它默认把滚动条给覆盖了,所以将padding-left改成margin-left */
[class*=v-md-prism-] {
    margin-left: 72px !important;
    padding-left: 0 !important;
}

/* 滚动条样式,参考: */
/* 滚动条轨道 */
::-webkit-scrollbar-track {
    background-color: #dbeffd;
    border-radius: 32px;
}

.article {
    height: 100vh;
    width: 100vw;

    display: flex;

    .left {
        width: 220px;
        flex-shrink: 0;
        background-color: #ddd;
    }

    .main {
        overflow: hidden;
        flex-grow: 1;

        .main-header {
            height: 54px;
            background: #ccc;
        }

        .main-content {
            height: calc(100% - 54px);
            box-sizing: border-box;
            padding: 30px;
            background-color: #aaa;

            .md-wrapper {
                width: 100%;
                height: 100%;
                background-color: #bbb;
            }
        }
    }

}
</style>

<template>
    <div class="article">
        <div class="left"></div>
        <div class="main">
            <div class="main-header"></div>
            <div class="main-content">
                <div class="md-wrapper">
                    <div>
                        <el-input v-model="title" style="margin-bottom: 10px;margin-right:10px;width: 300px;"></el-input>
                        <el-button @click="publish" type="success">发表</el-button>
                    </div>
                    <v-md-editor 
                        ref="vmdEditorRef"
                        v-model="mdContent" 
                        height="calc(100% - 50px)" 
                        left-toolbar="undo redo clear | h bold italic strikethrough quote | ul ol table hr | link image code | emoji | tip | save"
                        :disabled-menus="[]"
                        :include-level="[1, 2, 3, 4, 5, 6]"
                        @change="handleChange"
                        @upload-image="handleUploadImage"></v-md-editor>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
import axiosInstance from '@/utils/request'
export default {
    name: 'Article',
    data() {
        return {
            title: '',
            mdContent: '',
            htmlContent :''
        }
    },
    methods: {
        publish() {
            axiosInstance({
                url: "http://127.0.0.1:8083/article/save",
                method: 'POST',
                data: {title:this.title,mdContent:this.mdContent,htmlContent: this.htmlContent},
            }).then(res => {
                this.mdContent = ''
                this.title = ''
                this.$router.push('/articleMdView/' + res)
            })
        },
        handleChange(text, html) {
            // 获取到对应的htmlContent
            this.htmlContent = html
            console.log(this.htmlContent);
        },
        handleUploadImage(event, insertImage, files) {
            // 拿到 files 之后上传到文件服务器,然后向编辑框中插入对应的内容
            console.log(files);
            let formData = new FormData()
            formData.append('mfile', files[0])
            axiosInstance({
                url: "http://127.0.0.1:8083/article/uploadImg",
                method: 'POST',
                data: formData,
                headers: { 'Content-Type': 'multipart/form-data' }
            }).then(res => {
                insertImage({
                    url: 'http://127.0.0.1:8083/img/' + res,
                    desc: res
                })
            })
        }
    }
}
</script>

ArticleMdView.vue

<style lang="scss">
/* 整个滚动条 */
::-webkit-scrollbar {
    width: 10px;
    height: 10px;
}

/* 滚动条上的滚动滑块,参考: 滚动条样式修改->https://blog.csdn.net/coder_jxd/article/details/124213962 */
::-webkit-scrollbar-thumb {
    background-color: #49b1f5;
    /* 关键代码 */
    background-image: -webkit-linear-gradient(45deg,
            rgba(255, 255, 255, 0.4) 25%,
            transparent 25%,
            transparent 50%,
            rgba(255, 255, 255, 0.4) 50%,
            rgba(255, 255, 255, 0.4) 75%,
            transparent 75%,
            transparent);
    border-radius: 32px;
}

/* 添加行号插件后,它默认把滚动条给覆盖了,所以将padding-left改成margin-left */
[class*=v-md-prism-] {
    margin-left: 72px !important;
    padding-left: 0 !important;
}

/* 滚动条样式,参考: */
/* 滚动条轨道 */
::-webkit-scrollbar-track {
    background-color: #dbeffd;
    border-radius: 32px;
}

.sticky .article-anchor {
    position: fixed;
}

.article-wrapper {
    width: 1200px;
    margin: 20px auto;

    position: relative;

    .article-content {
        margin-right: 320px;
        border-radius: 6px;
        overflow: hidden;

        .article-title {
            background: #fff;
            color: #49b1f5;
            font-size: 1.6em;
            font-weight: bold;
            text-align: center;
            height: 50px;
            padding: 10px;
            line-height: 60px;
            border-bottom: 1px dashed #bebebe;
        }
    }

    .article-side {
        width: 310px;
        box-sizing: border-box;
        position: absolute;
        right: 0;
        top: 0;

        .article-anchor {
            width: 310px;
            padding: 10px;
            background: #fff;
            border-radius: 6px;
            transition: all 0.28s;
            & > div {
                text-overflow: ellipsis;
                white-space: nowrap;
                overflow: hidden;
            }
            a {
                color: #3eaf7c;
            }
        }
    }


}
</style>

<template>
    <div class="articleView">

        <div class="article-wrapper">
            <div class="article-content">
                <div class="article-title">
                    {{ articleTitle }}
                </div>
                <v-md-preview :text="mdContent" ref="preview"></v-md-preview>
            </div>
            <div :class="['article-side',{'sticky':isSticky}]">
                <div :class="classArr">
                    <div v-for="anchor, idx in titles" :key="idx"
                        :style="{ padding: `10px 0 10px ${anchor.indent * 20}px` }" @click="handleAnchorClick(anchor)">
                        <a style="cursor: pointer">{{ anchor.title }}</a>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
import axiosInstance from '@/utils/request'
export default {
    name: 'ArticleView',
    data() {
        return {
            mdContent: '',
            articleTitle: '',
            titles: [],
            isSticky:false,
            classArr: ['article-anchor','animate__animated']
        }
    },
    created() {
        console.log(this.$route.params.articleId, 'created');
        window.addEventListener('scroll', () => {
            if(document.documentElement.scrollTop > 240) {
                this.isSticky = true
                if(this.classArr.indexOf('animate__backInDown') == -1) {
                    this.classArr.push('animate__backInDown')
                }
                
            } else {
                this.isSticky = false
                if(this.classArr.indexOf('animate__backInDown') != -1) {
                    this.classArr.splice(this.classArr.indexOf('animate__backInDown'),1)
                }
            }
        })
        axiosInstance({
            method: 'POST',
            url: 'http://localhost:8083/article/findById/' + this.$route.params.articleId
        }).then(res => {
            console.log('请求完成');
            this.mdContent = res.mdContent
            this.articleTitle = res.title
            this.$nextTick(() => {
                const anchors = this.$refs.preview.$el.querySelectorAll('h1,h2,h3,h4,h5,h6');
                const titles = Array.from(anchors).filter((title) => !!title.innerText.trim());

                console.log('titles1', titles, this.$refs.preview.$el);
                window.test = this.$refs.preview.$el
                if (!titles.length) {
                    this.titles = [];
                    return;
                }

                const hTags = Array.from(new Set(titles.map((title) => title.tagName))).sort();

                this.titles = titles.map((el) => ({
                    title: el.innerText,
                    lineIndex: el.getAttribute('data-v-md-line'),
                    indent: hTags.indexOf(el.tagName),
                }));
            })
        })
    },
    mounted() {
        console.log('mounted');

    },
    methods: {
        handleAnchorClick(anchor) {
            const { preview } = this.$refs;
            const { lineIndex } = anchor;

            const heading = preview.$el.querySelector(`[data-v-md-line="${lineIndex}"]`);

            if (heading) {
                preview.scrollToTarget({
                    target: heading,
                    scrollContainer: window,
                    behavior: 'smooth',
                    top: 0,
                });
            }
        },
    },
}
</script>

ArticleHtmlView.vue

功能完全同上方的ArticleMdView.vue,只是这里用的是html。还有就是别把下面那个preview-class="vuepress-markdown-body"类名给丢了,否则,样式不能正确显示出来。

<style lang="scss">
/* 整个滚动条 */
::-webkit-scrollbar {
    width: 10px;
    height: 10px;
}

/* 滚动条上的滚动滑块,参考: 滚动条样式修改->https://blog.csdn.net/coder_jxd/article/details/124213962 */
::-webkit-scrollbar-thumb {
    background-color: #49b1f5;
    /* 关键代码 */
    background-image: -webkit-linear-gradient(45deg,
            rgba(255, 255, 255, 0.4) 25%,
            transparent 25%,
            transparent 50%,
            rgba(255, 255, 255, 0.4) 50%,
            rgba(255, 255, 255, 0.4) 75%,
            transparent 75%,
            transparent);
    border-radius: 32px;
}

/* 添加行号插件后,它默认把滚动条给覆盖了,所以将padding-left改成margin-left */
[class*=v-md-prism-] {
    margin-left: 72px !important;
    padding-left: 0 !important;
}

/* 滚动条样式,参考: */
/* 滚动条轨道 */
::-webkit-scrollbar-track {
    background-color: #dbeffd;
    border-radius: 32px;
}

.sticky .article-anchor {
    position: fixed;
}

.article-wrapper {
    width: 1200px;
    margin: 20px auto;

    position: relative;

    .article-content {
        margin-right: 320px;
        border-radius: 6px;
        overflow: hidden;

        .article-title {
            background: #fff;
            color: #49b1f5;
            font-size: 1.6em;
            font-weight: bold;
            text-align: center;
            height: 50px;
            padding: 10px;
            line-height: 60px;
            border-bottom: 1px dashed #bebebe;
        }
    }

    .article-side {
        width: 310px;
        box-sizing: border-box;
        position: absolute;
        right: 0;
        top: 0;

        .article-anchor {
            width: 310px;
            padding: 10px;
            background: #fff;
            border-radius: 6px;
            transition: all 0.28s;
            & > div {
                text-overflow: ellipsis;
                white-space: nowrap;
                overflow: hidden;
            }
            a {
                color: #3eaf7c;
            }
        }
    }


}
</style>

<template>
    <div class="articleView">

        <div class="article-wrapper">
            <div class="article-content">
                <div class="article-title">
                    {{ articleTitle }}
                </div>
                <v-md-preview-html :html="htmlContent" preview-class="vuepress-markdown-body" ref="preview"></v-md-preview-html>
            </div>
            <div :class="['article-side',{'sticky':isSticky}]">
                <div :class="classArr">
                    <div v-for="anchor, idx in titles" :key="idx"
                        :style="{ padding: `10px 0 10px ${anchor.indent * 20}px` }" @click="handleAnchorClick(anchor)">
                        <a style="cursor: pointer">{{ anchor.title }}</a>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
import axiosInstance from '@/utils/request'
export default {
    name: 'ArticleView',
    data() {
        return {
            htmlContent: '',
            articleTitle: '',
            titles: [],
            isSticky:false,
            classArr: ['article-anchor','animate__animated']
        }
    },
    created() {
        console.log(this.$route.params.articleId, 'created');
        window.addEventListener('scroll', () => {
            if(document.documentElement.scrollTop > 240) {
                this.isSticky = true
                if(this.classArr.indexOf('animate__backInDown') == -1) {
                    this.classArr.push('animate__backInDown')
                }
                
            } else {
                this.isSticky = false
                if(this.classArr.indexOf('animate__backInDown') != -1) {
                    this.classArr.splice(this.classArr.indexOf('animate__backInDown'),1)
                }
            }
        })
        axiosInstance({
            method: 'POST',
            url: 'http://localhost:8083/article/findById/' + this.$route.params.articleId
        }).then(res => {
            console.log('请求完成');
            this.htmlContent = res.htmlContent
            this.articleTitle = res.title
            this.$nextTick(() => {
                const anchors = this.$refs.preview.$el.querySelectorAll('h1,h2,h3,h4,h5,h6');
                const titles = Array.from(anchors).filter((title) => !!title.innerText.trim());

                if (!titles.length) {
                    this.titles = [];
                    return;
                }

                const hTags = Array.from(new Set(titles.map((title) => title.tagName))).sort();

                this.titles = titles.map((el) => ({
                    title: el.innerText,
                    lineIndex: el.getAttribute('data-v-md-line'),
                    indent: hTags.indexOf(el.tagName),
                }));
            })
        })
    },
    mounted() {
        console.log('mounted');

    },
    methods: {
        handleAnchorClick(anchor) {
            const { preview } = this.$refs;
            const { lineIndex } = anchor;

            const heading = preview.$el.querySelector(`[data-v-md-line="${lineIndex}"]`);

            if (heading) {
                preview.scrollToTarget({
                    target: heading,
                    scrollContainer: window,
                    behavior: 'smooth',
                    top: 0,
                });
            }
        },
    },
}
</script>

WebConfig

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry
                .addMapping("/**")
                .maxAge(3600)
                .allowCredentials(true)
                .allowedOrigins("*")
                .allowedMethods("*")
                .allowedHeaders("*")
                .exposedHeaders("token","Authorization")
        ;
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/img/**")
                .addResourceLocations("file:/D:\\Projects\\vue-springboot\\src\\main\\resources\\static\\img\\");
    }
}

ArticleController

package com.zzhua.controller;

import com.zzhua.dto.ArticleDto;
import com.zzhua.entity.ArticleEntity;
import com.zzhua.service.ArticleService;
import com.zzhua.utils.Result;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.ServletContext;
import java.io.File;
import java.io.IOException;

@RestController
@RequestMapping("article")
public class ArticleController {

    @Autowired
    private ArticleService articleService;

    @PostMapping("save")
    public Result save(@RequestBody ArticleDto articleDto){
        ArticleEntity articleEntity = new ArticleEntity();
        BeanUtils.copyProperties(articleDto, articleEntity);
        articleService.save(articleEntity);
        return Result.ok(articleEntity.getId());
    }

    @PostMapping("findById/{articleId}")
    public Result findById(@PathVariable("articleId") Integer articleId) {
        return Result.ok(articleService.getById(articleId));
    }

    @Autowired
    private ServletContext sc;

    @PostMapping("uploadImg")
    public Result uploadImg(MultipartFile mfile) throws IOException {
        String filename = mfile.getOriginalFilename();
        mfile.transferTo(new File("D:\\Projects\\vue-springboot\\src\\main\\resources\\static\\img\\"+filename));
        return Result.ok(filename);
    }

}

使用el-tree做目录结构

参考:Vue2根据文章h标签,自动生成一个目录树(Toc),并可以跳转位置(动画),同时可以监听滑动位置,改变对应目录高亮

效果

在这里插入图片描述

代码

<style lang="scss">
/* 整个滚动条 */
::-webkit-scrollbar {
    width: 10px;
    height: 10px;
}

/* 滚动条上的滚动滑块,参考: 滚动条样式修改->https://blog.csdn.net/coder_jxd/article/details/124213962 */
::-webkit-scrollbar-thumb {
    background-color: #49b1f5;
    /* 关键代码 */
    background-image: -webkit-linear-gradient(45deg,
            rgba(255, 255, 255, 0.4) 25%,
            transparent 25%,
            transparent 50%,
            rgba(255, 255, 255, 0.4) 50%,
            rgba(255, 255, 255, 0.4) 75%,
            transparent 75%,
            transparent);
    border-radius: 32px;
}

/* 添加行号插件后,它默认把滚动条给覆盖了,所以将padding-left改成margin-left */
[class*=v-md-prism-] {
    margin-left: 72px !important;
    padding-left: 0 !important;
}

/* 滚动条样式,参考: */
/* 滚动条轨道 */
::-webkit-scrollbar-track {
    background-color: #dbeffd;
    border-radius: 32px;
}

.sticky .article-anchor {
    position: fixed;
}

.sticky .toc-tree {
    position: fixed;
}

.articleView {
    display: flex;
}

.el-tree-node:focus>.el-tree-node__content {}

.el-tree-node:focus>.el-tree-node__content {
    background-color: #eee;
    color: #606266;
}


.el-tree--highlight-current .el-tree-node.is-focusable>.el-tree-node__content {
    background-color: transparent;
    border-radius: 5px;
    color: #606266;
}

.el-tree--highlight-current .el-tree-node.is-current>.el-tree-node__content {
    background-color: #49b1f5;
    border-radius: 5px;
    color: #fff;
}

.el-tree-node__content:hover,
.el-upload-list__item:hover {
    background-color: #fff;
}

@keyframes launch {
    0% {}

    100% {
        background-position: -600px;
    }
}

.rocket:hover {
    background-image: url(@/assets/img/rocket_seq.png);
    animation: launch 0.5s infinite steps(4);
}

.rocket {
    position: fixed;
    bottom: 20px;
    right: 160px;
    width: 150px;
    height: 174px;
    cursor: pointer;
    background-image: url(@/assets/img/rocket.png);

}

.article-wrapper {
    width: 1200px;
    margin: 20px auto;

    position: relative;

    .article-content {
        margin-right: 320px;
        border-radius: 6px;
        overflow: hidden;

        .article-title {
            background: #fff;
            color: #49b1f5;
            font-size: 1.6em;
            font-weight: bold;
            text-align: center;
            height: 50px;
            padding: 10px;
            line-height: 60px;
            border-bottom: 1px dashed #bebebe;
        }
    }

    .article-side {
        width: 310px;
        box-sizing: border-box;
        position: absolute;
        right: 0;
        top: 0;

        .article-anchor {
            width: 310px;
            padding: 10px;
            background: #fff;
            border-radius: 6px;
            transition: all 0.28s;

            &>div {
                text-overflow: ellipsis;
                white-space: nowrap;
                overflow: hidden;
            }

            a {
                color: #3eaf7c;
            }
        }

        .toc-tree {
            padding: 10px;
            background: #fff;
            border-radius: 6px;
            max-height: 280px;
            overflow-y: auto;
        }
    }


}
</style>

<template>
    <div class="articleView">

        <div class="article-wrapper">
            <div class="article-content" ref="articleContentRef">
                <div class="article-title">
                    {{ articleTitle }}
                </div>
                <v-md-preview :text="mdContent" ref="preview"></v-md-preview>
            </div>
            <div :class="['article-side', { 'sticky': isSticky }]">
                <!-- <div :class="classArr">
                            <div v-for="anchor, idx in titles" :key="idx"
                                :style="{ padding: `10px 0 10px ${anchor.indent * 20}px` }" @click="handleAnchorClick(anchor)">
                                <a style="cursor: pointer">{{ anchor.title }}</a>
                            </div>
                        </div> -->
                <div :class="classArr2" id="toc-tree">
                    <el-tree :data="tocData" ref="menuTree" node-key="id" highlight-current @node-click="handleNodeClick"
                        default-expand-all :expand-on-click-node="false">
                    </el-tree>
                </div>
            </div>

        </div>

        <transition enter-active-class="animate__animated animate__backInRight"
            leave-active-class="animate__animated animate__backOutRight">
            <div class="rocket" @click="scrollToTop" v-show="rocketShow"></div>
        </transition>

    </div>
</template>

<script>
import axiosInstance from '@/utils/request'
export default {
    name: 'ArticleView',
    data() {
        return {
            mdContent: '',
            articleTitle: '',
            titles: [],
            isSticky: false,
            // classArr: ['article-anchor', 'animate__animated'],
            classArr2: ['toc-tree', 'animate__animated'],
            tocTreeHeight: 240,
            tocData: [],
            currentNodeId: null,
            cachedTitleHeights: {}, // 记录高度
            rocketShow: false,
        }
    },
    created() {
        console.log(this.$route.params.articleId, 'created');
       
        window.addEventListener('scroll', () => {
            
            if (document.documentElement.scrollTop > 200) {
                this.rocketShow = true
            } else {
                this.rocketShow = false
            }
            if (document.documentElement.scrollTop > this.tocTreeHeight) {
                this.isSticky = true
                if (this.classArr2.indexOf('animate__backInDown') == -1) {
                    this.classArr2.push('animate__backInDown')
                }

            } else {
                this.isSticky = false
                if (this.classArr2.indexOf('animate__backInDown') != -1) {
                    this.classArr2.splice(this.classArr2.indexOf('animate__backInDown'), 1)
                }
            }
        })
        axiosInstance({
            method: 'POST',
            url: 'http://localhost:8083/article/findById/' + this.$route.params.articleId
        }).then(res => {
            console.log('请求完成');
            this.mdContent = res.mdContent
            this.articleTitle = res.title
            this.$nextTick(() => {
                this.makeToc()
            })
            /* 
            this.$nextTick(() => {
               const anchors = this.$refs.preview.$el.querySelectorAll('h1,h2,h3,h4,h5,h6');
                const titles = Array.from(anchors).filter((title) => !!title.innerText.trim());

                console.log('titles1', titles, this.$refs.preview.$el);
                window.test = this.$refs.preview.$el
                if (!titles.length) {
                    this.titles = [];
                    return;
                }

                const hTags = Array.from(new Set(titles.map((title) => title.tagName))).sort();

                this.titles = titles.map((el) => ({
                    title: el.innerText,
                    lineIndex: el.getAttribute('data-v-md-line'),
                    indent: hTags.indexOf(el.tagName),
                })); 

            })*/
        })
    },
    mounted() {
        console.log('mounted');

    },
    methods: {
        // 将一个集合的数据变成一个树形的数据结构
        toTree(data) {
            // 删除 所有 children,以防止多次调用
            data.forEach(function (item) {
                delete item.children;
            });

            // 将数据存储为 以 id 为 KEY 的 map 索引数据列
            var map = {};
            data.forEach(function (item) {
                map[item.id] = item;
            });
            var val = [];
            data.forEach(function (item) {
                // 以当前遍历项的pid,去map对象中找到索引的id
                var parent = map[item.p_id];
                // 好绕啊,如果找到索引,那么说明此项不在顶级当中,那么需要把此项添加到,他对应的父级中
                if (parent) {
                    (parent.children || (parent.children = [])).push(item);
                } else {
                    //如果没有在map中找到对应的索引ID,那么直接把 当前的item添加到 val结果集中,作为顶级
                    val.push(item);
                }
            });
            return val;
        },
        /**
         * 生成目录
         * */
        makeToc() {
            // 获取所有的h标签,给他们加上id,同时创建符合toTree方法要求的对象
            //{
            //          id:'',// 抛出id
            //           tag:'',// 抛出标签名称
            //          label:'',// 抛出标题
            //          p_id:'',// 抛出父级id
            // }

            // 定义参与目录生成的标签
            const tocTags = ["H1", "H2", "H3", "H4", "H5", "H6"];

            // 目录树结果
            const tocArr = [];

            //   debugger
            console.log(this.$refs.preview.$el);
            // 获取所有标题标签
            const headDoms = Array.from(this.$refs.preview.$el.querySelector('.vuepress-markdown-body').childNodes).filter(item => tocTags.includes(item.tagName));

            // 遍历标题标签
            headDoms.forEach((item, index, arr) => {
                // 给标题添加id
                item.id = `h-${index + 1}`;
                // 获取当前节点前面的节点
                let prevs = arr.filter((i, j) => j < index);
                // 过滤前面的节点为合理节点
                // 如 h3节点前  只能为 h1 h2 h3
                prevs = prevs.filter(i => tocTags.filter((i, j) => j <= tocTags.findIndex(i => i == item.tagName)).includes(i.tagName));
                // 对前面的节点进行排序,距离自身节点近的排在前面
                // 如 div > p > span > img  当前为img
                // 常规获取节点为 [div,p,span,img]
                // 排序后获取节点为 [img,span,p,div]
                prevs = prevs.sort((a, b) => -(a.id.replace('h-', '')) - b.id.replace('h-', ''));
                // 查询距离自身节点最近的不同于当前标签的节点
                const prev = prevs.find(i => i.tagName != item.tagName);
                this.maxum = Math.max(this.maxum, index + 1)
                tocArr.push({
                    id: index + 1,// 抛出id
                    tag: item.tagName,// 抛出标签名称
                    label: item.innerText,// 抛出标题
                    p_id: item.tagName == "H1" || prev == null ? 0 : Number(prev.id.replace("h-", '')),// 抛出父级id
                })
            })

            // 使用上述方法生成树 最后在el-tree的data中使用 tocData即可
            this.tocData = this.toTree(tocArr);
            /* 如:[{"id":1,"tag":"H2","label":"@Configuration注解介绍","p_id":0,"children":[{"id":2,"tag":"H3","label":"full模式和lite模式","p_id":1,"children":[{"id":3,"tag":"H4","label":"如何确定配置类是full模式或lite模式?","p_id":2},{"id":4,"tag":"H4","label":"full模式增强","p_id":2,"children":[{"id":5,"tag":"H5","label":"full模式增强实例","p_id":4}]}]}]}] */
            // console.log(JSON.stringify(this.tocData));

            this.$nextTick(() => {
                let tocTree = document.getElementById('toc-tree')
                let articleWrapper = document.querySelector('.article-wrapper')
                let extraHeight = articleWrapper.offsetTop
                let articleHeight = articleWrapper.offsetHeight

                let that = this

                let nodeTotalNum = 0 // 节点总数量
                function getcachedTitleHeights(node) {
                    if (node.id) {
                        // {'h-1': 123, 'h-2': 607,  ...}
                        that.cachedTitleHeights[`h-${node.id}`] = document.getElementById('h-' + node.id).offsetTop + extraHeight
                        nodeTotalNum++
                    }
                    if (node.children && node.children.length > 0) {
                        for (let index = 0; index < node.children.length; index++) {
                            getcachedTitleHeights(node.children[index])
                        }
                    }
                }
                if (this.tocData && this.tocData.length > 0) {
                    getcachedTitleHeights({ children: this.tocData })
                    console.log('->', this.cachedTitleHeights);
                }

                window.addEventListener('scroll', () => {
                    let scrollTop = document.documentElement.scrollTop
                    console.log('scrollTop', scrollTop);

                    if (scrollTop + 1 <= this.cachedTitleHeights['h-1']) {
                        console.log('还没到文章第一个标题');
                        // 还没滚动到第一个标题那里
                        return
                    }
                    if (scrollTop + 1 >= extraHeight + articleHeight) {
                        // 整个文章都滚动完了
                        console.log('文章已经看完了');
                        return
                    }
                    let foundIndex;
                    for (let index = 2; index <= nodeTotalNum; index++) {
                        console.log('scrollTop + 1', scrollTop + 1, this.cachedTitleHeights[`h-${index}`], this.cachedTitleHeights[`h-${index - 1}`], index);
                        if (scrollTop + 1 >= this.cachedTitleHeights[`h-${nodeTotalNum}`]) {
                            foundIndex = nodeTotalNum
                            console.log('应该高亮的是: ' + 'h-' + nodeTotalNum);
                        } else if (scrollTop + 1 <= this.cachedTitleHeights[`h-${index}`]
                            && scrollTop + 1 >= this.cachedTitleHeights[`h-${index - 1}`]) {
                            foundIndex = index - 1
                            console.log('应该高亮的是: ' + 'h-' + foundIndex);
                            break;
                        }
                    }
                    if (this.$refs.menuTree && foundIndex) {
                        console.log('foundIndex->', foundIndex);
                        this.$refs.menuTree.setCurrentKey(foundIndex)
                    }
                })
            })
        },
        handleNodeClick(data) {
            // console.log(data);
            // 平滑滚动
            document.getElementById(`h-${data.id}`).scrollIntoView({ 'behavior': 'smooth' })
            this.$refs.menuTree.setCurrentKey(null)
        },
        handleAnchorClick(anchor) {
            const { preview } = this.$refs;
            const { lineIndex } = anchor;

            const heading = preview.$el.querySelector(`[data-v-md-line="${lineIndex}"]`);

            if (heading) {
                preview.scrollToTarget({
                    target: heading,
                    scrollContainer: window,
                    behavior: 'smooth',
                    top: 0,
                });
            }
        },

        scrollToTop() {
            // 平滑滚动到顶部
            document.documentElement.scrollIntoView({ 'behavior': 'smooth', block: 'start' })
        }
    },
}
</script>
  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值