- date
- entry
- 029
- topic
- tooling
- rev
- —
Pasting into a ProseMirror editor from a script: blank lines vanish, and the paste lands after the old text
What I sent looked fine. Counting paragraphs afterwards showed the blank lines gone and the old text still there. Both causes live in ProseMirror's own state.
On October 10 I used a browser extension to run JavaScript in my own logged-in browser and put a post into LinkedIn's composer. The composer is a contenteditable whose class reads tiptap ProseMirror. The first time, I dispatched a paste event carrying text/plain. All the text went in, as 13 paragraphs, and every blank line between them was gone.
The second time, I selected everything in the box first and pasted an HTML version. The result was 39 paragraphs, 13 of them empty, with the first copy still there and the new one appended after it. This note is about why both happened, plus a third snag: behind the image upload button there was no file input anywhere on the page. Four questions, in order: why use a paste event at all, where the blank lines went, why select-all didn't take, and where the file input is.
I can't see LinkedIn's source, so what follows is a simplified local reproduction used to analyse the likely causes: Tiptap 3.31.4 (StarterKit, on prosemirror-view 1.42.6) on a blank page, run in headless Chrome 154, with the same kind of JavaScript executed over the DevTools Protocol. It doesn't mean LinkedIn uses the same version or configuration.
Why a paste event
ProseMirror keeps its own document model, and the DOM on screen is a rendering of it. If you edit the DOM directly, it tries to read the change back, but not necessarily the way you meant. Locally, setting textContent to "A\n\nB" left the editor with a single paragraph: A, two in-paragraph line breaks (hardBreak), B. The newlines survived, but no paragraphs were created.
A paste event takes the editor's own path. prosemirror-view's paste handler reads event.clipboardData directly, parses it into a document slice, inserts it with a transaction, and on the default path calls preventDefault() once it has handled it. The built-in handler in this version doesn't check event.isTrusted. Locally, a ClipboardEvent created by a script has isTrusted: false; after dispatch defaultPrevented is true and the pasted content is in the editor's document (html and plain are strings built beforehand):
const ed = document.querySelector(".ProseMirror");
ed.focus();
const dt = new DataTransfer();
dt.setData("text/html", html);
dt.setData("text/plain", plain);
ed.dispatchEvent(new ClipboardEvent("paste",
{ clipboardData: dt, bubbles: true, cancelable: true }));
document.execCommand("insertText", false, "A\n\nB") also works locally: three paragraphs, the middle one empty. I still prefer the paste event because it can carry HTML, so links, lists and bold all go in at once. MDN also marks execCommand as deprecated.
Where the blank lines went: plain text is split into paragraphs
This is the line in prosemirror-view that parses plain text (parseFromClipboard):
text.split(/(?:\r\n?|\n)+/).forEach(block => { /* wrap each block in a <p> */ })
The trailing + means a run of newlines counts as one separator. One newline, two or three, each just means "next paragraph", and no empty paragraph appears between non-empty text (newlines at the very start or end of the string are a separate case). Four inputs into the same empty editor, locally:
| What I sent | Paragraphs in the editor |
|---|---|
text/plain: A\n\nB\n\n\nC | A, B, C (no empty ones) |
text/html: <p>A</p><p></p><p>B</p> | A, empty, B |
text/html: <p>A</p><p><br></p><p>B</p> | A, empty, B |
Setting textContent: A\n\nB | One paragraph: A, two hardBreaks, B |
So on this default plain-text path, I keep blank lines by sending HTML, with each blank line written as an empty <p></p>. I escape each paragraph, wrap it in <p>, put an empty <p></p> between paragraphs, and include text/plain too for receivers that don't take HTML.
Why select-all didn't take: locally, the selection hadn't synced
With blank lines sorted, the second problem is quieter. Before the second paste I did select everything: range.selectNodeContents(ed), added to getSelection(), and dispatched the paste on the next line. The selection on screen did cover everything. But ProseMirror decides where to insert from the selection in its own state, not from the screen (doPaste uses view.state.selection).
When the DOM selection changes, ProseMirror's state doesn't follow in the same instant. It mainly syncs on the selectionchange event, which is queued and dispatched asynchronously, so if you change the selection and paste in the same synchronous block, the editor still has the old cursor. Locally I started with two paragraphs, A and B, cursor at the end of B, selected all, and pasted C and D after different delays:
| Delay set after select-all (0 = no timer, paste directly) | Result |
|---|---|
| 0 (same synchronous block) | A, BC, D |
| 1 ms | C, D |
| 10 ms | C, D |
| 50 ms | C, D |
The no-delay row has a second issue: the first pasted paragraph merged into the paragraph at the cursor, giving BC. The slice's start is "open", so it joins the current paragraph. What I observed on LinkedIn was the original kept and the new copy appended after it. The local result supports the "selection not yet synced" explanation, but I don't have LinkedIn's editor state from that moment, and I didn't check the merge boundary paragraph by paragraph.
Two fixes, both tested locally: after changing the selection, await a setTimeout before pasting, or dispatch document.dispatchEvent(new Event("selectionchange")) yourself first. More reliable than waiting is checking afterwards: total paragraphs, empty ones, and the first and last words make a quick check, and comparing the full text is how you know nothing was lost. That step is how I caught the duplicate on LinkedIn; I then selected the first copy and removed it with the Delete key. In hindsight, clearing the whole editor and pasting once more is easier to verify than deleting pieces in place.
Images: the file input isn't on the page
After the text came the image. LinkedIn's upload flow did end up using an <input type="file">, but with the composer freshly open I found zero file inputs in the document and in the shadow root. One way to write an upload button: on click, createElement("input"), set type="file", call input.click() to open the file picker, and never attach it to the DOM. I can't confirm LinkedIn does exactly this; the interception below works for that pattern.
I built a button like that locally. After clicking it, querySelectorAll('input[type=file]') still returned 0. The way to intercept it is to swap HTMLInputElement.prototype.click for a moment:
const orig = HTMLInputElement.prototype.click;
HTMLInputElement.prototype.click = function () {
if (this.type === "file") {
window.__cap = this; // keep this input
if (!this.isConnected) { // put it on the page so tools can find it
this.style.display = "none";
document.body.appendChild(this);
}
return; // don't open the file picker
}
return orig.call(this);
};
Run that first, then click the page's upload button, check that window.__cap is set, and run the restore separately:
HTMLInputElement.prototype.click = orig;
When the button is clicked, the input the page created lands in window.__cap, with accept and the other attributes the page set. One detail from the local test: if you drive the DevTools Protocol directly, DOM.setFileInputFiles works on an input that was never attached. Get its objectId with Runtime.evaluate, set the file, and the page's change handler receives the name and size as usual. The appendChild is there because the browser extension I used on LinkedIn has to find the element on the page before it can hand it a file.
You're replacing HTMLInputElement.prototype.click for the whole page. Until it's restored, any code on the page that calls click() on a file input is intercepted, including calls you didn't mean to catch. Restore it as soon as you have the input, and in a script put the restore in a finally.
If you remember one thing
An editor like ProseMirror has its own state, and the DOM is just how it draws that state. When a script sends content in, use an event the editor understands, in a format it parses, let it finish syncing the selection, and then check what actually ended up in the editor. The 13 and the 39 paragraphs at the top both looked fine at the moment of sending and were only caught by counting afterwards.
Revision history
- First published