打开网易新闻 查看精彩图片

一个Backspace键,让粗体标题的格式"渗"进了普通段落。开发者Saul Shanabrook花了整整3小时,最后发现是ProseMirror内部两个函数在互相甩锅——每个单独看都没错,叠在一起就翻车。

第一层:格式迁移的"合理"设计

第一层:格式迁移的"合理"设计

问题始于Milkdown(一个基于ProseMirror的Markdown编辑器)的一个诡异行为。用户输入一个粗体标题,光标移到最开头按Backspace,标题确实和上方段落合并了,但粗体格式却像墨水一样染进了原本没有格式的段落文字。

Shanabrook的第一反应很直接:joinTextblockBackward这个命令有问题。这是ProseMirror处理"删除块边界"的核心函数,按Backspace时触发。他顺着调用链往下挖,发现真正干活的是joinTextblocksAround,它执行的是:

replaceStep(state.doc, beforePos, afterPos, Slice.empty)

这行代码删掉两个块之间的边界,Fitter算法把后一个块的内容缝进前一个。关键细节:它完全不碰行内标记(inline marks)。文本节点原封不动转移,粗体、斜体、链接,该带的全带着。

打开网易新闻 查看精彩图片

ProseMirror其实有个clearIncompatible函数,会剥离目标节点类型不允许的标记。但段落允许粗体,所以什么都没发生。还有个splitBlockKeepMarks处理回车键的反向操作,但那管的是storedMarks(光标后续输入的格式),不是已有内容的格式迁移。

标记能存活下来,这是设计意图。Shanabrook举了个例子:两段文字,第二段开头是"**statistically significant**",按Backspace合并后,粗体必须保留——这是用户明确输入的内容,ProseMirror没资格擅自删除。

第二层:Markdown编辑器的"双重记账"

标题场景的矛盾在于:用户眼里,# **Bold Heading**的粗体是"标题的视觉属性";ProseMirror眼里,这是"恰好放在标题节点里的行内标记"。

Markdown编辑器在这里搞了双重存储:标题的视觉权重既体现在节点类型(heading),又体现在行内标记(strong/bold)。当标题解包成段落时,只有节点类型变了,标记纹丝不动。ProseMirror根本不知道这些粗体是"标题专属"还是"用户硬加的"。

Shanabrook的结论很干脆:不是ProseMirror的bug,是Markdown编辑器特有的建模问题。

打开网易新闻 查看精彩图片

他的修复方案是拦截joinTextblockBackward的dispatch。事务到达编辑器状态之前,先快照标题内容区域,然后追加removeMark调用,清除掉原本属于标题的那部分粗体。同时清理storedMarks,防止光标继承格式。所有操作塞进同一个事务,撤销时能原子级回退。

代码很克制,一个Plugin搞定:

拦截Backspace → 判断是否在标题开头 → 快照内容 → 追加removeMark → 清理storedMarks → 统一dispatch

叠在一起的两个"正确"

叠在一起的两个"正确"

回头看这个bug的诡异之处:joinTextblockBackward按规格实现了"保留用户标记",Markdown解析器按规格实现了"标题可以带粗体",两个正确叠在一起,交互体验却错了。

Shanabrook在修复过程中一度怀疑是不是ProseMirror版本问题,甚至检查了2023年的一次相关提交。最后确认核心逻辑多年未变——这意味着这个行为模式一直存在,只是大多数用户没触发,或者触发了没在意。

他的插件方案本质上是在ProseMirror的通用逻辑和Markdown的特定语义之间搭了一座桥。不是推翻底层,而是在 dispatch 层做补偿。这种修复方式很"ProseMirror生态":核心库保持中立,边缘场景交给插件

3小时的排查,最后定位到的是自己对工具链的理解盲区。Shanabrook在文末贴了完整插件代码,注释里写了一句:"希望这能省下某个未来开发者的时间。"