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.
What Doesn't Work
1. innerHTML
editor.innerHTML = '<p>My description</p>';
editor.dispatchEvent(new Event('input', { bubbles: true }));
ProseMirror maintains its own internal document model. When you change innerHTML, ProseMirror doesn't know about it. The next time it renders, your changes vanish. Even dispatching input events doesn't help — ProseMirror ignores DOM mutations it didn't initiate.
2. Playwright's fill()
await page.locator('[contenteditable]').fill('text')
Playwright's fill() doesn't work on contenteditable elements. It's designed for <input> and <textarea> elements.
3. Playwright's type()
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 (types character by character), and sometimes the focus gets lost mid-typing.
What Actually Works
document.execCommand('insertText')
// Focus the editor first
editor.focus();
// Select all existing content
document.execCommand('selectAll');
// Delete it
document.execCommand('delete');
// Insert new text
document.execCommand('insertText', false, 'Your new text here');
In Playwright:
# Click to focus
await editor.click(force=True)
# Clear existing content
await page.keyboard.press('Meta+a')
await page.keyboard.press('Backspace')
# Insert text line by line
for line in description.split('\n'):
if line.strip():
await page.evaluate(
'(text) => document.execCommand("insertText", false, text)',
line
)
await page.keyboard.press('Enter')
Why This Works
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.
This is different from:
- innerHTML: Bypasses the editor entirely
- dispatchEvent: ProseMirror checks if events come from trusted sources
- keyboard.type(): Works but character-by-character is slow
execCommand is technically deprecated, but every browser still supports it, and it's the only reliable way to programmatically input text into contenteditable editors.
The Full Pattern
Here's my battle-tested function for filling any ProseMirror/TipTap editor:
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
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 i < len(lines) - 1:
await page.keyboard.press('Enter')
return True
Where You'll Hit This
- Gumroad product descriptions (TipTap/ProseMirror)
- Notion (custom editor, but same principle)
- Confluence (TipTap-based)
- Ghost admin editor (Mobiledoc/Koenig)
- Any app using ProseMirror, TipTap, Slate, or Lexical
Basically, if you see a [contenteditable='true'] div with a toolbar, you need execCommand.
Bonus: Saving After Insert
After inserting text, you need to trigger the save button. Rich text editors often intercept normal clicks:
# JS click bypasses Playwright's actionability checks
await page.evaluate('''() => {
const buttons = document.querySelectorAll('button');
for (const btn of buttons) {
if (btn.textContent.includes('Save')) {
btn.click();
return true;
}
}
return false;
}''')
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.
Top comments (0)