跳转到内容
关系图谱

mindmap:标题大纲一键变思维导图

2,063 字 7 分钟

属性 1
series
VaultKit 包指南

长文章的结构,目录列表看得见层级、看不见形状。@vaultkit/vitepress-plugin-mindmap 把 VitePress 已渲染文章的标题层级一键变成可交互的思维导图:横向树状铺开、分支按深度着色、圆点折叠展开、点标题跳回正文——本站每篇文章侧栏的「思维导图」页签就是它,现场即可体验。

本文通读包的四个公开导出(collectHeadings 收集器 + MindmapTree / MindmapCard / MindmapModal 三个组件),逐个讲清 props、事件、v-modeldefineExpose,并展开它与 markmap 生态的取舍、canvas 测量布局这些源码注释里才有的设计决策。

为什么需要它

「Markdown 变思维导图」这个需求,markmap 生态早有答案,但那套方案的语义是 markmap 代码围栏 → 作者手写导图:你要在文章里另写一份大纲。依赖链也重——markmap-lib 拖着 markdown-it 全家桶,markmap-view 拖着整个 d3。

本包做的是另一件事:文章大纲 → 导图。数据不走 Markdown 管线,而是从最终渲染 DOM 现取(collectHeadings)——无论标题是标准 Markdown、wikilink 还是自定义容器语法产出的,只要出现在 DOM 里就算数,路由切换后重新收集即可。运行时依赖只有 d3-hierarchy(约 10KB),渲染层是自实现的 SVG。

安装

sh
pnpm add -D @vaultkit/vitepress-plugin-mindmap

若使用 @vaultkit/vitepress-theme-vault 主题则开箱即带——文章页侧栏的「思维导图」页签、小屏顶部导航条的导图入口都已接好。

快速上手

最小闭环:收集标题 → 喂给模态 → 处理跳转。

vue
<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 | null
  • MindmapTree——裸画布:布局 + 渲染 + 手势,无任何外壳
  • MindmapCard——侧栏卡片:标题行 + 放大按钮 + 内嵌小图,放大复用模态
  • MindmapModal——全屏模态(默认导出):Teleport body、Esc/遮罩关闭、滚动锁
  • 类型:MindmapNode{ text, id, children })与 CollectHeadingsOptions

collectHeadings 逐项详解

rootText(必填)

  • 类型:string

根节点文案,通常传文章标题。根节点的 id 恒为 null——select 事件收到 null 时意味着用户点了根节点,约定俗成的处理是滚回页首。

container

  • 类型:string
  • 默认值:'.vp-doc'

标题容器选择器。默认值覆盖 VitePress 正文区;如果你把组件用在非 VitePress 页面,改成自己的正文容器:

ts
const root = collectHeadings({ rootText: '我的文章', container: '#article-body' })

headings

  • 类型:string
  • 默认值:'h2, h3, h4, h5, h6'

参与收集的标题选择器。只想要两层大纲就收窄:

ts
const root = collectHeadings({ rootText: page.value.title, headings: 'h2, h3' })

收集算法

内部用一个级别栈维护祖先链:遍历标题时,弹出栈内级别大于等于自身的项,把新节点挂到栈顶节点下再入栈——h2 后面跳出 h4 也能正确挂接,层级不连续不报错。标题文本会剥离 VitePress 注入的 .header-anchor 装饰子节点(它含零宽空格,直接取 textContent 会带脏字符)。没有任何标题时返回 null,宿主据此隐藏入口。

MindmapTree:裸画布

props

名称类型默认值说明
rootMindmapNode—(必填)导图树
heightnumber600画布高(px)

root 换引用时组件会清空折叠状态并重新 autofit(watch 深度语义:换文章 = 换树)。height 决定 SVG viewBox 的纵向尺度,也参与 autofit 缩放计算,嵌哪里就按哪里的空间给:

vue
<MindmapTree :root="root" :height="360" @select="jump" />

事件

唯一事件 select,载荷 id: string | null。点击节点文本触发(键盘 Enter 同);点击分支圆点不触发 select,而是折叠/展开该分支——这套「文本导航、圆点折叠」的分工与 markmap 一致,两个动作都可键盘操作(role="button" + tabindex="0",圆点带 aria-expanded)。

