Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

大纲导图 · Outline Mind Map

English version: README_en_US.md

把思源笔记里的无序列表、有序列表、任务列表三种大纲块,一键渲染成专业级思维导图。

为什么用它

思源的大纲天然就是一棵树,但纯文本的层级在层级变深后会很难一眼看清结构。这个插件把大纲块变成真正的导图:

  • 卡片式节点 —— 根节点实心胶囊、一级分支浅色填充卡、深层节点带左侧色条,而不是白框黑字
  • 分支色系 —— 每个一级分支一种颜色,子节点沿层级继承,一眼分清归属
  • 贝塞尔曲线连线 —— 线宽与透明度随层级递减,形成视觉纵深
  • 真正的布局引擎 —— tidy-tree 算法,左右自动平衡、父节点对齐子树中点,长文本不会撑歪整张图
  • 三种布局 —— 逻辑结构图、思维导图(左右分布)、树状图(自上而下)
  • 跟随思源主题 —— 默认使用思源当前配色,也可切换到内置的深空 / 极简白 / 莫兰迪 / 霓虹 / 高对比度
  • 可缩放可折叠 —— Ctrl + 滚轮缩放、拖拽平移、一键适应画布、折叠任意子树
  • 全键盘操作 —— 方向键在节点间导航,Tab / Enter / F2 / Delete 直接改结构,不用回到编辑器
  • 图内直接编辑 —— 双击节点就能改文字,节点里原有的双链 / 图片 / 加粗 / 公式原样带进编辑框, 改完回车提交、改动写回思源;画布上也能打 / 插图片与超链接、打 [[ 选块引用
  • 拖拽重排 —— 拖动节点即可调整顺序与层级,右键还有增删 / 升降级 / 定位等操作
  • 搜索与多选 —— Ctrl + F 定位节点,搜索范围还能切到全文档;Shift 框选或 Ctrl + A 批量选中后统一操作
  • 状态过滤 —— 任务列表里一键「只看未完成 / 只看已完成」,非待办节点跟着最近的任务祖先一起留或走
  • 节点标记 —— 给节点挂图标 / 标签 / 自定义色,存在块属性上,可随笔记同步
  • 小地图与缩放胶囊 —— 大图里也能随时知道自己在哪
  • 长文档友好 —— 列表块进入视口才渲染,节点过多时自动收紧间距并给出保护提示
  • 导出 SVG / PNG —— 矢量输出,方便分享与插入文档

使用

  1. 在任意列表块(无序 / 有序 / 任务列表)上点击块标打开块菜单

  2. 选择 插件 → 大纲导图,然后在三个模式里挑一个:

    模式 作用
    大纲模式(关闭导图) 就是这个列表块本来的样子,什么都不加
    导图模式 列表块变成导图;默认用逻辑结构图
    并排模式(大纲 + 导图) 左边保留大纲、右边开一张跟随的导图,改哪边另一边都跟着变
  3. 想换布局(逻辑结构图 / 思维导图 / 树状图)在导图自己的工具条上切, 不用回块菜单 —— 那里是「导图长什么样」该待的地方,点完立刻看得到效果

菜单里只有「要不要导图、要不要并排」,布局不在菜单里。 这是有意的:老菜单把「大纲视图 / 逻辑结构图 / 思维导图 / 树状图 / 并排查看」 五项平铺,混了两个层级的东西(「要不要导图」和「导图长什么样」)。

顺带一条容易踩的:已经在导图模式时再点「导图模式」,不会把布局重置回默认 —— 否则你手动切到「树状图」之后点一下菜单就被打回逻辑结构图了。

导图右上角的工具条提供:

按钮 作用
逻辑结构图 / 思维导图 / 树状图 切换布局,会写回块属性
全部 / 未完成 / 已完成 按状态过滤(仅任务列表出现;只影响当前这张图,不改内容)
− / + / 适应 缩放与一键适应画布
折叠 / 展开 批量折叠或展开所有子树
导出 导出 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 时把变量解析成具体值即可完整还原外观。
  • 布局引擎:把坐标抽象成「深度轴」和「交叉轴」,一套代码同时支持左右和上下两种方向;父节点的交叉位置会钳制在自己的占位槽内,避免子树不对称时溢出到兄弟节点位置。
  • 性能保护:列表块进入视口附近才挂载;节点数超过阈值自动收紧间距,超过渲染上限则暂停渲染并给出「仍然渲染」入口。

维护者须知

1. 发布已自动化

.github/workflows/release.yml 在每次 push 到 main 时运行:

  1. 跑 npm run check —— 8 道静态校验 + 317 条单元断言 + 96 条产物冒烟断言
  2. 构建 package.zip,并校验它含齐必需文件
  3. 创建 tag 为 v<version> 的 GitHub Release —— 该版本已发过就跳过(幂等)

发新版本只需两步:改 plugin.json 的 version,push 到 main。

⚠️ 为什么非得有 Release,不能只 push 代码:集市拉取的是 Release 里的 package.zip, 不是仓库里的源码。 这一点在本仓库比一般插件更要紧 —— .gitignore 排除了 index.js / index.css / i18n/ 这些构建产物,所以「仓库源码」本身 不是一个可用的插件包。

2. 仓库侧要求(已满足,别改坏)

  • 仓库名必须与 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 静默排除在仓库之外。

3. 语言与 README(默认中文)

本插件面向中文用户,所以兜底值是中文: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,构建直接失败。

4. 关于 minAppVersion(曾经写错,记一下为什么是现在这个值)

现在是 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, 那就只放行实测过的版本,代价是老用户装不上。

5. 已核对过的字段(无需改动)

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

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages