How I Defeated ProseMirror: The Only Way to Programmatically Insert Text Into Rich Text Editors
Source: Dev.to
Introduction
If you’ve ever tried to automate form filling on a modern web app, you’ve probably hit this wall: rich‑text editors that ignore your input. I spent hours trying to get Playwright to fill a ProseMirror editor on Gumroad. Here’s what I learned.
Why innerHTML and dispatchEvent Don’t Work
editor.innerHTML = '
My description
';
editor.dispatchEvent(new Event('input', { bubbles: true }));ProseMirror maintains its own internal document model. When you change innerHTML, ProseMirror doesn’t know about it, so the next render discards your changes. Even dispatching input events doesn’t help—ProseMirror ignores DOM mutations it didn’t initiate.
Playwright’s fill() on contenteditable
await page.locator('[contenteditable]').fill('text')fill() is designed for <input> and <textarea> elements; it doesn’t work on contenteditable elements.
Typing with the Keyboard
await editor.click()
await page.keyboard.type('My text')This actually types into the editor and ProseMirror picks it up, but it’s painfully slow for long text (character‑by‑character) and focus can be lost mid‑typing.
Using document.execCommand('insertText')
execCommand('insertText') is a browser‑native command that ProseMirror (and TipTap, Slate, Quill, and most rich‑text editors) listens for. When the browser processes this command, it triggers the same internal event pipeline as a real keystroke—the editor’s transaction system picks it up, updates its document model, and renders correctly.
Why It Works
| Method | What Happens |
|---|---|
innerHTML | Bypasses the editor entirely |
dispatchEvent | ProseMirror checks if events come from trusted sources |
keyboard.type() | Works but is slow (character‑by‑character) |
execCommand('insertText') | Triggers the editor’s internal transaction pipeline |
execCommand is technically deprecated, but every browser still supports it, and it’s currently the only reliable way to programmatically input text into contenteditable editors.
Battle‑Tested Function for Filling ProseMirror/TipTap Editors
async def fill_prosemirror(page, text):
# Find the editor (skip small contenteditable elements)
editors = page.locator('[contenteditable="true"]')
count = await editors.count()
editor = None
for i in range(count):
el = editors.nth(i)
box = await el.bounding_box()
if box and box['height'] > 80: # Skip URL slugs, etc.
editor = el
break
if not editor:
return False
# Focus
await editor.click(force=True)
await page.wait_for_timeout(300)
# Clear existing content
await page.keyboard.press('Meta+a') # Cmd+A on Mac
await page.keyboard.press('Backspace')
await page.wait_for_timeout(200)
# Insert line by line
lines = text.split('\n')
for i, line in enumerate(lines):
if line.strip():
await page.evaluate(
'(t) => document.execCommand("insertText", false, t)',
line
)If the editor includes a toolbar, you likely need execCommand.
Triggering the Save Button
Rich‑text editors often intercept normal clicks. Bypass Playwright’s actionability checks with a direct JS click:
await page.evaluate(`() => {
const buttons = document.querySelectorAll('button');
for (const btn of buttons) {
if (btn.textContent.includes('Save')) {
btn.click();
return true;
}
}
return false;
}`);Complementary Tool: SessionKeeper
I built SessionKeeper to handle the authentication side of web automation (CAPTCHAs, login walls). The ProseMirror trick above handles the form‑filling side. Together, they cover most of the “last mile” problems in browser automation.
Have you battled other rich‑text editors? Drop a comment with your war stories.