Table Of Content是一个十分常用的功能,这个系列的第13篇Blog养成记13 增加一个TOC侧边栏就是为了这个做准备,只不过是在静态页面上尝试想要的样式。本文用到的样式都基于第13篇中增加的css样式。
Hugo的Table of Content
Hugo对于Table Of Content也有内建变量,可以参考这里。
在正文部分,Markdown正文中所有的标题都会自动建一个id,id为标题内容,也可以用{#your-id}来重新定义。TOC目录部分,Hugo对于这一部分渲染为<nav id="TableOfContents"><ul></ul></nav>,目录会连接到对应的id上。
我建立的partial模板如下:
{{ if .Params.toc }}
<!--Grid column-->
<div class="col-md-2">
	<!--Scrollspy-->
	<div id="page-scrollspy" class="toc-nav">
		{{ .TableOfContents }}
	</div>
	<!--Scrollspy-->
</div>
<!--Grid column-->
{{- end -}}
存在问题
其中,正如第13篇Blog养成记13 增加一个TOC侧边栏说的,我在baseof.html模板中规定了<body>的样式,想使用滚动监听,同样也配置了第14篇Blog养成记14 让同页滚动更平滑。
<body class="bg-light" data-spy="scroll" data-target="#page-scrollspy" data-offset="90">
但可惜的是,由于Hugo自动渲染的html代码中缺少滚动监听所必须的类,因此无法实现滚动监听的功能。此外,Hugo的TOC对1~6级标题都包括了进来,这样光1级和6级之间的缩进空间就占了不少。因此不得不自己重新写TOC的模板。
自建TOC模板
在Github的Issue中找到了对于这些问题的一个方法,因此决定参考着也定制化地写一个我自己的TOC模板。模板中我并没有选择监听所有的标题,而只选择了<h1>~<h4>。此外,由于一些post中并不存在<h1>,对这种情况作了额外处理,避免TOC目录右移太多。
{{ if .Params.toc }}
	<!-- ignore empty links with + -->
	{{ $headers := findRE "<h[1-4].*?>(.|\n])+?</h[1-4]>" .Content }}
	<!-- at least one header to link to -->
	{{ if ge (len $headers) 1 }}
		{{ $h1_n := len (findRE “(.|\n])+?” .Content) }} 
		{{ $re := (cond (eq $h1_n 0) “<h[2-4]” “<h[1-4]“) }}
		{{ $renum := (cond (eq $h1_n 0) “[2-4]” “[1-4]“) }}
	
		<!--Grid column-->
		<div class="col-md-2 pl-0">
			<!--Scrollspy-->
			<div id="page-scrollspy" class="toc-nav">
				
				<ul class="nav nav-pills ml-0">
					<!-- TOC header -->
					<li class="nav-item pb-3 text-center">
						<span class="font-weight-bold mb-2">- CATALOG - </span>
					</li>
					{{ range $headers }}
						{{ $header := . }}
						{{ range first 1 (findRE $re $header 1) }}
							{{ range findRE $renum . 1 }}
								{{ $next_heading := (cond (eq $h1_n 0) (sub (int .) 1 ) (int . ) ) }}
								{{ range seq $next_heading }}
									<ul class="nav">
								{{end}}
								{{ $anchorId :=  (replaceRE ".* id=\"(.*?)\".*" "$1" $header ) }}
										<li class="nav-item">
						 					<a class="nav-link" href="#{{ $anchorId }}">
												 {{ $header | plainify | htmlUnescape }}
											</a>
										</li>
						 
								<!-- close list -->
								{{ range seq $next_heading }}
									</ul>
								{{end}}
							{{ end }}
						{{ end }}
				 {{end}}
				</ul>
			</div>
			<!--Scrollspy-->
		</div>
		<!--Grid column-->
	{{ end }}
{{- end -}}
解决href中文乱码问题
需要注意的是,在上述的toc模板中$anchorId在<a href>中由于出现中文,会变成乱码,影响滚动监听。但很神奇的是,我直接在html中的href写中文,正常显示,把$anchorId显示在文中,也是正常显示。
多次尝试无果,无奈之下,决定修改bootstrap.js来解决这个问题。
对于bootstrap.js,需要修改两个地方。
一个是Public Util Api中的getSelectorFromElement。需要对href的内容进行解码,获得正确的中文,因为正文中的id是正确的中文,只有这样才能获得正确的selector。
getSelectorFromElement: function getSelectorFromElement(element) {
        var selector = element.getAttribute('data-target');
        if (!selector || selector === '#') {
          selector = decodeURI(element.getAttribute('href')) || '';
        }
        try {
          return document.querySelector(selector) ? selector : null;
        } catch (err) {
          return null;
        }
      }
