Skip to content

Instantly share code, notes, and snippets.

@ncjones
Last active October 25, 2025 07:24
Show Gist options
  • Select an option

  • Save ncjones/acc6bae44148ee0baa30e8c93827cee7 to your computer and use it in GitHub Desktop.

Select an option

Save ncjones/acc6bae44148ee0baa30e8c93827cee7 to your computer and use it in GitHub Desktop.
javascript:(function(){
/**
* Bookmarklet to workaround MacOS Voice Control not being able to dictate
* into rich text editors that use <div contenteditable> such as ChatGPT.
*
* ChatGPT uses a nonstandard contenteditable input that does not expose
* itself as a real text input through Apple accessibility APIs. As a result,
* Voice Control shows "dictation not available here" and cannot type into it.
*
* This bookmarklet injects a plain <textarea> above the contenteditable input that
* fully supports macOS Voice Control, then transfers the composed message
* into the original editor on enter.
* Usage
*
* 1) Create a browser bookmark with the URL set to the entire contents of this file.
* 2) Click the bookmarklet to inject a functional text editor above a "contenteditable" element.
* 3) Press the "return" key to replicate content into the original element.
*/
const VC_INPUT_ID = 'vc-textarea';
function createInput() {
const el = document.createElement('textarea');
el.id = VC_INPUT_ID;
el.placeholder = 'Dictate here with Voice Control...';
Object.assign(el.style, {
width: '99%',
height: '80px',
margin: '10px 1px',
borderRadius: '6px',
background: 'inherit',
color: 'inherit',
});
return el;
}
function sendEnterTo(el) {
const opts = {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
};
el.dispatchEvent(new KeyboardEvent('keydown', opts));
el.dispatchEvent(new KeyboardEvent('keypress', opts));
el.dispatchEvent(new KeyboardEvent('keyup', opts));
}
function populateEditableEl(editable, content) {
editable.innerHTML = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.split('\n')
.map(s => `<p>${s}</p>`)
.join('\n');
editable.dispatchEvent(new InputEvent('input', {bubbles:true}));
}
function populateAndFocusInput(input, content) {
input.value = content;
input.focus();
document.execCommand('selectAll');
document.getSelection().collapseToEnd();
}
function findOrCreateShim(original) {
let input = document.getElementById(VC_INPUT_ID);
if (input) {
return input;
}
input = createInput();
original?.parentNode.insertBefore(input, original);
suppressMouse(input);
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
populateEditableEl(original, input.value);
input.value = '';
setTimeout(() => sendEnterTo(original), 50);
}
});
return input;
}
function suppressMouse(target) {
[
'click','dblclick','mousedown','mouseup','pointerdown','pointerup','contextmenu'
].forEach(t => {
target.addEventListener(t, e => e.stopPropagation(), true);
});
}
function safeHide(el) {
Object.assign(el.style, {
visibility: 'hidden',
position: 'absolute',
height: '0',
minHeight: '0',
overflow: 'hidden',
pointerEvents: 'none',
opacity: '0'
});
}
const editableArea = document.querySelector('[contenteditable="true"]');
if (!editableArea) {
return;
}
const inputShim = findOrCreateShim(editableArea);
populateAndFocusInput(inputShim, editableArea.innerText.trim());
safeHide(editableArea);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment