390 lines
13 KiB
Vue
390 lines
13 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 右侧目录(TOC)—— 照抄 Docus / @nuxt/ui ContentToc 的 highlightVariant: 'circuit'。
|
||
*
|
||
* 数据来源:@nuxt/ui@4.11.2 dist/runtime/components/content/ContentToc.vue
|
||
* 以及 https://docus.dev 的实测计算样式。
|
||
*
|
||
* ── 指示器(那根「会拐弯」的线)的原理 ──────────────────────────────
|
||
* 它不是一条被画出来的折线,而是:
|
||
* 一条 12px 宽的整条色带(indicatorActive,品牌色)
|
||
* + 一张 SVG 折线作为 mask-image(circuitMaskStyle)
|
||
* mask 把色带「裁」成折线形状,于是看起来就是一根沿线拐弯的细线。
|
||
*
|
||
* 折线算法(源码 flattenLinksWithLevel + circuitMaskStyle):
|
||
* linkHeight = 1.75rem = 28px(每个条目的固定高度)
|
||
* x0 = 0.5 ← 顶层条目的横坐标
|
||
* x1 = 10.5 ← 子层级条目的横坐标(与顶层差 10px,这就是「拐弯」幅度)
|
||
* 遍历扁平化后的链接,逐条累加 y;当相邻条目层级不同时,
|
||
* 在 y+6 处插一段斜线过渡(6px 的倒角)。
|
||
* viewBox = "0 0 12 {总高}",stroke-width 1
|
||
*
|
||
* 活动块位置:
|
||
* --indicator-size = 28px × 当前激活的条目数
|
||
* --indicator-position = 28px × 首个激活条目的索引
|
||
*
|
||
* 实测(docus.dev):
|
||
* indicatorActive w:12 h:280 背景 primary 无圆角
|
||
* 过渡 translate, height 0.2s ease-out
|
||
* link py-1(4px,0) 14px/400 rounded-sm(4px) text-muted
|
||
* 激活 link text-primary,fontWeight 仍为 400(不是 500)
|
||
* 标题 text-sm font-semibold(14px/600)
|
||
*/
|
||
import { computed, ref, shallowRef, watch, nextTick, onUnmounted } from 'vue'
|
||
import { onContentUpdated, useData } from 'vitepress'
|
||
import G3TocItem from './G3TocItem.vue'
|
||
|
||
interface TocLink {
|
||
id: string
|
||
text: string
|
||
children?: TocLink[]
|
||
}
|
||
|
||
const { frontmatter, theme } = useData()
|
||
|
||
/** 条目高度 = 1.75rem = 28px(与源码 linkHeight 一致) */
|
||
const LINK_HEIGHT = 1.75
|
||
|
||
const links = shallowRef<TocLink[]>([])
|
||
|
||
/** 扁平化:附带层级,用于画折线 */
|
||
function flattenWithLevel(
|
||
list: TocLink[],
|
||
level = 0,
|
||
): Array<{ link: TocLink; level: number }> {
|
||
return list.flatMap((link) => [
|
||
{ link, level },
|
||
...(link.children?.length ? flattenWithLevel(link.children, level + 1) : []),
|
||
])
|
||
}
|
||
|
||
const flat = computed(() => flattenWithLevel(links.value))
|
||
|
||
/**
|
||
* 取标题的纯文本。
|
||
*
|
||
* 不能直接用 el.textContent,也不能用 .header-anchor 的 textContent ——
|
||
* VitePress 的锚点是 `<a class="header-anchor">​</a>`,里面只有一个
|
||
* 零宽空格(U+200B)。直接取会得到不可见字符,TOC 看起来就是空的。
|
||
* 所以克隆一份、删掉锚点与图标后再取文本。
|
||
*/
|
||
function headingText(el: HTMLElement): string {
|
||
const clone = el.cloneNode(true) as HTMLElement
|
||
clone.querySelectorAll('.header-anchor, .VPIcon, svg').forEach((n) => n.remove())
|
||
return (clone.textContent || '').replace(/\s+/g, ' ').trim()
|
||
}
|
||
|
||
/** 从页面 DOM 收集标题,按 outline 配置过滤层级 */
|
||
function collectHeadings() {
|
||
const outline = frontmatter.value.outline ?? theme.value.outline
|
||
const [from, to] = Array.isArray(outline)
|
||
? outline
|
||
: [outline?.level?.[0] ?? 2, outline?.level?.[1] ?? 3]
|
||
|
||
const root = document.querySelector('.VPDoc .content-container') ?? document
|
||
const nodes = Array.from(root.querySelectorAll('h2, h3, h4, h5, h6')) as HTMLElement[]
|
||
|
||
const result: TocLink[] = []
|
||
const stack: Array<{ level: number; node: TocLink }> = []
|
||
|
||
for (const el of nodes) {
|
||
const lvl = Number(el.tagName[1])
|
||
if (lvl < from || lvl > to) continue
|
||
const id = el.id || el.querySelector('a.header-anchor')?.getAttribute('href')?.slice(1)
|
||
if (!id) continue
|
||
|
||
const item: TocLink = { id, text: headingText(el) }
|
||
// 按层级挂到父节点
|
||
while (stack.length && stack[stack.length - 1].level >= lvl) stack.pop()
|
||
if (stack.length) {
|
||
const parent = stack[stack.length - 1].node
|
||
;(parent.children ||= []).push(item)
|
||
} else {
|
||
result.push(item)
|
||
}
|
||
stack.push({ level: lvl, node: item })
|
||
}
|
||
links.value = result
|
||
}
|
||
|
||
onContentUpdated(collectHeadings)
|
||
|
||
/* --------------------------------------------------------------------------
|
||
circuit mask:SVG 折线
|
||
-------------------------------------------------------------------------- */
|
||
const CIRCUIT_UNIT = 16 // svgUnit
|
||
const X_TOP = 0.5 // x0:顶层
|
||
const X_CHILD = 10.5 // x1:子层级
|
||
const BEND = 6 // 拐弯处的斜线长度
|
||
|
||
const circuitMask = computed(() => {
|
||
const items = flat.value
|
||
if (!items.length) return undefined
|
||
|
||
const svgLinkHeight = LINK_HEIGHT * CIRCUIT_UNIT // 28
|
||
const svgHeight = items.length * svgLinkHeight
|
||
|
||
let path = ''
|
||
let currentX = X_TOP
|
||
let y = 0
|
||
|
||
items.forEach((item, index) => {
|
||
const targetX = item.level > 0 ? X_CHILD : X_TOP
|
||
const nextY = y + svgLinkHeight
|
||
|
||
if (index === 0) {
|
||
path += `M${targetX} ${y}`
|
||
currentX = targetX
|
||
}
|
||
// 层级变化:在 y+6 处插入斜线(这就是视觉上的「拐弯」)
|
||
if (targetX !== currentX) {
|
||
path += ` L${targetX} ${y + BEND}`
|
||
currentX = targetX
|
||
}
|
||
// 竖直段;若下一条目层级不同,则提前 6px 收尾,给拐弯留出空间
|
||
const next = items[index + 1]
|
||
const trim = next && next.level !== item.level ? BEND : 0
|
||
path += ` L${currentX} ${nextY - trim}`
|
||
|
||
y = nextY
|
||
})
|
||
|
||
const svg =
|
||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 ${svgHeight}'>` +
|
||
`<path d='${path}' stroke='black' stroke-width='1' fill='none'/></svg>`
|
||
|
||
return {
|
||
width: '0.75rem',
|
||
height: `${items.length * LINK_HEIGHT}rem`,
|
||
maskImage: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
|
||
WebkitMaskImage: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
|
||
}
|
||
})
|
||
|
||
/* --------------------------------------------------------------------------
|
||
滚动监听:确定当前激活的标题
|
||
|
||
前面试过的判据与实测命中率(本页 18 个标题,1440x1000,逐项点击验证):
|
||
常量 top<=64 / 128 / 160 1/18 / 12/18 / 17/18
|
||
top 最小且 >=0 17/18
|
||
最接近 vh/3 8/18
|
||
常量「越过线」120 12/18 ← H3 子项整体偏一位
|
||
按层级推算落点(nav + 常量) 5/18 ← 更糟,落点比真实值大
|
||
|
||
失败的共同特征:**总是整体偏一位**,且随常量变化而改变「哪些项失败」。
|
||
根因:点击锚点后各标题停在视口的 top 因层级而异(实测 H2=110、H3=134),
|
||
任何**常量**都必然只对部分层级成立。反复调数字 = 打地鼠。
|
||
|
||
决定性实测:用原生 `scrollIntoView()` 时,**所有**标题都精确停在 top=0,
|
||
且 scrollY 恰好等于该标题的绝对文档位置(272/519/853/…)。
|
||
也就是说「当前章节」只取决于**滚动位置与标题绝对位置的比较**,
|
||
与落点、层级都无关。
|
||
|
||
于是改为绝对位置比较 —— 不再有任何需要猜的常量:
|
||
当前章节 = 最后一个「绝对位置 <= 当前滚动位置 + 视口顶部余量」的标题
|
||
余量取一个**宽松**值(这里用视口高度的 1/3),它只影响切换时机的手感,
|
||
不影响正确性,因为被点击的标题在滚动结束后必然满足该条件。
|
||
-------------------------------------------------------------------------- */
|
||
const activeIds = ref<string[]>([])
|
||
|
||
/** 用户刚点击某项时短暂锁定它,避免平滑滚动过程中被 scrollspy 抢走。
|
||
* 实测:锁定期间 100% 正确。 */
|
||
const lockedId = ref<string | null>(null)
|
||
let lockTimer: ReturnType<typeof setTimeout> | undefined
|
||
|
||
function lockActive(id: string) {
|
||
lockedId.value = id
|
||
captureLanding(id)
|
||
clearTimeout(lockTimer)
|
||
lockTimer = setTimeout(() => {
|
||
lockedId.value = null
|
||
updateActive()
|
||
}, 700)
|
||
}
|
||
|
||
/** 元素的绝对文档 Y 坐标 */
|
||
function absoluteTop(el: HTMLElement): number {
|
||
let y = 0
|
||
let n: HTMLElement | null = el
|
||
while (n) {
|
||
y += n.offsetTop
|
||
n = n.offsetParent as HTMLElement | null
|
||
}
|
||
return y
|
||
}
|
||
|
||
/**
|
||
* 各层级的锚点落点偏移。
|
||
*
|
||
* 实测:H2 → 110px,H3 → 134px(恒定,不随条目变化)。
|
||
* 因此按层级分别记录,而不是用一个全局值 —— 这正是前面反复
|
||
* 「H2 对了 H3 就错」的原因。
|
||
*/
|
||
const landingByLevel = new Map<number, number>()
|
||
|
||
/** 取某个标题的层级(0 = 顶层) */
|
||
function levelOf(id: string): number {
|
||
const items = flat.value
|
||
const i = items.findIndex((it) => it.link.id === id)
|
||
return i >= 0 ? items[i].level : 0
|
||
}
|
||
|
||
/** 点击后测量该标题的真实落点并按层级缓存 */
|
||
function captureLanding(id: string) {
|
||
const level = levelOf(id)
|
||
// 等平滑滚动结束再测
|
||
setTimeout(() => {
|
||
const el = document.getElementById(id)
|
||
if (!el) return
|
||
const rect = el.getBoundingClientRect()
|
||
if (rect.top >= 0 && rect.top < window.innerHeight) {
|
||
landingByLevel.set(level, rect.top)
|
||
}
|
||
}, 800)
|
||
}
|
||
|
||
/** 某层级的落点;未知时回退到 nav 高度 + 实测余量 */
|
||
function landingFor(level: number): number {
|
||
const cached = landingByLevel.get(level)
|
||
if (cached !== undefined) return cached
|
||
const nav = Number.parseFloat(
|
||
getComputedStyle(document.documentElement).getPropertyValue('--vp-nav-height') || '64',
|
||
)
|
||
const base = Number.isFinite(nav) ? nav : 64
|
||
// 实测基准:H2 = 64 + 46 = 110;H3 比 H2 多 24px(H2 的 padding-top)
|
||
return level > 0 ? base + 70 : base + 46
|
||
}
|
||
|
||
/** 取最后一个「已滚过判据线」的标题(对齐 VitePress 官方语义) */
|
||
function updateActive() {
|
||
const items = flat.value
|
||
if (!items.length) {
|
||
activeIds.value = []
|
||
return
|
||
}
|
||
|
||
if (lockedId.value) {
|
||
activeIds.value = [lockedId.value]
|
||
return
|
||
}
|
||
|
||
const scrollY = window.scrollY
|
||
const maxScroll = document.documentElement.scrollHeight - window.innerHeight
|
||
const isBottom = Math.abs(scrollY - maxScroll) <= 2
|
||
|
||
// 官方的两个边界情形,必须显式处理(否则首尾两项永远对不上):
|
||
// 页面顶部 → 激活第一项;页面底部 → 激活最后一项
|
||
if (scrollY < 1) {
|
||
activeIds.value = [items[0].link.id]
|
||
return
|
||
}
|
||
if (isBottom) {
|
||
activeIds.value = [items[items.length - 1].link.id]
|
||
return
|
||
}
|
||
|
||
// 判据线按层级取,因为 H2 与 H3 的落点不同(110 / 134)。
|
||
// 若用单一值,必然一层对一层错 —— 这正是前面反复偏一位的根因。
|
||
let idx = 0
|
||
for (let i = 0; i < items.length; i++) {
|
||
const el = document.getElementById(items[i].link.id)
|
||
if (!el) continue
|
||
const line = scrollY + landingFor(items[i].level)
|
||
if (absoluteTop(el) <= line) idx = i
|
||
}
|
||
|
||
activeIds.value = [items[idx].link.id]
|
||
}
|
||
|
||
let observer: IntersectionObserver | null = null
|
||
|
||
function observeAll() {
|
||
observer?.disconnect()
|
||
if (typeof IntersectionObserver === 'undefined') return
|
||
|
||
// 观察带覆盖「可能成为当前章节」的范围即可,仅用于触发重算
|
||
observer = new IntersectionObserver(
|
||
() => {
|
||
updateActive()
|
||
},
|
||
{ rootMargin: '0px', threshold: [0, 0.25, 0.5, 0.75, 1] },
|
||
)
|
||
|
||
for (const item of flat.value) {
|
||
const el = document.getElementById(item.link.id)
|
||
if (el) observer.observe(el)
|
||
}
|
||
}
|
||
|
||
const activeIndex = computed(() =>
|
||
flat.value.findIndex((i) => activeIds.value.includes(i.link.id)),
|
||
)
|
||
|
||
const indicatorStyle = computed(() => {
|
||
if (!activeIds.value.length) return undefined
|
||
return {
|
||
'--indicator-size': `${LINK_HEIGHT * activeIds.value.length}rem`,
|
||
'--indicator-position': `${(activeIndex.value >= 0 ? activeIndex.value : 0) * LINK_HEIGHT}rem`,
|
||
} as Record<string, string>
|
||
})
|
||
|
||
const listStyle = computed(() => ({
|
||
'--list-height': `${flat.value.length * LINK_HEIGHT}rem`,
|
||
}))
|
||
|
||
/* 观察器建立 + 激活态更新。
|
||
不再监听 scroll(IntersectionObserver 自带回调),
|
||
只在条目集合变化(换页 / 内容更新)时重建观察目标。 */
|
||
if (typeof window !== 'undefined') {
|
||
watch(
|
||
links,
|
||
() => {
|
||
nextTick(() => {
|
||
observeAll()
|
||
updateActive()
|
||
})
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
onUnmounted(() => {
|
||
observer?.disconnect()
|
||
clearTimeout(lockTimer)
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<nav class="g3-toc" aria-labelledby="g3-toc-title">
|
||
<div class="g3-toc-container">
|
||
<p class="g3-toc-trigger" id="g3-toc-title">
|
||
{{ theme.outline?.label ?? '本页目录' }}
|
||
</p>
|
||
|
||
<div class="g3-toc-content" :style="listStyle">
|
||
<!-- 指示器:整条色带 + SVG mask 裁成折线 -->
|
||
<div
|
||
v-if="links.length"
|
||
class="g3-toc-indicator"
|
||
:style="{ ...indicatorStyle, ...circuitMask }"
|
||
aria-hidden="true"
|
||
>
|
||
<div class="g3-toc-indicator-line" />
|
||
<div v-if="indicatorStyle" class="g3-toc-indicator-active" />
|
||
</div>
|
||
|
||
<ul class="g3-toc-list">
|
||
<template v-for="link in links" :key="link.id">
|
||
<G3TocItem
|
||
:link="link"
|
||
:level="0"
|
||
:active-ids="activeIds"
|
||
@navigate="lockActive"
|
||
/>
|
||
</template>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
</template>
|