Files
workspace/code/g3soft-libs/docs/.vitepress/theme/G3DocAside.vue
T
2026-10-09 17:32:14 +08:00

390 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">&#8203;</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>