feat(qa): 拆出"划词追问"独立 tab + 修 Windows CI Send 错误

UI 拆分(用户面)
  - 新一级 tab "划词追问"(NAV 在"翻译"后面),icon 是 stroke 文本+问号
  - 新页 src/pages/SelectionAsk.tsx:3 个 Card——触发快捷键预设(含 Cmd+Option
    chord 选项 + 4 个三键预设 + 不启用),保存历史 toggle,使用方法 5 步说明 +
    浮窗位置/生命周期块 + 隐私契约块
  - Settings → 录音 删去 qaHotkey + qaSaveHistory 两行(已搬到新 tab)
  - i18n 加 nav.selectionAsk + 完整 selectionAsk.* 命名空间(zh+en),
    删 settings.recording.qa{Hotkey,SaveHistory}* 旧 keys
  - useAppState.ts AppTab 加 'selectionAsk'

Windows CI fix(PR #119 失败原因)
  - qa_hotkey.rs::Inner 含 GlobalHotKeyManager → Windows 上含 HHOOK 这种
    *mut c_void 不实现 Send/Sync,导致 coordinator.rs:1998 的 async_runtime::spawn
    捕获 Arc<Inner> 时 Windows 编译失败(macOS 编译过)
  - 加 unsafe impl Send + Sync for Inner,与现有 hotkey.rs::CallbackContext 同款
    做法(OS 句柄实际跨线程安全,crate 没标)

不动后端 hotkey 实现
  Cmd+Option chord 选项 UI 已暴露但底层 global-hotkey crate 不支持
  modifier-only chord(需要 CGEventTap 自己写状态机),UI 上加 ⚠️ 警告说明
  "需要 v1.2.9+ 才支持"。当前默认仍为 Cmd+Shift+;。
This commit is contained in:
baiqing committed 2026-05-01 13:37:13 +08:00
1 parent e68b29d5a3
commit 78ff8113eb
8 files changed
+311 -89

No files matched your search

@@ -53,6 +53,15 @@ struct Inner {
active_id: Arc<std::sync::atomic::AtomicU32>,
}
// global-hotkey 0.6 的 GlobalHotKeyManager 在 Windows 内部持有 HHOOK / window
// handle 等 `*mut c_void`,crate 没标 Send/Sync。但这些句柄实际是 OS 进程级
// 资源,跨线程读写是 OS 自己同步的;coordinator.rs 又需要把 `Arc<Inner>`(间接含
// QaHotkeyMonitor)放进 async_runtime::spawn 里,强制要求 Send。手动标记。
// macOS 上 GlobalHotKeyManager 内部用 Carbon EventHotKey,同理。
// 与 hotkey.rs::CallbackContext 已有的 unsafe impl Send/Sync 同款做法。
unsafe impl Send for Inner {}
unsafe impl Sync for Inner {}
impl QaHotkeyMonitor {
/// 启动监听并注册一个 hotkey。`tx` 在每次按下边沿收到 `QaHotkeyEvent::Pressed`。
///
@@ -15,6 +15,7 @@ import { History } from '../pages/History';
import { Vocab } from '../pages/Vocab';
import { Style } from '../pages/Style';
import { Translation } from '../pages/Translation';
import { SelectionAsk } from '../pages/SelectionAsk';
import { APP_VERSION_LABEL } from '../lib/appVersion';
import {
HOTKEY_MODE_MIGRATION_ACK_KEY,
@@ -46,6 +47,7 @@ const NAV_BASE: Array<Omit<NavItem, 'name'>> = [
{ id: 'vocab', icon: 'vocab', cmp: Vocab },
{ id: 'style', icon: 'style', cmp: Style },
{ id: 'translation', icon: 'translate', cmp: Translation },
{ id: 'selectionAsk', icon: 'selectionAsk', cmp: SelectionAsk },
];
const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
+1
View File
@@ -9,6 +9,7 @@ export const ICONS: Record<string, string> = {
vocab: 'M5 4h11a2 2 0 0 1 2 2v13l-3-2-3 2-3-2-3 2V6a2 2 0 0 1 2-2zM8 9h7M8 13h5',
style: 'M12 3a9 9 0 1 0 0 18 3 3 0 0 0 3-3v-1a2 2 0 0 1 2-2h1a3 3 0 0 0 3-3 9 9 0 0 0-9-9z',
translate:'M3 5h7M6 4v2M5 8c0 4 3 6 5 6M9 8c0 4-3 6-5 6M13 20l4-10 4 10M14.5 17h5',
selectionAsk:'M4 6h11M4 10h11M4 14h7M17 14a3 3 0 1 1 3 3v1M20 22h.01',
settings:'M12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1A2 2 0 1 1 7 4.9l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
help: 'M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z',
mic: 'M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM19 11a7 7 0 0 1-14 0M12 18v3M8 21h8',
+30 -5
View File
@@ -49,6 +49,7 @@ export const en: typeof zhCN = {
vocab: 'Vocabulary',
style: 'Style',
translation: 'Translation',
selectionAsk: 'Ask',
},
shell: {
shortcutLabel: 'Recording shortcut',
@@ -200,6 +201,35 @@ export const en: typeof zhCN = {
fallbackDesc: 'When the target is "Disabled", Shift is a no-op. If the LLM call fails mid-translation, the raw transcript is inserted directly so nothing gets lost. See issue #4.',
},
},
selectionAsk: {
kicker: 'SELECTION ASK',
title: 'Selection Ask',
desc: 'Select text in any app, press the hotkey, ask a question by voice, and the AI answer appears in a floating panel above the recording capsule.',
statusEnabled: 'Enabled',
statusDisabled: 'Disabled',
hotkey: {
title: 'Trigger hotkey',
desc: 'When set, selecting text in any app and pressing this combo opens a panel and starts recording. Press again to stop and generate the answer. Pick "Disabled" to turn the feature off.',
optionDisabled: 'Disabled',
chordWarning: '⚠️ Modifier-only chord (Cmd+Option / Ctrl+Alt): both modifiers held without any other key, then released. Fewer keys, but higher false-trigger rate than 3-key combos. Chord backend requires v1.2.9+; older builds, please pick a 3-key combo.',
},
history: {
title: 'Save history',
desc: 'When on, every selection + voice question + AI answer is persisted locally (never uploaded). Off by default — closing the panel forgets the exchange entirely, more private.',
},
howto: {
title: 'How to use',
step1: 'Select text in any app (browser, Mail, IDE, PDF reader…).',
step2: 'Press「{{hotkey}}」— the panel appears at the bottom of the screen and recording starts.',
step3: 'Ask your question into the mic — e.g. "what does this mean", "explain in plain words", "how is it different from X".',
step4: 'Press「{{hotkey}}」again to stop. The system sends your selection + voice question to the LLM.',
step5: 'The answer renders as markdown in the panel. Press Esc or click outside to dismiss; pin (📌, top-right) to keep it open until manually closed.',
windowTitle: 'Panel position + lifecycle',
windowDesc: 'The panel sits 8px above the recording capsule by default, sized 380×280. Auto-closes after 30s of inactivity (unless pinned). It scrolls if the answer is long.',
privacyTitle: 'Privacy contract',
privacyDesc: 'Selected text lives only in memory until the panel closes — it is **never** written to the history archive (Save history toggles only Q&A metadata). Selections over 4000 chars are truncated to head+tail of 2000 each before being sent. LLM calls go through your configured ARK / DeepSeek / OpenAI-compatible endpoint.',
},
},
settings: {
kicker: 'SETTINGS',
title: 'Settings',
@@ -228,11 +258,6 @@ export const en: typeof zhCN = {
capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.',
restoreClipboardLabel: 'Restore clipboard after insert',
restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.',
qaHotkeyLabel: 'Question-mode hotkey',
qaHotkeyDesc: 'Select some text, press this hotkey to open the QA panel and start recording; press again to stop and get the answer. See issue #118.',
qaHotkeyOptionDisabled: 'Disabled',
qaSaveHistoryLabel: 'Save Q&A history',
qaSaveHistoryDesc: 'Persist each question and answer to local storage. Turn off to leave no trace.',
},
providers: {
llmTitle: 'LLM (polishing)',
+30 -5
View File
@@ -47,6 +47,7 @@ export const zhCN = {
vocab: '词汇表',
style: '风格',
translation: '翻译',
selectionAsk: '划词追问',
},
shell: {
shortcutLabel: '录音快捷键',
@@ -198,6 +199,35 @@ export const zhCN = {
fallbackDesc: '翻译模式选「不启用」时 Shift 是没作用的;翻译过程中如果大模型调用失败,会回退到把原始中文转写直接插入,不会丢字。详见 issue #4。',
},
},
selectionAsk: {
kicker: 'SELECTION ASK',
title: '划词追问',
desc: '选中任意 app 里的一段文字,按下快捷键,对着麦克风口头提问,AI 答案会以浮窗显示在屏幕底部录音胶囊正上方。',
statusEnabled: '已启用',
statusDisabled: '未启用',
hotkey: {
title: '触发快捷键',
desc: '选了某组按键后,在任意 app 选中文字时按下,浮窗即可弹出并开始录音;再按一次停止录音并生成答案。选「不启用」则关闭整个功能。',
optionDisabled: '不启用',
chordWarning: '⚠️ 双修饰键 chord(Cmd+Option / Ctrl+Alt)触发条件:两个修饰键同时按下、不按其他键、再松开两个键。优点是按键少;缺点是误触率比 3 键组合高(你想按 Cmd+Option+某字母 但还没按字母时容易触发)。后端 chord 监听需要 v1.2.9+ 才支持,更早版本请选 3 键组合。',
},
history: {
title: '保存历史',
desc: '勾上则把每次追问的「选中文本 + 你的语音问题 + AI 答案」写入本地存档(不上云)。默认关,关闭时浮窗一关问答即遗忘,更注重隐私。',
},
howto: {
title: '使用方法',
step1: '在任意 app(浏览器、Mail、IDE、PDF reader…)选中一段文字。',
step2: '按下「{{hotkey}}」——浮窗在屏幕底部弹出,同时进入语音录音状态。',
step3: '对着麦克风提问——比如"这是什么意思 / 用大白话解释 / 跟 X 有什么区别 / 把它拆成几个要点"。',
step4: '再按一次「{{hotkey}}」停止录音。系统会把「你选中的文字 + 你的语音问题」一起送给大模型生成答案。',
step5: '答案以 markdown 格式显示在浮窗里。读完按 Esc 或点击外部即可关闭;右上角 📌 钉住则会一直保留直到你手动关。',
windowTitle: '浮窗位置 + 生命周期',
windowDesc: '浮窗默认在录音胶囊正上方 8px 处,宽 380×高 280。30 秒未操作自动关闭(钉住的不算)。窗口有自己的滚动条,长答案可以滚动阅读。',
privacyTitle: '隐私契约',
privacyDesc: '选中的文本只在内存里活到浮窗关闭,**绝不**写入历史存档(保存历史开关只控制问答 metadata);超过 4000 字符会截首+尾各 2000 后再上送大模型,避免泄露太多。LLM 调用走你已配的 ARK / DeepSeek 等 OpenAI 兼容 endpoint。',
},
},
settings: {
kicker: 'SETTINGS',
title: '设置',
@@ -226,11 +256,6 @@ export const zhCN = {
capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。',
restoreClipboardLabel: '插入后恢复剪贴板',
restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。',
qaHotkeyLabel: '提问模式快捷键',
qaHotkeyDesc: '选中一段文字后按下此快捷键,弹出问答浮窗并开始录音;再按一次结束录音、生成答案。详见 issue #118。',
qaHotkeyOptionDisabled: '不启用',
qaSaveHistoryLabel: '保存 Q&A 历史',
qaSaveHistoryDesc: '把每次划词问答的问题与答案写入本地存档。关闭则不保留任何痕迹。',
},
providers: {
llmTitle: 'LLM 模型(润色)',
+238
View File
@@ -0,0 +1,238 @@
// SelectionAsk.tsx — 独立的"划词追问"页(issue #118 / PR #119 配置 UI 拆分版)。
// 功能:用户在任意 app 选中一段文字 → 按 hotkey → 浮窗弹出 + 进入语音录音 →
// 用户口述提问 → ASR + 选区 + 提问 一起送 LLM → 答案以 markdown 显示在浮窗。
//
// 这一页把原本散在 Settings → 录音 里的两条配置(hotkey 预设 / 保存 Q&A 历史)
// 集中起来 + 加完整使用指南,跟"翻译"页平级。
import { useTranslation } from 'react-i18next';
import { Card, PageHeader } from './_atoms';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { setQaHotkey } from '../lib/ipc';
import type { QaHotkeyBinding } from '../lib/types';
const QA_HOTKEY_DISABLED_ID = 'disabled' as const;
interface QaHotkeyPreset {
id: string;
binding: QaHotkeyBinding;
label: string;
}
const QA_HOTKEY_PRESETS: readonly QaHotkeyPreset[] = [
// 双修饰键 chord(Cmd+Option / Ctrl+Alt)默认排第一——用户偏好的纯组合键。
// 后端实现需要 CGEventTap 的"双修饰键按下后无其他键插入即释放"模式(待后端补)。
{ id: 'cmd+option', label: 'Cmd+Option', binding: { primary: '', modifiers: ['cmd', 'option'] } },
{ id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } },
{ id: 'cmd+option+;', label: 'Cmd+Option+;', binding: { primary: ';', modifiers: ['cmd', 'option'] } },
{ id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } },
{ id: 'cmd+option+/', label: 'Cmd+Option+/', binding: { primary: '/', modifiers: ['cmd', 'option'] } },
] as const;
function bindingToPresetId(binding: QaHotkeyBinding | null): string {
if (!binding) return QA_HOTKEY_DISABLED_ID;
const sortedMods = [...binding.modifiers].map(m => m.toLowerCase()).sort();
const match = QA_HOTKEY_PRESETS.find(p => {
const pMods = [...p.binding.modifiers].sort();
return p.binding.primary === binding.primary
&& pMods.length === sortedMods.length
&& pMods.every((m, i) => m === sortedMods[i]);
});
return match ? match.id : QA_HOTKEY_PRESETS[0].id;
}
export function SelectionAsk() {
const { t } = useTranslation();
const { prefs, updatePrefs: savePrefs } = useHotkeySettings();
if (!prefs) {
return (
<>
<PageHeader
kicker={t('selectionAsk.kicker')}
title={t('selectionAsk.title')}
desc={t('selectionAsk.desc')}
/>
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
</Card>
</>
);
}
const onHotkeyChange = async (id: string) => {
if (id === QA_HOTKEY_DISABLED_ID) {
await savePrefs({ ...prefs, qaHotkey: null });
return;
}
const preset = QA_HOTKEY_PRESETS.find(p => p.id === id);
if (!preset) return;
try {
await setQaHotkey(preset.binding);
} catch (error) {
console.error('[selectionAsk] failed to set qa hotkey', error);
}
await savePrefs({ ...prefs, qaHotkey: preset.binding });
};
const onSaveHistoryChange = (qaSaveHistory: boolean) =>
savePrefs({ ...prefs, qaSaveHistory });
const enabled = prefs.qaHotkey !== null;
const currentId = bindingToPresetId(prefs.qaHotkey);
const currentLabel = QA_HOTKEY_PRESETS.find(p => p.id === currentId)?.label ?? '';
return (
<>
<PageHeader
kicker={t('selectionAsk.kicker')}
title={t('selectionAsk.title')}
desc={t('selectionAsk.desc')}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* 1. 触发快捷键 */}
<Card>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('selectionAsk.hotkey.title')}</div>
<span
style={{
padding: '2px 8px',
fontSize: 10.5,
fontWeight: 600,
letterSpacing: '0.04em',
borderRadius: 999,
background: enabled ? 'rgba(37,99,235,0.10)' : 'rgba(0,0,0,0.05)',
color: enabled ? 'var(--ol-blue)' : 'var(--ol-ink-4)',
textTransform: 'uppercase',
}}
>
{enabled ? t('selectionAsk.statusEnabled') : t('selectionAsk.statusDisabled')}
</span>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('selectionAsk.hotkey.desc')}
</div>
<select
value={currentId}
onChange={e => onHotkeyChange(e.target.value)}
style={{
width: '100%',
maxWidth: 360,
height: 32,
padding: '0 10px',
fontSize: 13,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: '#fff',
color: 'var(--ol-ink)',
fontFamily: 'inherit',
cursor: 'default',
}}
>
<option value={QA_HOTKEY_DISABLED_ID}>{t('selectionAsk.hotkey.optionDisabled')}</option>
{QA_HOTKEY_PRESETS.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
{currentId === 'cmd+option' && (
<div
style={{
marginTop: 10,
padding: '8px 10px',
borderRadius: 8,
background: 'rgba(217, 119, 6, 0.08)',
border: '0.5px solid rgba(217, 119, 6, 0.2)',
fontSize: 11.5,
color: 'var(--ol-warn)',
lineHeight: 1.55,
}}
>
{t('selectionAsk.hotkey.chordWarning')}
</div>
)}
</Card>
{/* 2. 历史保存 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('selectionAsk.history.title')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('selectionAsk.history.desc')}
</div>
<button
onClick={() => onSaveHistoryChange(!prefs.qaSaveHistory)}
style={{
position: 'relative',
width: 44,
height: 24,
borderRadius: 999,
border: 0,
background: prefs.qaSaveHistory ? 'var(--ol-blue)' : 'rgba(0,0,0,0.18)',
cursor: 'default',
transition: 'background 0.15s ease-out',
padding: 0,
}}
>
<span
style={{
position: 'absolute',
top: 2,
left: prefs.qaSaveHistory ? 22 : 2,
width: 20,
height: 20,
borderRadius: 999,
background: '#fff',
boxShadow: '0 1px 2px rgba(0,0,0,.18)',
transition: 'left .15s',
}}
/>
</button>
</Card>
{/* 3. 使用方法 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>{t('selectionAsk.howto.title')}</div>
<ol style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.7 }}>
<li>{t('selectionAsk.howto.step1')}</li>
<li>{t('selectionAsk.howto.step2', { hotkey: enabled ? currentLabel : '快捷键' })}</li>
<li>{t('selectionAsk.howto.step3')}</li>
<li>{t('selectionAsk.howto.step4', { hotkey: enabled ? currentLabel : '快捷键' })}</li>
<li>{t('selectionAsk.howto.step5')}</li>
</ol>
<div
style={{
marginTop: 14,
padding: '10px 12px',
borderRadius: 10,
background: 'rgba(37,99,235,0.06)',
border: '0.5px solid rgba(37,99,235,0.15)',
fontSize: 11.5,
color: 'var(--ol-ink-2)',
lineHeight: 1.55,
}}
>
<div style={{ fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>{t('selectionAsk.howto.windowTitle')}</div>
{t('selectionAsk.howto.windowDesc')}
</div>
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 10,
background: 'rgba(0,0,0,0.04)',
fontSize: 11.5,
color: 'var(--ol-ink-3)',
lineHeight: 1.55,
}}
>
<div style={{ fontWeight: 600, color: 'var(--ol-ink-2)', marginBottom: 4 }}>{t('selectionAsk.howto.privacyTitle')}</div>
{t('selectionAsk.howto.privacyDesc')}
</div>
</Card>
</div>
</>
);
}
-78
View File
@@ -21,7 +21,6 @@ import {
setActiveAsrProvider,
setActiveLlmProvider,
setCredential,
setQaHotkey,
} from '../lib/ipc';
import type {
HotkeyCapability,
@@ -29,7 +28,6 @@ import type {
HotkeyStatus,
HotkeyTrigger,
PermissionStatus,
QaHotkeyBinding,
} from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import i18n, {
@@ -134,37 +132,6 @@ function SettingRow({ label, desc, children }: SettingRowProps) {
);
}
// QA 提问模式快捷键预设。详见 issue #118。
// id="disabled" 是哨兵 — savePrefs({ qaHotkey: null }) 表示禁用。
const QA_HOTKEY_DISABLED_ID = 'disabled' as const;
interface QaHotkeyPreset {
id: string;
binding: QaHotkeyBinding;
/** UI 上展示的字面值;后端的 get_qa_hotkey_label 仅用作冗余提示,不必一致。 */
label: string;
}
const QA_HOTKEY_PRESETS: readonly QaHotkeyPreset[] = [
{ id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } },
{ id: 'cmd+option+;', label: 'Cmd+Option+;', binding: { primary: ';', modifiers: ['cmd', 'option'] } },
{ id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } },
{ id: 'cmd+option+/', label: 'Cmd+Option+/', binding: { primary: '/', modifiers: ['cmd', 'option'] } },
] as const;
function bindingToPresetId(binding: QaHotkeyBinding | null): string {
if (!binding) return QA_HOTKEY_DISABLED_ID;
const sortedMods = [...binding.modifiers].map(m => m.toLowerCase()).sort();
const match = QA_HOTKEY_PRESETS.find(p => {
const pMods = [...p.binding.modifiers].sort();
return p.binding.primary === binding.primary
&& pMods.length === sortedMods.length
&& pMods.every((m, i) => m === sortedMods[i]);
});
// 后端如果当前绑定是非预设值,UI 兜底回到默认 cmd+shift+;
return match ? match.id : QA_HOTKEY_PRESETS[0].id;
}
function RecordingSection() {
const { t } = useTranslation();
const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings();
@@ -185,24 +152,6 @@ function RecordingSection() {
savePrefs({ ...prefs, showCapsule });
const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
savePrefs({ ...prefs, restoreClipboardAfterPaste });
const onQaHotkeyChange = async (id: string) => {
if (id === QA_HOTKEY_DISABLED_ID) {
await savePrefs({ ...prefs, qaHotkey: null });
return;
}
const preset = QA_HOTKEY_PRESETS.find(p => p.id === id);
if (!preset) return;
// 同步两条路:set_qa_hotkey 让后端立刻重装 hotkey 监听;
// savePrefs 让 UserPreferences 落盘。两个 IPC 由后端决定能否合并。
try {
await setQaHotkey(preset.binding);
} catch (error) {
console.error('[settings] failed to set qa hotkey', error);
}
await savePrefs({ ...prefs, qaHotkey: preset.binding });
};
const onQaSaveHistoryChange = (qaSaveHistory: boolean) =>
savePrefs({ ...prefs, qaSaveHistory });
const choices: Array<[HotkeyMode, string]> = [
['toggle', t('settings.recording.modeToggle')],
@@ -280,33 +229,6 @@ function RecordingSection() {
>
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
</SettingRow>
<SettingRow
label={t('settings.recording.qaHotkeyLabel')}
desc={t('settings.recording.qaHotkeyDesc')}
>
<select
value={bindingToPresetId(prefs.qaHotkey)}
onChange={e => { void onQaHotkeyChange(e.target.value); }}
style={{
...inputStyle,
maxWidth: 220,
fontFamily: 'var(--ol-font-mono)',
}}
>
{QA_HOTKEY_PRESETS.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
<option value={QA_HOTKEY_DISABLED_ID}>
{t('settings.recording.qaHotkeyOptionDisabled')}
</option>
</select>
</SettingRow>
<SettingRow
label={t('settings.recording.qaSaveHistoryLabel')}
desc={t('settings.recording.qaSaveHistoryDesc')}
>
<Toggle on={prefs.qaSaveHistory} onToggle={onQaSaveHistoryChange} />
</SettingRow>
{capability.statusHint && (
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
{capability.statusHint}
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState } from 'react';
export type AppTab = 'overview' | 'history' | 'vocab' | 'style' | 'translation';
export type AppTab = 'overview' | 'history' | 'vocab' | 'style' | 'translation' | 'selectionAsk';
export interface AppState {
currentTab: AppTab;