StarryNights

每一个你所后悔的现在,都有一个不够努力的曾经

站内检索

循着字句,邂逅星光

搜索文章标题与正文,直达命中的章节

开始一次轻盈的站内探索

从这些主题开始探索

输入关键词

可同时输入多个词语缩小范围

发现内容

在摘要中查看高亮的关键词

快速直达

点击文章或章节标题继续阅读

# 引言

多出去看看,见的多了,就知道该折腾啥了~

这次盯上的,是文章右侧那块平时不怎么起眼的 目录。

折腾博客主题迁移的时候,看到 Fumadocs 的文章目录:左边一根细线,遇到子标题会拐个弯,当前阅读位置对应的那一段还会亮起来。

好看,想要,安排 🤣

不过真开始做才发现,这东西不是给目录加一个 border-left 就完事了。线会随着阅读位置 游走,同屏几个标题可以同时高亮,目录展开和收起的时候,线还得跟着变。

于是乎,又有了这篇折腾记录。

本文右侧目录就是效果展示,往下滚动即可体验;窄屏需要先打开主题侧栏。

# 需求整理

# 先把效果看明白

第一眼看过去,很容易把那根线理解成 阅读进度条:读到哪儿,就从头亮到哪儿。

我第一版还真就是这么做的,然后对着参考页面一滚,发现不对劲。

它表达的是 当前视口中的标题范围,不是已经读过多少:

  1. 一个标题出现在屏幕里,对应目录项就参与高亮。
  2. 同屏能看到多个标题,它们可以一起亮。
  3. 标题离开屏幕后,不会因为“曾经读过”就一直亮着。
  4. 如果屏幕里全是正文、没有标题,则需要一个兜底位置。

所以高亮线的 起点和终点 都会动,而不是只有终点往下增长。

参考实现的标题观察逻辑可以看 Fumadocs 的 toc.tsx。本文记录的是这次 ShokaX 适配的行为,后续上游实现可能会变化。

# 好看的同时,原来的功能不能丢

曲线只是表现形式,目录该有的功能还是得有:

  • 1.、2.1. 这些层级编号保留。
  • 点击目录仍然跳到对应章节。
  • 高亮颜色跟随主题,不单独写死一套蓝色。
  • 长标题能够换行,日间和夜间模式使用主题已有颜色变量。
  • 子目录按需展开,不能长文章一打开就铺满整个侧栏。

另外,这个效果不是所有文章都一定合适,所以需要 全局开关 + 文章单独覆盖。

# 命中就展开

这里又绕了一小段路。

最开始保留旧目录的展开条件:等某个章节成为原来的“当前章节”,再打开它的子目录;曲线则按照可见标题高亮。

结果出现了一个很别扭的情况:父标题明明已经亮了,下面却还是收着的。

原因也很直接,两套判断标准不一样。

最终统一为:只要标题命中可见范围,就展开它拥有的子目录;如果命中的是深层标题,则同时展开它的父级链。

不必等标题跨过屏幕顶部。没有命中的分支再收起,展开和高亮用同一份结果,这才算对上了。

# 解决方案

# 不搬整套框架,只迁移交互

当前主题是 ShokaX,已有 Pug 模板、Stylus 样式和 TypeScript 前端代码。

为了一个目录换掉整个页面技术栈,多少有点买椟还珠了 🤣

所以这次保留 Hexo 的 toc(page.content) 生成结果,在现有目录上补一层展示逻辑:

部分负责的事情
Pug判断文章最终使用哪种目录样式
Stylus缩进、颜色、编号、换行和动画样式
TypeScript可见标题判断、分支展开、轨道坐标与生命周期
SVG绘制底线、弯折路径和高亮区间

原来的目录点击跳转继续复用;曲线模式不再依赖旧 .current 类决定高亮范围,也不让旧的单项居中滚动逻辑干扰它。default 模式保持原有行为。

# 文件放在哪里