另一个是scrollspy中_activate函数。由于是需要根据href的内容获取selector,因此需要将id进行编码获得真实的href值。另外,为了能够与href正常中文的进行兼容,增加了判断。
_proto._activate = function _activate(target) {
        this._activeTarget = target;
        this._clear();
        var queries = this._selector.split(','); // eslint-disable-next-line arrow-body-style
        queries = queries.map(function (selector) {
            return selector + "[data-target=\"" + target + "\"]," + (selector + "[href=\"" + target + "\"]");
        });
        var $link = $$$1([].slice.call(document.querySelectorAll(queries.join(','))));
    
        if ($link.length==0) {
    
            queries = this._selector.split(',');
            queries = queries.map(function (selector) {
              return selector + "[data-target=\"" + target + "\"]," + (selector + "[href=\"" + encodeURI(target).toLowerCase() + "\"]");
            });
            var $link = $$$1([].slice.call(document.querySelectorAll(queries.join(','))));   
        }
                                
        if ($link.hasClass(ClassName.DROPDOWN_ITEM)) {
            $link.closest( Selector.DROPDOWN ).find( Selector.DROPDOWN_TOGGLE ).addClass( ClassName.ACTIVE );
            $link.addClass(ClassName.ACTIVE);
        } else {
            // Set triggered link as active
            $link.addClass(ClassName.ACTIVE); // Set triggered links parents as active
            // With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor
            $link.parents(Selector.NAV_LIST_GROUP).prev(Selector.NAV_LINKS + ", " + Selector.LIST_ITEMS).addClass(ClassName.ACTIVE); // Handle special case when .nav-link is inside .nav-item
            $link.parents( Selector.NAV_LIST_GROUP ).prev( Selector.NAV_ITEMS ).children(  Selector.NAV_LINKS ).addClass( ClassName.ACTIVE );
        }
    
        $$$1(this._scrollElement).trigger(Event.ACTIVATE, {
            relatedTarget: target
        });
      };
解决监控位置问题
在某些情况下,明明被监控内容中显示的是A,但是TOC中激活的是A的上一个目录。这样就会出现,我点击了TOC中的目录A,内容滚动到了A,但是后来TOC中激活的是A的上一个目录。
仔细跟踪了一下bootstrap.js,发现一个问题:offset是使用函数getBoundingClientRect()获得的,是小数型,但是scrollTop是整型的,因此在边界点会出现问题。在Github/Issues上发现,还有其他小伙伴遇到了同样的问题,记录下我第一次回答Issue,希望能帮上~
解决方法就是在_proto._process = function _process()中修改scrollTop的数值:
_proto._process = function _process() {
        var scrollTop = this._getScrollTop() + this._config.offset 
- 1
;
        var scrollHeight = this._getScrollHeight();
        var maxScroll = this._config.offset + scrollHeight - this._getOffsetHeight();
        if (this._scrollHeight !== scrollHeight) {
          this.refresh();
        }
        if (scrollTop >= maxScroll) {
          var target = this._targets[this._targets.length - 1];
          if (this._activeTarget !== target) {
            this._activate(target);
          }
          return;
        }
        if (this._activeTarget && scrollTop < this._offsets[0] && this._offsets[0] > 0) {
          this._activeTarget = null;
          this._clear();
          return;
        }
        var offsetLength = this._offsets.length;
        for (var i = offsetLength; i--;) {
          var isActiveTarget = this._activeTarget !== this._targets[i] && scrollTop >= this._offsets[i] && (typeof this._offsets[i + 1] === 'undefined' || scrollTop < this._offsets[i + 1]);
          if (isActiveTarget) {
            this._activate(this._targets[i]);
          }
        }
      };标题太长怎么办
经常会出现这种情况,一行的标题太长,于是就换行显示。问题到不大,只是不太美观,还是希望能够只显示一行。有两种方案,一个是允许TOC有水平滚动条,另一个是将太长的标题省略显示。
使用水平滚动条
这个只需要在css样式中标记对应的元素不能换行即可。
.toc-nav .nav-link {
  white-space:nowrap;
} 
这样如果标题太长,在TOC会出现水平滚动条。
太长标题省略显示
TOC模块出现水平滚动条总嫌有些冗余,于是决定将太长的标题省略显示,我选择使用这种方案。在css中增加以下内容:
.toc-nav ul {
  overflow:hidden;
  white-space:nowrap;
}
.toc-nav .nav-link {
  text-overflow:ellipsis;
  overflow:hidden;
} 
Resource资源链接汇总
Hugo官网对TableOfContent介绍、自建TOC参考
版本控制
| Version | Action | Time | 
|---|---|---|
| 1.0 | Init | 2018-08-25 | 
| 1.1 | 处理没有<h1> | 2018-09-02 | 
| 1.2 | 解决监控位置问题 | 2018-09-18 | 
