MindElixir 可以很快渲染一张可编辑思维导图,但真正可用的在线编辑器远不止 mind.init(data)。用户会期待视觉树与可携带的大纲保持同步、快捷键编辑符合直觉、空白画布可以拖动、全屏后自动适配,并且能够可靠导出成果。
下面这套架构把 MindElixir 视为命令式浏览器运行时,把 Markdown 视为易读的交换格式。React 管理产品外壳和派生 UI 状态;纯工具函数负责 Markdown、JSON 转换;编辑器实例负责节点、历史、连线、概要、专注模式、缩放与渲染。
每个边界只保留一种标准树结构
MindElixir 原生数据以 nodeData 为中心:每个节点包含 id、topic 和可选 children,direction 表示左侧、右侧或双侧布局。视觉编辑过程中应使用这棵原生树;Markdown 和 JSON 是导入导出表示,不是同时竞争的实时状态源。
产品外壳可以在编辑器操作后派生一份快照:当前 Markdown、选中主题、选中数量、是否允许删除或专注、缩放比例、专注模式和布局。这样选择节点时不需要 React 重新渲染整个命令式画布。
| 数据表示 | 用途 |
|---|---|
| MindElixirData | 实时编辑数据与原生 JSON 备份 |
| Markdown 大纲 | 人类可读编辑和数据交换 |
| React 快照 | 按钮、状态、选择、缩放与布局 UI |
| SVG 或 PNG | 分享视觉结果 |
| 独立 HTML | 同时携带视觉图与可读大纲 |
组件挂载后再初始化命令式编辑器
在 route layout 引入 mind-elixir/style.css,然后在 effect 中动态加载运行时与 i18n 模块。拿到真实 host 元素后只创建一次实例,卸载时调用 destroy。disposed 标记可以防止异步加载完成后重新激活已卸载的编辑器。
实例应保存在 ref,而不是 React state。改变选择、相机位置或节点文字,都不应该让 React 重新创建画布。还可以用 ResizeObserver 监听容器变化并调用 scaleFit。
const [{ default: MindElixir }, i18n] = await Promise.all([
import('mind-elixir'),
import('mind-elixir/i18n'),
]);
const mind = new MindElixir({
el: hostElement,
direction: MindElixir.SIDE,
editable: true,
contextMenu: {
locale: locale === 'zh' ? i18n.zh_CN : i18n.en,
focus: true,
link: true,
},
keypress: true,
overflowHidden: true,
toolBar: false,
});
mind.init(data);
mind.scaleFit();
// On unmount:
mind.destroy();用纯函数和栈解析 Markdown
实用的大纲格式可以同时接受 ATX 标题和缩进列表。把每个非空行解析成 level 与 topic,再用栈记录各层最近节点。添加新节点前,弹出 level 大于等于当前层级的栈项,剩余栈顶就是父节点。
如果文档出现多个顶级根节点,则创建一个兜底根节点。导入时为节点生成新 ID,导出时把 topic 内换行规范为单行。解析错误返回 Outcome,不要把异常直接抛进组件。
type Outcome =
| { ok: true; data: MindElixirData }
| { ok: false; message: string };
function parseLine(line: string) {
const heading = line.match(/^\s*(#{1,6})\s+(.+)$/u);
if (heading) return { level: heading[1].length, topic: heading[2] };
const bullet = line.match(/^(\s*)[-*+]\s+(.+)$/u);
if (bullet) {
return {
level: Math.floor(bullet[1].replace(/\t/g, ' ').length / 2) + 2,
topic: bullet[2],
};
}
return null;
}- 约定两个空格为一级列表缩进,并统一规范 Tab。
- 只移除行首列表标记,不破坏主题内部标点。
- 导出时根节点使用标题,后代使用缩进列表。
- 解析和序列化都不依赖 React 与 DOM。
监听编辑器事件同步,不要轮询
MindElixir 提供 operation、selection、新节点和 scale 等事件。初始化后一次性订阅,并把快照读取安排到下一个任务,让编辑器先完成内部数据与 DOM 更新。cleanup 时必须移除完全相同的监听函数引用。
用户打开 Markdown 大纲并正在编辑时,不要用选择事件覆盖输入框。可以用 ref 保存最近一次已提交 Markdown,只有用户明确应用大纲或大纲面板关闭时才替换 draft,否则点一下节点就可能丢掉写到一半的内容。
- operation:更新文档派生 UI,并识别 beginEdit。
- selectNewNode:同步刚创建、正准备编辑的新节点。
- selectNodes 与 unselectNodes:更新操作按钮可用状态。
- scale:只更新缩放百分比,不重建地图。
让快捷键编辑行为符合预期
命令式编辑器通常会临时创建 contenteditable 节点用于改名。全局快捷键可能在内容提交前触发,导致 Enter 或 Tab 一边新增节点,一边让当前文字停留在临时状态。可以在编辑器容器捕获 keydown:Enter、Tab 先提交,Escape 恢复原文,然后再同步快照。
通过按钮开始编辑时,要等输入节点真正出现,再聚焦并选中文字。结束后把焦点还给地图容器,撤销、重做、新增与删除快捷键才能继续工作。
增加画布拖动,但不要破坏节点交互
完整编辑器应该允许拖动空白画布,但拖动主题、展开按钮、连线、概要、输入框或按钮时必须保留原生行为。pointerdown 时先判断目标是否位于交互元素内,只有真正的背景拖动才 capturePointer。
记录上一次指针位置,在 pointermove 调用 mind.move(dx, dy)。pointerup 或 pointercancel 时释放捕获、恢复鼠标样式并把焦点还给编辑器。Pointer Events 可以统一处理鼠标、触控笔和触摸。
用小型适配器暴露编辑器能力
用一个 runMapAction 包住命令式操作,统一捕获错误、延迟同步快照和恢复焦点。按钮就可以安全调用 addChild、insertSibling、beginEdit、removeNodes、undo、redo、scaleFit、initLeft、initRight、initSide、focusNode、cancelFocus、createSummary 或 createArrow,不必重复生命周期代码。
创建连线时,把来源节点与单向/双向模式存进 ref,下一次点击有效主题时把它作为目标,然后清除连线模式。这比在无关 selection 事件之后猜测两个选中节点更容易理解。
为编辑、分享和恢复提供不同导出格式
原生 JSON 最适合往返备份,因为它保留 MindElixir 数据;Markdown 最容易阅读和交换;SVG 适合文档中的高清矢量图;PNG 方便即时分享;独立 HTML 可以把导出的 SVG 和可折叠 Markdown 大纲放在一起。
导入 JSON 时必须递归验证 nodeData,不能只做类型断言。每个节点都要有字符串 id、topic,children 必须是递归节点数组,direction 只能取支持值。下载使用 Blob Object URL,完成后立即 revoke。
- JSON:原生可编辑备份。
- Markdown:便携大纲和文本编辑。
- SVG:不受分辨率影响的视觉导出。
- PNG:方便直接分享图片。
- HTML:包含可访问大纲的独立快照。
全屏和容器变化后重新适配
沿用其他画布工具的“原生全屏加 fixed 降级”模式。每次模式切换后,等布局稳定再调用 scaleFit。map host 上的 ResizeObserver 还能处理响应式侧栏、屏幕方向和其他容器尺寸变化。
组件卸载时销毁 observer、事件监听、指针状态、连线模式和 MindElixir 实例。否则热更新后可能留下重复的键盘与事件总线监听,让每个操作执行两次。
总结
可维护的 MindElixir 架构要把命令式编辑器与 React 分开,也要把转换逻辑与两者分开。实例只初始化一次,通过事件同步,保护正在编辑的大纲,用统一错误边界适配操作,并同时提供原生 JSON 与易读 Markdown,才能从画布示例成长为真正的在线工具。