本文路径以 主题源码目录 为起点,主要涉及:

_config.yml
layout/_mixin/sidebar.pug
source/css/_common/outline/sidebar/toc.styl
source/css/_common/outline/sidebar/toc-curve.styl
source/js/_app/components/toc-curve.ts
source/js/_app/components/sidebar.ts
source/js/_app/pjax/refresh.ts

我本地的 themes/shokax-src 是主题源码入口,themes/shokax 是构建出来给 Hexo 使用的目录。

修改时要改源码,不要直接改构建产物,否则下次构建就被覆盖了。普通安装没有这层目录拆分的话,按自己的主题源码位置处理即可。

# 代码实现

先通过几个关键片段说明各层怎么配合;后面的「完整复刻:按文件接入」给出两个新增文件的全文和所有接入改动,实际操作按那一节逐项完成即可。

# 全局配置与文章覆盖

博客的 _config.shokax.yml 中,在已有 sidebar 下添加配置,不要重复声明两段 sidebar:

sidebar:
    position: right
    toc_style: curve

支持两个值:

  • default:主题原来的目录。
  • curve:这次新增的曲线目录。

主题自身的默认值仍然是 default,本站显式开启 curve。

如果某一篇文章希望使用旧目录,在它顶部的 Front-matter 加上:

---
title: 一篇使用原目录的文章
toc_style: default
---

反过来,全局关闭的时候,也可以在单篇文章中设置 toc_style: curve。

对应的 Pug 判断很简单:

- const tocStyle = page.toc_style ?? theme.sidebar.toc_style ?? 'default'

优先级就是 文章配置 > 全局配置 > 默认值。文章没写就继承全局,不需要每篇都加一遍。

最终只在开启时,给目录容器追加 toc-curve 类;新样式和新脚本都通过这个类限定范围。

# 一份命中结果,同时控制高亮和展开

先通过目录链接的 hash 找到正文标题。标题可能包含中文,所以取 id 时需要处理 URL 编码;找不到对应元素的条目直接跳过。

判断标题有没有进入视口,关键条件如下:

const rect = heading.getBoundingClientRect()
if (rect.bottom > 0 && rect.top < window.innerHeight) {
  visible.push(index)
}

当前实现以 浏览器视口 为边界,只要标题有部分进入就算命中,并没有额外扣除顶部固定导航的遮挡高度。

如果没有标题命中,就比较各标题顶部与视口顶部的距离,取绝对值最小的那个作为兜底。这不是判断文章“读完了没有”,也不一定是上一个章节;下一个标题更近时,也可能选中下一个。

然后,从命中的目录项向上收集父级:

const owners = new Set<Element>()
for (const index of visible) {
  let item = links[index].closest('.toc-item')
  while (item) {
    owners.add(item)
    item = item.parentElement?.closest('.toc-item') || null
  }
}

某个子目录的父节点在 owners 里,就展开;不在,就收起。

这样父标题刚进入屏幕时能展开,直接跳到深层小节时也不会出现“正文到了,目录还藏着”的情况。同屏命中多个分支时,也允许同时展开,不强行做成只能打开一项的手风琴。

# 左边细线怎么拐弯

目录本来就有缩进,没必要再从标题层级重新算一套位置。

读取每个目录链接的实际位置,减去目录根容器的位置,就能得到 SVG 使用的局部坐标。长标题换行后,高度也会自然反映到坐标里。

两个相邻节点:

  • 横坐标相同,使用直线连接。
  • 横坐标不同,使用三次贝塞尔曲线完成转折。

路径里的核心命令就是:

d += ` C ${prev.x} ${middle}, ${point.x} ${middle}, ${point.x} ${point.top + 8}`

这里的 C 是 SVG 路径命令,不是什么目录插件的特殊语法。

同一条路径绘制两遍:底层是普通颜色,上层是主题高亮色。上层通过一个 clipPath 矩形裁剪,只露出当前命中范围。

