静态博客生成系统(二):markdown大纲提取以及页面滚动到对应区域时高亮处理
这篇文章发布于 2020/02/02,归类于 前端工程化
标签:
提取markdown文件的大纲结构数据,markdown大纲提取,mardown目录提取,根据大纲数据生成html,页面滚动时,自动切换大纲focus,怎么监听页面滚动到了某个标题区域
在考虑把个人博客站点静态化时,其中一个原因是markdown的大纲显示比较好,文章结构层次分明,可以更好的阅读。那怎么提取markdown文件的大纲数据,并呈现在网页上呢?下面来看看zuo-blog是怎么处理的。
提取markdown文件的大纲结构数据
知道将md转换为html文件的方法后,需要生成对应的大纲数据。其实在marked这个npm包里有对应的方法。来看看具体实现
// 截取至 zuo-blog 源码
// 读取文件内容,通过maked转换为html字符串
const fileStr = fs.readFileSync(articlePath).toString()
// let htmlStr = marked(fileStr)
let headers = marked.lexer(fileStr).filter(item => item.type === 'heading')
let outline = _generateOutline(headers) // 根据文件内容生成大纲数据
/**
* @description 将md文件heading列表,转换为层级结构,用于生成大纲
* @param {*} headers 原数据格式
* [ { type: 'heading', depth: 1, text: '站点优化 页面打开较慢处理' },
* { type: 'heading', depth: 2, text: '代码托管' },
* { type: 'heading', depth: 2, text: '速度慢的原因分析' },
* { type: 'heading', depth: 3, text: '代码分析' },
* { type: 'heading', depth: 2, text: '速度测试' } ]
* @returns [ { text: 'xx', children: [ { text:'xxx', children:[...] } ] } ]
*/
function _generateOutline(headers) {
let tree = []
// 加try catch是为了如果中间出现跨越的层级问题,直接返回错误
try {
for (let i = 0, len = headers.length; i < len; i++) {
let item = headers[i]
// 如果是一级目录,直接挂载到tree下
if (item.depth === 1) {
tree.push(item)
} else {
let target
// 如果是二级目录,挂载到当前tree最后一个元素的children上
if (item.depth === 2) {
target = tree.slice(-1)[0]
} else {
// 如果是3级+,遍历到最近一个层级的list
let count = item.depth - 2
target = tree.slice(-1)[0]
while(count--) {
target = target.children.slice(-1)[0]
}
}
!target.children && (target.children = [])
target.children.push(item)
}
}
} catch(e) {
console.log(e)
let text = '目录生成异常,请确保目录层级从H1到H6是正常顺序,对于没有H1或目录中间断层的情况需要修正'
return [ { text } ]
}
return tree
}
// 最开始比较low的写法
// let tree = []
// for (let i = 0, len = headers.length; i < len; i++) {
// let item = headers[i]
// if (item.depth === 1) {
// tree.push(item)
// } else if (item.depth === 2) {
// // 找最近的一个1级目录,加入到其list里面
// let level1 = tree[tree.length - 1]
// !level1.list && (level1.list = [])
// level1.list.push(item)
// } else if (item.depth === 3) {
// // 找最近的一个二级目录
// let level1 = tree[tree.length - 1]
// let level2 = level1.list[level1.list.length - 1]
// !level2.list && (level2.list = [])
// level2.list.push(item)
// } else if (item.depth === 4) {
// // 找最近的一个三级目录
// let level1 = tree[tree.length - 1]
// let level2 = level1.list[level1.list.length - 1]
// let level3 = level2.list[level2.list.length - 1]
// !level3.list && (level3.list = [])
// level3.list.push(item)
// } else if (item.depth === 5) {
// // 找最近的一个4级目录
// let level1 = tree[tree.length - 1]
// let level2 = level1.list[level1.list.length - 1]
// let level3 = level2.list[level2.list.length - 1]
// let level4 = level3.list[level3.list.length - 1]
// !level4.list && (level4.list = [])
// level4.list.push(item)
// } else if (item.depth === 6) {
// // 找最近的一个5级目录
// let level1 = tree[tree.length - 1]
// let level2 = level1.list[level1.list.length - 1]
// let level3 = level2.list[level2.list.length - 1]
// let level4 = level3.list[level3.list.length - 1]
// let level5 = level4.list[level4.list.length - 1]
// !level5.list && (level5.list = [])
// level5.list.push(item)
// } else if (item.depth === 7) {
// // 找最近的一个6级目录
// let level1 = tree[tree.length - 1]
// let level2 = level1.list[level1.list.length - 1]
// let level3 = level2.list[level2.list.length - 1]
// let level4 = level3.list[level3.list.length - 1]
// let level5 = level4.list[level4.list.length - 1]
// let level6 = level5.list[level5.list.length - 1]
// !level6.list && (level6.list = [])
// level6.list.push(item)
// }
// }
根据大纲数据生成html
在md文件显示的右侧,显示大纲html,将大纲JSON数据,生成html。注意:
- ul 的padding-left要修改为0,而不是1em,因为发现语雀、gaylab对应的大纲实现里,focus时都有左侧border,菜单的padding-left根据其depth来生成,padding-left: (depth * 1)em
- 这里大纲的每一个标题都没有使用a标签,不是走hash,而是直接通过点击js来滚动到对应id的位置。
/**
* @description 根据大纲数据(JSON)生成侧边栏html
* @param {*} outline
*/
_getAsideHtml(outline) {
function handlerId(id) {
let newId = id.toLowerCase().replace(/\s/g, '-')
newId = newId.replace(/[\(\)\/\,\=\>\.\:\+]/g, '')
return newId
}
let asideHtml = ''
let backupOutline = JSON.parse(JSON.stringify(outline))
for (let i = 0, len = outline.length; i < len; i++) {
asideHtml += '<ul>'
asideHtml += `<li><span class="ul-span" data-id="${handlerId(outline[i].text)}" style="padding-left:${outline[i].depth + 'em'}">${outline[i].text}<span></li>`
if (outline[i].children) {
asideHtml += getChildrenAsideHtml(outline[i].children)
}
asideHtml += '</ul>'
}
function getChildrenAsideHtml(outline) {
if (!outline || outline.length === 0) {
return ''
}
let asideHtml = ''
for (let i = 0, len = outline.length; i < len; i++) {
asideHtml += '<ul>'
asideHtml += `<li><span class="ul-span" data-id="${handlerId(outline[i].text)}" style="padding-left:${outline[i].depth + 'em'}">${outline[i].text}<span></li>`
if (outline[i].children) {
asideHtml += getChildrenAsideHtml(outline[i].children)
}
asideHtml += '</ul>'
}
return asideHtml
}
return asideHtml
}
页面滚动到对应的大纲区域时怎么设置高亮
这里需要注意有两点:
- 点击大纲里某个标题,页面滚动到对应区域
- 滚动页面后自动改变大纲focus
这里的核心问题是: 怎么获取页面滚动到了哪个标题区域?
每次一进入页面,将每个标题(h1,h2,..)的id,offsetTop(距离页面顶部距离)按顺序存到数组,监听页面滚动事件,根据document.documentElement.scrollTop的高度,来匹配之前的数组,就可以找到滚动到哪个标题了
来看具体代码
// 监听大纲的点击事件
let asideDiv = document.getElementsByTagName('aside')[0]
asideDiv.onclick = (e) => {
let id = e.target.dataset.id
if (!id) return
// 移除所有的active
let nodes = document.getElementsByClassName('ul-span')
for (let i = 0, len = nodes.length; i < len; i++) {
nodes[i].classList.remove('active')
}
e.target.classList.add('active')
document.getElementById(id).scrollIntoView(true)
document.documentElement.scrollBy(0, -70)
}
let headersArr = []
window.onload = () => {
// 如果是category,且有hash值,向上滚动 -70
// 通过category.html#web进入页面时, 由于顶部fixed会有遮挡,fix方案
let { pathname, hash } = location
pathname.includes('category.html') && hash && document.documentElement.scrollBy(0, -70)
// 将每个标题的高度,存到数组里,当滚动时,自动focus右侧大纲
let nodes = document.getElementsByClassName('ul-span')
for (let i = 0, len = nodes.length; i < len; i++) {
// console.log(nodes.dataset)
let id = nodes[i].dataset.id
headersArr.push({id: id, offsetTop: document.getElementById(id).offsetTop})
}
// console.log(headersArr)
window.onscroll = () => {
focusAsideSpan()
// debounce(focusAsideSpan)
}
}
// 效果不好,没有实时滚动的感觉,关闭防抖
// function debounce(method, context) {
// clearTimeout(method.tId)
// method.tId = setTimeout(function() {
// method.call(context)
// }, 100)
// }
function focusAsideSpan() {
let scrollTop = document.documentElement.scrollTop
let curNode
for (let i = 0, len = headersArr.length; i < len; i++) {
if (headersArr[i].offsetTop - scrollTop >= 0) {
// 移除所有的active
let nodes = document.getElementsByClassName('ul-span')
for (let j = 0, len = nodes.length; j < len; j++) {
if (headersArr[i].id === nodes[j].dataset.id) {
nodes[j].classList.remove('active')
nodes[j].classList.add('active')
} else {
nodes[j].classList.remove('active')
}
}
return
}
}
// 如果走到这里,说明滚到底部了
// 移除所有的active
let nodes = document.getElementsByClassName('ul-span')
for (let i = 0, len = nodes.length; i < len; i++) {
nodes[i].classList.remove('active')
}
nodes[nodes.length - 1].classList.add('active')
}
大纲更好的实现方式
这里实现很复杂,对于非标准层级结构来说会有bug,其实这里没必要做的这么麻烦,没必要将对象数组,转换为树形结构,只需要渲染时根据不同的depth设置不同的margin-left或padding-left,其值为 (depth - 1) * baseWidth基础间距,这样就可以很好的显示了,待0.3.0版本优化