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

一个Backspace键,能让加粗标题的格式"污染"到普通段落。这个bug在Milkdown(基于ProseMirror的markdown编辑器)里藏了多久?作者花了3小时追踪,最后发现:根子不在ProseMirror,而在markdown编辑器对"格式"的理解方式上。

这不是一个代码错误,是两个bug叠在一起的建模困境。

第一步:复现那个"诡异"的行为

第一步:复现那个"诡异"的行为

操作路径极其简单。打一个加粗的标题,光标移到标题最开头,按Backspace。标题会和上面的段落合并,但加粗格式会"流"进原本没有格式的段落文字里。

用户视角:我只是删了个换行,为什么下面的格式跑上面去了?

开发者第一反应:joinTextblockBackward这个命令有问题。这是ProseMirror处理"向前合并文本块"的核心命令,Backspace触发的正是它。

但跟进去之后,事情开始变味。

joinTextblockBackward的调用链是这样的:用户按Backspace → 检测到光标在文本块开头 → 触发joinTextblockBackward → 内部调用joinTextblocksAround → 执行replaceStep删除两个块之间的边界。

replaceStep用的是Slice.empty,意思是"用空切片替换掉这段边界"。真正干活的是Fitter算法,它负责把第二个块的内容"缝"进第一个块。

Fitter只关心节点结构,不碰内联标记(inline marks)。文本节点原样转移,加粗、斜体、代码格式全都跟着走。

这里有道安全阀:clearIncompatible函数会剥离目标节点类型不允许的标记。但段落允许加粗,所以什么都没发生。还有splitBlockKeepMarks处理的是回车键(Enter)的反向操作,但它管的是storedMarks——光标行为,不是内容合并。

标记就这么活下来了。而且它们本来就该活下来。

第二步:为什么"活下来"是对的,也是错的

第二步:为什么"活下来"是对的,也是错的

想象两段普通段落:

She said the results were

**statistically significant** and could not be ignored.

光标在第二段开头,按Backspace。合并成一段,加粗必须保留——用户明确加粗了"statistically significant",这是有意的内容,不是误操作。

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

如果ProseMirror在合并时剥离所有标记,等于主动销毁用户内容。这个设计是对的。

但标题场景不同。在markdown编辑器里,"# **Bold Heading**"的加粗标记来自源码,用户看到的是"标题的视觉权重",格式被存了两个地方:节点类型(heading)和内联标记(bold)。

ProseMirror的视角:heading是个块节点,里面包着带bold标记的文本节点。合并时块类型变成paragraph,但文本节点原封不动,bold标记还在。

ProseMirror不知道这些bold是"标题的视觉属性",还是"用户明确要加粗"。它看到的只是内联标记,而段落允许加粗,所以保留。

这不是ProseMirror的bug。这是markdown编辑器特有的建模问题——你把一个视觉概念(标题加粗)拆成了两层存储,却在合并时只处理了一层。

第三步:拦截、修复、原子化

第三步:拦截、修复、原子化

作者最终的解法是在命令到达编辑器状态之前拦截它。

具体动作:快照标题内容区域 → 在事务里追加removeMark调用,清掉原标题区域的加粗 → 同时清除storedMarks,防止光标继承格式 → 所有操作打包进一个事务,保证undo能原子回退。

代码很干净。一个独立的ProseMirror插件,只监听Backspace键,只在检测到heading块被向前合并时触发。

import { Plugin, PluginKey } from "prosemirror-state";

import { joinTextblockBackward } from "prosemirror-commands";

const headingBackspacePlugin = new Plugin({

key: new PluginKey("heading-backspace"),

props: {

handleKeyDown(view, event) {

if (event.key !== "Backspace") return false;

// ... 检测光标位置、块类型、执行修复逻辑

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

这个方案的关键是"原子性"。如果分两步执行(先合并、再清格式),undo会卡在中间状态。打包成一个事务,用户按Ctrl+Z时,整个操作一起回退,体验才对。

第四步:为什么这事值得写三千字

第四步:为什么这事值得写三千字

这个案例戳中了富文本编辑器的一个深层张力:用户的心智模型和编辑器的数据结构模型, rarely 对齐。

用户说"标题是加粗的",意思是"标题这个整体看起来粗"。markdown编辑器说"标题是加粗的",意思是"heading节点里包着带bold标记的text节点"。合并时用户期待的是前者(视觉属性随块类型消失),ProseMirror执行的是后者(内联标记独立存活)。

类似的裂缝无处不在。列表项按Tab缩进,用户觉得是"层级变化",编辑器可能是"换了个list_item节点、改了indent属性、或者套了层嵌套列表"。粘贴富文本时,用户期待"看起来一样",编辑器要在完全不同的schema之间做映射。

ProseMirror的设计哲学是"不猜测用户意图"。标记存了就是存了,合并时不动,除非schema明确禁止。这个保守策略避免了数据丢失,但也把"意图修复"的责任推给了上层。

Milkdown作为markdown编辑器,必须在ProseMirror的通用能力和markdown的特定语义之间搭桥。这个Backspace处理,就是搭桥的成本。

有意思的是,作者最初以为要改ProseMirror核心,最后发现只需要一个30行左右的插件。这种"问题在上层"的错位感,做过大型系统的人都懂。

另一个细节:storedMarks的处理。光标继承格式是ProseMirror的默认行为,但在这个场景下,如果不清掉,用户继续打字会默认加粗,体验更诡异。修复方案里专门处理了这一点,说明作者完整走完了用户场景。

最后看数据。这个插件只影响heading块的向前合并,不影响普通段落的正常合并,也不影响其他编辑操作。范围控制得极窄,副作用极小。

但每个markdown编辑器都要面对这个问题吗?取决于你怎么存heading的格式。如果你把heading的"粗"完全交给CSS(比如所有h1默认font-weight: bold),不在数据层存bold标记,就不会有这个bug。但那样又没法支持"# **partial bold** heading"这种混合样式。

建模没有银弹,只有取舍。

这个修复方案会进Milkdown主分支吗?作者没提。但issue页面有人追问:如果用户确实想在heading里保留部分加粗(比如"# **Warning:** Message"),这个修复会不会误伤?作者的回答是:检测逻辑会精确匹配"整个heading内容都被加粗"的情况,部分加粗不会触发清理。

边界情况永远比想象中多。一个Backspace键,牵出的是整类"块级语义 vs 内联标记"的协调难题。