于是移动高亮线时,不必重新造一条完全不同的曲线,只需要更新 裁剪窗口的位置和高度,起点的小圆点跟着一起移动即可。

每次初始化都会生成独立的裁剪 ID,避免 PJAX 切换时 SVG 引用混到别的实例上。

# 展开动画与细线同步

只给子目录加动画还不够。如果线只在动画开始时算一次位置,文字还在往下走,线却已经到了终点,看起来就会脱节。

这次分成三步处理:

  1. 先记录需要变化的分支当前高度,并取消它们上一轮未完成的动画。
  2. 将目标状态设为 auto 或 0px,统一测量终态高度。
  3. 使用 Web Animations API 在两个数值高度之间过渡,动画期间逐帧重算轨道。
const animation = branch.animate(
  [{ height: `${from}px` }, { height: `${to}px` }],
  { duration: 220, easing: 'cubic-bezier(.22,.68,0,1)' }
)

这里刻意没有直接尝试把 0 动画到 auto,而是先取得可插值的实际高度。嵌套分支也要先测量、再统一启动动画,避免父级把子级的中间高度当成最终高度。

收起过程中,还要检查目录项与各层父容器的裁剪范围。完全被藏住的项不参与绘线,露出的高度太小也先不画;暂时还没展开出来的命中项,则先映射到可见父项。

这样细线不会穿过已经隐藏的子目录,也不会留下突兀的长尾巴。

# 配色、编号和长标题

第一版为了贴近参考图,用了蓝色,还顺手把编号隐藏了。

结果放回本站一看,旁边都是主题粉色,就它另起炉灶;再一看,2.1. 也没了。属于是 形似了,自己的习惯丢了。

最后改回主题变量:

.contents.toc-curve {
  --toc-accent: var(--primary-color);

  .toc-number {
    font-variant-numeric: tabular-nums;
  }

  .toc-link {
    color: var(--grey-6);
    overflow-wrap: anywhere;
  }

  .toc-link[data-toc-visible='true'] {
    color: var(--toc-accent);
    font-weight: 600;
  }
}

编号继续用 Hexo 原本生成的内容,不重新编号;轨道底色使用 --grey-4,高亮线和文字使用同一个主题主色。日夜模式切换时,也就能跟随主题变量变化。

# PJAX 切页不能只管创建

主题切页不是每次都刷新整个浏览器。如果每次只增加监听器、不清理,目录就可能越切越卡,甚至旧页面的回调还在操作新页面。

所以在 siteRefresh 中,原来的 sidebarTOC() 后面调用 refreshTocCurve();新组件每次初始化前,先清理上一个实例。

清理范围包括:

  • scroll、resize 事件监听。
  • MutationObserver 和 ResizeObserver。
  • 等待中的 requestAnimationFrame。
  • 尚未结束的展开、收起动画。
  • SVG 节点、临时高度、高亮属性和 inert 状态。

滚动更新通过 requestAnimationFrame 合并,同一帧不重复排队。目录展开过程中继续调度,动画结束后则不再自行空转。

折叠分支设置 inert,避免键盘焦点进入隐藏链接;开启系统“减少动态效果”时,跳过折叠动画,并关闭轨道裁剪窗口与圆点的过渡。

# 完整复刻:按文件接入

前面讲的是原理,下面才是可以照着落地的代码。以本文所用 ShokaX 0.5.4 的目录结构为基线:新增文件给出全文,已有文件给出本功能的全部差异,不需要从原理片段里猜缺失的代码。

不额外安装 React、Fumadocs 或目录插件,复用主题已有的 Pug、Stylus 和 TypeScript 构建链。若你的主题版本不同,按代码上下文定位,不要按行号机械覆盖。

# 长代码怎么折叠

