修复 ProseMirror 中堆叠的两个 bug

发布: (2026年3月29日 GMT+8 08:38)
5 分钟阅读
原文: Dev.to

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

此修复拦截 joinTextblockBackwarddispatch,在合并前快照标题内容的范围,然后移除原标题所拥有的所有标记。它还会清除 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() 返回标题起始标记之前的位置。
  • joinReplaceStep 删除块边界后,tr.mapping.map(prevEnd) 给出标题内容在合并后段落中的新起始位置。
  • headingContentSize 保持不变,因为 join 只改变标题的位置,而不修改其内容。
  • tr.removeMark 可以在同一个事务中多次调用;它会安全地从指定范围内移除每一种标记类型。
  • tr.setStoredMarks([]) 清除光标的存储标记,防止在合并后输入时继承粗体/斜体等标记。

整个操作在单一事务中完成,因此撤销时会同时恢复合并和标记的移除。

集成

如果您正在使用 MilkdownTiptap,请通过框架的插件 API 注册此插件。底层的 ProseMirror 逻辑是相同的。

0 浏览
Back to Blog

相关文章

阅读更多 »

网络怀旧

概述 我一直对互联网的快速演变感到着迷。从90年代那种杂乱、色彩斑斓的网站,到今天的简洁、极简设计——它……

Show HN: 字母时钟

请提供您希望翻译的具体摘录或摘要内容,我才能为您进行翻译。