feat(dictation): dictate into the focused field of a page in the browser pane (#40191)

* feat(dictation): dictate into the focused field of a page in the browser pane

* test(dictation): cover dictation into a focused browser page

* fix(dictation): type page dictation through the frame that owns the field

* fix(dictation): space page rich-text dictation within its block and offer editors the beforeinput

* test(dictation): simplify the page insertion test mock

* fix(dictation): copy page dictation to the clipboard when an editor blocks the input
This commit is contained in:
Kelvin Amoaba
2026-11-09 08:52:28 +00:00
committed by GitHub
parent f358eb6a7d
commit c323f876b8
14 changed files with 288 additions and 26 deletions
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import { insertDictationTextIntoGuest } from './browser-guest-dictation-text'
function guestWithFocusedField(field: unknown) {
const executeJavaScript = vi
.fn<(code: string) => Promise<unknown>>()
.mockResolvedValueOnce(field)
.mockResolvedValueOnce(true)
const frame = { executeJavaScript }
return { guest: { focusedFrame: frame, mainFrame: frame }, executeJavaScript }
}
describe('insertDictationTextIntoGuest', () => {
it('types the text into the focused field, spaced from the words around the caret', async () => {
const { guest, executeJavaScript } = guestWithFocusedField({ before: 'Hello', after: 'again' })
await expect(insertDictationTextIntoGuest(guest, 'there')).resolves.toBe(true)
expect(executeJavaScript).toHaveBeenCalledTimes(2)
expect(executeJavaScript.mock.calls[1][0]).toContain('" there "')
})
it('reports nothing landed when the page has no editable field focused', async () => {
const { guest, executeJavaScript } = guestWithFocusedField(null)
await expect(insertDictationTextIntoGuest(guest, 'there')).resolves.toBe(false)
expect(executeJavaScript).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,92 @@
import {
DICTATION_TEXT_BLOCK_SELECTOR,
dictationSeparators
} from '../../../shared/dictation-transcript-spacing'
// The focused text field of this frame's document, looking through open shadow roots, or null.
const FOCUSED_FIELD_EXPRESSION = `(() => {
let field = document.activeElement
while (field?.shadowRoot?.activeElement) field = field.shadowRoot.activeElement
const typedInto = field instanceof HTMLTextAreaElement ||
(field instanceof HTMLInputElement && ['text', 'search', 'url', 'tel', 'email'].includes(field.type))
if (typedInto) return field.readOnly || field.disabled ? null : field
return field instanceof HTMLElement && field.isContentEditable ? field : null
})()`
// Text around the caret of the focused field, so dictated words are spaced from their neighbours.
const READ_FOCUSED_FIELD_SCRIPT = `(() => {
const limit = 200
const field = ${FOCUSED_FIELD_EXPRESSION}
if (!field) return null
if (!field.isContentEditable) {
const start = field.selectionStart ?? field.value.length
const end = field.selectionEnd ?? start
return {
before: field.value.slice(Math.max(0, start - limit), start),
after: field.value.slice(end, end + limit)
}
}
const root = field.getRootNode()
const selection = root.getSelection ? root.getSelection() : document.getSelection()
if (!selection || selection.rangeCount === 0) return { before: '', after: '' }
const caret = selection.getRangeAt(0)
const blockOf = (node) => {
const block = (node instanceof Element ? node : node.parentElement)?.closest(${JSON.stringify(DICTATION_TEXT_BLOCK_SELECTOR)})
return block && field.contains(block) ? block : field
}
const before = document.createRange()
before.selectNodeContents(blockOf(caret.startContainer))
before.setEnd(caret.startContainer, caret.startOffset)
const after = document.createRange()
after.selectNodeContents(blockOf(caret.endContainer))
after.setStart(caret.endContainer, caret.endOffset)
return { before: before.toString().slice(-limit), after: after.toString().slice(0, limit) }
})()`
// execCommand types into the frame that owns the field; webContents.insertText only reaches the top frame.
// Rich-text editors get a cancelable beforeinput first; a cancel counts as landed only if the field's text then changes.
const typeIntoFocusedFieldScript = (text: string): string => `(() => {
const text = ${JSON.stringify(text)}
const field = ${FOCUSED_FIELD_EXPRESSION}
if (!field) return false
if (field.isContentEditable) {
const previous = field.textContent
const beforeInput = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText', data: text })
if (!field.dispatchEvent(beforeInput)) {
return new Promise((resolve) => setTimeout(() => resolve(field.textContent !== previous), 50))
}
}
return document.execCommand('insertText', false, text)
})()`
type FocusedFieldText = { before: string; after: string }
type ScriptFrame = { executeJavaScript(code: string): Promise<unknown> }
type DictationGuest = { focusedFrame: ScriptFrame | null; mainFrame: ScriptFrame }
function isFocusedFieldText(value: unknown): value is FocusedFieldText {
return (
typeof value === 'object' &&
value !== null &&
'before' in value &&
'after' in value &&
typeof value.before === 'string' &&
typeof value.after === 'string'
)
}
/** Types dictated text into the page's focused field, spaced from its neighbours; false when no field is focused. */
export async function insertDictationTextIntoGuest(
guest: DictationGuest,
text: string
): Promise<boolean> {
const frame = guest.focusedFrame ?? guest.mainFrame
const field = await frame.executeJavaScript(READ_FOCUSED_FIELD_SCRIPT)
if (!isFocusedFieldText(field)) {
return false
}
const { leading, trailing } = dictationSeparators(field.before, text, field.after)
const typed = await frame.executeJavaScript(
typeIntoFocusedFieldScript(`${leading}${text}${trailing}`)
)
return typed === true
}
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', async () => {
const { EventEmitter } = await import('node:events')
return {
BrowserWindow: class {
webContents = new (class extends EventEmitter {
send = vi.fn()
})()
},
screen: { getCursorScreenPoint: vi.fn(() => ({ x: 0, y: 0 })) },
webContents: { fromId: vi.fn() }
}
})
import { BrowserWindow } from 'electron'
import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding'
function pressDictationInPage(isComposing: boolean) {
const guest = new BrowserWindow().webContents
const renderer = new BrowserWindow().webContents
const cleanup = setupGuestShortcutForwarding({
browserTabId: 'tab-1',
guest,
resolveRenderer: () => renderer,
shouldForwardDictationShortcut: () => true,
getKeybindings: () => ({})
})
const isMac = process.platform === 'darwin'
const event: Electron.Event = { preventDefault: vi.fn(), defaultPrevented: false }
const input: Electron.Input = {
type: 'keyDown',
code: 'KeyE',
key: 'e',
meta: isMac,
control: !isMac,
alt: false,
shift: false,
isAutoRepeat: false,
isComposing,
location: 0,
modifiers: []
}
guest.emit('before-input-event', event, input)
cleanup()
return { prevented: vi.mocked(event.preventDefault).mock.calls.length > 0, renderer }
}
describe('dictation shortcut from a focused browser page', () => {
it('leaves the chord to the page while an IME composition owns it', () => {
const composing = pressDictationInPage(true)
expect(composing.prevented).toBe(false)
expect(composing.renderer.send).not.toHaveBeenCalledWith('ui:dictationKeyDown')
const typed = pressDictationInPage(false)
expect(typed.prevented).toBe(true)
expect(typed.renderer.send).toHaveBeenCalledWith('ui:dictationKeyDown')
})
})
@@ -10,10 +10,14 @@ import type { BrowserFindTarget } from '../../../../../shared/browser-find-sourc
import type { BrowserPageTabShortcut } from '../../../../../shared/browser-page-command-target'
import type { ResolveRenderer } from '../../browser-guest-renderer-target'
import { matchesGuestTabShortcut } from './browser-guest-tab-shortcut'
import { textInputOwnsComposition } from '../../../../../shared/text-input-shortcut-policy'
export type ShouldForwardDictationShortcut = () => boolean
export type IsMobileEmulatorEnabled = () => boolean
export type GuestShortcutInput = WindowShortcutInput & { isAutoRepeat?: boolean }
export type GuestShortcutInput = WindowShortcutInput & {
isAutoRepeat?: boolean
isComposing?: boolean
}
export type GuestShortcutForwardContext = {
browserTabId: string
@@ -47,6 +51,11 @@ export function forwardGuestShortcutInput(
} = ctx
const page = ctx.browserPage !== false
const keybindings = getKeybindings?.()
// An IME composition keeps the key, as in the main window.
const forwardsDictation =
action?.type === 'dictationKeyDown' &&
!textInputOwnsComposition(input) &&
shouldForwardDictationShortcut?.() === true
if (action?.type === 'zoom') {
if (!page) {
// Why: a plugin frame has no page zoom; Orca zooms what it would with focus outside a pane.
@@ -59,7 +68,7 @@ export function forwardGuestShortcutInput(
}
if (input.isAutoRepeat) {
if (
(action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) ||
forwardsDictation ||
action?.type === 'deleteCurrentWorkspace' ||
// Why: Orca claims a held tab chord on every repeat but acts once, so repeats never reach the page.
matchesGuestTabShortcut(
@@ -204,7 +213,7 @@ export function forwardGuestShortcutInput(
renderer.send('ui:jumpToTabIndex', action.index)
}
} else if (action?.type === 'dictationKeyDown') {
if (!shouldForwardDictationShortcut?.()) {
if (!forwardsDictation) {
return false
}
renderer.send('ui:dictationKeyDown')
@@ -0,0 +1,16 @@
import { ipcMain } from 'electron'
import { insertDictationTextIntoGuest } from '../../browser/dictation/browser-guest-dictation-text'
import { pageGuest } from './browser-guest-view-ipc'
export function registerBrowserDictationHandlers(): void {
ipcMain.removeHandler('browser:insertDictationText')
ipcMain.handle(
'browser:insertDictationText',
(event, args: { browserPageId: string; text: unknown }): Promise<boolean> | false => {
const guest = pageGuest(event, args?.browserPageId)
return guest && typeof args.text === 'string' && args.text
? insertDictationTextIntoGuest(guest, args.text).catch(() => false)
: false
}
)
}
+15 -15
View File
@@ -32,6 +32,21 @@ function isPagePoint(value: unknown): value is { x: number; y: number } {
)
}
// Page/session operations exclude document previews.
const pageGuestId = (event: IpcMainInvokeEvent, browserPageId: unknown): number | null =>
isTrustedBrowserRenderer(event.sender) && typeof browserPageId === 'string'
? browserManager.getGuestWebContentsId(browserPageId)
: null
export function pageGuest(
event: IpcMainInvokeEvent,
browserPageId: unknown
): Electron.WebContents | null {
const guestId = pageGuestId(event, browserPageId)
const guest = guestId === null ? undefined : webContents.fromId(guestId)
return guest && !guest.isDestroyed() ? guest : null
}
export function registerBrowserGuestViewHandlers(): void {
ipcMain.removeHandler('browser:openDevTools')
ipcMain.removeHandler('browser:closeDevTools')
@@ -49,21 +64,6 @@ export function registerBrowserGuestViewHandlers(): void {
ipcMain.removeHandler('browser:setPageSiteColorScheme')
ipcMain.removeHandler('browser:setPageAudioMuted')
// Page/session operations exclude document previews.
const pageGuestId = (event: IpcMainInvokeEvent, browserPageId: unknown): number | null =>
isTrustedBrowserRenderer(event.sender) && typeof browserPageId === 'string'
? browserManager.getGuestWebContentsId(browserPageId)
: null
const pageGuest = (
event: IpcMainInvokeEvent,
browserPageId: unknown
): Electron.WebContents | null => {
const guestId = pageGuestId(event, browserPageId)
const guest = guestId === null ? undefined : webContents.fromId(guestId)
return guest && !guest.isDestroyed() ? guest : null
}
ipcMain.handle(
'browser:getPageSiteDisplay',
(event, args: { browserPageId: string }): BrowserPageSiteDisplay | null => {
+2
View File
@@ -11,6 +11,7 @@ import {
resolveTabRegistrationWaiters
} from './browser-tab-registration-wait'
import { registerBrowserGuestViewHandlers } from './browser-guest-view-ipc'
import { registerBrowserDictationHandlers } from './browser-dictation-ipc'
import {
disposeGrabModeStateForPage,
registerBrowserGrabHandlers,
@@ -250,6 +251,7 @@ export function registerBrowserHandlers(store: BrowserPasswordsSettingsSource):
})
registerBrowserGuestViewHandlers()
registerBrowserDictationHandlers()
registerBrowserGrabHandlers()
@@ -15,6 +15,7 @@ vi.mock('../browser/guest-lifecycle/manager/browser-manager', async () =>
)
import { createMainWindow } from './createMainWindow'
import { browserManager } from '../browser/guest-lifecycle/manager/browser-manager'
import { ipcMain } from 'electron'
import { resetExpectedTeardownStateForTest } from '../crash-reporting/expected-teardown-state'
import { browserWindowMock, resetMainWindowMocks } from './createMainWindow-test-harness'
@@ -69,6 +70,10 @@ describe('createMainWindow', () => {
getSettings: () => ({ windowBackgroundBlur: false, voice }) as never,
updateUI: vi.fn()
} as never)
// A focused browser page forwards only keydown, so it hands the chord over unless Hold needs the release.
const pageForwardsDictation = vi.mocked(browserManager.setDictationShortcutForwardingPredicate)
.mock.lastCall?.[0]
expect(pageForwardsDictation?.()).toBe(false)
const isDarwin = process.platform === 'darwin'
const dictationInput = {
@@ -91,6 +96,7 @@ describe('createMainWindow', () => {
// #18691: with no model the renderer still needs the chord to show its setup toast.
voice.enabled = true
expect(pageForwardsDictation?.()).toBe(true)
const noModelPreventDefault = vi.fn()
windowHandlers['before-input-event']({ preventDefault: noModelPreventDefault }, dictationInput)
expect(noModelPreventDefault).toHaveBeenCalledTimes(1)
@@ -99,6 +105,7 @@ describe('createMainWindow', () => {
webContents.send.mockClear()
voice.sttModel = 'test-model'
voice.dictationMode = 'hold'
expect(pageForwardsDictation?.()).toBe(false)
const holdPreventDefault = vi.fn()
windowHandlers['before-input-event'](
{ preventDefault: holdPreventDefault } as never,
@@ -108,6 +115,7 @@ describe('createMainWindow', () => {
expect(webContents.send).not.toHaveBeenCalledWith('ui:dictationKeyDown')
voice.dictationMode = 'auto'
expect(pageForwardsDictation?.()).toBe(true)
const autoPreventDefault = vi.fn()
windowHandlers['before-input-event']({ preventDefault: autoPreventDefault }, dictationInput)
expect(autoPreventDefault).not.toHaveBeenCalled()
+3 -2
View File
@@ -100,8 +100,9 @@ export function createMainWindow(
const settings = store?.getSettings()
browserManager.setDictationShortcutForwardingPredicate(() => {
// Webview guests have no safe transcript target; yield the dictation shortcut to the page.
return false
const voice = store?.getSettings().voice
// Guests forward only keydown, so Hold's release can't reach the renderer; the page keeps the key then.
return voice?.enabled === true && voice.dictationMode !== 'hold'
})
const windowMaterial = resolveWindowMaterial(
settings,
+2
View File
@@ -130,6 +130,8 @@ export type BrowserApi = {
browserPageId: string
point: { x: number; y: number }
}) => Promise<boolean>
/** Types dictated text into the page's focused field; false when no field is focused. */
insertDictationText: (args: { browserPageId: string; text: string }) => Promise<boolean>
setViewportOverride: (args: {
browserPageId: string
override: BrowserViewportOverride | null
@@ -102,6 +102,8 @@ export const browserGuestRegistrationAndDownloadsApi = {
browserPageId: string
point: { x: number; y: number }
}): Promise<boolean> => ipcRenderer.invoke('browser:copyImageAt', args),
insertDictationText: (args: { browserPageId: string; text: string }): Promise<boolean> =>
ipcRenderer.invoke('browser:insertDictationText', args),
setViewportOverride: (args: {
browserPageId: string
override: BrowserViewportOverride | null
@@ -6,7 +6,7 @@ import {
TEXT_CONTROL_PASTE_DIRECT_MAX_BYTES,
TEXT_CONTROL_PASTE_MAX_BYTES
} from '@/lib/text-control-paste'
import { insertText } from './dictation-insertion-target'
import { captureInsertionTarget, insertText } from './dictation-insertion-target'
type DocumentWithExecCommand = Document & {
execCommand?: (commandId: string, showUi?: boolean, value?: string) => boolean
@@ -18,6 +18,7 @@ afterEach(() => {
document.body.replaceChildren()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: originalExecCommand
@@ -45,6 +46,24 @@ function installExecCommandMock(
}
describe('dictation insertion target', () => {
it('sends text dictated into a focused browser page to that page', async () => {
const insertDictationText = vi.fn(async () => true)
vi.stubGlobal('api', { browser: { insertDictationText } })
const viewport = document.createElement('div')
viewport.dataset.browserPageViewportId = 'page-1'
const guest = document.createElement('webview')
guest.tabIndex = 0
viewport.appendChild(guest)
document.body.appendChild(viewport)
guest.focus()
const target = captureInsertionTarget()
expect(target).toEqual({ kind: 'browser', browserPageId: 'page-1' })
await expect(insertText('hello', target!)).resolves.toBe(true)
expect(insertDictationText).toHaveBeenCalledWith({ browserPageId: 'page-1', text: 'hello' })
})
it('cancels chunked contenteditable dictation when the target disconnects', async () => {
vi.useFakeTimers()
const editor = appendContentEditable()
@@ -8,7 +8,11 @@ import {
measureTextControlPasteByteLengthWithYield,
pasteTextIntoTextControl
} from '@/lib/text-control-paste'
import { dictationSeparators } from '../../../../shared/dictation-transcript-spacing'
import {
DICTATION_TEXT_BLOCK_SELECTOR,
dictationSeparators
} from '../../../../shared/dictation-transcript-spacing'
import { focusedBrowserPageId } from '@/hooks/resolve-zoom-target'
import { DICTATION_FOCUS_INPUT_EVENT } from './dictation-control-events'
/** Each returns whether the text landed. */
@@ -18,6 +22,7 @@ type TerminalInsert = (text: string, paneId: number) => Promise<boolean>
export type DictationInsertionTarget =
| { kind: 'composer'; insert: ComposerInsert; root: HTMLElement }
| { kind: 'terminal'; tabId: string; paneId: number; pane: HTMLElement }
| { kind: 'browser'; browserPageId: string }
| { kind: 'text'; element: HTMLInputElement | HTMLTextAreaElement }
| { kind: 'contentEditable'; element: HTMLElement }
@@ -81,6 +86,15 @@ function readFocusedInsertionTarget(): DictationInsertionTarget | null {
return null
}
if (activeElement.tagName === 'WEBVIEW') {
const browserPageId =
focusedBrowserPageId(activeElement) ??
activeElement
.closest('[data-browser-client-page-id]')
?.getAttribute('data-browser-client-page-id')
return browserPageId ? { kind: 'browser', browserPageId } : null
}
if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) {
return { kind: 'text', element: activeElement }
}
@@ -103,9 +117,6 @@ function readSelectionRangeIn(element: HTMLElement): Range | null {
return range && element.contains(range.commonAncestorContainer) ? range : null
}
const CONTENT_EDITABLE_BLOCK_SELECTOR =
'p, li, h1, h2, h3, h4, h5, h6, pre, blockquote, td, th, div'
/** The selection's neighbours within its own block, so a new paragraph's start has none. */
function readContentEditableNeighbours(element: HTMLElement): { before: string; after: string } {
const range = readSelectionRangeIn(element)
@@ -114,7 +125,7 @@ function readContentEditableNeighbours(element: HTMLElement): { before: string;
}
const blockOf = (node: Node): Node => {
const block = (node instanceof Element ? node : node.parentElement)?.closest(
CONTENT_EDITABLE_BLOCK_SELECTOR
DICTATION_TEXT_BLOCK_SELECTOR
)
return block && element.contains(block) ? block : element
}
@@ -143,6 +154,13 @@ export async function insertText(
const insert = terminalTargets.get(target.tabId)
return insert ? insert(spaced(text, previousText, ''), target.paneId) : false
}
if (target.kind === 'browser') {
// The page spaces the text from its own field.
return (
window.api.browser?.insertDictationText({ browserPageId: target.browserPageId, text }) ??
false
)
}
if (target.kind === 'text') {
const element = target.element
if (!element.isConnected) {
@@ -1,3 +1,7 @@
/** Blocks that bound a rich-text caret's neighbours, so a new paragraph's start has none. */
export const DICTATION_TEXT_BLOCK_SELECTOR =
'p, li, h1, h2, h3, h4, h5, h6, pre, blockquote, td, th, div'
const CJK_BOUNDARY_CHAR_RE =
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]$/u
const NO_SPACE_BEFORE_CHAR_RE = /^[,.;:!?%。,、!?;:))\]}”’»]$/u