本站旧文《单例模式》(源文件 source/_posts/DesignPattern/singleton-design-pattern.md)已经使用了 +++ 折叠语法,当前 hexo-renderer-aether 仍会将它渲染成默认关闭的 details。

这里要区分一下:旧代码围栏的 min|语言 写法,不是当前 Shiki / shokax-uikit 的折叠参数;下面使用的是 Markdown 渲染器的折叠容器,里面还是普通代码块。这样代码高亮、复制和完整内容都能保留。

写法如下,primary 是容器样式,后面是展开标题;不写 open,默认收起:

+++primary 点击展开完整代码

```ts
const enabled = true
```

+++

# 1. 新建目录逻辑文件

新建 source/js/_app/components/toc-curve.ts,全文如下。不需要拼接上面的示意片段,也没有省略初始化、动画或清理部分。

source/js/_app/components/toc-curve.ts(完整文件,点击展开)
let cleanup: (() => void) | undefined
let instance = 0

// Keep native TOC navigation, but highlight visible headings rather than read progress.
export function refreshTocCurve() {
  cleanup?.()
  cleanup = undefined
  const panel = document.querySelector<HTMLElement>('.contents.toc-curve')
  const list = panel?.querySelector<HTMLElement>(':scope > .toc')
  if (!panel || !list) return
  const links = Array.from(list.querySelectorAll<HTMLAnchorElement>('.toc-link'))
  if (!links.length) return
  const headings = links.map(link => {
    try {
      return document.getElementById(decodeURIComponent(link.hash.slice(1)))
    } catch { return null }
  })
  const ns = 'http://www.w3.org/2000/svg'
  const svg = document.createElementNS(ns, 'svg')
  svg.classList.add('toc-curve-rail')
  svg.setAttribute('aria-hidden', 'true')
  const rail = document.createElementNS(ns, 'path')
  const progress = document.createElementNS(ns, 'path')
  progress.classList.add('toc-curve-progress')
  const defs = document.createElementNS(ns, 'defs')
  const clip = document.createElementNS(ns, 'clipPath')
  clip.id = `toc-curve-window-${++instance}`
  clip.setAttribute('clipPathUnits', 'userSpaceOnUse')
  const windowRect = document.createElementNS(ns, 'rect')
  windowRect.classList.add('toc-curve-window')
  clip.append(windowRect)
  defs.append(clip)
  progress.setAttribute('clip-path', `url(#${clip.id})`)
  const dot = document.createElementNS(ns, 'circle')
  dot.setAttribute('r', '2.5')
  svg.append(defs, rail, progress, dot)
  list.prepend(svg)
  let frame = 0
  const branches = Array.from(list.querySelectorAll<HTMLElement>('.toc-child'))
  const expanded = new Map<HTMLElement, boolean>()
  const animations = new Map<HTMLElement, Animation>()

  const syncBranches = (visible: number[]) => {
    const owners = new Set<Element>()
    for (const index of visible) {
      let item = links[index].closest('.toc-item')
      while (item) {
        owners.add(item)
        item = item.parentElement?.closest('.toc-item') || null
      }
    }
    const changes: { branch: HTMLElement, from: number, initial: boolean }[] = []
    for (const branch of branches) {
      const open = owners.has(branch.parentElement)
      if (expanded.get(branch) === open) continue
      const initial = !expanded.has(branch)
      const from = branch.getBoundingClientRect().height
      const previous = animations.get(branch)
      if (previous) { previous.onfinish = null; previous.cancel() }
      animations.delete(branch)
      expanded.set(branch, open)
      branch.inert = !open
      branch.style.height = open ? 'auto' : '0px'
      changes.push({ branch, from, initial })
    }
    // Measure all final heights before animating any nested branch.
    const measured = changes.map(change => ({ ...change, to: change.branch.getBoundingClientRect().height }))
    for (const { branch, from, initial, to } of measured) {
      if (!initial && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        const animation = branch.animate([{ height: `${from}px` }, { height: `${to}px` }], {
          duration: 220, easing: 'cubic-bezier(.22,.68,0,1)'
        })
        animations.set(branch, animation)
        animation.onfinish = () => {
          animations.delete(branch)
          schedule()
        }
      }
    }
  }

  const draw = () => {
    frame = 0
    if (!list.offsetWidth) return
    const visible: number[] = []
    let nearest = -1
    let distance = Infinity
    headings.forEach((heading, index) => {
      if (!heading || !heading.getClientRects().length) return
      const rect = heading.getBoundingClientRect()
      if (rect.bottom > 0 && rect.top < window.innerHeight) visible.push(index)
      if (Math.abs(rect.top) < distance) {
        nearest = index
        distance = Math.abs(rect.top)
      }
    })
    if (!visible.length && nearest >= 0) visible.push(nearest)
    syncBranches(visible)
    const root = list.getBoundingClientRect()
    const points = links.map((link, index) => {
      const rect = link.getBoundingClientRect()
      let top = rect.top
      let bottom = rect.bottom
      let parent = link.parentElement?.parentElement
      while (parent && parent !== list) {
        if (parent.classList.contains('toc-child')) {
          const clipRect = parent.getBoundingClientRect()
          top = Math.max(top, clipRect.top)
          bottom = Math.min(bottom, clipRect.bottom)
        }
        parent = parent.parentElement
      }
      return { index, x: rect.left - root.left + 9, top: top - root.top, bottom: bottom - root.top }
    }).filter(point => point.bottom - point.top > 16)
    if (!points.length) return
    svg.setAttribute('width', String(root.width))
    svg.setAttribute('height', String(root.height))
    let d = `M ${points[0].x} ${points[0].top + 8}`
    points.forEach((point, index) => {
      if (index) {
        const prev = points[index - 1]
        if (prev.x !== point.x) {
          const middle = (prev.bottom + point.top) / 2
          d += ` C ${prev.x} ${middle}, ${point.x} ${middle}, ${point.x} ${point.top + 8}`
        } else d += ` L ${point.x} ${point.top + 8}`
      }
      d += ` L ${point.x} ${point.bottom - 8}`
    })
    rail.setAttribute('d', d)
    progress.setAttribute('d', d)
    // During expansion, map a clipped heading to its currently visible parent.
    const displayed = new Set(points.map(point => point.index))
    const highlighted = new Set<number>()
    for (const index of visible) {
      let link: Element | null = links[index]
      while (link) {
        const mapped = links.indexOf(link as HTMLAnchorElement)
        if (displayed.has(mapped)) {
          highlighted.add(mapped)
          break
        }
        link = link.parentElement?.parentElement?.closest('.toc-item')?.querySelector(':scope > .toc-link') || null
      }
    }
    const activePoints = points.filter(point => highlighted.has(point.index))
    links.forEach((link, index) => {
      link.dataset.tocVisible = String(highlighted.has(index))
      if (index === activePoints[0]?.index) link.setAttribute('aria-current', 'location')
      else link.removeAttribute('aria-current')
    })
    const first = activePoints[0]
    const last = activePoints[activePoints.length - 1]
    progress.style.display = dot.style.display = first ? '' : 'none'
    if (first) {
      windowRect.setAttribute('width', String(root.width))
      windowRect.style.y = `${first.top + 8}px`
      windowRect.style.height = `${Math.max(0, last.bottom - first.top - 16)}px`
      dot.style.cx = `${first.x}px`
      dot.style.cy = `${first.top + 8}px`
    }
    // Keep the rail attached to the animated layout, not its final geometry.
    if (animations.size) schedule()
  }
  const schedule = () => { if (!frame) frame = requestAnimationFrame(draw) }
  const mutation = new MutationObserver(schedule)
  mutation.observe(panel, { attributes: true, attributeFilter: ['class'], subtree: true })
  const resize = new ResizeObserver(schedule)
  resize.observe(list)
  const article = document.querySelector('.post.block')
  if (article) resize.observe(article)
  window.addEventListener('scroll', schedule, { passive: true })
  window.addEventListener('resize', schedule, { passive: true })
  schedule()
  cleanup = () => {
    mutation.disconnect()
    resize.disconnect()
    window.removeEventListener('scroll', schedule)
    window.removeEventListener('resize', schedule)
    cancelAnimationFrame(frame)
    animations.forEach(animation => { animation.onfinish = null; animation.cancel() })
    branches.forEach(branch => { branch.style.height = ''; branch.inert = false })
    links.forEach(link => {
      link.removeAttribute('aria-current')
      delete link.dataset.tocVisible
    })
    svg.remove()
  }
}

