为网页添加侧边栏目录导航

起因

一直很反感那些花里胡哨的博客模板,所以为这个网站选择了一套非常简约的主题,并在此基础上简单地修改。主要是将默认的衬线字体改成了非衬线字体,增加了顶部的导航栏等。

主题默认的章节目录和正文是顺序堆叠(stacklayout)的。然而,在实际使用过程中发现,当文章内容很长时,如果没有章节目录导航的话,阅读体验相当不好,特别是在触屏移动设备上,体验更加不堪。因此,决定给文章页加上章节目录导航。

需求

现在的文章页是单列布局,正文栏最大宽度为 800 像素,如果要换成左右两列布局,整个模板都需要大改动,不切实际。在不改变现有布局的前提下,章节导航应实现如下需求:

  1. 只有当文章页启用目录(table of contents)时,才会显示导航栏;
  2. 当浏览器 viewport 宽度大于 1280 像素时,导航栏悬浮在网页的左侧空白区域;
    当浏览器 viewport 宽度大于 1280 像素时

    当浏览器 viewport 宽度大于 1280 像素时

  3. 当浏览器 viewport 宽度小于 1280 像素时,导航栏通过悬浮按钮召唤弹出,覆盖浏览器 viewport;
    当浏览器 viewport 宽度小于 1280 像素时

    当浏览器 viewport 宽度小于 1280 像素时

    弹出导航栏

    弹出导航栏

  4. 当导航栏内容高度超出 viewport 高度时,能在垂直方向滚动;
  5. 导航栏目录会跟随当前 viewport 的正文偏移位置自动高亮或加粗。

实现原理

悬浮侧边栏

让侧边栏悬浮的原理很简单,使用 CSS 属性 position: sticky。这个属性的作用是,当元素在 viewport 内,表现为 relative;当元素要滑出 viewport 时,表现为 fixed。

响应式布局

利用 CSS 的媒体查询,实现响应式布局。案例中使用了如下几档宽度:

目录高亮跟随

目录高亮跟随需要通过脚本实现:

  1. 遍历页面中所有的 h1~h6 章节的 DOM 节点,将每个节点的 id 属性和 offsetTop 属性保存到数组 anchorPositions 中,按 offsetTop 大小倒序;
  2. 监听 window.onscroll 事件,在 handler 中顺序遍历 anchorPositions 数组中的节点,当 window.pageYOffset 大于当前节点的 offsetTop 时,高亮显示当前节点对应的目录菜单项并退出遍历;
  3. 由于页面内容变化,比如载入图片或动态生成内容等,章节的 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
  }
});