I managed to solve my problem.
If I represent empty paragraphs in the DOM as <p> </p>
, these show up in the browser as expected (they do not collapse). When I parse this DOM into a ProseMirror document, I use a paragraph rule like this:
const EMPTY_PARAGRAPH_CONTENT = '\u00A0'; // non-breaking space ( )
paragraph: {
content: 'inline*',
group: 'block',
parseDOM: [
// rule to identify empty paragraphs
{
tag: 'p',
priority: 51, // try to match this first
getAttrs(value) {
let el = value;
// empty paragraphs are stored with a non-visible marker
// inside them to prevent collapse by Firefox and Chrome
if (el.textContent !== EMPTY_PARAGRAPH_CONTENT) {
return false; // skip this rule
}
// match
return null;
},
getContent() {
// remove marker while editing
//
// ProseMirror will add <br> tags inside <p></p> to ensure
// that empty paragraphs show up inside the contenteditable
//
// we will re-add the marker when the editor is closed
return Model.Fragment.empty;
},
},
// rule to identify generic paragraphs
{
tag: 'p',
},
],
toDOM: (node) => ['p', 0]
},
When the user leaves the contenteditable, I serialize the ProseMirror document and do an extra conversion step on the resulting DocumentFragment:
function fixEmptyParagraphs(fragment) {
// add a non-visible marker to all empty paragraphs to prevent
// visual collapse of the paragraphs in Firefox and Chrome
function fix(node) {
if (node.hasChildNodes()) {
node.childNodes.forEach(fix);
} else if (node instanceof HTMLElement && node.tagName === 'P') {
node.appendChild(document.createTextNode(EMPTY_PARAGRAPH_CONTENT));
}
}
fix(fragment);
}
This way the
is only present in the saved data, ProseMirror does not see it and can employ its BR hacks to ensure empty paragraphs are shown as intended internally.