想把文章的标签放到网页右侧,做法是仿照 PaperMod 已有的左侧目录(TOC)侧边栏机制,在后面又加上了分类和系列,做成三个独立卡片,随页面滚动固定不动。本文记录实现过程和踩的坑。

需求

  • 文章的标签、分类、系列显示在页面右侧;
  • 宽屏时浮出到正文右侧并固定,窄屏(手机)时退回文档流,以卡片的形式跟在正文之后;
  • 三个模块独立成卡片,随页面滚动时在屏幕中的位置不变。

思路:复用 TOC 的侧边栏机制

PaperMod 的目录(toc.html)在宽屏下是这么定位的:.toc-container.wide 用 position: absolute 挂在文章元素上,偏移到文章左侧外面,内部再用 position: sticky 让目录随滚动固定。右侧侧边栏完全照抄这套,只是方向相反:

1
2
3
4
5
6
7
.tags-aside.wide {
    position: absolute;
    width: var(--toc-width);                 /* 300px */
    right: calc((var(--toc-width) + var(--gap)) * -1);  /* 平移到文章右侧外面 */
    top: calc(var(--gap) * 2);
    height: 100%;                            /* 高度覆盖整个文章,sticky 才能全程生效 */
}

(窄屏下不挂 wide,这条规则不生效,侧边栏就退回文档流。)

关键点:aside 的高度必须是 100%(等于文章高度),内部的 sticky 才有足够的"可移动空间",否则滚动到一半卡片就跟着跑了。

模板改动(layouts/single.html)

1. 顶部定义 taxonomy 变量

1
2
3
{{- $tags := .Language.Params.Taxonomies.tag | default "tags" }}
{{- $categories := .Language.Params.Taxonomies.category | default "categories" }}
{{- $series := .Language.Params.Taxonomies.series | default "series" }}

2. 目录之后插入侧边栏

放在 partial "toc.html" 后面、正文之前。三个卡片用 tags-aside-sticky 包起来整体 sticky,哪个有内容就显示哪个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{{- if or .Params.tags .Params.categories .Params.series }}
<aside id="side-aside" class="tags-aside">
  <div class="tags-aside-sticky">
  {{- if .Params.categories }}
  <div class="tags-aside-box">
    <div class="tags-aside-title">分类</div>
    <ul class="post-tags">
      {{- range ($.GetTerms $categories) }}
      <li><a href="{{ .Permalink }}">{{ .LinkTitle }}</a></li>
      {{- end }}
    </ul>
  </div>
  {{- end }}
  {{- if .Params.tags }}
  <div class="tags-aside-box">
    <div class="tags-aside-title">标签</div>
    <ul class="post-tags">
      {{- range ($.GetTerms $tags) }}
      <li><a href="{{ .Permalink }}">{{ .LinkTitle }}</a></li>
      {{- end }}
    </ul>
  </div>
  {{- end }}
  {{- if .Params.series }}
  <div class="tags-aside-box">
    <div class="tags-aside-title">系列</div>
    <ul class="post-tags">
      {{- range ($.GetTerms $series) }}
      <li><a href="{{ .Permalink }}">{{ .LinkTitle }}</a></li>
      {{- end }}
    </ul>
  </div>
  {{- end }}
  </div>
</aside>
{{- end }}

3. 底部脚本:和左侧目录共用一份宽屏判断

判断条件是「文档宽 − 文章宽 − 2 × 侧边栏宽 − 4 × 间距 > 0」。这份判定左侧目录同样要用,而且两侧必须同时进入或退出 wide,否则会出现左边目录浮出、右边标签栏还留在文档流里的不对称。所以判定只留一份,两个 aside 在同一次遍历里一起切:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script>
(function () {
    // 左侧目录(#toc-container)与右侧标签栏(#side-aside)必须同时进入 / 退出 wide,
    // 否则两侧会不对称。所以宽度判定只保留这一份,两个元素在同一次遍历里一起切。
    // 元素在 DOMContentLoaded 时才查,脚本放哪儿都不影响。
    const style = getComputedStyle(document.body);
    const main = parseInt(style.getPropertyValue('--article-width'), 10);
    const toc = parseInt(style.getPropertyValue('--toc-width'), 10);
    const gap = parseInt(style.getPropertyValue('--gap'), 10);

    function updateAsideWide() {
        const wide = (document.body.scrollWidth - main - (toc * 2) - (gap * 4)) > 0;
        ['toc-container', 'side-aside'].forEach((id) => {
            const aside = document.getElementById(id);
            if (aside) aside.classList.toggle('wide', wide);
        });
    }

    document.addEventListener('DOMContentLoaded', updateAsideWide);
    window.addEventListener('resize', updateAsideWide);
})();
</script>

