前端

Odoo 编辑器的撤销为什么不只是 Ctrl+Z:HistoryPlugin、MutationObserver 与 previewable operation 讲透

很多人把富文本编辑器的撤销理解成浏览器原生 Ctrl+Z。但 Odoo 的 html_editor 源码表明,它自己维护了一套可序列化、可预览、可恢复的历史系统。本文从 HistoryPlugin 出发,讲清 Odoo 编辑器为什么要自己接管 DOM 变更历史。

前端
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

很多人做富文本编辑器时,会天然把“撤销 / 重做”理解成浏览器送的免费能力:

  • 用户按 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 里不只是“用户做了某个动作”,而是有:

  • type
  • selection
  • mutations
  • previousStepId
  • extraStepInfos

这说明 Odoo 想保存的是:

  1. 这一步改了哪些 DOM 事实
  2. 光标/选择区当时在哪里
  3. 这一步与前一步怎么衔接

也就是说,编辑器历史在 Odoo 眼里,不是操作日志,更接近可逆 DOM 状态变更链

三、为什么选择区也必须序列化

很多人只关注内容恢复,却低估了 selection 的重要性。

但真实编辑体验里,撤销之后最容易让人觉得“系统坏了”的,不是文字差一个字,而是:

  • 光标跑飞
  • 选区丢失
  • 焦点回不去原节点
  • redo 之后继续输入位置不对

HistoryPlugin 专门定义了 SerializedSelection,存:

  • anchorNodeId
  • anchorOffset
  • focusNodeId
  • focusOffset

这意味着 Odoo 明确认为:

编辑器历史不只是内容历史,也是交互位置历史。

没有这层,撤销看起来就会“逻辑上恢复了,体验上没恢复”。

四、MutationObserver 在这里不是附属工具,而是历史系统的感知器官

在 setup 里,插件直接创建 MutationObserver 去监听 DOM 变化。

这一步非常关键,因为富文本编辑器里的改动来源很多:

  • 用户输入
  • 插件修改 DOM
  • 命令执行
  • 拖拽插入
  • 异步逻辑回填

如果只在“已知命令”里手工记历史,很多旁路改动会漏掉。

MutationObserver 的价值就在于:

  • 不先假设改动一定来自哪个插件;
  • 只要 DOM 真变了,就有机会被统一纳入历史系统;
  • 然后再经过过滤、归类、序列化。

这是一种非常“编辑器内核”的做法。

五、HistoryPlugin 为什么要区分多种 mutation 类型

源码里定义了:

  • characterData
  • attributes
  • classList
  • add
  • remove

这说明 Odoo 不是把所有改动粗暴当成“整块 HTML 替换”。

这样细分的意义很大:

1)可逆性更强

知道是文本改了、属性改了、节点加了还是删了,undo / redo 才能做得更精准。

2)能支持更细的过滤策略

有些 class 变化或系统属性,本来就不该进入可见历史。

3)更适合插件扩展

不同插件可以对不同 mutation 类型做额外处理,而不是把所有事都塞进一团 innerHTML diff。

六、previewable operation 是这套历史系统里很“高级”的一环

源码里暴露了:

  • makePreviewableOperation
  • makePreviewableAsyncOperation
  • getIsPreviewing

这很有意思。它说明 Odoo 不满足于“操作完再记一步历史”,而是支持某些操作先进入预览态

为什么这很重要?

因为编辑器里很多动作不是非黑即白的一次提交,例如:

  • 鼠标 hover 某个选项先预览效果
  • 临时尝试一个布局再决定是否确认
  • 异步选择资源前先显示候选结果

如果预览过程直接污染正式历史,就会出现:

  • 撤销栈里塞满临时状态
  • 用户按一次撤销,却要退很多“试试看”的中间步骤

previewable operation 的本质,就是把“试运行”和“正式提交”分开。

这是一种非常成熟的编辑器思路。

七、为什么有 savepoint 和 snapshot step

历史系统不是永远只靠连续 mutation 累积就够了。

一旦遇到这些场景:

  • 批量结构变更
  • 外部组件插入复杂节点
  • 需要在某个大步骤前做安全锚点
  • 要从已有状态整体恢复

你就会需要更强的历史节点类型。

所以 Odoo 给了:

  • makeSavePoint
  • makeSnapshotStep
  • resetFromSteps

这意味着它的历史模型并不只是“事件流”,而是允许在关键位置建立恢复锚点

这对复杂编辑器非常重要,否则历史链越长,恢复与诊断都会越来越脆弱。

八、为什么 HistoryPlugin 还要允许 custom mutation / external step

这体现了 Odoo 编辑器不是一个封闭产品,而是可扩展平台。

源码开放了:

  • addCustomMutation
  • applyCustomMutation
  • addExternalStep

这等于在告诉插件作者:

不要强行把所有动作伪装成普通 DOM 输入;如果你有特殊结构语义,可以显式接入历史系统。

这比“让插件各自偷偷维护一份局部撤销”健康得多。

统一历史系统的价值就在这里:

  • 用户视角只有一套 undo / redo
  • 内部实现却允许不同插件贡献自己的变更语义

九、快捷键只是这套系统的表层接口,不是核心

源码里确实注册了:

  • control+z
  • control+y
  • control+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

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。