MindmapCard:侧栏卡片

props

名称类型默认值说明
rootMindmapNode—(必填)导图树
titlestring'Mindmap'卡片标题
heightnumber220内嵌画布高(px)
expandablebooleantrue是否显示放大按钮
labels{ expand?: string, close?: string }{}按钮文案(i18n 由宿主注入)

侧栏空间紧张时 height 给小些,靠放大按钮进全屏细看;不想提供全屏就关掉 expandable

vue
<MindmapCard
  :root="root"
  title="思维导图"
  :height="220"
  :labels="{ expand: '放大导图', close: '关闭' }"
  @select="jump"
/>

一个实现细节:内嵌小图与全屏大图是两个独立的 MindmapTree 实例——放大不是把小图 transform 拉伸,而是在更大画布上重新 autofit 布局,字号与间距天然合适。

事件

select(同 MindmapTree,卡片与其内部模态的选中都透传)。

MindmapModal:全屏模态

props 与 v-model

名称类型默认值说明
rootMindmapNode | null—(必填)导图树;为 null 时即使 open 也不渲染
labels{ close?: string }{}关闭按钮文案

显隐由 v-model:opendefineModel,默认 false)控制。模态高度自适应视口(min(760, 视口高 × 0.82)),打开期间锁定 body 滚动、注册 Esc 监听,关闭或组件卸载时精确还原——不会把页面滚动条「锁死在半路」。

事件与 defineExpose

  • select——节点选中后先自动关闭模态再透传,用户点完标题直接看到正文滚动,不用手动关
  • defineExpose({ open })——暴露 open 的 ref,模板 ref 场景下可编程开合:
vue
<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>

设计机制

布局:markmap 的轻量近似

画布用 d3-hierarchy 的 tidy tree 算行坐标nodeSize([34, 1]),行高定距 34px),但列坐标弃用 d3 的输出、自己算:每一层取「该层最宽标签」累进出列偏移。这是 markmap 逐节点变宽布局的轻量近似——深层短标签不会被浅层长标签顶得很远,整树紧凑。

标签宽度不是估算的:onMounted 时建一个离屏 canvas,把 getComputedStyle(document.body).fontFamily 拼上 13px 设为测量字体,用 measureText 拿真实像素宽。canvas 拿不到时退化为 字符数 × 13 的粗估。

节点视觉与折叠

每个节点 = 文本 + 下划线基线 + 分支圆点,基线与连线颜色按深度循环取 mm-depth-0mm-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 阻止手势误触发。

高级与边界

Important

包发布的 dist 内含 .vue 源文件,消费方必须把它加进 vite.ssr.noExternal,否则 VitePress SSR 构建直接报错:

ts
// .vitepress/config.ts
export default defineConfig({
  vite: { ssr: { noExternal: ['@vaultkit/vitepress-plugin-mindmap'] } },
})
  • SSR:布局测量依赖 canvas,只能客户端执行(onMounted 后)。SSR 输出固定高度的空容器占位,无水合差异、无布局跳动
  • 收集时机:VitePress 场景推荐 onContentUpdated 里收集;注意本站主题踩过的坑——异步 chunk 组件硬刷新时挂载晚于 Content mounted,会错过唯一一次回调,onMounted 再补采一次兜底(幂等)
  • a11y:文本与圆点均为 role="button" + tabindex="0",Enter 可操作;圆点带 aria-expanded;模态 role="dialog" + aria-modal
  • peer 依赖vue ^3.5.0

常见问题

Q:导图是空的?collectHeadings 返回了 null——容器选择器没匹配到,或正文没有 h2-h6 标题。先确认 container 与页面结构。

Q:换文章后导图还是旧的? 收集是快照式的,路由切换后要重新调 collectHeadings。放进 onContentUpdated 回调即可。

Q:能给围栏代码块写导图吗? 不能,也不打算。那是 markmap 生态的场景;本包只做「文章大纲 → 导图」这一件事。

相关链接