很多人做富文本编辑器时,会天然把“撤销 / 重做”理解成浏览器送的免费能力:
- 用户按
Ctrl+Z - 浏览器回退一步
- 结束。
但 Odoo 在 addons/html_editor/static/src/core/history_plugin.js 里,显然不满足于这个层次。
它自己实现了一整套历史系统,里面包含:
- DOM 变更捕获
- 选择区序列化
- 自定义 mutation
- savepoint
- snapshot step
- previewable operation
- undo / redo
- 外部步骤接入
这说明 Odoo 处理编辑器历史时,核心目标不是“键盘能不能撤销”,而是:
编辑器里的每一次结构修改,能不能被系统可靠地理解、恢复、预览和重放。
一、为什么 Odoo 不直接把撤销交给浏览器
因为浏览器原生撤销只适合“简单输入框”层面的体验,不适合 Odoo 这种可插拔 HTML 编辑器。
Odoo 编辑器里会发生的事情远比普通文本输入复杂:
- 插入组件
- 动态占位符
- 格式化节点
- 表格改造
- 栏位布局调整
- 拖拽移动节点
- power button / powerbox 插件动作
- 异步插图与资源加载
这些动作很多都不是浏览器原生编辑命令能完整表达的。
如果只靠浏览器 undo,你很难保证:
- 结构恢复正确
- 选择区回到对的位置
- 自定义节点属性同步恢复
- 多插件协作时历史边界一致
所以 Odoo 必须自己做一套更高层的历史模型。
二、HistoryPlugin 记录的不是“按键”,而是 DOM mutation + selection
源码里的 HistoryStep 非常说明问题。一个 step 里不只是“用户做了某个动作”,而是有:
typeselectionmutationspreviousStepIdextraStepInfos
这说明 Odoo 想保存的是:
- 这一步改了哪些 DOM 事实;
- 光标/选择区当时在哪里;
- 这一步与前一步怎么衔接。
也就是说,编辑器历史在 Odoo 眼里,不是操作日志,更接近可逆 DOM 状态变更链。
三、为什么选择区也必须序列化
很多人只关注内容恢复,却低估了 selection 的重要性。
但真实编辑体验里,撤销之后最容易让人觉得“系统坏了”的,不是文字差一个字,而是:
- 光标跑飞
- 选区丢失
- 焦点回不去原节点
- redo 之后继续输入位置不对
HistoryPlugin 专门定义了 SerializedSelection,存:
anchorNodeIdanchorOffsetfocusNodeIdfocusOffset
这意味着 Odoo 明确认为:
编辑器历史不只是内容历史,也是交互位置历史。
没有这层,撤销看起来就会“逻辑上恢复了,体验上没恢复”。
四、MutationObserver 在这里不是附属工具,而是历史系统的感知器官
在 setup 里,插件直接创建 MutationObserver 去监听 DOM 变化。
这一步非常关键,因为富文本编辑器里的改动来源很多:
- 用户输入
- 插件修改 DOM
- 命令执行
- 拖拽插入
- 异步逻辑回填
如果只在“已知命令”里手工记历史,很多旁路改动会漏掉。
MutationObserver 的价值就在于:
- 不先假设改动一定来自哪个插件;
- 只要 DOM 真变了,就有机会被统一纳入历史系统;
- 然后再经过过滤、归类、序列化。
这是一种非常“编辑器内核”的做法。
五、HistoryPlugin 为什么要区分多种 mutation 类型
源码里定义了:
characterDataattributesclassListaddremove
这说明 Odoo 不是把所有改动粗暴当成“整块 HTML 替换”。
这样细分的意义很大:
1)可逆性更强
知道是文本改了、属性改了、节点加了还是删了,undo / redo 才能做得更精准。
2)能支持更细的过滤策略
有些 class 变化或系统属性,本来就不该进入可见历史。
3)更适合插件扩展
不同插件可以对不同 mutation 类型做额外处理,而不是把所有事都塞进一团 innerHTML diff。
六、previewable operation 是这套历史系统里很“高级”的一环
源码里暴露了:
makePreviewableOperationmakePreviewableAsyncOperationgetIsPreviewing
这很有意思。它说明 Odoo 不满足于“操作完再记一步历史”,而是支持某些操作先进入预览态。
为什么这很重要?
因为编辑器里很多动作不是非黑即白的一次提交,例如:
- 鼠标 hover 某个选项先预览效果
- 临时尝试一个布局再决定是否确认
- 异步选择资源前先显示候选结果
如果预览过程直接污染正式历史,就会出现:
- 撤销栈里塞满临时状态
- 用户按一次撤销,却要退很多“试试看”的中间步骤
previewable operation 的本质,就是把“试运行”和“正式提交”分开。
这是一种非常成熟的编辑器思路。
七、为什么有 savepoint 和 snapshot step
历史系统不是永远只靠连续 mutation 累积就够了。
一旦遇到这些场景:
- 批量结构变更
- 外部组件插入复杂节点
- 需要在某个大步骤前做安全锚点
- 要从已有状态整体恢复
你就会需要更强的历史节点类型。
所以 Odoo 给了:
makeSavePointmakeSnapshotStepresetFromSteps
这意味着它的历史模型并不只是“事件流”,而是允许在关键位置建立恢复锚点。
这对复杂编辑器非常重要,否则历史链越长,恢复与诊断都会越来越脆弱。
八、为什么 HistoryPlugin 还要允许 custom mutation / external step
这体现了 Odoo 编辑器不是一个封闭产品,而是可扩展平台。
源码开放了:
addCustomMutationapplyCustomMutationaddExternalStep
这等于在告诉插件作者:
不要强行把所有动作伪装成普通 DOM 输入;如果你有特殊结构语义,可以显式接入历史系统。
这比“让插件各自偷偷维护一份局部撤销”健康得多。
统一历史系统的价值就在这里:
- 用户视角只有一套 undo / redo
- 内部实现却允许不同插件贡献自己的变更语义
九、快捷键只是这套系统的表层接口,不是核心
源码里确实注册了:
control+zcontrol+ycontrol+shift+z
还有移动端 toolbar 按钮。
但这只是把历史系统暴露给用户的交互入口。
真正核心的是:
- 能不能捕获结构变更
- 能不能正确分步
- 能不能恢复 selection
- 能不能支持预览与正式提交分离
- 能不能让插件协同接入
所以别把 HistoryPlugin 理解成“热键处理器”,它其实是编辑器的一层状态内核。
十、实战里最容易犯的几个错
误区 1:插件直接改 DOM,却不接历史系统
这样界面当下能对,撤销时就会出鬼影。
误区 2:把一次复杂操作拆成很多临时 DOM 改动,却没做 preview / commit 边界
最终会让 undo 栈非常脏。
误区 3:只恢复内容,不恢复 selection
用户会明显觉得“撤销没真正回到刚才”。
误区 4:偷懒用整块 innerHTML 覆盖
短期方便,长期会丢失细粒度可逆性,还容易破坏插件之间的结构假设。
误区 5:把异步操作当同步历史来写
比如上传图片、选媒体、远程加载内容,如果没有明确历史边界,很容易出现中间态被记进正式步骤。
十一、结论
Odoo 编辑器里的撤销系统,核心从来不是“支持 Ctrl+Z”。
真正厉害的地方在于,它把富文本编辑中的复杂行为拆成了:
- 可观察的 DOM mutation
- 可恢复的 selection
- 可扩展的自定义步骤
- 可预览与可提交分离的操作模型
- 可建立锚点的 savepoint / snapshot
所以更准确的理解应该是:
HistoryPlugin 不是撤销快捷键插件,而是 Odoo HTML 编辑器的变更账本。
理解了这一点,你在做自定义编辑器插件时,就不会只想着“功能先跑起来”,而会更早考虑:
- 这一步怎么进入历史;
- 预览和提交如何分开;
- 选择区如何恢复;
- 撤销时结构能不能真正回得去。
这才是编辑器二开能不能长期稳定的分水岭。
DISCUSSION
评论区