From 3e1eb0292c51b4324ccd394c0e6faf8b5356598d Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Mon, 19 Oct 2026 01:21:45 +0800 Subject: [PATCH] Fix microphone context menu delegation Route microphone right-click through the global context-menu delegate and cover the production menu capture path in speech browser tests. Validated Web tests, build, browser speech regression and strict OpenSpec checks. --- .../changes/add-speech-recognition/design.md | 9 +++++ .../specs/speech-recognition/spec.md | 4 ++ .../changes/add-speech-recognition/tasks.md | 4 ++ web/scripts/test-speech-recognition.mjs | 37 ++++++++++++++++++- web/src/components/SpeechMicrophoneButton.jsx | 26 ++++++++++++- web/src/lib/desktopContextMenu.js | 1 + 6 files changed, 77 insertions(+), 4 deletions(-) diff --git a/openspec/changes/add-speech-recognition/design.md b/openspec/changes/add-speech-recognition/design.md index e1824369..9d024291 100644 --- a/openspec/changes/add-speech-recognition/design.md +++ b/openspec/changes/add-speech-recognition/design.md @@ -32,6 +32,8 @@ ## Validation +麦克风右键必须通过已有 `DesktopContextMenu` 委托协议接管捕获阶段的事件,按按钮实例确认归属;不能仅依赖按钮冒泡阶段的 `onContextMenu`。打开设备菜单时关闭已有通用菜单及延迟打开任务,编辑器区域继续使用复制/粘贴等通用动作。浏览器回归必须同时挂载真实全局菜单,验证按钮图标、空白区域和菜单切换。 + 配置解析/持久化、HTTP 参数映射与错误解析的 C++ 测试;录音状态机、WAV、配置同步的 JS 测试;pnpm test/build;Windows 定向构建和后端 HTTP 实测;浏览器检查主题、入口可见性、录音/停止/取消/发送及草稿隔离。最终审查原有脏改动仍保留。 ### 当前验收证据(2026-10-18) @@ -59,3 +61,10 @@ - 普通/首页、明暗主题和 390 px 窄屏的录音前后 x/y/width/height 差均小于 1 CSS px,菜单不含语言项。原工具栏持续占位,录音层覆盖显示。 - 最终 pnpm test、pnpm build、完整浏览器交互、OpenSpec 严格校验及 diff 检查通过。浏览器脚本通过真实按钮打开快捷键设置,避免测试驱动直接插入设置页时破坏聚焦编辑器的提交阶段;异步回填也不会再抢走弹窗焦点。 - 这些增补已更新源码和 Web 构建;先前 Windows 验证二进制未包含本轮增补,当前未替换运行中的 Desktop,也未追加 Mac/Linux 真机验收。 + +### 全局右键菜单回归修正(2026-10-19) + +- 前轮浏览器夹具漏挂 `DesktopContextMenu`,只验证到按钮自身事件。补挂后准确复现右键麦克风出现“全选”:全局 document 捕获处理先于 React 按钮冒泡处理并阻止传播。 +- 麦克风通过现有委托事件按按钮实例认领;打开时关闭通用菜单和待打开任务,保留无全局组件时的按钮处理。 +- 带真实全局菜单的浏览器回归通过:图标和按钮空白区、明暗主题、已有编辑菜单切换、无 pointerdown 的 contextmenu 事件、编辑器粘贴/全选;原设备选择、快捷键编辑、按住/松开、迟到授权、布局稳定和录音完整流程仍通过。 +- `pnpm test`、`pnpm build`、OpenSpec 严格校验及差异检查通过。本轮验证使用 Chromium 模拟音频设备和受控识别 API;未重新构建或替换正在运行的 Desktop。 diff --git a/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md b/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md index d2c9b890..74bd95f9 100644 --- a/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md +++ b/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md @@ -66,6 +66,10 @@ - **WHEN** 用户右键麦克风并选择一个音频输入设备 - **THEN** 下次录音使用该设备,菜单显示选中状态,设备不可用时提示重新选择且保留草稿 +#### Scenario: 全局右键菜单共存 +- **WHEN** 全局右键菜单已挂载,用户右键麦克风图标或按钮空白区域 +- **THEN** 只打开该输入框的麦克风配置,关闭已有通用菜单,不显示“全选”;编辑器内右键仍显示原有编辑菜单 + #### Scenario: 可配置快捷键 - **WHEN** 用户按 Ctrl+D 或在快捷键设置中自定义的录音快捷键 - **THEN** 在可用输入框开始录音,再按停止并回填;快捷键录入、弹窗及其他编辑区域不会误触录音 diff --git a/openspec/changes/add-speech-recognition/tasks.md b/openspec/changes/add-speech-recognition/tasks.md index ba1dbee6..4ef0da81 100644 --- a/openspec/changes/add-speech-recognition/tasks.md +++ b/openspec/changes/add-speech-recognition/tasks.md @@ -23,3 +23,7 @@ - [x] 6.1 麦克风 hover 提示“右键配置麦克风”;右键菜单列出实际音频输入设备、选中标记和按住录音开关,不含语言项,设备偏好在本机保存并用于实际采集。 - [x] 6.2 默认 Ctrl+D 开始/停止录音并接入快捷键设置、冲突校验及上下文守卫;按住模式松开即停止,迟到授权、窗口失焦和切换会话不残留录音。 - [x] 6.3 录音工具栏覆盖原工具栏且保持原布局尺寸;验证普通/首页、明暗、窄屏切换时边界不跳动及菜单、快捷键和录音完整流程。 + +## 7. 麦克风右键事件修正 +- [x] 7.1 在浏览器回归中挂载生产用 DesktopContextMenu,复现右键麦克风误开“全选”;接入已有委托机制,使图标及按钮空白区均打开麦克风配置,保留编辑器通用菜单。 +- [x] 7.2 验证菜单切换互斥、设备选择、快捷键和原有录音流程,运行 Web 测试/构建及 OpenSpec 严格校验,并记录实际验证范围。 diff --git a/web/scripts/test-speech-recognition.mjs b/web/scripts/test-speech-recognition.mjs index e4120ea2..17bb91a9 100644 --- a/web/scripts/test-speech-recognition.mjs +++ b/web/scripts/test-speech-recognition.mjs @@ -15,6 +15,7 @@ import React,{useState,useRef} from 'react'; import {createRoot} from 'react-dom/client'; import '/src/styles/globals.css'; import {InputBar} from '/src/components/InputBar.jsx'; +import {DesktopContextMenu} from '/src/components/DesktopContextMenu.jsx'; import {SettingsPage} from '/src/components/SettingsPage.jsx'; import '/src/i18n/index.js'; import {Toaster} from '/src/components/Toast.jsx'; @@ -72,7 +73,7 @@ function App(){ onSubmit={text=>window.fixture.sent.push(text)} sessionControls={{model:'ACEModel-gpt-6-astra',modelOptions:[model],selectedModelName:model.name, onModelChange(){},onRefreshModels(){},permissionMode:'default',reasoningOptions:{label:'极高',items:[]}}}/> - + ; } createRoot(document.getElementById('root')).render(); @@ -197,6 +198,39 @@ try { await page.waitForFunction(() => window.fixture.saved().base_url==='http://speech.internal:7777'); await settings.getByRole('button', { name: '收起', exact: true }).click(); await page.evaluate(() => window.fixture.settings(false)); + const microphoneMenu=page.getByRole('menu',{name:'麦克风配置'}); + const genericMenu=page.locator('.ace-desktop-context-menu'); + // Include the document capture listener used in production, not only the button's React handler. + await page.getByRole('button',{name:'听写',exact:true}).click({button:'right'}); + await page.waitForFunction(()=>document.querySelector('[aria-label="麦克风配置"],.ace-desktop-context-menu')); + assert.equal(await genericMenu.count(),0,'microphone right-click must not open the global Select All menu'); + await microphoneMenu.waitFor(); + await page.keyboard.press('Escape'); + await editor.click({button:'right'}); + await genericMenu.getByRole('menuitem',{name:'粘贴',exact:true}).waitFor(); + assert.equal(await genericMenu.getByRole('menuitem',{name:'全选',exact:true}).count(),1,'editor retains the generic editing menu'); + assert.equal(await microphoneMenu.count(),0); + // A context-menu event can arrive without pointerdown first (keyboard/native delegation). + await page.locator('[data-composer-speech] svg').dispatchEvent('contextmenu',{bubbles:true,cancelable:true}); + await microphoneMenu.waitFor(); + assert.equal(await genericMenu.count(),0,'opening microphone settings closes an existing generic menu'); + await page.keyboard.press('Escape'); + for (const theme of ['light','dark']) { + await page.evaluate(theme=>{document.documentElement.dataset.theme=theme;},theme); + await page.locator('[data-composer-speech] svg').click({button:'right'}); + await microphoneMenu.locator('[data-microphone-device]').first().waitFor(); + assert.equal(await genericMenu.count(),0,'right-clicking the icon reaches the microphone delegate'); + await microphoneMenu.screenshot({path:path.join(output,`microphone-context-${theme}.png`)}); + await page.keyboard.press('Escape'); + await page.locator('[data-composer-speech]').click({button:'right',position:{x:2,y:14}}); + await microphoneMenu.waitFor(); + assert.equal(await genericMenu.count(),0,'button padding also belongs to the microphone menu'); + await editor.click({button:'right'}); + await genericMenu.getByRole('menuitem',{name:'粘贴',exact:true}).waitFor(); + assert.equal(await microphoneMenu.count(),0,'editor context menu dismisses the microphone menu'); + await page.keyboard.press('Escape'); + } + console.log('[pass] real global context-menu delegation: icon, button padding, menu switching and editor actions'); const start = async () => { const before=await page.locator('[data-speech-composer]').boundingBox(); await editor.click(); @@ -281,7 +315,6 @@ try { const microphone=page.getByRole('button',{name:'听写',exact:true}); assert.equal(await microphone.getAttribute('title'),'右键配置麦克风'); await microphone.click({button:'right'}); - const microphoneMenu=page.getByRole('menu',{name:'麦克风配置'}); await microphoneMenu.waitFor(); await microphoneMenu.locator('[data-microphone-device]').first().waitFor(); assert.equal(await microphoneMenu.getByText('Language',{exact:true}).count(),0); diff --git a/web/src/components/SpeechMicrophoneButton.jsx b/web/src/components/SpeechMicrophoneButton.jsx index 0f5524eb..3eda7cbf 100644 --- a/web/src/components/SpeechMicrophoneButton.jsx +++ b/web/src/components/SpeechMicrophoneButton.jsx @@ -1,9 +1,14 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { AnchoredMenu } from './AnchoredMenu.jsx'; import { Toggle } from './Modal.jsx'; import { VsIcon } from './Icon.jsx'; import { listSpeechDevices } from '../lib/speechDevices.js'; import { speechErrorMessage } from '../lib/speechRecognition.js'; +import { + closeDesktopContextMenu, + CONTEXT_MENU_DELEGATE_EVENT, + SPEECH_MICROPHONE_CONTEXT_MENU_DELEGATE, +} from '../lib/desktopContextMenu.js'; function MicrophoneMenu({ speech, anchorRef, onClose }) { const [devices, setDevices] = useState([]); @@ -53,12 +58,29 @@ export function SpeechMicrophoneButton({ speech, disabled, onBeforeStart }) { const anchor = useRef(null); const [open, setOpen] = useState(false); useEffect(() => { if (disabled || speech.active || !speech.available) setOpen(false); }, [disabled, speech.active, speech.available]); + const openMenu = useCallback(() => { + closeDesktopContextMenu(); + if (!disabled && !speech.active && speech.available) setOpen(true); + }, [disabled, speech.active, speech.available]); + useEffect(() => { + const handler = (event) => { + const detail = event.detail; + if (!detail || detail.handled || detail.name !== SPEECH_MICROPHONE_CONTEXT_MENU_DELEGATE + || detail.element !== anchor.current) return; + // The global menu owns document capture; claim only this composer's button. + detail.handled = true; + openMenu(); + }; + window.addEventListener(CONTEXT_MENU_DELEGATE_EVENT, handler); + return () => window.removeEventListener(CONTEXT_MENU_DELEGATE_EVENT, handler); + }, [openMenu]); const beginHold = (kind) => { onBeforeStart?.(); speech.beginHold(kind); }; return <>