diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 914139b4..e9e34ff6 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -1,41 +1,76 @@ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { ChevronDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { formatComboParts, getHotkeyTriggerLabel, modifiersFromPressedCodes, shortcutFromLegacyTrigger } from '../lib/hotkey'; +import { formatComboParts, modifiersFromPressedCodes } from '../lib/hotkey'; import { KbdGroup } from './Kbd'; import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc'; -import type { HotkeyTrigger, ShortcutBinding } from '../lib/types'; +import type { ShortcutBinding } from '../lib/types'; + +/** 主行与「正在录入」面板切换时的水平滑动距离(px)。 */ +const SLIDE_DISTANCE = 48; +/** 下拉菜单展开后的固定高度(px):菜单按钮行高恒定,用固定值动画避免每次测量。 */ +const MENU_HEIGHT = 34; +/** 滑动切换用 spring(与 Style.tsx 编辑抽屉同款)。只动 transform/opacity,不驱动布局,避免抽搐。 */ +const slideSpring = { type: 'spring' as const, damping: 26, stiffness: 280 }; +/** 下拉菜单展开/收起缓动,与 --ol-motion-soft 一致。 */ +const menuEase = [0.22, 0.8, 0.22, 1] as const; export function ShortcutRecorder({ value, onSave, - alignRecordButton = false, disabled = false, onDisable, disableLabel, + disableDisabled = false, + disableHint, + onReset, + resetLabel, comboOnly = false, - modifierPresets = [], sideSpecificModifiers = false, }: { - value: ShortcutBinding; + value: ShortcutBinding | null; onSave: (binding: ShortcutBinding) => Promise; - alignRecordButton?: boolean; disabled?: boolean; - /** 提供则在「录制」按钮左侧并排渲染一个「停用」旋钮。 */ + /** 提供则下拉菜单里的「停用」可点(问答/切风格等可停用快捷键)。 */ onDisable?: () => void | Promise; disableLabel?: string; + /** 置灰「停用」(核心快捷键不可停用),配合 disableHint 展示原因。 */ + disableDisabled?: boolean; + disableHint?: string; + /** 提供则下拉菜单里渲染「重置」——恢复该快捷键的默认绑定。 */ + onReset?: () => void | Promise; + resetLabel?: string; /** 仅允许组合键(修饰键+主键 / 功能键);拒绝单修饰键,因为全局热键无法注册它。 */ comboOnly?: boolean; - /** 平台可用的单修饰键快捷选项(如 Fn,WebView 无法录制)。 */ - modifierPresets?: HotkeyTrigger[]; /** 听写 start/stop 专用:录制 cmd-left / ctrl-right 等侧向修饰键。 */ sideSpecificModifiers?: boolean; }) { const { t } = useTranslation(); const [recording, setRecording] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); const [error, setError] = useState(null); const pendingModifier = useRef(null); const pendingTimer = useRef(null); const pressedCodes = useRef>(new Set()); + const rootRef = useRef(null); + + // 菜单打开时:Esc 或点击菜单外部即收起,避免菜单"赖着不关"。 + useEffect(() => { + if (!menuOpen) return; + const onKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape') setMenuOpen(false); + }; + const onPointerDown = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false); + }; + window.addEventListener('keydown', onKeyDown); + window.addEventListener('mousedown', onPointerDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('mousedown', onPointerDown); + }; + }, [menuOpen]); const clearPressedCodes = () => { pressedCodes.current.clear(); @@ -84,6 +119,15 @@ export function ShortcutRecorder({ } }; + /** 开始录入:同时收起菜单——「录制快捷键」按下后,重置/停用两个按钮随之消失。 */ + const startRecording = () => { + if (disabled || recording) return; + setMenuOpen(false); + setError(null); + resetRecordingState(); + setRecording(true); + }; + const onKeyDown = (e: KeyboardEvent) => { if (!recording || disabled) return; e.preventDefault(); @@ -132,123 +176,204 @@ export function ShortcutRecorder({ } }; + const doReset = () => { + setMenuOpen(false); + setError(null); + if (onReset) void onReset(); + }; + + const doDisable = () => { + setMenuOpen(false); + setError(null); + if (onDisable) void onDisable(); + }; + + // 「停用」可点:有 onDisable 且未被置灰(录音快捷键置灰,核心热键不可停用)。 + const canDisable = Boolean(onDisable) && !disableDisabled; + const rootStyle: CSSProperties = { display: 'flex', flexDirection: 'column', gap: 6, - width: alignRecordButton ? '100%' : undefined, + width: '100%', }; const recorderRowStyle: CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', - width: alignRecordButton ? '100%' : undefined, + width: '100%', }; - const recordButtonStyle: CSSProperties = { - fontSize: 12, - padding: '5px 14px', - background: recording ? 'rgba(37,99,235,0.12)' : 'var(--ol-blue)', - color: recording ? 'var(--ol-blue)' : '#fff', + const chevronButtonStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 26, + height: 26, + padding: 0, border: 0, borderRadius: 6, - fontFamily: 'inherit', - fontWeight: 500, - cursor: recording || disabled ? 'default' : 'pointer', - opacity: disabled ? 0.68 : 1, - }; - const disableKnobStyle: CSSProperties = { - fontSize: 12, - padding: '5px 12px', background: 'transparent', color: 'var(--ol-ink-4)', - border: '0.5px solid var(--ol-line-strong)', - borderRadius: 6, fontFamily: 'inherit', - fontWeight: 500, - cursor: recording ? 'default' : 'pointer', + cursor: 'pointer', + transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', }; const controlsGroupStyle: CSSProperties = { display: 'inline-flex', alignItems: 'center', - gap: 8, - marginLeft: alignRecordButton ? 'auto' : undefined, + gap: 4, + marginLeft: 'auto', }; - const presetChipStyle: CSSProperties = { - fontSize: 11, - padding: '4px 10px', - borderRadius: 999, + const menuButtonStyle: CSSProperties = { + fontSize: 12, + padding: '5px 12px', border: '0.5px solid var(--ol-line-strong)', + borderRadius: 6, background: 'transparent', - color: 'var(--ol-ink-3)', + color: 'var(--ol-ink-2)', fontFamily: 'inherit', - cursor: disabled || recording ? 'default' : 'pointer', + fontWeight: 500, + cursor: 'pointer', + transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', + }; + const menuPrimaryStyle: CSSProperties = { + ...menuButtonStyle, + background: 'rgba(37,99,235,0.08)', + borderColor: 'rgba(37,99,235,0.25)', + color: 'var(--ol-blue)', + }; + const disabledMenuButtonStyle: CSSProperties = { + ...menuButtonStyle, + opacity: 0.45, + cursor: 'default', + }; + const menuRowStyle: CSSProperties = { + display: 'flex', + gap: 8, + paddingTop: 2, }; - - const presetTriggers = modifierPresets.filter(t => t !== 'custom' && t !== 'mediaPlayPause'); return ( -
-
- {/* 键帽逐键展示(Kbd 组件,用户拍板的快捷键展示标准),替代整块灰底文本。 */} - -
- {onDisable && ( - - )} - -
-
- {!comboOnly && presetTriggers.length > 0 && ( -
- - {t('settings.recording.modifierPresetsLabel', '常用单键:')} - - {presetTriggers.map(trigger => ( - - ))} -
- )} - {recording && ( -
el?.focus()} - > - {t('settings.recording.comboRecordHint')} -
Esc 取消
-
- )} + {t('settings.recording.comboRecordHint')} +
Esc 取消
+ + ) : ( + +
+ {/* 键帽逐键展示(Kbd 组件,用户拍板的快捷键展示标准),替代整块灰底文本。 + 录入入口在展开菜单里(录制快捷键);主行只留箭头,统一靠最右。 */} + {value && } +
+ setMenuOpen(open => !open)} + aria-label={t('settings.recording.comboMenuToggle', 'More options')} + aria-expanded={menuOpen} + title={t('settings.recording.comboMenuToggle', 'More options')} + style={chevronButtonStyle} + > + + + + +
+
+ {/* 下拉菜单:整个板块向下展开,出现 录制快捷键 / 重置 / 停用 三个按钮。 + 高度用固定值动画(菜单内容高度恒定),避免 'auto' 每次测量带来的卡顿。 */} + + {menuOpen && ( + +
+ + {t('settings.recording.comboRecordBtn')} + + {onReset && ( + + {resetLabel ?? t('settings.recording.comboResetBtn', 'Reset')} + + )} + + {disableLabel ?? t('settings.shortcuts.disable', 'Disable')} + +
+
+ )} +
+
+ )} + {error &&
{error}
}
); diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 8f5ec75c..95d94651 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -26,6 +26,12 @@ export function defaultLessComputerShortcut(): ShortcutBinding { return { primary: 'LeftControl', modifiers: [] }; } +// 默认录音快捷键:右侧 Control。与 mock-data 默认值 / Rust legacy trigger +// (shortcut_binding.rs 的 rightControl 映射)保持一致。 +export function defaultDictationHotkey(): ShortcutBinding { + return { primary: 'RightControl', modifiers: [] }; +} + export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string { if (!trigger) return i18n.t('hotkey.fallback'); if (trigger === 'custom') return i18n.t('hotkey.triggers.custom'); diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 52f9538e..5064542f 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ShortcutRecorder } from '../../components/ShortcutRecorder'; import { playRecordStartCue } from '../../lib/audioCue'; +import { defaultDictationHotkey } from '../../lib/hotkey'; import { emitSaved } from '../../lib/savedEvent'; import { isHotkeyModeMigrationNoticeActive } from '../../lib/hotkeyMigration'; import { @@ -224,12 +225,19 @@ export function RecordingInputSection() { { await setDictationHotkey(binding); await savePrefs({ ...prefs, dictationHotkey: binding }); }} + onReset={async () => { + const binding = defaultDictationHotkey(); + await setDictationHotkey(binding); + await savePrefs({ ...prefs, dictationHotkey: binding }); + }} /> )} diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index e5ff9e59..5efa16b3 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -1,7 +1,6 @@ // 快捷键设置:开始/停止、翻译、问答、切风格、唤起 App、以及只读取消/确认提示。 import { useEffect, useState } from 'react'; -import type { CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { ShortcutRecorder } from '../../components/ShortcutRecorder'; import { @@ -25,30 +24,17 @@ import { Card } from '../_atoms'; import { SettingRow } from './shared'; import { detectOS } from '../../components/WindowChrome'; -const enableBtnStyle: CSSProperties = { - alignSelf: 'flex-start', - fontSize: 12, - padding: '5px 14px', - background: 'var(--ol-blue)', - color: '#fff', - border: 0, - borderRadius: 6, - fontFamily: 'inherit', - fontWeight: 500, - cursor: 'pointer', -}; - export function ShortcutsSection() { const { t } = useTranslation(); const os = detectOS(); - const { prefs, hotkey, capability, updatePrefs: savePrefs } = useHotkeySettings(); + const { prefs, hotkey, updatePrefs: savePrefs } = useHotkeySettings(); const [platformCaps, setPlatformCaps] = useState(null); useEffect(() => { void getPlatformCapabilities().then(setPlatformCaps); }, []); - if (!prefs || !hotkey || !capability) { + if (!prefs || !hotkey) { return (
{t('common.loading')}
@@ -73,9 +59,10 @@ export function ShortcutsSection() {
{ await setDictationHotkey(binding); await savePrefs({ ...prefs, dictationHotkey: binding }); @@ -89,7 +76,6 @@ export function ShortcutsSection() { { await setTranslationHotkey(binding); await savePrefs({ ...prefs, translationHotkey: binding }); @@ -97,113 +83,73 @@ export function ShortcutsSection() { /> - {prefs.qaHotkey ? ( - { - await setQaHotkey(binding); - await savePrefs({ ...prefs, qaHotkey: binding }); - }} - onDisable={async () => { - await setQaHotkey(null); - await savePrefs({ ...prefs, qaHotkey: null }); - }} - /> - ) : ( - - )} + { + await setQaHotkey(binding); + await savePrefs({ ...prefs, qaHotkey: binding }); + }} + onDisable={async () => { + await setQaHotkey(null); + await savePrefs({ ...prefs, qaHotkey: null }); + }} + onReset={async () => { + const binding = defaultQaShortcut(); + await setQaHotkey(binding); + await savePrefs({ ...prefs, qaHotkey: binding }); + }} + /> - {prefs.switchStyleHotkey ? ( - { - await setSwitchStyleHotkey(binding); - await savePrefs({ ...prefs, switchStyleHotkey: binding }); - }} - onDisable={async () => { - await setSwitchStyleHotkey(null); - await savePrefs({ ...prefs, switchStyleHotkey: null }); - }} - /> - ) : ( - - )} + { + await setSwitchStyleHotkey(binding); + await savePrefs({ ...prefs, switchStyleHotkey: binding }); + }} + onDisable={async () => { + await setSwitchStyleHotkey(null); + await savePrefs({ ...prefs, switchStyleHotkey: null }); + }} + onReset={async () => { + const binding = defaultSwitchStyleShortcut(); + await setSwitchStyleHotkey(binding); + await savePrefs({ ...prefs, switchStyleHotkey: binding }); + }} + /> - {prefs.openAppHotkey ? ( - { - await setOpenAppHotkey(binding); - await savePrefs({ ...prefs, openAppHotkey: binding }); - }} - onDisable={async () => { - await setOpenAppHotkey(null); - await savePrefs({ ...prefs, openAppHotkey: null }); - }} - /> - ) : ( - - )} + { + await setOpenAppHotkey(binding); + await savePrefs({ ...prefs, openAppHotkey: binding }); + }} + onDisable={async () => { + await setOpenAppHotkey(null); + await savePrefs({ ...prefs, openAppHotkey: null }); + }} + onReset={async () => { + const binding = defaultOpenAppShortcut(); + await setOpenAppHotkey(binding); + await savePrefs({ ...prefs, openAppHotkey: binding }); + }} + /> {os === 'mac' && ( - {prefs.codingAgentEnabled && prefs.codingAgentVoiceHotkey ? ( - { - await savePrefs({ ...prefs, codingAgentVoiceHotkey: binding }); - }} - onDisable={async () => { - await savePrefs({ ...prefs, codingAgentVoiceHotkey: null }); - }} - /> - ) : ( - - )} + { + await savePrefs({ ...prefs, codingAgentEnabled: true, codingAgentVoiceHotkey: binding }); + }} + onDisable={async () => { + await savePrefs({ ...prefs, codingAgentVoiceHotkey: null }); + }} + onReset={async () => { + await savePrefs({ ...prefs, codingAgentEnabled: true, codingAgentVoiceHotkey: defaultLessComputerShortcut() }); + }} + /> )} {readonlyRows.map(([k, v]) => (