English version: README_en_US.md
把思源笔记里的无序列表、有序列表、任务列表三种大纲块,一键渲染成专业级思维导图。
思源的大纲天然就是一棵树,但纯文本的层级在层级变深后会很难一眼看清结构。这个插件把大纲块变成真正的导图:
- 卡片式节点 —— 根节点实心胶囊、一级分支浅色填充卡、深层节点带左侧色条,而不是白框黑字
- 分支色系 —— 每个一级分支一种颜色,子节点沿层级继承,一眼分清归属
- 贝塞尔曲线连线 —— 线宽与透明度随层级递减,形成视觉纵深
- 真正的布局引擎 —— tidy-tree 算法,左右自动平衡、父节点对齐子树中点,长文本不会撑歪整张图
- 三种布局 —— 逻辑结构图、思维导图(左右分布)、树状图(自上而下)
- 跟随思源主题 —— 默认使用思源当前配色,也可切换到内置的深空 / 极简白 / 莫兰迪 / 霓虹 / 高对比度
- 可缩放可折叠 —— Ctrl + 滚轮缩放、拖拽平移、一键适应画布、折叠任意子树
- 全键盘操作 —— 方向键在节点间导航,
Tab/Enter/F2/Delete直接改结构,不用回到编辑器 - 图内直接编辑 —— 双击节点就能改文字,节点里原有的双链 / 图片 / 加粗 / 公式原样带进编辑框,
改完回车提交、改动写回思源;画布上也能打
/插图片与超链接、打[[选块引用 - 拖拽重排 —— 拖动节点即可调整顺序与层级,右键还有增删 / 升降级 / 定位等操作
- 搜索与多选 ——
Ctrl + F定位节点,搜索范围还能切到全文档;Shift框选或Ctrl + A批量选中后统一操作 - 状态过滤 —— 任务列表里一键「只看未完成 / 只看已完成」,非待办节点跟着最近的任务祖先一起留或走
- 节点标记 —— 给节点挂图标 / 标签 / 自定义色,存在块属性上,可随笔记同步
- 小地图与缩放胶囊 —— 大图里也能随时知道自己在哪
- 长文档友好 —— 列表块进入视口才渲染,节点过多时自动收紧间距并给出保护提示
- 导出 SVG / PNG —— 矢量输出,方便分享与插入文档
-
在任意列表块(无序 / 有序 / 任务列表)上点击块标打开块菜单
-
选择 插件 → 大纲导图,然后在三个模式里挑一个:
模式 作用 大纲模式(关闭导图) 就是这个列表块本来的样子,什么都不加 导图模式 列表块变成导图;默认用逻辑结构图 并排模式(大纲 + 导图) 左边保留大纲、右边开一张跟随的导图,改哪边另一边都跟着变 -
想换布局(逻辑结构图 / 思维导图 / 树状图)在导图自己的工具条上切, 不用回块菜单 —— 那里是「导图长什么样」该待的地方,点完立刻看得到效果
菜单里只有「要不要导图、要不要并排」,布局不在菜单里。 这是有意的:老菜单把「大纲视图 / 逻辑结构图 / 思维导图 / 树状图 / 并排查看」 五项平铺,混了两个层级的东西(「要不要导图」和「导图长什么样」)。
顺带一条容易踩的:已经在导图模式时再点「导图模式」,不会把布局重置回默认 —— 否则你手动切到「树状图」之后点一下菜单就被打回逻辑结构图了。
导图右上角的工具条提供:
| 按钮 | 作用 |
|---|---|
| 逻辑结构图 / 思维导图 / 树状图 | 切换布局,会写回块属性 |
| 全部 / 未完成 / 已完成 | 按状态过滤(仅任务列表出现;只影响当前这张图,不改内容) |
| − / + / 适应 | 缩放与一键适应画布 |
| 折叠 / 展开 | 批量折叠或展开所有子树 |
| 导出 | 导出 PNG 或 SVG |
| 全屏 | 在独立窗口中查看大导图 |
| 退出 | 关闭导图,回到大纲模式 |
除了列表块菜单,导图还可以从这些地方打开:
| 入口 | 位置 | 说明 |
|---|---|---|
| 块菜单 | 列表块标 → 插件 → 大纲导图 | 对单个列表生效 |
| 顶栏图标 | 顶部工具栏右侧的树形图标 | 当前列表转换、并排查看、本页列表批量转换、打开设置 |
| 命令面板 | ⌥⇧P,搜「导图」 |
两条命令都在这里,显示中文 |
| 快捷键 | Ctrl + Shift + D 切换导图 / 大纲,Ctrl + Shift + X 并排打开 |
可在思源「设置 → 快捷键」里改键 |
快捷键在 macOS 上写作
⇧⌘D/⇧⌘X(思源内部统一用 macOS 字形存键位, 在 Windows 上由思源自己换算成Ctrl + Shift + …)。为什么不用空格:这两条默认键位一开始是
Ctrl + Alt + D/Ctrl + Alt + V, 中间试过Ctrl + 空格/Alt + 空格,结果真机上按了没反应 ——Ctrl + 空格是中文输入法「中/英文模式切换」的默认键(输入法在应用之前就把它吃掉, 而且只在输入法处于活动状态时才吞,所以表现成「有时灵、有时不灵」),Alt + 空格是 Windows 的窗口系统菜单。两者都是静默拦截,没有任何报错。为什么「并排打开」是 X 而不是 S 或 B:S 看着更顺(Side / 并排),我们试过
Ctrl + Shift + S,结果在编辑器里按不动。原因是Ctrl + S那一族归思源的 编辑器(Protyle)自己管,它会在事件上浮的路上把它拦下 —— 而思源的全局快捷键匹配器在更外层,根本收不到这个按键。 更麻烦的是它只在光标位于编辑器内时失效(焦点在文档树等别处时又能用), 又是一个「有时灵、有时不灵」。接着换成
Ctrl + Shift + B(Beside,并排),栽在更隐蔽的一层: 按键确实送到了最外层,但思源内置的「上方插入块」(editor.general.insertBefore) 出厂就默认绑在这个键上,它在更早的一步把事件标记成「已处理」。 于是插件命令一次都没轮到 —— 画面毫无反应,正文里却悄悄多出一个空段落。 「送到没送到」和「轮不轮得到」是两道不同的关,只看前者会误判成「空闲」。现在这组不含空格、也不含单独的
Ctrl + Shift(那个是切换输入语言),Ctrl + Shift + X实测在思源侧未被任何命令或插件占用,按键也能正常送达。
⚠️ 思源内部把「空格」存成一个字面空格字符(不是Space这个词), 所以「查看快捷键」里会把空格渲染成可见的「空格」二字 —— 直接拼出来会变成Ctrl +这样一个看不见的尾随空格,用户读不出该按什么。 速查里仍有这类行(折叠 / 展开、演示推进)。思源会过滤插件声明的热键:带
⌃/⌥/⌘前缀的放行,只有不带修饰键的 裸单字符才会被清空,所以这两条能正常绑上。
-
Ctrl + 滚轮(macOS 为 ⌘)缩放
-
拖拽空白处 平移画布
-
单击节点 选中并聚焦该分支(祖先路径高亮,其余节点淡出)
-
双击节点 直接改文字 —— 回车提交、Esc 取消,改动写回思源。 节点里原有的双链 / 图片 / 加粗 / 公式会原样出现在编辑框里,只改文字不会把它们抹掉
-
在画布上直接插入行内内容(不必先回大纲):
想做什么 怎么做 插入图片 / 超链接 / 块引用 / 标签 / 行内公式 打 /呼出斜杠菜单,再打几个字过滤,回车确认插入块引用(双链) 打 [[呼出块引用搜索,输入关键词,↑↓ 选择、回车插入加粗 / 斜体 / 下划线 / 删除线 / 高亮 / 行内代码 选中文字,浮出的工具条上点一下 给选中的文字加超链接、变成块引用、清掉格式 同一根工具条右侧的三个按钮 粘贴图片 直接在节点里 Ctrl + V,图片会上传到assets/再插进来斜杠菜单只在词首触发(行首,或前面是空格)—— 这与思源编辑器里的行为一致。 含行内格式的节点进编辑态时光标落在末尾而不是全选:全选之后随手敲一个字, 会把双链和图片一起替换掉,而那些东西在编辑框里只是普通文字和缩略图,看不出删了什么。
-
拖动节点 调整位置与层级:落在目标的上 / 下缘成为同级,落在中间成为其子节点
-
右键节点 打开操作菜单
-
悬停节点 出现
+(加子节点)与▾(折叠)快捷按钮,不用再走右键 -
Shift + 拖拽空白处 框选多个节点
-
点击节点侧边圆点 折叠 / 展开子树,状态会记住
-
多选 ≥2 个节点 时,画布左上角浮出批量操作条:升级 / 降级 / 折叠 / 展开 / 导出这些 / 删除。 批量操作要么全部成功、要么整体回滚,中途失败会退回操作前的快照
-
悬停折叠的节点 600ms 后浮出预览卡片,列出它前几个子节点 —— 不用展开就知道里面有什么
-
已完成的待办(
- [x])通往父节点的连线是虚线,一眼看出这条支线已经做完了 -
Ctrl / ⌘ + 双击节点 下钻,只看这一支;面包屑可以逐级返回
-
拖动节点 到折叠的节点上停留一会儿,会自动展开它(圆环进度提示还剩多久)
-
Ctrl + F搜索 默认只搜当前这张图;把搜索框上的范围开关切到全文档, 会列出本文档里所有导图中的命中(标注了来自哪张图),点一条直接跳过去 -
点工具条上的状态 chip 只看未完成 / 已完成。命中节点连同它的祖先一起留下 —— 一个没做完的子任务总得挂在它那个(可能已完成的)父任务下面,否则看不出它为什么在这儿
-
右键 →「添加标记」 给节点挂图标 / 标签 / 自定义色,用来标重点、分类或做视觉分组
改结构要等「写内核 → 内核回推 → 重新挂载」一整圈,中间几百毫秒画面本来毫无变化。 现在这段空窗有反馈了:
- 加子节点时先放一个虚线占位框,拿到结果再换成真节点
- 失败时在源节点上打一圈红边并抖动,同时说清原因,不是只弹一句「操作未生效」
- 新插入的节点带一圈由亮转灭的高亮描边,回答「加在哪了」
- 折叠时子节点朝父节点聚拢并淡出,而不是「啪一下没了」
- 小地图上标出当前选中的节点(主色点)与搜索命中(暖色点)
工具条上的 ▶ 进入演示模式:整张图折到只剩根,然后随着方向键逐层展开, 观众跟着讲述的节奏看到结构一层层长出来。
演示是「换个视角看」,不是「改内容」 —— 全程不写回大纲,退出即恢复原状 (有断言钉死这一点:演示全程 kramdown 一字未改)。
| 按键 | 作用 |
|---|---|
→ ↓ 空格 PageDown Enter |
下一个节点(必要时展开它的父级) |
← ↑ PageUp |
上一个节点 |
Home / End |
第一个 / 最后一个 |
Esc |
退出演示 |
单击任意节点后导图接管键盘(画布左上角会有一圈焦点环)。此时方向键在节点间导航,
Tab / Enter 等直接改结构,不需要再回到编辑器。
| 按键 | 作用 |
|---|---|
↑ ↓ |
上一个 / 下一个同级节点 |
← |
回到父节点 |
→ |
进入第一个子节点(折叠状态会先展开) |
Home End |
同级里的第一个 / 最后一个 |
空格 |
折叠 / 展开当前节点 |
Tab |
插入子节点 |
Shift + Tab |
降级(成为上一个节点的子节点) |
Alt + ← / Alt + → |
降级 / 升级 |
Enter |
插入同级节点 |
F2 |
编辑文字 |
Delete / Backspace |
删除节点及其子树(有子节点时会先确认) |
Ctrl + ↑ / Ctrl + ↓ |
在同级内上移 / 下移 |
Ctrl + A |
全选同级;再按一次选中整棵树 |
Ctrl + C / Ctrl + V / Ctrl + X |
复制子树 / 粘贴为子节点 / 剪切 |
Ctrl + D |
快速复制(在节点之后插入一份含子树的副本) |
Ctrl + = / Ctrl + - |
放大 / 缩小 |
Ctrl + 0 |
适应画布 |
Ctrl + 1 |
回到 100% |
Ctrl + F |
搜索节点 |
F |
全屏(仅行内导图) |
Esc |
逐级退出:关搜索 → 清选中 → 移出导图 |
焦点三态:浏览态(键盘归思源)→ 选中态(键盘归导图)→ 编辑态(键盘归输入框)。
进入改名输入框后,方向键、Ctrl + C / Ctrl + V 都交还给输入框,Esc 退出编辑。
不会抢的键:Ctrl + S / P / W / R、Ctrl + Z / Y(撤销重做交给思源自己的
undo 栈)、F5 / F11 / F12 一律放行。
⌥⌘ 与 ⇧⌘ 两族也一律放行 —— 那是思源与其它插件共用的命名空间
(本工作空间里 flowmind、魔法排版、quick-notes、webview 等插件都在用),
导图必须整族让开。这条不是礼貌,是数据安全:导图自己绑了 Ctrl + A / C / V / X / D / F,
判据漏掉哪个修饰键,对应的那一族就会变成一次误操作 —— 其中 ⌥⌘X / ⇧⌘X
会照着「剪掉选中节点的子树」去改内核,等于静默删掉你的笔记。
插件自己的两条全局快捷键
⇧⌘D/⇧⌘X也落在这一族里。 它们只在导图没有接管键盘时才走思源那条全局路径,两者不会互相吃掉; 导图有焦点时按⇧⌘D也不会被自己的Ctrl + D(快速复制)截走。
如果不希望导图接管键盘,可在设置里关掉「导图内快捷键」。
节点右键菜单提供:
| 菜单项 | 说明 |
|---|---|
| 编辑文字 | 等同双击;节点里原有的双链 / 图片 / 格式都会保留 |
| 插入子节点 / 在上方插入 / 在下方插入 | 新建节点并同步到思源 |
| 上移 / 下移 | 在同级内调整顺序 |
| 降级为上一个节点的子节点 | 相当于编辑器里的 Tab |
| 升级为父节点的兄弟 | 相当于编辑器里的 Shift + Tab |
| 折叠 / 展开子节点 | 仅当节点有子节点时出现 |
| 添加标记 / 编辑标记 | 图标 / 标签 / 颜色,存块属性 |
| 复制文字 | 复制节点纯文本 |
| 定位到编辑器 | 退出导图并滚动定位到对应块 |
| 回到大纲编辑 | 退出导图、把光标放进对应段落,并在底部留一个「回到导图」按钮 —— 适合多行粘贴、拖拽排序这类在大纲里更顺手的操作 |
| 删除节点 | 连同其子树一起删除 |
改名与新建节点都只改动涉及的块,块 ID 保持不变,块引用与反链不受影响。 仅当拖拽目标位置在内核 API 里无法表达时(例如插入到列表最前、或目标节点还没有子列表), 才会改用「插入副本 + 删除原块」的方式,此时被移动的那棵子树会重建块 ID。
设置面板是侧边选项卡式的,按用途分成五组,不用在一长条列表里找:
| 分区 | 装什么 | 条目 |
|---|---|---|
| 外观 | 导图长什么样 | 主题、默认布局、连线样式、分支配色、自定义一级分支配色、显示层级编号、紧凑模式、布局动效 |
| 画布 | 画布多大、怎么自动取景 | 画布高度、画布高度(px)、自动适应画布、逻辑图自动分列、小地图、小地图始终显示 |
| 交互 | 鼠标和键盘的行为 | 双击编辑节点、拖拽调整节点、悬停预览折叠节点、导图内快捷键、Ctrl + 滚轮缩放、滚轮平移导图、视图偏好跟文档走、折叠状态与大纲同步 |
| 性能 | 大文档下的取舍 | 懒渲染、紧凑模式阈值、渲染上限 |
| 帮助 | 排查与迁移 | 查看快捷键、复制诊断信息、迁移【自定义块样式】标记 |
各分区里值得一提的:
- 滚轮行为 ——
Ctrl + 滚轮缩放/滚轮平移导图,两者都关时滚轮交给页面 - 导图内快捷键 关掉后方向键 /
Tab交还思源 - 「查看快捷键」 —— 弹出一张分组表格对话框(「键位 | 作用」两列,12 组 37 行),
而不是一条十几秒就消失的通知。键位按平台换算:Windows 显示
Ctrl + Shift + D/Ctrl + Shift + X、 macOS 显示⇧⌘D/⇧⌘X;空格渲染成可见的「空格」二字(否则是看不见的尾随空白); 没有键位的行(点复选框、右键菜单这类)显式写「菜单操作」 - 「复制诊断信息」 —— 把版本、内核版本、配置快照、各视图状态与最近的插件警告 复制到剪贴板,方便贴给别人排障。只复制到剪贴板,不联网、不上报
其中「视图偏好跟文档走」开启后,在某篇文档里改过布局 / 主题 / 连线 / 缩放, 下次打开这篇文档还是它 —— 只记显式改过的项,没改过的继续跟随上面的全局默认 (否则改了全局设置所有文档都不跟着变)。
视图偏好存在列表块的
custom-mindmap-view属性里(layout=mind;scale=1.25这样的键值对), 和「这个块是导图」的custom-mindmap分开存 —— 关掉导图标记时不会顺手把视图偏好也丢掉。 编码用键值对而不是固定位置,以后加字段不用动老数据的解析。
- 四层节点层级:中心主题(实心胶囊)→ 一级分支(分支色浅填充)→ 二级(中性卡片 + 左侧分支色条)→ 三级及以下(更轻)
- 主干汇聚的连线:父节点引出一条粗主干,沿"脊"分叉成细支线,越深的层级线越细
- 六套主题:跟随思源 / 深空 / 极简白 / 莫兰迪 / 霓虹 / 高对比度
- 连线用不透明混色而非半透明色,重叠段不会叠深;点阵网格作为画布底纹
- 右下角缩放胶囊(点开是菜单:100% / 适应画布 / 适应选中节点 / 只看当前分支 / 记住这个缩放)、 可点击跳转的小地图、增删节点时的位移动效
- 大纲 ↔ 导图 双向高亮:导图里选中的节点,在大纲里也会被标出来(并排查看时尤其明显)
- 导出除了 PNG / SVG,还可以只导出选中的子树、复制为图片到剪贴板、导出 Markdown 大纲
- 迁移【自定义块样式】的列表导图标记
- 导图不是独立数据,它是列表块的一种视图。所有内容始终以块为唯一数据源。
- 标记写在块属性
custom-mindmap上(值为布局名),可随笔记同步到其他设备。 - 节点标记也存块属性(
custom-mindmap-mark,形如{"icon":"⭐","label":"重要","color":"#e5534b"}), 所以图标 / 标签 / 自定义色同样随笔记走。它们不参与节点文字 —— 改名、复制、导出都不会带上它。 - 折叠状态不另存:用的就是思源原生的列表折叠(
.li上的fold), 所以在大纲里折叠会立刻反映到导图、在导图上折叠也会写回大纲,双向同步、随文档一起保存。 - 与「自定义块样式」插件(
custom-block)可以共存:两者使用不同的属性命名空间,互不干扰。 - 想从该插件迁移过来,可在设置面板点击「开始迁移」,或运行命令「迁移【自定义块样式】的列表导图标记」。迁移只处理
custom-block-list-view = map的列表块,表格 / 看板视图保持原样。
npm install
npm run dev # 监听重建
npm run build # 一次性构建
npm run package # 构建并产出 package.zip
npm run test # 解析 / 布局 / 连线几何 / 结构操作的单元测试
npm run smoke # 产物冒烟测试(需先 build)
npm run visual # 无头浏览器验收:真实渲染 + 真实键盘事件
npm run live # 真机验收:连真实思源内核 + 真实 Web 前端,点真按钮
npm run live:gpu # 真机 + 真实 GPU 合成(会短暂弹出一个浏览器窗口)
npm run typecheck # 类型检查
npm run check # 一键跑构建与测试(不含浏览器)
npm run check:all # 构建 + 测试 + 浏览器验收(含光标边界探针)
npm run deploy # 构建 + 同步到思源工作空间(自动探测路径)
npm run verify # 确认思源是否已经加载了插件
npm run ux:all # 真机验收全家桶(19 支:交互 / 编辑 / 重开 / 折叠 / 过滤 / 标记 / 命令 / 设置项…)
npm run ux:settings # 只跑设置项的**效果**(关 / 开对照,43 条断言)
npm run ux:settingsui # 只跑设置面板的**外壳**:侧边分区 + 快捷键速查(30 条断言)
npm run audit:coverage # 静态覆盖审计:从源码枚举全部 UI 入口 × tests/ 命中,产出缺口清单
npm run probe:inline-canvas # 画布上的行内操作真机验收(36 条:斜杠菜单 / 双链 / 工具条 / 图片 / 浮层定位 / 新建即聚焦)
npm run probe:caret # 光标边界:末尾是行内元素时字该落在哪(不依赖思源,几秒跑完)
probe:caret验的是一条 Blink 行为:把光标设在「某个行内元素之后的边界」上, 浏览器会把它归一化到那个元素内部 —— 节点末尾是双链时,接着敲的字会变成双链的 锚文本。它用data:页面做最小复现(顺带把六种错误写法和两种正确写法都钉成断言), 所以不需要思源在跑,也因此进了check:all。详见src/utils/caret.ts的文件头。
ux:settings和ux:settingsui是刻意分开的两支:前者测「改一个开关,画布上有没有变化」, 靠「关 / 开 两次测量结果不同」自证;后者测「分组怎么排、点选项卡切不切、快捷键怎么显示」。 两者的失败原因完全不同,混在一起会让「面板白屏」和「开关没接线」看起来是同一类问题。
npm run visual 值得单独说一句:Node 端的 DOM 模拟拿不到真实布局(offsetWidth
全是桩值),所以「节点宽度有没有坍缩成一个字」「Tab 有没有漏给思源」「折叠按钮
在高对比主题下是不是隐形」这类问题只能在真实浏览器里验证。它会把渲染结果量化成
一份 JSON 报告并逐条断言,同时产出 tests/.build/shot.png 截图供肉眼确认。
在地址栏 hash 上还能控制观察模式:#hover 强制显示悬停按钮、#only=0&zoom=2
只留第 N 个视图并放大。
npm run live 走的是另一条路:真的连上正在运行的思源。它用内核 API 建一篇临时文档、
打上 custom-mindmap 标记,再开一个无头 Chrome 访问思源的 Web 前端
(http://127.0.0.1:6806/stage/build/desktop/?id=…),用 CDP 派发真实鼠标与键盘事件,
断言完自动删掉临时文档。之所以要这一层,是因为「思源自己的弹层焦点陷阱」「Protyle 抢焦点」
「内核事务在前端怎么落地」这些宿主行为,在仿造页面里根本验不出来。
前提:思源在跑、conf/conf.json 里 accessAuthCode 为空、token 可读(脚本自己读)。
想看现场就 MM_KEEP=1 npm run live,临时文档会保留下来。
npm run audit:coverage 是给「测试写全了吗」这个问题准备的:它把插件的全部用户可见入口
从源码里枚举出来(命令 addCommand({langKey})、菜单 label:、设置面板
addToggle/addSelect/addNumber/addText/addButton/addItem({title})、以及那份对用户公开的
快捷键清单 —— 快捷键的「承诺」是从 src/core/shortcuts.ts 的数据表里读的,不是写死一份),
再逐条对照 tests/ 里有没有出现过,输出 ✓ / △ / ✗ 和一份零命中清单。
⚠️ 这个脚本自己会静默漏项。本仓库开着core.autocrlf=true,工作区是 CRLF, 而它原先用的解析正则都以\n收尾 —— 于是「解析onKeyDown里声明了哪些键」 那一节长期返回 0 个键,整节全是噪音。现在统一在读文件入口.replace(/\r\n/g, "\n")归一化行尾。 教训:用正则解析自己源码的脚本,第一件事就是归一化行尾, 而不是逐个正则去兼容\r?\n(漏一个就静默漏一项)。
它不看「有没有碰过这个模块」,只看「有没有碰过这个入口」—— 这个区别很实在:
有一次它报出「连线样式」零覆盖,而实际上有 4 支脚本都在数 .mm-edges path,
却没有一支验过「三种样式产出的是三种不同的线」。按模块算是测过的,按入口算是零覆盖。
(脚本头写明了:静态 grep 只说明「这个字串没在测试里出现过」,不等于行为没被测,
零命中清单是排查线索而不是结论。)
npm run deploy 会自动从 ~/.config/siyuan/workspace.json 读出当前工作空间,把产物复制到
{工作空间}/data/plugins/siyuan-plugin-mindmap/。也可以显式指定:
node scripts/deploy.mjs --workspace "D:\常青Data"
node scripts/deploy.mjs --no-build # 只同步,不重新构建同步完成后在思源里 重载界面(Ctrl+Shift+R)或重启,然后到 设置 → 集市 → 已下载 找到「大纲导图」打开开关。
npm run verify 会调用内核的 /api/petal/loadPetals 直接确认插件有没有被思源认到,
排查「装了没反应」时比翻日志快。
开发时把本目录放到 {工作空间}/data/plugins/ 下,构建产物 index.js / index.css 会被思源直接加载。
src/
├── index.ts 插件入口:生命周期、块菜单(三种模式)、命令、设置面板、全屏视图
├── types.ts 公共类型与默认配置
├── core/
│ ├── parser.ts Protyle DOM → MMNode 树
│ ├── layout.ts tidy-tree 布局引擎(深度轴 / 交叉轴抽象)
│ ├── renderer.ts MindMapView:DOM 构建、绘制、编辑、拖拽、缩放平移折叠
│ ├── inline-assistant.ts 编辑态的行内输入辅助:`/` 斜杠菜单、`[[` 双链搜索、选中工具条、图片上传
│ ├── tree.ts 纯树操作:祖先判断、下标、markdown 序列化与转义
│ ├── actions.ts 写回内核的结构操作:改名 / 增删 / 升降级 / 移动 / 勾选待办
│ ├── history.ts 自建撤销栈(思源的 Ctrl+Z 管不到块 API 写出的内容)
│ ├── prefs.ts 文档级视图偏好的编码 / 解码
│ ├── theme.ts 主题 token 与解析
│ ├── scanner.ts 变更侦测、懒渲染、幂等挂载、折叠状态持久化
│ ├── exporter.ts SVG / PNG / Markdown 导出
│ ├── shortcuts.ts 快捷键速查的**数据源**(12 组 37 行:键位 + i18n 键名)
│ └── settings-tabs.ts 把思源的扁平设置列表重排成侧边选项卡(含契约校验与回退)
├── utils/api.ts 思源内核 API 薄封装
├── utils/caret.ts 光标落点(末尾是行内元素时要补零宽垫片)+ 零宽字符的唯一出处
├── utils/hotkey.ts 思源键位表示法 → 当前平台的可读写法
├── styles/index.css 全部样式(只挂在 .mm-root 之下;.mm-set / .mm-sc 例外,见文件头)
└── i18n/*.json 多语言(键必须**扁平**,见下)
core/shortcuts.ts是单一数据源:addCommand的默认键位、菜单里的提示、 以及「查看快捷键」表格都读它。别在别处再手写一份键位 —— 改了绑定忘改说明, 用户按文档去按、按不动。
core/settings-tabs.ts为什么存在:思源的插件版Setting没有分组、没有选项卡、 也不暴露 DOM(只有addItem/open),所以分区只能自己实现 —— 在open()之后把.b3-dialog__content的子元素搬进自建的.mm-set__nav/.mm-set__pane骨架里。它的核心约束是 契约校验先全查完再动 DOM,任何不符就一个节点都不动、退回思源的扁平列表 (宁可丑,不能丢设置项);对应的硬断言是「content直接子项里.config-item数 == 0」 与「各 pane 条目数之和 == 面板条目总数」,见tests/kernel/diag-settings-tabs.mjs。
根目录的
i18n/是构建产物(build.mjs从src/i18n/复制生成), 改文案要改src/i18n/*.json。直接改根目录那份,下次构建会被原样覆盖回去。i18n 的键不能嵌套。 思源解析命令显示名用的是
a.langText || i18n[a.langKey] || a.langKey—— 扁平查表。 写成{"command": {"foo": "…"}}的话i18n["foo"]是undefined, 命令面板里会直接显示原始langKey(toggleMindMap这种)。 核实脚本:node tests/kernel/probe-i18n-command.mjs。
- 挂载策略:导图容器插在被标记的
.list元素内部,原大纲通过 CSS 隐藏但保留在 DOM 中。这样 Protyle 重建块时导图随之销毁,下一轮扫描重新挂载,天然不会错位。 - 变更侦测:MutationObserver 监听整个文档,按
.protyle-wysiwyg聚合后防抖 160ms 扫描;扫描期间暂停观察器,避免自己的 DOM 写入造成回环。同时用源列表的文本签名判断内容是否真的变了,变了才重渲染。 - 写回策略:结构操作优先用
moveBlock(保留块 ID)。内核的moveBlock只支持「移到某块之后」和「追加到某容器末尾」,没有「插入到某块之前」;且CheckListItemNesting禁止列表项直接包含列表项(降级时 parentID 必须是NodeList)。因此上移 / 下移 / 升级 / 降级都转换成内核能表达的等价操作,只有少数边界位置退回「插入副本 + 删除原块」。 - 行内富文本必须走
updateBlock(dataType: "dom"),而且外壳要照抄源块:改名走updateBlock(id, markdown)这条路只承载纯文本 —— 编辑框里显示的双链、图片、加粗一旦被重新序列化就会变形。改成把编辑框的innerHTML直接经 DOM 通道写回,问题变成「外壳」:实测(探针tests/kernel/probe-inline-canvas.mjs的取证脚本)收到什么 DOM,块就变成什么块 —— 写裸行内片段、或写死NodeParagraph外壳,都会把标题降级成段落(数据损失)。只有从源块元素上抄下真实开标签(剔除updated/data-node-index两个易变属性)拼回去,段落 / h1 / h2 / h3 的类型与级别才全部原样。实现见parser.ts的shellOf()(返回MMBlockShell{ open, tag, wrap })与utils/api.ts的updateBlockDom()。顺带翻了一个旧案的案:段落与标题的 DOM 形状完全一致(<div data-type=…><div contenteditable="true">…</div><div class="protyle-attr">…</div></div>),此前「标题元素自己可编辑」的注释是错的。 - 编辑态的光标落点要分两种:纯文本节点进编辑态时全选(改起来快),含行内格式的节点则把光标落在末尾。全选之后随手敲一个字会把双链和图片一起替换掉,而它们在编辑框里只是普通文字和缩略图,看不出删了什么。判据用
tree.ts的hasInlineTag()(收敛了原先散落的HAS_TAG正则)。 ⚠️ 但「末尾」不能直接把光标设在最后一个子节点之后:Blink 会把「某个行内元素之后的边界」归一化到那个元素内部 —— 节点末尾是双链时,接着敲的字会变成双链的锚文本(((… "锚文本补充"))),界面上完全看不出异常。六种写法并排实测后只有一种可行:末尾补一个零宽空格文本节点、再把光标落在它之后;插入路径还要额外先清掉Range.insertNode()留下的空文本节点。这条已抽成utils/caret.ts的caretToEnd()/caretAfterNode(),并由npm run probe:caret钉成断言(不依赖思源,进了check:all)。零宽垫片在写回前会被prepareInlineForWrite()剥掉,不会落库;但取词与判空必须跟着剥(见下一条)。- 斜杠菜单只在词首触发:行首,或前一字符是空白 —— 与思源编辑器一致。浮层里的上下键与回车由浮层自己处理(焦点在浮层输入框时
.mm-txt上的处理器收不到事件),过滤后的可选项列表与键盘选中项必须同步收缩,否则打/链接回车会点到被过滤掉的「图片」。触发串识别前必须剥掉零宽字符(stripZeroWidth)—— 光标垫片就贴在末尾,零宽空格既不是空白也不是行首,不剥的话含格式节点的斜杠菜单会整个失灵。同理,选区判空也要剥(否则末尾点一下弹工具条)、按词扩选要把零宽当边界(否则包出一个空的****)。 - 浮层定位是「记锚点 + 每次尺寸变化重算」,不是「开的时候摆一次」:绝对定位元素不设
left/top时会停在包含块内容盒的左上角 —— 所以「一次都没定位过」的症状是「搜索框跑到画布左上角」([[块引用搜索最初就漏了这一步)。反过来,只定位一次也不够:块引用搜索的浮层是先矮后高的,建出来时只有一个输入框,30 条候选填进来会长到三百多像素,而.mm-root是overflow: hidden—— 于是底部被裁掉一块(症状是「少一截」而不是「看不见」,很容易漏诊)。所以place()只负责记下锚点,真正的摆放交给reposition(),并在「填完候选 / 切到空态 / 换关键词」之后各调一次;摆不下时还会翻到光标上方。坐标系用「视口 rect 相减」而不是乘缩放比(见下面zoom那条)。回归断言在tests/kernel/probe-inline-canvas.mjs的 E / F 两段:浮层必须四边都在画布内、横向与正在编辑的节点有交集、且不能整块缩在左上角 —— 而合法的边界钳制不算红。 stopPropagation那条红线不能碰:.mm-root在冒泡阶段拦input/beforeinput/composition*/paste/cut/keyup。自绘 UI 里的事件冒泡到 Protyle 会让宿主从 DOM 重序列化该块,而源大纲是display:none的、序列化时看不见 ⇒ 正文被插件渲染结果覆盖(而所有断言仍然全绿,文字还在,只是块类型变了)。不能挂捕获(自己的oninput会收不到),不能拦keydown(会废掉思源的全局快捷键)。红线回归断言:.sy里contenteditable=计数必须为 0。- 新增子节点的锚点:
parentID不能给列表块(内核拒绝 NodeList 套 NodeList),给列表项 id 又会被插到最前面、让子节点跑到父节点文字上方。所以「追加为最后一个子节点」用previousID = 最后一个子节点id,还没有子节点时用previousID = 自己段落的id。四种写法的实测结果见tests/kernel/insert-shapes.mjs。 - 缩放用
zoom而不是transform: scale():transform: scale()走的是「先把整棵子树栅格化成位图、再交给合成器放大」的路径,合成器一旦缓存了低分辨率位图,放大后文字、描边、SVG 连线会一起变糊;zoom是布局级缩放,浏览器直接按最终尺寸排版并栅格化,不存在「放大一张位图」这一步。代价是zoom会把元素自身的transform: translate一起放大,所以屏幕位移要除以缩放比再写回。同时.mm-world上不加will-change: transform、浮层不用backdrop-filter,避免把子树提升成会缓存位图的合成层。 - 键盘分发挂在
document捕获阶段:全屏视图在思源的Dialog里,而 Dialog 构造时会注册一个document捕获阶段的焦点陷阱trapFocus,它会在「焦点在弹层第一个可聚焦元素上按 Shift+Tab」时preventDefault + stopPropagation,把降级操作整个吃掉。捕获阶段由外向内,监听器挂得越深排得越靠后,拦不住它;只有自己也挂到document捕获阶段才能同时拿到事件(stopPropagation不阻止同节点上的其它监听器)。分发时按「事件目标是否落在本视图内」路由。 - 点击节点要拦
mousedown的默认行为:.mm-root虽然是contenteditable="false",但祖先.protyle-wysiwyg是可编辑的,浏览器默认会把光标放进编辑器,随后document.activeElement变成.protyle-wysiwyg,导图失去焦点、快捷键整套失效。在节点与空白区的mousedown上preventDefault()即可(编辑态不拦,保证能正常选词)。 - 解析要容忍前端的畸形 DOM:用块 API 往列表里插 markdown 时,内核返回的事务是「插入一个 NodeList」,前端会照着原样插成
.list > [.li, .li, .list],而内核那边其实已经摊平成同级兄弟了(getBlockDOM是干净的)。解析时遇到直接嵌在列表块里的子列表要递归取出并摊平,否则新插入的节点在导图里根本不出现。 insertBlock的响应结构跨版本不一致:思源 3.8.4 的data是数组(data[0].doOperations),旧版本是data.transactions[0].doOperations。按旧结构取值会拿到null,把一次已经成功的插入误判成失败并弹「操作未生效,请重试」。判断成败看code === 0,取新块 ID 时两种结构都要兼容,并且要的是里面 NodeListItem 的 ID(外层那个是 NodeList 的 ID)。- 全屏弹层里的视图必须交给扫描器一起照看,而且要每次重新解析源元素:弹层视图不挂在
.protyle-wysiwyg下,扫描流程遍历不到它,会出现「内核改了、行内视图刷新了、唯独弹层里一动不动」。更麻烦的是:结构操作(尤其moveBlock)之后 Protyle 会把整个.list元素重建,弹层视图握着的是旧元素的引用(游离节点),parseList读到的永远是旧内容 —— 于是文本签名永不变化、永远判成「没变」、永远不重渲染。行内视图靠「源元素不在文档里就卸载重挂」自然绕过,弹层视图不会跟着源元素消失,必须每次扫描重新querySelector出当前那个元素并换源。 - 零宽字符要清掉:Protyle 会往块内容里塞
U+200B(零宽空格)。它不在 ECMAScript 的 WhiteSpace 集合里,trim()去不掉,会顺着node.text渗进序列化 markdown(复制 / 降级副本路径会把它写回内核)、导出文本和搜索匹配。解析时统一清掉U+200B–200D / U+2060 / U+FEFF。 - 撤销 / 重做得插件自己实现:思源前端的撤销栈只在事务带
undoOperations时才入栈(common.js:if (undoOperations && ...) protyle.undo.add(...)),而块 API 写出来的事务里undoOperations恒为空数组(insertBlock/deleteBlock实测条数 0,moveBlock/updateBlock干脆不返回事务)。也就是说导图上的增删改天生进不了思源的 Ctrl+Z —— 一次误删就是不可逆的数据丢失。所以插件自建History:操作前后各取一次列表块的 kramdown 原文当快照,撤销就把原文喂回去。代价写在src/core/history.ts的注释里(被删块会拿到新 ID、引用失效、折叠状态丢失),但对「误删能救回来」来说值得。Ctrl+Z 只在插件栈非空时接管,栈空时放行给思源 —— 用户刚在正文里打过字,撤销的应该是那次输入。 - 还原必须写两次:
updateBlock(listId, kramdown)喂一次不一定够。形态是「带子列表的列表项后面又跟同级项」时,一次写回会让最后一个列表项的段落子块脱开,落成一个「有内容、没有段落」的坏列表项 ——/api/block/getChildBlocks读它读不出文字,导图里就等于凭空少一个节点。真机验收里「Ctrl+Z 撤销删除节点」就是这么失败的:写回载荷 7 个节点一个不少、code也是 0,落库后却只有 6 个。同样的内容再喂一次就完全还原,第二次是幂等的。注意 kramdown 层面看不出这个缺陷(坏状态读回来的{: … }块属性行数和目标一模一样),所以别指望「读回来比对再重试」,直接写两次。三种喂法的实测对比见tests/kernel/restore-roundtrip.mjs。 - 源元素被重建时不能走
unmount:.mm-root是插在.list内部的,所以 Protyle 重建.list时导图跟着一起消失,视图会看到source.isConnected === false。这时不能调unmount—— 它带 3 秒的「抑制重挂」,而抑制项只在下一次扫描时才清;如果那之后没有新的 DOM 变动,扫描就不再发生,导图会一直消失。正确做法是在同一轮扫描里就地把视图重挂到新元素上(卸载时不抑制)。 - 焦点会被思源编辑器抢走,接管按键之后要抢回来:
.mm-root长在.protyle-wysiwyg里,按键事件一样会经过编辑器的 keydown 监听,而它会调protyle.wysiwyg.element.focus()—— 给HTMLElement.prototype.focus打桩实测:按下 Delete 之后 +2ms 焦点就从.mm-root变成.protyle-wysiwyg了(之后 3.5 秒内不再变)。所以handleGlobalKey只要preventDefault了就补一个setTimeout(0)的抢回(同步的focus()谁后调用谁赢);结构写回之后还要再短时重试几轮,因为「扫描 → 重挂」是异步的。时序证据见tests/kernel/focus-timeline.mjs。但「接管了才抢回」有一个致命缺口:真实键盘按Ctrl+D会先产生一个key === "Control"的 keydown,这一下导图没有任何动作可做(不会preventDefault),思源编辑器照样在这个 keydown 里 focus 自己 —— 焦点一丢,紧接着的D那一下e.target已经不在.mm-root里,handleGlobalKey开头就return,于是整套Ctrl+A/C/V/X/D在真实键盘下全部失灵(只有「一次rawKeyDown+modifiers位」的合成按键能过,所以测试一直没暴露)。所以修饰键单独按下那一下也要抢回焦点,并且多补两次(60ms / 200ms,因为编辑器抢焦点有快有慢);补的两次必须收窄到「焦点确实落在.protyle-wysiwyg里」,否则会把焦点从刚打开的命令面板输入框里抢回导图。实测见tests/kernel/probe-altmod-swallow.mjs,回归断言见tests/kernel/diag-commands.mjs的 H 段。 - 样式隔离:所有选择器都挂在
.mm-root之下,不写裸标签选择器;颜色只通过--mm-*/--c-*自定义属性注入,不直接引用--b3-*变量。这样导出 SVG 时把变量解析成具体值即可完整还原外观。 - 布局引擎:把坐标抽象成「深度轴」和「交叉轴」,一套代码同时支持左右和上下两种方向;父节点的交叉位置会钳制在自己的占位槽内,避免子树不对称时溢出到兄弟节点位置。
- 性能保护:列表块进入视口附近才挂载;节点数超过阈值自动收紧间距,超过渲染上限则暂停渲染并给出「仍然渲染」入口。
.github/workflows/release.yml 在每次 push 到 main 时运行:
- 跑
npm run check—— 8 道静态校验 + 317 条单元断言 + 96 条产物冒烟断言 - 构建
package.zip,并校验它含齐必需文件 - 创建 tag 为
v<version>的 GitHub Release —— 该版本已发过就跳过(幂等)
发新版本只需两步:改 plugin.json 的 version,push 到 main。
⚠️ 为什么非得有 Release,不能只 push 代码:集市拉取的是 Release 里的package.zip, 不是仓库里的源码。 这一点在本仓库比一般插件更要紧 ——.gitignore排除了index.js/index.css/i18n/这些构建产物,所以「仓库源码」本身 不是一个可用的插件包。
- 仓库名必须与
plugin.json的name完全一致(即siyuan-plugin-mindmap)—— 集市按name做唯一性校验 - 默认分支必须是
main—— 发布工作流靠它触发 - Release 的 tag 用
v<version>形式(与官方plugin-sample风格一致) .gitignore里的根级产物必须带前导/锚定。不要写裸的i18n/或index.css: 它们会匹配任意层级,从而把真源src/i18n/*.json与src/styles/index.css静默排除在仓库之外。
本插件面向中文用户,所以兜底值是中文:plugin.json 里 displayName /
description / readme 三处的 default 都是中文,英文单独用 en / en_US 两个键。
思源内核的取值顺序是(kernel/bazaar/package.go 的 GetPreferredLocaleString):
当前语种 → 旧式下划线语种 → default → en → en_US
LangToLegacy 会把 zh-CN 映射成 zh_CN、en 映射成 en_US,所以两种写法内核都认。
注意 default 排在 en 前面 —— 一旦把 default 改成中文,英文用户就必须靠
en 或 en_US 兜住,否则会看到中文。这就是为什么两个英文键都留着:
| 用户的思源语言 | 命中的键 | 看到 |
|---|---|---|
zh-CN / zh_CN |
default |
中文 |
en |
en |
英文 |
en_US(旧版) |
en_US |
英文 |
ja / de / … 其它 |
default |
中文 |
两份 README 的分工:
README.md—— 中文,GitHub 首页与集市详情的默认展示README_en_US.md—— 英文
⚠️ 改文件名时必须同步改plugin.json的readme映射。漏改在本地看不出来 —— 集市只会安静地不显示 README,不报任何错。scripts/build.mjs里有一道门会拦住这种漏改: 清单指向的文件没被打进package.zip,构建直接失败。
现在是 3.6.4,不是随手填的:插件用到了
/api/block/batchUpdateTaskListItemMarker —— 批量勾选待办的内核接口
(选中若干节点后一次性勾选,一次往返即一次事务)。它由
PR #17461 引入、
里程碑 3.6.4。低于这个版本,「批量勾选」会失败。
⚠️ 别把它和/api/block/updateTaskListItemMarker搞混 —— 那是单条勾选 (勾一个待办)走的老接口,不构成版本门槛。定minAppVersion时要取的是 插件用到的所有内核接口里门槛最高的那个,不是第一个想起来的那个。
早先这里是
3.1.0—— 一个没有任何依据的值。开发与验收全部在 思源 3.8.x 上做的(3.8.4 起,后升级到 3.8.5 复跑过全量真机套件; 源码里的实测注释标的就是这两个版本)。 如果你想要更保守的声明,可以把minAppVersion提到3.8.4, 那就只放行实测过的版本,代价是老用户装不上。
name / version / displayName / description / readme / icon / preview /
backends / frontends 都已按官方规范核对:
- 图标 160×160、4.4 KB(上限 64 KB);预览图 1024×768、28.8 KB(上限 512 KB)
package.zip含index.js/index.css/plugin.json/i18n/*/README.md/README_en_US.md/icon.png/preview.png,符合必需文件清单frontends: ["all"](含移动端)是实测过的,不是空口声明 —— 见tests/kernel/probe-mobile-touch.mjs:在真实的移动端前端 (/stage/build/mobile/)上验过加载、渲染、点选、长按菜单、拖动平移与复选框写入disabledInPublish: true:插件在发布服务(分享出去的页面)里不启用。 这是有意为之 —— 发布页没有本插件的运行环境,声明成启用只会让分享出去的页面 出现半成品状态。也就是说:分享出去的文档里,导图会退化成普通大纲列表。
MIT