Ace editor automation
Ace renders only the rows on screen and receives keystrokes through a hidden textarea that auto-closes quotes/brackets and auto-indents new lines. Typing {, Enter, "a": [1], Enter, } into a default Ace JSON editor produces:
{
"a": [1]
}
}
So:
- Read and write through the editor API with JavaScript running in the page. Don't scrape
.ace_lineelements or screenshots for content, and don't type code key by key. - Run in the page's main world. Properties the page sets on elements (such as
el.env) are invisible from isolated worlds, e.g. browser-extension content scripts. Use your tool's main-world option, or the fallback in step 5. - Respect read-only editors. The API can still change them, but the app doesn't expect it — only do it when the user asks.
- Changing the editor is not saving. Confirm with the user before clicking Save / Submit / Apply.
Snippets below are plain page JavaScript whose last expression is the result. Step 7 uses await; if your tool has no top-level await, wrap the snippet in an async function (e.g. Playwright page.evaluate(async () => { ... })).
1. Find the editors
Ace's renderer adds the ace_editor class to every editor container, in every version, so the selector is stable. What varies is where editors live (main document, open shadow roots, same-origin iframes), which elements are real editors (the autocomplete popup is an Ace editor too) and whether the instance is reachable. Search dynamically:
(() => {
const hits = [];
const walk = (root, where) => {
for (const el of root.querySelectorAll('.ace_editor')) hits.push({ el, where });
for (const node of root.querySelectorAll('*')) {
if (node.shadowRoot) walk(node.shadowRoot, `${where} > ${node.localName}::shadow`);
}
for (const frame of root.querySelectorAll('iframe, frame')) {
let doc = null;
try { doc = frame.contentDocument; } catch {}
if (doc) walk(doc, `${where} > iframe${frame.id ? '#' + frame.id : ''}`);
else hits.push({ el: null, where: `${where} > cross-origin iframe ${frame.src}` });
}
};
walk(document, 'document');
// the autocomplete popup is also an Ace editor: skip it
const found = hits.filter(h => !h.el?.classList.contains('ace_autocomplete'));
window.__aceEditors = found.map(h => h.el);
return found.map(({ el, where }, i) => {
if (!el) return { i, where, note: 'not reachable from this page: open the frame URL directly' };
const ed = el.env?.editor;
return {
i,
where,
id: el.id || undefined,
label: el.closest('[aria-label]')?.getAttribute('aria-label') || undefined,
visible: el.getClientRects().length > 0,
handle: !!ed,
mode: ed?.session.getMode().$id,
readOnly: ed?.getReadOnly(),
lines: ed?.session.getLength(),
firstLine: (ed ? ed.session.getLine(0) : el.querySelector('.ace_line')?.textContent ?? '').slice(0, 60),
};
});
})()
- Pick the target by
label,id,mode,firstLine,visible; use itsiin the next steps (the examples use0). - Re-run the finder after navigation or re-renders. An empty list on a single-page app usually means the editor isn't mounted yet: wait and retry.
handle: false→ the page built the editor withoutace.edit()(noel.env) or you are in an isolated world: use step 5. Never callace.edit(el)to get a handle — on an element withoutenvit creates a second editor on top of the page's one.
2. Inspect and read
const editor = window.__aceEditors[0].env.editor; // index from the finder
({
mode: editor.session.getMode().$id, // e.g. ace/mode/json (ace/mode/text while a mode is still loading)
readOnly: editor.getReadOnly(),
lines: editor.session.getLength(),
indent: JSON.stringify(editor.session.getTabString()),
cursor: editor.getCursorPosition(), // { row, column }, 0-based
selection: editor.getSelectedText(),
text: editor.getValue(),
})
For very large documents read slices with editor.session.getLines(firstRow, lastRow) (0-based, inclusive).
3. Replace the whole document
const editor = window.__aceEditors[0].env.editor;
const newText = '...'; // the full new content
editor.session.getUndoManager().startNewGroup?.(); // keep this change as its own undo step
editor.setValue(newText, -1); // -1 puts the cursor at the start; fires 'change' so the app updates its state
editor.getValue() === newText
Generate structured content from data instead of writing it by hand: JSON.stringify(data, null, editor.session.getTabString()) matches the editor's indentation.
4. Targeted edits
const editor = window.__aceEditors[0].env.editor;
// first match; options: wrap, caseSensitive, wholeWord, regExp, backwards, range
if (editor.find('oldValue', { wrap: true, caseSensitive: true })) editor.replace('newValue');
// every match, returns the count; with regExp, $1 back-references work
const replaced = editor.replaceAll('version-$1', { needle: 'v(\\d+)', regExp: true });
// by position: rows and columns are 0-based, columns past the end of the line are clipped
editor.session.replace({ start: { row: 2, column: 0 }, end: { row: 2, column: Infinity } }, 'whole new line 3');
editor.session.insert({ row: editor.session.getLength(), column: 0 }, '\nappended line');
({ replaced, text: editor.getValue() })
More — cursor, selection, folding, scrolling, commands, events: references/ace-api.md.
5. No handle? Paste into the hidden textarea
const el = window.__aceEditors[0]; // works with handle: false
const newText = '...';
const input = el.querySelector('textarea.ace_text-input');
const mac = /Mac|iPhone|iPad/.test(navigator.platform);
input.focus();
// select all: Ace binds Ctrl+A, or Cmd+A on Apple platforms
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', keyCode: 65, which: 65, ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true }));
const data = new DataTransfer();
data.setData('text/plain', newText);
input.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
'pasted'
- Pasted text is inserted verbatim: no auto-pairing, no auto-indent. Read-only editors ignore it.
- Alternative with the textarea focused and everything selected:
document.execCommand('insertText', false, newText). - Without JavaScript: put the text on the OS clipboard, click inside the editor, press
Ctrl+A(Cmd+Aon macOS), thenCtrl+V(Cmd+V). - Without a handle you can only read rendered rows (
.ace_text-layer .ace_line, after the next frame). For the full text, select all and copy via the OS clipboard.
6. If you really must type
const editor = window.__aceEditors[0].env.editor;
const assists = ['behavioursEnabled', 'wrapBehavioursEnabled', 'enableAutoIndent', 'enableLiveAutocompletion', 'enableBasicAutocompletion', 'enableSnippets'];
window.__aceSavedOptions = Object.fromEntries(assists.filter(name => editor.$options[name]).map(name => [name, editor.getOption(name)]));
editor.setOptions(Object.fromEntries(Object.keys(window.__aceSavedOptions).map(name => [name, false])));
editor.focus();
window.__aceSavedOptions // restore after typing: editor.setOptions(window.__aceSavedOptions)
Type, then restore the options. Prefer single-line content and press Escape befor