From 2f92a9cb322fbaab9314f44f267093e367608d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?d=20=F0=9F=94=B9?= <258577966+voidborne-d@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:07:22 +0000 Subject: [PATCH] fix: add execCommand fallback for contenteditable input (#168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When typing into contenteditable elements (e.g. LinkedIn post editor), the synthetic event approach (Plan A) may fail silently — the events fire but the editor's internal state doesn't update, leaving the element empty. This adds an automatic fallback: after Plan A, we verify the text was actually inserted by checking element.innerText. If it wasn't, we fall back to execCommand('insertText') which integrates natively with most rich-text editors including LinkedIn, Quill, and Slate.js. The fallback uses proper Selection/Range API to select-all before replacing, and preserves the undo stack since execCommand is handled by the browser natively. Fixes #168 --- packages/page-controller/src/actions.ts | 38 +++++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index 983192f..e496b2d 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -119,11 +119,16 @@ export async function inputTextElement(element: HTMLElement, text: string) { // - Monaco/CodeMirror: Require direct JS instance access. No universal way to obtain. // - Draft.js: Not responsive to synthetic/execCommand/Range/DataTransfer. Unmaintained. // + // Strategy: Try Plan A (synthetic events) first, then verify and fall back + // to Plan B (execCommand) if the text wasn't actually inserted. + // // Plan A: Dispatch synthetic events - // Works: LinkedIn, React contenteditable, Quill. - // Fails: Slate.js + // Works: React contenteditable, Quill, some LinkedIn versions. + // Fails: Slate.js, some contenteditable editors that ignore synthetic events. // Sequence: beforeinput -> mutation -> input -> change -> blur + let planASucceeded = false + // Dispatch beforeinput + mutation + input for clearing if ( element.dispatchEvent( @@ -164,18 +169,33 @@ export async function inputTextElement(element: HTMLElement, text: string) { ) } + // Verify Plan A worked by checking if the text was actually inserted + const currentText = element.innerText.trim() + planASucceeded = currentText === text.trim() + + if (!planASucceeded) { + // Plan B: execCommand fallback (deprecated but widely supported) + // Works: LinkedIn, Quill, Slate.js, react contenteditable components. + // This approach integrates with the browser's undo stack and is handled + // natively by most rich-text editors. + element.focus() + + // Select all existing content and delete it + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(element) + selection?.removeAllRanges() + selection?.addRange(range) + + document.execCommand('delete', false) + document.execCommand('insertText', false, text) + } + // Dispatch change event (for good measure) element.dispatchEvent(new Event('change', { bubbles: true })) // Trigger blur for validation element.blur() - - // Plan B: execCommand (deprecated but works better for some editors) - // Works: LinkedIn, Quill, Slate.js, react contenteditable components - // - // document.execCommand('selectAll') - // document.execCommand('delete') - // document.execCommand('insertText', false, text) } else if (element instanceof HTMLTextAreaElement) { nativeTextAreaValueSetter.call(element, text) } else {