# 2. 新建样式文件

新建 source/css/_common/outline/sidebar/toc-curve.styl,全文如下。它只作用于 .contents.toc-curve,不会把默认目录也改掉。

source/css/_common/outline/sidebar/toc-curve.styl(完整文件,点击展开)
.contents.toc-curve {
  --toc-accent: var(--primary-color);

  ol {
    padding: 0;
    list-style: none;
  }

  > .toc {
    position: relative;
    margin: 0;
  }

  .toc-child {
    display: block;
    padding-left: 1rem;
    height: 0;
    overflow: hidden;
  }

  .active > .toc-child {
    height: auto;
  }

  .toc-item {
    overflow: visible;
    white-space: normal;
    font-size: .9375rem;
  }

  .toc-number {
    font-variant-numeric: tabular-nums;
  }

  .toc-link {
    display: block;
    position: relative;
    padding: .5rem .25rem .5rem 1.5rem;
    color: var(--grey-6);
    line-height: 1.6;
    overflow-wrap: anywhere;
    transition: color .18s ease;

    &:hover {
      color: var(--text-color);
    }

    &:focus-visible {
      outline: 2px solid var(--toc-accent);
      outline-offset: -2px;
      border-radius: .25rem;
    }
  }

  .active > .toc-link,
  .current > .toc-link {
    color: var(--grey-6);
    font-weight: normal;
  }

  .toc-link[data-toc-visible='true'],
  .toc-link[data-toc-visible='true']:hover {
    color: var(--toc-accent);
    font-weight: 600;
  }

  .toc-curve-rail {
    position: absolute;
    inset: 0;
    pointer-events: none;
    overflow: visible;
    fill: none;
    stroke: var(--grey-4);
    stroke-width: 1.5;
    stroke-linecap: round;

    .toc-curve-progress {
      stroke: var(--toc-accent);
    }

    circle {
      fill: var(--toc-accent);
      stroke: none;
      transition: cx .18s ease, cy .18s ease;
    }

    .toc-curve-window {
      transition: y .18s ease, height .18s ease;
    }
  }
}

