搜索 K
主题
长文章的结构,目录列表看得见层级、看不见形状。@vaultkit/vitepress-plugin-mindmap 把 VitePress 已渲染文章的标题层级一键变成可交互的思维导图:横向树状铺开、分支按深度着色、圆点折叠展开、点标题跳回正文——本站每篇文章侧栏的「思维导图」页签就是它,现场即可体验。
本文通读包的四个公开导出(collectHeadings 收集器 + MindmapTree / MindmapCard / MindmapModal 三个组件),逐个讲清 props、事件、v-model 与 defineExpose,并展开它与 markmap 生态的取舍、canvas 测量布局这些源码注释里才有的设计决策。
「Markdown 变思维导图」这个需求,markmap 生态早有答案,但那套方案的语义是 markmap 代码围栏 → 作者手写导图:你要在文章里另写一份大纲。依赖链也重——markmap-lib 拖着 markdown-it 全家桶,markmap-view 拖着整个 d3。
本包做的是另一件事:文章大纲 → 导图。数据不走 Markdown 管线,而是从最终渲染 DOM 现取(collectHeadings)——无论标题是标准 Markdown、wikilink 还是自定义容器语法产出的,只要出现在 DOM 里就算数,路由切换后重新收集即可。运行时依赖只有 d3-hierarchy(约 10KB),渲染层是自实现的 SVG。
pnpm add -D @vaultkit/vitepress-plugin-mindmap若使用 @vaultkit/vitepress-theme-vault 主题则开箱即带——文章页侧栏的「思维导图」页签、小屏顶部导航条的导图入口都已接好。
最小闭环:收集标题 → 喂给模态 → 处理跳转。
<script setup lang="ts">
import type { MindmapNode } from '@vaultkit/vitepress-plugin-mindmap'
import { collectHeadings, MindmapModal } from '@vaultkit/vitepress-plugin-mindmap'
import { useData } from 'vitepress'
import { ref } from 'vue'
const { page } = useData()
const open = ref(false)
const root = ref<MindmapNode | null>(null)
function show() {
root.value = collectHeadings({ rootText: page.value.title })
open.value = true
}
function jump(id: string | null) {
if (!id)
return window.scrollTo({ top: 0, behavior: 'smooth' })
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
history.replaceState(null, '', `#${id}`)
}
</script>
<template>
<button @click="show">
思维导图
</button>
<MindmapModal v-model:open="open" :root="root" :labels="{ close: '关闭' }" @select="jump" />
</template>注意 select 事件的分工:组件只告诉你「用户点了哪个标题的锚点 id」,滚动、写 hash 这些跳转行为全部交给宿主——这让它在任何路由方案下都不越权。
collectHeadings(options)——DOM 标题收集器,返回 MindmapNode | nullMindmapTree——裸画布:布局 + 渲染 + 手势,无任何外壳MindmapCard——侧栏卡片:标题行 + 放大按钮 + 内嵌小图,放大复用模态MindmapModal——全屏模态(默认导出):Teleport body、Esc/遮罩关闭、滚动锁MindmapNode({ text, id, children })与 CollectHeadingsOptionsstring根节点文案,通常传文章标题。根节点的 id 恒为 null——select 事件收到 null 时意味着用户点了根节点,约定俗成的处理是滚回页首。
string'.vp-doc'标题容器选择器。默认值覆盖 VitePress 正文区;如果你把组件用在非 VitePress 页面,改成自己的正文容器:
const root = collectHeadings({ rootText: '我的文章', container: '#article-body' })string'h2, h3, h4, h5, h6'参与收集的标题选择器。只想要两层大纲就收窄:
const root = collectHeadings({ rootText: page.value.title, headings: 'h2, h3' })内部用一个级别栈维护祖先链:遍历标题时,弹出栈内级别大于等于自身的项,把新节点挂到栈顶节点下再入栈——h2 后面跳出 h4 也能正确挂接,层级不连续不报错。标题文本会剥离 VitePress 注入的 .header-anchor 装饰子节点(它含零宽空格,直接取 textContent 会带脏字符)。没有任何标题时返回 null,宿主据此隐藏入口。
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
root | MindmapNode | —(必填) | 导图树 |
height | number | 600 | 画布高(px) |
root 换引用时组件会清空折叠状态并重新 autofit(watch 深度语义:换文章 = 换树)。height 决定 SVG viewBox 的纵向尺度,也参与 autofit 缩放计算,嵌哪里就按哪里的空间给:
<MindmapTree :root="root" :height="360" @select="jump" />唯一事件 select,载荷 id: string | null。点击节点文本触发(键盘 Enter 同);点击分支圆点不触发 select,而是折叠/展开该分支——这套「文本导航、圆点折叠」的分工与 markmap 一致,两个动作都可键盘操作(role="button" + tabindex="0",圆点带 aria-expanded)。
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
root | MindmapNode | —(必填) | 导图树 |
title | string | 'Mindmap' | 卡片标题 |
height | number | 220 | 内嵌画布高(px) |
expandable | boolean | true | 是否显示放大按钮 |
labels | { expand?: string, close?: string } | {} | 按钮文案(i18n 由宿主注入) |
侧栏空间紧张时 height 给小些,靠放大按钮进全屏细看;不想提供全屏就关掉 expandable:
<MindmapCard
:root="root"
title="思维导图"
:height="220"
:labels="{ expand: '放大导图', close: '关闭' }"
@select="jump"
/>一个实现细节:内嵌小图与全屏大图是两个独立的 MindmapTree 实例——放大不是把小图 transform 拉伸,而是在更大画布上重新 autofit 布局,字号与间距天然合适。
select(同 MindmapTree,卡片与其内部模态的选中都透传)。
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
root | MindmapNode | null | —(必填) | 导图树;为 null 时即使 open 也不渲染 |
labels | { close?: string } | {} | 关闭按钮文案 |
显隐由 v-model:open(defineModel,默认 false)控制。模态高度自适应视口(min(760, 视口高 × 0.82)),打开期间锁定 body 滚动、注册 Esc 监听,关闭或组件卸载时精确还原——不会把页面滚动条「锁死在半路」。
select——节点选中后先自动关闭模态再透传,用户点完标题直接看到正文滚动,不用手动关defineExpose({ open })——暴露 open 的 ref,模板 ref 场景下可编程开合:<script setup lang="ts">
import { MindmapModal } from '@vaultkit/vitepress-plugin-mindmap'
import { useTemplateRef } from 'vue'
const modal = useTemplateRef('modal')
function openFromAnywhere() {
modal.value!.open = true
}
</script>
<template>
<MindmapModal ref="modal" :root="root" @select="jump" />
</template>画布用 d3-hierarchy 的 tidy tree 算行坐标(nodeSize([34, 1]),行高定距 34px),但列坐标弃用 d3 的输出、自己算:每一层取「该层最宽标签」累进出列偏移。这是 markmap 逐节点变宽布局的轻量近似——深层短标签不会被浅层长标签顶得很远,整树紧凑。
标签宽度不是估算的:onMounted 时建一个离屏 canvas,把 getComputedStyle(document.body).fontFamily 拼上 13px 设为测量字体,用 measureText 拿真实像素宽。canvas 拿不到时退化为 字符数 × 13 的粗估。
每个节点 = 文本 + 下划线基线 + 分支圆点,基线与连线颜色按深度循环取 mm-depth-0 至 mm-depth-5 六档(VitePress 调色板变量:brand / indigo / green / yellow / red / purple,明暗自适应)。折叠状态存的是路径键(如 0.2.1,根到该节点的索引链)组成的 Set——树重建后同位置节点的折叠状态天然保留;折叠某分支后重新布局,圆点变实心提示「这里还有内容」。悬停文本只变色不加下划线,因为节点自带彩色基线,再叠 text-decoration 是双下划线冗余——这种细节源码注释里都有交代。
与 local-graph 包同款:滚轮以指针为锚缩放(锚点处图坐标缩放前后不动,范围 0.2–4 倍),背景拖拽平移(Pointer Capture,坐标经 viewBox 尺度换算),初始 autofit 整树入框居中。文本与圆点上的 pointerdown.stop 阻止手势误触发。
包发布的 dist 内含 .vue 源文件,消费方必须把它加进 vite.ssr.noExternal,否则 VitePress SSR 构建直接报错:
// .vitepress/config.ts
export default defineConfig({
vite: { ssr: { noExternal: ['@vaultkit/vitepress-plugin-mindmap'] } },
})onMounted 后)。SSR 输出固定高度的空容器占位,无水合差异、无布局跳动onContentUpdated 里收集;注意本站主题踩过的坑——异步 chunk 组件硬刷新时挂载晚于 Content mounted,会错过唯一一次回调,onMounted 再补采一次兜底(幂等)role="button" + tabindex="0",Enter 可操作;圆点带 aria-expanded;模态 role="dialog" + aria-modalvue ^3.5.0Q:导图是空的?collectHeadings 返回了 null——容器选择器没匹配到,或正文没有 h2-h6 标题。先确认 container 与页面结构。
Q:换文章后导图还是旧的? 收集是快照式的,路由切换后要重新调 collectHeadings。放进 onContentUpdated 回调即可。
Q:能给围栏代码块写导图吗? 不能,也不打算。那是 markmap 生态的场景;本包只做「文章大纲 → 导图」这一件事。