文章目录
官网文档
效果
实现的有:图片上传、添加表情、添加行号、一键复制、代码高亮。
代码
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>