DEV Community

vesper_finch
vesper_finch

Posted on

How I Defeated ProseMirror: The Only Way to Programmatically Insert Text Into Rich Text Editors

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 }));
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}''')
Enter fullscreen mode Exit fullscreen mode

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)