@media (prefers-reduced-motion: reduce) {
  .contents.toc-curve .toc-curve-rail {
    circle, .toc-curve-window {
      transition: none;
    }
  }
}

然后在已有 source/css/_common/outline/sidebar/toc.styl 的 文件末尾、所有大括号之外追加:

@import 'toc-curve';

只创建文件、不导入,是不会进入最终页面样式的。

# 3. 接上模板开关

修改 layout/_mixin/sidebar.pug,只有下面两处。代码使用 diff 展示:删除减号行,添加加号行,前面的 + / - 不属于 Pug 源码。

 mixin sidebarRender(display_toc)
+    - const tocStyle = page.toc_style ?? theme.sidebar.toc_style ?? 'default'
     - var related = _category_posts(page)

找到原来的目录面板,把整个开始标签行替换掉;保留其下面的 if display_toc 和 toc(page.content):

-                div(class="contents panel pjax" data-title=__('sidebar.toc'))
+                div(class="contents panel pjax" class=tocStyle === 'curve' ? 'toc-curve' : '' data-title=__('sidebar.toc'))
                     if display_toc
                         != toc(page.content)

这里没有新增一套目录 HTML,也没有手工拼编号。Hexo 输出的 .toc-item、.toc-link、.toc-child 等结构继续沿用。