--article-width、--toc-width、--gap 是 CSS 变量,站点里自定义过(见下),脚本从 getComputedStyle(document.body) 读出来计算,不用写死数字。

这段脚本早期还负责隐藏 footer 里的标签胶囊(document.querySelector('.post-footer .post-tags'))。后来 footer 改成只放上下篇导航和分享按钮,那几行每次都取到 null,成了死代码,已经删掉;现在分类和标签只出现在右侧卡片里。

样式(assets/css/extended/toc.css)

侧边栏样式追加在目录样式后面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/* ===== 文章标签侧边栏(右侧,与目录对称) =====
   窄屏(无 .wide):以文档流展示在正文之后;
   宽屏(.wide):绝对定位到正文右侧。 */
.tags-aside {
    display: block;
    width: 100%;
    margin-top: calc(var(--gap) * 1.5);
}

.tags-aside.wide {
    position: absolute;
    width: var(--toc-width);
    right: calc((var(--toc-width) + var(--gap)) * -1);
    top: calc(var(--gap) * 2);
    height: 100%;
    margin-top: 0;
    border-left: 1px solid var(--border);
    padding-left: calc(var(--gap) * 0.5);
}

.tags-aside-sticky {
    position: sticky;
    /* 吸顶导航栏固定后,标签栏停留位置下移到导航栏之下 */
    top: calc(var(--header-height) + var(--gap));
}

.tags-aside-box {
    border: 1px solid var(--border);
    background: var(--entry);
    border-radius: var(--radius);
    padding: 0.8em 1em 1em;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.10);
}

/* 暗色模式下阴影不明显,适当加深 */
[data-theme="dark"] .tags-aside-box {
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}

.tags-aside-title {
    font-weight: 600;
    font-size: 0.95rem;
    color: var(--secondary);
    margin-bottom: 0.7em;
}

.tags-aside-box + .tags-aside-box {
    margin-top: var(--gap);
}

(卡片里胶囊的间距与悬停配色在 series.css 的 .tags-aside .post-tags 里。)

站点根部的 CSS 变量(toc.css 开头就有,这里一并记录):

1
2
3
4
5
:root {
    --nav-width: 1380px;
    --article-width: 650px;
    --toc-width: 300px;
}

宽屏阈值 文章宽 + 2 × 侧边栏宽 + 4 × 间距 = 650 + 600 + 96 = 1346px 左右,文档宽度大于这个值才会让左右两条侧边栏一起浮出。

过程中的几个决定

  • 卡片顺序:分类在最上面、标签居中、系列在最下面(系列卡片内部是带 1/N 进度和「当前」标记的导航列表,样式在 series.css);
  • 卡片底色:尝试过 --code-bg(跟代码块太像)、淡蓝灰、米黄、带绿的浅色,最后觉得都不如默认的白色配边框和阴影干净,改回了 --entry;
  • 整体 sticky:一开始把 position: sticky 写在每张卡片上,结果是三张卡片轮流吸附、各自固定,后来包了一层 tags-aside-sticky 容器,三张卡片作为一个整体固定;
  • 只在有对应 frontmatter 时渲染:if .Params.categories 这样的判断,没有分类的文章不会出现空卡片。

小结

PaperMod 的左右侧边栏本质上都是 position: absolute 定位 + 内部 sticky 固定这套组合,摸清 TOC 的实现后右侧照抄就行。两个容易漏的点:aside 高度要 100%,以及 sticky 容器要包住所有卡片。宽屏/窄屏的切换靠一段几行的 JS 判断,而且左右两条侧边栏共用同一份判定,必须同时进出 wide,否则两侧会不对称。