u
This commit is contained in:
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">​</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>
|
||||
Reference in new issue
Block a user