只要做过富文本编辑器,你迟早会发现:
粘贴,看起来像小功能,实际上经常是编辑器最脏、最容易失控的一条链路。
因为用户粘进来的内容几乎什么都有:
- 浏览器复制的普通文本;
- 别的网站的 HTML;
- Word / Excel 输出的脏结构;
- 图片文件;
- 同一个 Odoo 编辑器里复制出来的专有内容;
- 还可能带着一堆类名、行内样式、伪节点和奇怪换行。
如果你只把粘贴理解成:
- 读
clipboardData; - 拿到
text/html; insertHTML();
那基本等于把内容污染、结构错乱和撤销异常主动请进来。
Odoo 在 addons/html_editor/static/src/core/clipboard_plugin.js 和 utils/clipboard.js 里的处理非常说明问题:它把粘贴视为编辑器内核级输入管线,不是普通事件回调。
一、ClipboardPlugin 不是单点处理器,而是挂在多个核心依赖之间
ClipboardPlugin 的依赖里直接写着:
baseContainerdomselectionsanitizehistorysplitdeletelineBreak
这已经告诉你了:
粘贴在 Odoo 里不是“拿数据然后塞进去”,而是会牵动选择区、结构清洗、历史记录、块拆分、删除逻辑和基础容器规则的一整条管线。
如果一个功能要同时依赖这么多核心插件,就说明它已经不是外围小功能,而是编辑器稳定性的中心环节。
二、复制时 Odoo 就已经在为“可控粘贴”做准备
很多人只看 onPaste,其实 onCopy 和 setSelectionTransferData() 同样关键。
Odoo 在复制时不只写:
text/plaintext/html
它还会在合适场景下写入专有 MIME:
application/vnd.odoo.odoo-editor
而且 fillHtmlTransferData() 还会给相对路径图片补上 origin。
这意味着 Odoo 复制链路的目标不是“让浏览器剪贴板里有点东西”,而是:
让同类编辑器之间复制时,能识别出这是一段已经按 Odoo 规则整理过的内容。
这会直接降低跨实例复制时的信息损失。
三、onPaste 的顺序说明 Odoo 对粘贴来源有明确优先级
onPaste() 里很关键的一段是:
- 先
preventDefault(); history.stageSelection();- 触发
before_paste_handlers; - 然后按顺序尝试:
handlePasteUnsupportedHtmlhandlePasteOdooEditorHtmlhandlePasteHtmlhandlePasteText- 最后
after_paste_handlers和history.addStep()。
这个顺序特别重要。
它说明 Odoo 不是“拿到什么插什么”,而是先判断:
- 当前选区是否支持 HTML;
- 有没有 Odoo 自有 MIME;
- 有没有外部 HTML / 图片;
- 最后才退回纯文本。
这是一条分层降级链路。
也就是说,粘贴并不是一个 API,而是一组判断后的导流策略。
四、为什么 Odoo 优先识别自有 MIME
handlePasteOdooEditorHtml() 会读 application/vnd.odoo.odoo-editor。
如果能命中,Odoo 就会:
parseHTMLsanitize- 再插入 fragment
这一步的价值非常大。
因为同一个编辑器体系里复制出来的内容,通常已经更接近系统认可的结构。相比从第三方网站来的 HTML,自有 MIME 更可能保留:
- 合法容器结构;
- 可接受类名;
- 编辑器内部语义。
所以 Odoo 会优先走这条“高可信来源”路径。
五、外部 HTML 不会直接入库,而要经过 prepareClipboardData() 重写
真正最值得反复读的是 prepareClipboardData()。
它至少做了这些事情:
parseHTML把字符串转 fragment;sanitize.sanitize(fragment)先过一遍安全清洗;- 给表格补
table table-bordered o_table; - 某些场景去掉图片;
- 对 Excel 粘贴内容提取 style 规则并写回单元格;
- 遍历子节点执行
cleanForPaste(); - 把根层 inline 内容包成 base container;
- 把
<br>驱动的伪段落切分成独立块。
这已经不是“清洗 HTML”,而是把外部内容重新编译成 Odoo 编辑器能接受的文档结构。
六、cleanForPaste() 的重点不是删脏标签,而是重建结构边界
很多编辑器讲白名单时,大家只想到 XSS 或危险标签清除。
但 Odoo 的 cleanForPaste() 更进一步。它处理的不只是安全,还有结构可编辑性。
例如:
- 黑名单节点直接移除或拆开;
- 非法节点会被 unwrap;
DIV在满足条件时会被替换成合适的 base container;THEAD可能被改成TBODY;- 空
TD会补入基础容器; FONT标签的历史属性会映射成现代 style;- 图片形式的 checklist 会被还原成 Odoo 的 checklist 语义类。
这说明 Odoo 想要的不是“HTML 大致长得对”,而是:
粘贴后的内容必须重新回到编辑器自己的结构法则里。
这一步如果不做,后面的列表转换、标题切换、选区定位、历史记录都会越来越脆。
七、白名单不只是标签白名单,还有类名、属性和可样式标签白名单
源码里专门维护了:
CLIPBOARD_WHITELISTS.nodesCLIPBOARD_WHITELISTS.classesCLIPBOARD_WHITELISTS.attributesstyledTags
这体现了 Odoo 很务实的一点:
不是所有 style 都一刀切,也不是所有 class 都无差别保留。
它会尽量保留对编辑器生态有意义的内容,例如:
img-fluidtableo_table- checklist 类名
- 部分彩色文本类
- 合法链接和图片属性
但超出边界的 class / attribute 会被剥掉。
这套策略本质上是在平衡两件事:
- 别把外部内容洗得什么都不剩;
- 也别把第三方页面的整套样式污染带进来。
八、为什么粘贴还要和 history、selection、split 协同
这点特别能看出 Odoo 的编辑器思维。
和 history 协同
粘贴前 stageSelection(),结束后 addStep(),说明粘贴必须是可撤销的正式历史步骤。
和 selection 协同
粘贴、拖放、拆分块时都要基于当前可编辑选区,不然内容会插错地方。
和 split / lineBreak 协同
纯文本粘贴不是简单插字符串。多行文本需要决定:
- 在
PRE里保留; - 还是切成多个块;
- 遇到不可拆块怎么办;
- 遇到 base container 怎么选正确节点类型。
这说明 Odoo 粘贴处理的目标不是“内容进去了”,而是“内容以编辑器可继续操作的形式进去”。
九、Excel、图片、链接这些特殊分支说明粘贴从来不是单一路径
源码里一些特殊逻辑非常有代表性:
- 如果是 Excel,解析 style 并补回
td; - 如果 HTML 和图片文件同时存在,表格优先于截图图片;
- 如果文本只是一个链接,可能走更适合链接的逻辑;
- 如果在
<a>内粘 HTML,会退成文本避免结构污染。
这都在说明一件事:
粘贴不是“格式越丰富越好”,而是“当前上下文下,什么结果最稳定可编辑”。
十、二开里最容易踩的坑
误区 1:自己在 paste 事件里直接插 innerHTML
短期能看到内容,长期一定会在历史、选区、块结构、样式污染上出问题。
误区 2:只做 sanitize,不做结构重写
安全了,但编辑体验仍会坏,因为编辑器内部结构语义没恢复。
误区 3:忽略复制时写专有 MIME
这样同编辑器之间复制会丢掉本来可以保留的上下文质量。
误区 4:把纯文本换行当作简单 <br>
在富文本编辑器里,换行到底对应行内换行还是新块,差别非常大。
十一、结论
Odoo 编辑器里的粘贴,真正难的从来不是“拿到剪贴板内容”。
真正难的是:
- 识别内容来源;
- 给自有内容走更高可信路径;
- 对外部 HTML 执行安全与结构双重清洗;
- 让插入结果回到 base container / selection / history 能理解的世界;
- 最后仍然保留尽可能合理的视觉信息。
所以更准确的理解应该是:
ClipboardPlugin 不是把剪贴板内容塞进编辑器,而是在把外部内容重新翻译成 Odoo 可持续编辑的内部文档。
理解了这一点,你再做编辑器粘贴二开,就不会把它当成一个小事件,而会把它当成真正的内容入口管线来设计。
DISCUSSION
评论区