修复 ProseMirror 中堆叠的两个 bug
Source: Dev.to
请提供需要翻译的正文内容,我才能为您完成简体中文翻译。
问题描述
我正在基于 Milkdown(它封装了 ProseMirror)构建一个 Markdown 编辑器。
当我输入一个加粗的标题后,将光标移动到标题的开头并按下 Backspace,标题会合并到上面的段落中,但加粗的标记会渗透到之前未加样式的段落文本中。
Source: …
为什么标记在合并时会保留
在文本块的位置 0 按下 Backspace 会触发 joinTextblockBackward。在内部,joinTextblocksAround 会调用:
replaceStep(state.doc, beforePos, afterPos, Slice.empty)这会删除两个块之间的边界。Fitter 算法会把第二个块的内容直接缝合到第一个块中 而不触及行内标记——文本节点会原样转移,粗体也会随之转移。
有一个 clearIncompatible 函数会剥除目标节点类型不允许的标记,但段落允许粗体,所以不会剥除任何标记。splitBlockKeepMarks 处理相反的操作(Enter),并涉及 storedMarks(光标在下一个输入字符时的行为),这属于不同的问题。
因此,标记在合并后仍然保留,这是预期的行为。考虑下面两个段落:
She said the results were
**statistically significant** and could not be ignored.在第二段开头按 Backspace 会把它们合并,而粗体必须保留下来,因为用户明确地应用了它。如果 ProseMirror 在合并时剥除标记,就会破坏用户的内容。
标题的情况之所以看起来不对,是出于另一种原因。在 markdown 中,# **Bold Heading** 在文本节点上有显式的粗体标记,因为源文本就是这么写的。我们把粗体视为标题的视觉属性,但 ProseMirror 将其视为标题节点内部的行内标记。当标题被展开为段落时,仅节点类型发生了变化;标记仍然保留,因为 ProseMirror 并不知道它们代表的是“标题属性”而不是用户有意加的粗体样式。
结论: 这不是 ProseMirror 的 bug;而是 markdown 编辑器特有的建模问题。
解决方案:拦截 joinTextblockBackward
此修复拦截 joinTextblockBackward 的 dispatch,在合并前快照标题内容的范围,然后移除原标题所拥有的所有标记。它还会清除 storedMarks,以防光标继承这些标记。所有更改都在同一个事务中完成,因此撤销(Ctrl+Z)会原子地恢复所有内容。
独立插件
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;
const { state, dispatch } = view;
const headingType = state.schema.nodes.heading;
if (!headingType) return false;
const { $from, empty } = state.selection;
if (
!empty ||
$from.parentOffset !== 0 ||
$from.node().type !== headingType
)
return false;
// Snapshot before the join mutates anything.
const headingContentSize = $from.node().content.size;
const prevEnd = $from.before();
const wrappedDispatch = (tr) => {
// Map prevEnd through the join's ReplaceStep to find where
// the heading content now sits inside the merged paragraph.
const from = tr.mapping.map(prevEnd);
const to = from + headingContentSize;
// Strip every mark type from the former‑heading range.
for (const markType of Object.values(state.schema.marks)) {
tr.removeMark(from, to, markType);
}
// Clear storedMarks so the cursor doesn't inherit marks.
tr.setStoredMarks([]);
dispatch(tr);
};
return joinTextblockBackward(state, wrappedDispatch, view);
},
},
});工作原理
$from.before()返回标题起始标记之前的位置。- 在
join的ReplaceStep删除块边界后,tr.mapping.map(prevEnd)给出标题内容在合并后段落中的新起始位置。 headingContentSize保持不变,因为join只改变标题的位置,而不修改其内容。tr.removeMark可以在同一个事务中多次调用;它会安全地从指定范围内移除每一种标记类型。tr.setStoredMarks([])清除光标的存储标记,防止在合并后输入时继承粗体/斜体等标记。
整个操作在单一事务中完成,因此撤销时会同时恢复合并和标记的移除。
集成
如果您正在使用 Milkdown 或 Tiptap,请通过框架的插件 API 注册此插件。底层的 ProseMirror 逻辑是相同的。