[eric] dictation: a field that dies mid-decode drops the words instead of typing them into a stranger's box

This commit is contained in:
ciregenz
2026-08-07 06:42:41 -07:00
parent 44af7d29d9
commit 511e2b0029
2 changed files with 17 additions and 5 deletions
+8 -2
View File
@@ -1,6 +1,6 @@
import { getLastInteractedBrowser } from '@/shared/browserFocus';
import { getWebview } from '@/shared/browserRegistry';
import { takeInjectSnapshot, setInjectSnapshot } from './injectTargetSnapshot';
import { takeInjectSnapshot, setInjectSnapshot, isUsableTarget } from './injectTargetSnapshot';
// Dictation lands where the user's cursor actually is, like every real dictation tool: a focused
// in-app field gets the text typed in (undo-friendly, fires React input events), a focused browser
@@ -9,6 +9,9 @@ export type InjectTarget = 'field' | 'webview' | 'composer' | null;
export function injectAtFocus(text: string): InjectTarget {
const snap = takeInjectSnapshot();
// The field the user dictated into is gone. Whatever holds focus now is a stranger's box, and
// typing their words into it is worse than dropping them, so drop them.
if (snap.targetLost && !snap.browserId) return null;
const active = snap.el || (document.activeElement as HTMLElement | null);
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
try {
@@ -49,8 +52,11 @@ export function injectAtFocus(text: string): InjectTarget {
/** Called at press-start so the words land where the user was looking, not where focus drifted. */
export function snapshotInjectTarget(): void {
// Only a typeable element counts as "aimed at". document.activeElement is <body> when nothing is
// focused, and storing that would read as a lost target later and swallow the composer fallback.
const active = document.activeElement as HTMLElement | null;
setInjectSnapshot({
el: document.activeElement as HTMLElement | null,
el: isUsableTarget(active) ? active : null,
browserId: getLastInteractedBrowser(),
});
}
@@ -21,10 +21,16 @@ export function isUsableTarget(el: HTMLElement | null): boolean {
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'WEBVIEW' || el.isContentEditable;
}
export interface TakenSnapshot extends InjectSnapshot {
/** We aimed at a real field and it died mid-decode. Distinct from never having aimed anywhere. */
targetLost: boolean;
}
/** Consumes the snapshot: reading it once is the whole contract, so a stale one can never linger. */
export function takeInjectSnapshot(): InjectSnapshot {
export function takeInjectSnapshot(): TakenSnapshot {
const snap = p_snapshot;
p_snapshot = null;
if (!snap) return { el: null, browserId: null };
return { el: isUsableTarget(snap.el) ? snap.el : null, browserId: snap.browserId };
if (!snap) return { el: null, browserId: null, targetLost: false };
const usable = isUsableTarget(snap.el);
return { el: usable ? snap.el : null, browserId: snap.browserId, targetLost: !!snap.el && !usable };
}