i'm building a markdown editor built on Milkdown (which wraps ProseMirror). i hit a bug in how marks behave when you join blocks together.
the bug
type a bold heading. move your cursor to the start of it. press Backspace. the heading joins into the paragraph above, but the bold marks bleed into the previously-unstyled paragraph text.
i assumed this was a bug in joinTextblockBackward. it wasn't.
what's actually going on
when you press Backspace at position 0 of a textblock, joinTextblockBackward fires. under the hood, joinTextblocksAround calls:
replaceStep(state.doc, beforePos, afterPos, Slice.empty)
this deletes the boundary between the two blocks. the Fitter algorithm stitches the content of the second block into the first. it doesn't touch inline marks. the text nodes transfer as-is, bold and all.
there's a clearIncompatible function that strips marks disallowed by the target node type, but paragraphs allow bold, so nothing gets stripped. there's also splitBlockKeepMarks which handles the reverse operation (Enter), but that's about storedMarks — cursor behavior for the next typed character. different problem.
so the marks survive the join, and they're supposed to. consider two paragraphs:
She said the results were
**statistically significant** and could not be ignored.
press Backspace at the start of the second paragraph. they merge into one. the bold must survive because you put it there intentionally. if ProseMirror stripped marks on join, it would destroy user content.
the heading case feels wrong for a different reason. in a markdown editor, # **Bold Heading** has explicit bold marks on the text nodes because that's what the markdown source says. you see the boldness as part of the heading, like a visual property of the block. but ProseMirror sees it as an inline mark that happens to sit inside a heading node. the markdown editor stores the heading's visual weight in two places: the node type and the inline marks. when the heading unwraps into a paragraph, only the node type changes. the marks stay because ProseMirror doesn't know they were "heading-ness" and not "the user explicitly wanted bold."
not a ProseMirror bug. a modeling problem specific to markdown editors.
the fix
i intercept joinTextblockBackward's dispatch. before the transaction reaches the editor state, i snapshot the heading content range, then append removeMark calls for the region that used to be the heading. i also clear storedMarks so the cursor doesn't inherit marks either. everything lands in one transaction, so undo reverses it atomically.

here's the standalone plugin:
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: typeof state.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() gives the position just before the heading's opening token — after the join's ReplaceStep deletes the block boundary, tr.mapping.map(prevEnd) gives the new position where the heading content starts inside the merged paragraph. headingContentSize is stable because the join doesn't change the heading's content, just moves it. tr.removeMark is safe to call multiple times on the same transaction and tr.setStoredMarks([]) clears cursor marks so typing after the join doesn't inherit bold/italic. the whole thing is one transaction — Ctrl+Z undoes the join and the mark removal together.
if you're using Milkdown or Tiptap, register this through the framework's plugin API. the ProseMirror logic is identical.

Top comments (0)