A curious case of ProseMirror inline nodeViews
Tuesday, October 15, 2024Essential mini glossary:
- ProseMirror - a set of libraries for building rich text editors.
- Node - a ProseMirror document is a hierarchy of nodes, like the browser DOM. The document might be a "doc" with "paragraph" and "heading" as nodes, and "text" as a leaf node.
- NodeView - Another way to change how ProseMirror renders a node. Allows you to define a custom UI for a specific node.
Recently I was working on implementing a mentions feature in text fields at Chronicle. We use ProseMirror for all our text fields - excellent library - and we use @nytimes/react-prosemirror
for the React component based nodeView integration.
After getting the bulk of the implementation done, I noticed an extremely weird issue.
Cursor, cursor - where do you land?
If you have created a mention with some existing text content before it, and then after creating you delete all the way from before the mention to the start of the line - it will insert a hard break at the last backspace keystroke.
WAT
That's surely some weird hot reload issue, I'll just reload.
It happens again.
WTF
GitHub maxxing (aaaaand discuss.prosemirror.net)
I frantically start mashing away at my poor keyboard. Within a minute I have a 10s of tabs open pointing to the ProseMirror forums, none of them even remotely match my issue, and I'm dreading that I have fallen into a contenteditable
edge case.
The closest related forum post I found is the first discussion about inline nodes with content in ProseMirror itself. Funnily enough that did have the fix I needed, but it was impossible to connect it at the time.
This was more or less the starting discussion about this variety of nodes, which contrasted ProseMirror's inherent document structure as a tree of nodes, with no limitation on its contents, versus, cases with leaf nodes containing editable text in most cases, except when it has children (aka like mentions, where it is an inline span inside a text node).
For clarity,
generaly, ProseMirror's documents is like
doc > paragraph > text, text, text
where text is child-less
but with inline nodes
doc > paragraph > text, mention (has child elements), text, text
Another related (ish) issue is this one about widget decorations, which matched the prize but not the participants.
Here, the random hard_break insertion was happening, but with another of ProseMirror's abstractions - called Widgets decorations, which are DOM nodes that are shown in the document at a given position.
This apparently was a bug in Chrome's contenteditable implementation, which led to another dead end.
At this point I've spent hours searching both ProseMirror forums and GitHub issues, and I gave up for the day and went to bed-
WAAAIIIT A GODDAMN SECOND. LINEAR HAS MENTIONS. I CAN DEVTOOL THEM. SURELY THEY HAVE SOLVED THIS.
A span (or two) of hope
I jump back up from bed and go to Linear. Find a comment field to destroy, and start typing.
At first I just mention myself (user mention), and it didn't have the issue. My heart drops. I check the elements panel, and everything seems the same as I have.
Oh I can also mention issues, might as well try that.
I inspect the DOM.
JAAAAACKPOOOOOT
I wrap my existing nodeView with those 2 spans containing ZeroWidthSpace, and yussss - the issue disappears.
Why exactly does this work? Since ProseMirror works on contenteditable
s it is likely that not having a proper position to land the cursor after deleting leads to ProseMirror (or, more likely, the browser) inserting a hard break. That is somewhere it can land.
These zero width spaces provide a similar functionality, but without any visual impact.
Thank you Linear. I go back to sleep.
The cause
I wake up and first thing I think of is why the hell did Linear's normal mentions not have this problem? What is different?
On comparing the DOM, I realised that the user mention did not have any flex
children inside, while the issue mention, had its status logo + mention slug in a flex
container.
So, as any person would, I googled an insane query.
Conclusion
Well, if you do need to have flex items inside your inline node(view)s, then now you know how to fix it. Have two spans surrounding your nodeView content, so that the browser can position your cursor there if there is a loss of content surrounding it.
Or else, if you can, just avoid it.
Bye.
Playground
Try the behaviour out in this embedded ProseMirror instance. The checkbox adds/removes the extra spans needed (:has
is goated)