为网页添加侧边栏目录导航
一直很反感那些花里胡哨的博客模板,所以为这个网站选择了一套非常简约的主题,并在此基础上简单地修改。主要是将默认的衬线字体改成了非衬线字体,增加了顶部的导航栏等。
主题默认的章节目录和正文是顺序堆叠(stacklayout)的。然而,在实际使用过程中发现,当文章内容很长时,如果没有章节目录导航的话,阅读体验相当不好,特别是在触屏移动设备上,体验更加不堪。因此,决定给文章页加上章节目录导航。
需求
现在的文章页是单列布局,正文栏最大宽度为 800 像素,如果要换成左右两列布局,整个模板都需要大改动,不切实际。在不改变现有布局的前提下,章节导航应实现如下需求:
- 只有当文章页启用目录(table of contents)时,才会显示导航栏;
- 当浏览器 viewport 宽度大于 1280 像素时,导航栏悬浮在网页的左侧空白区域;
当浏览器 viewport 宽度大于 1280 像素时
- 当浏览器 viewport 宽度小于 1280 像素时,导航栏通过悬浮按钮召唤弹出,覆盖浏览器 viewport;
当浏览器 viewport 宽度小于 1280 像素时
弹出导航栏
- 当导航栏内容高度超出 viewport 高度时,能在垂直方向滚动;
- 导航栏目录会跟随当前 viewport 的正文偏移位置自动高亮或加粗。
实现原理
悬浮侧边栏
让侧边栏悬浮的原理很简单,使用 CSS 属性 position: sticky
。这个属性的作用是,当元素在 viewport 内,表现为 relative;当元素要滑出 viewport 时,表现为 fixed。
响应式布局
利用 CSS 的媒体查询,实现响应式布局。案例中使用了如下几档宽度:
@media (min-width: 1280px)
宽度大于 1280 像素,显示侧边栏,且设置侧边栏宽度为 230 像素;@media (min-width: 1360px)
宽度大于 1360 像素,显示侧边栏,且设置侧边栏宽度为 270 像素;@media (min-width: 1680px)
宽度大于 1680 像素,显示侧边栏,且设置侧边栏宽度为 430 像素;@media (max-width: 1279px)
宽度小于 1280 像素,不显示侧边栏,且设置召唤导航栏宽度为 800 像素;@media (max-width: 800px)
宽度小于 800 像素,不显示侧边栏,且设置召唤导航栏宽度为 viewport 宽度。
目录高亮跟随
目录高亮跟随需要通过脚本实现:
- 遍历页面中所有的
h1
~h6
章节的 DOM 节点,将每个节点的id
属性和offsetTop
属性保存到数组anchorPositions
中,按offsetTop
大小倒序; - 监听
window.onscroll
事件,在 handler 中顺序遍历anchorPositions
数组中的节点,当window.pageYOffset
大于当前节点的offsetTop
时,高亮显示当前节点对应的目录菜单项并退出遍历; - 由于页面内容变化,比如载入图片或动态生成内容等,章节的
offsetTop
属性会发生变化。监听window.onresize
事件,在 handler 中更新anchorPositions
数组的数据。
演示代码
HTML
演示代码为 HUGO 模板。
html<!-- 如果文章显示章节目录 -->
{{- if .Params.toc }}
<!-- 输出章节目录 -->
{{ .TableOfContents }}
<!-- 导航栏召唤按钮 -->
<div id="collapsed-toc">
<a class="button">
<i class="fa-solid fa-bars"></i>
</a>
</div>
<!-- 导航栏 -->
<aside id="aside-toc">
<!-- 标题栏 -->
<div class="header">
<span>章节目录</span>
<!-- 关闭按钮 -->
<a class="button close">
<i class="fa-solid fa-xmark"></i>
</a>
</div>
<!-- 章节目录 -->
<div class="content"></div>
</aside>
{{- end }}
<!-- 下面是文章的正文 -->
{{ .Content }}
CSS
演示代码使用了 SCSS 预处理器。
scss.is-clipped {
overflow: hidden!important
}
#aside-toc {
z-index: 100;
display: none;
float: left;
top: 0;
max-height: 100vh;
.header {
.button.close {
float: right;
}
}
ul {
li {
a.active {
font-weight: bold;
color: black;
}
}
}
}
#aside-toc:hover {
overflow-y: auto;
}
#collapsed-toc {
top: 0;
position: sticky;
z-index: 10;
display: none;
}
html {
@media (min-width: 1280px) {
#aside-toc {
display: block;
position: sticky;
margin-left: -240px;
width: 230px;
.button.close {
display: none;
}
}
}
@media (min-width: 1360px) {
#aside-toc {
margin-left: -280px;
width: 270px;
}
}
@media (min-width: 1680px) {
#aside-toc {
margin-left: -440px;
width: 430px;
}
}
@media (max-width: 1279px) {
#aside-toc {
display: none;
position: fixed;
width: 800px;
top: 0;
height: 100vh;
}
#aside-toc.active {
display: block;
}
#collapsed-toc {
display: block;
}
}
@media (max-width: 800px) {
#aside-toc {
width: 100vw;
}
}
}
JavaScript
演示代码使用 jQuery 库。
javascript$(function(){
var $tableOfContents = $('#TableOfContents');
if ($tableOfContents.length > 0) {
var $rootElement = $(document.documentElement);
var $asideBar = $('#aside-toc');
var $collapsedTOC = $('#collapsed-toc');
var $asideTOC = $asideBar.children('.content');
$asideTOC.append($tableOfContents.children('ul').clone());
var anchorPositions;
var loadAnchorPositions = function () {
anchorPositions = new Array();
$('h1,h2,h3,h4,h5,h6').each(function () {
if (!this.id) return;
anchorPositions.unshift({id: this.id, top: this.offsetTop});
});
};
loadAnchorPositions();
$(window).scroll(function () {
$(anchorPositions).each(function () {
if (window.pageYOffset + 1 >= this.top) {
$asideTOC.find('a').removeClass('active');
$asideTOC.find('a[href="#' + this.id + '"]').addClass('active');
return false;
}
});
});
$(window).resize(function () {
loadAnchorPositions();
});
$asideBar.on('click', 'a', function () {
$asideBar.removeClass('active');
$rootElement.removeClass('is-clipped');
});
$collapsedTOC.children('a').click(function () {
$rootElement.addClass('is-clipped');
$asideBar.addClass('active');
return false;
});
// end of toc processing
}
});