How to preserve <br> tags in empty paragraphs

Hi.

In the app I’m working on, the user can click on various HTML elements (currently P and H1-H6) to edit their contents. When the user clicks on element E, I parse the contents of E into a ProseMirror document, set up a state and a view, then render the view into E. When the user blurs the editor, I serialize the ProseMirror document and replace the contents of E with the resulting fragment.

This works as expected, with one glitch: if I append empty paragraphs (<p></p>) to the end of the edited content (by pressing Enter a number of times at the end of the text), ProseMirror renders these empty paragraphs as <p><br></p> which looks alright while the editor is active, but as soon as I blur the editor, these turn into actual empty paragraphs (<p></p>) which are not displayed by the browser.

Blurring (unmounting) the editor and then focusing (mounting) it again thus makes the empty paragraphs disappear then reappear which is not too pretty.

Do you have any pointers on how this problem could be solved? (I’d prefer not to apply any modifications to the CSS of the page.)

2 Likes

I managed to solve my problem.

If I represent empty paragraphs in the DOM as <p>&nbsp;</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 (&nbsp;)

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 &nbsp; 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.

The <br> nodes in empty textblocks are a standard kludge in editable content to make sure empty blocks show up and avoid weird cursor issues. ProseMirror doesn’t add them to the actual document content, but just renders them in the editable view. Making the paragraphs show up when rendered outside the editor is a separate problem, which you could fix with, for example, a css rule using the :empty pseudoselector.

3 Likes

I am also hoping to keep the br's that ProseMirror adds (but then takes away).

By doing this:

toDOM(node) {
  return node.content.size === 0 ? ['p', ['br']] : ['p', 0];
}

But there is no way to allow ['p', ['br']] to have a hole. So when the empty paragraph is created, it’s uneditable.

Any ideas? :thinking:

(p.s. I’m actually using divs, not p’s)

Definitely don’t use a toDOM like that in your editor. It’s possible to create a custom DOMSerializer for serialization outside of the editor.

Thanks, worked a treat

It works for me, thanks a lot~

Hi, sorry to bump this old topic. I too want the output HTML to look the same as in the editor-view. But I cannot get this to work. Modifying the DOMSerializer would be perfect. That way I could just modify this function provided by the TipTap team:

import { DOMSerializer, Node, Schema } from '@tiptap/pm/model'
import { createHTMLDocument, VHTMLDocument } from 'zeed-dom'

export function getHTMLFromFragment(doc: Node, schema: Schema): string {
  const document = DOMSerializer.fromSchema(schema).serializeFragment(doc.content, {
    document: createHTMLDocument() as unknown as Document,
  }) as unknown as VHTMLDocument

  return document.render()
}

But I cannot figure out where/how I should modify it. Thanks for any efforts!

You’ll want to adjust the serializer. Something like…

let base = DOMSerializer.fromSchema(schema)
let serializer = new DOMSerializer({
  ...base.nodes,
  paragraph(node) { return ... }
}, base.marks)

Thank you!

Because this ranks high in Google. Here is a package that I created to manipulate the DOMSerializer to create your own serialisation-logic in node.

It comes with a Transformer to add a <br> to all empty Textblocks