# 4. 接上每次页面刷新

在 source/js/_app/pjax/refresh.ts 顶部的 import 区域添加:

import { refreshTocCurve } from '../components/toc-curve'

在 siteRefresh 内找到原来的两行,改成下面三行,调用顺序保持不变:

  sideBarTab()
  sidebarTOC()
  refreshTocCurve()

不要仅在首次加载页面时调用,也不要包在 if (当前是 curve) 中:函数开头会清理上一页的实例,即使下一页使用默认目录或没有目录,也需要经过它。

# 5. 避免旧的目录自动居中干扰

在 source/js/_app/components/sidebar.ts 中,找到注释 Scrolling to center active TOC element 后的判断,按下面完整差异修改。只追加曲线模式的排除条件,保留原来的 pageScroll 调用:

-    if (getComputedStyle(sideBar).display !== 'none' && tocElement.classList.contains('active')) {
+    if (getComputedStyle(sideBar).display !== 'none' && tocElement.classList.contains('active') && !tocElement.classList.contains('toc-curve')) {
       pageScroll((tocElement as HTMLElement), target.offsetTop - ((tocElement as HTMLElement).offsetHeight / 4))
     }

不删除整个 sidebarTOC(),因为默认目录和原来的跳转行为仍然需要它。

# 6. 补齐配置并启用

在 主题源码的 _config.yml 中,合并到现有 sidebar 下,保留其他配置:

sidebar:
  toc_style: default
  position: left

这只是相关配置示例,不是让你覆盖整个配置文件。主题默认仍是旧目录。

在 博客根目录的 _config.shokax.yml 中,合并:

sidebar:
  toc_style: curve

若你的主题名称不同,使用与你实际主题名对应的 Hexo 主题覆盖配置文件。不要把这两份 _config.yml 的位置混起来,也不要重复定义 sidebar。

文章顶部可单独设 toc_style: default 或 toc_style: curve。未设置时继承全局;这里是样式选择,不是目录显示与否的开关。

到这里,本功能需要的改动就齐了:2 个新增文件、5 个已有主题文件的接入改动,以及博客自己的启用配置。无需新增语言字段,也不依赖本站的一言、图库或阅读次数功能。

# 本地验证

这次主要验证了几个容易出问题的场景:

  1. 全局 curve,文章未配置:使用曲线目录。
  2. 全局 curve,文章 default:恢复原目录。
  3. 全局 default,文章 curve:单篇开启。
  4. 多个标题同时可见:高亮不再只认一个 .current。
  5. 标题仍在屏幕中部:已经命中,就应该展开。
  6. 跳到后面的章节:高亮起点也向下移动,不从头累计。
  7. 离开命中分支:子目录收起,细线同步缩回。

我这个工程已经封装了主题构建命令,在博客根目录执行:

pnpm run theme:build
pnpm exec hexo generate

主题 TypeScript 检查则在主题源码目录执行:

pnpm test

theme:build 是本站工程脚本,不是所有 Hexo 博客都自带的命令。

修改全局配置后,如果运行中的 Hexo 服务仍然使用旧配置,需要重启服务;不要只盯着浏览器刷新,以为是 CSS 没生效。

# 结语

这次迁移看起来是在画一根线,实际上最重要的是统一 可见标题、高亮范围、展开分支 这三件事。

一开始只学了外形,后来把交互补齐,再把配色、编号和配置方式放回自己的主题里,才算真正用顺手了。

效果可以借鉴,习惯还是得留着。完结撒花~

更新于 阅读次数 — 次

请我喝[茶]~( ̄▽ ̄)~*

Seiun 微信支付

微信支付

Seiun 支付宝

支付宝