This commit is contained in:
oneao committed 2026-10-09 17:32:14 +08:00
1 parent e99a9fb274
commit 0be0b0767a
788 files changed
+112023 -14941

No files matched your search

@@ -0,0 +1,389 @@
<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>