) => {
if (!recording || disabled || !isModifierKey(e.key)) return;
e.preventDefault();
e.stopPropagation();
+ pressedCodes.current.delete(e.code);
+ if (comboOnly) return;
const primary = modifierPrimaryFromCode(e.code, e.key);
if (primary && pendingModifier.current?.primary === primary) {
const binding = pendingModifier.current;
@@ -137,7 +156,6 @@ export function ShortcutRecorder({
cursor: recording || disabled ? 'default' : 'pointer',
opacity: disabled ? 0.68 : 1,
};
- // 「停用」旋钮:与「录制快捷键」同高、紧贴在它左边,组成两个并排的小旋钮。
const disableKnobStyle: CSSProperties = {
fontSize: 12,
padding: '5px 12px',
@@ -149,13 +167,24 @@ export function ShortcutRecorder({
fontWeight: 500,
cursor: recording ? 'default' : 'pointer',
};
- // 录制按钮(+ 可选停用旋钮)成组靠右,保证「停用」永远贴着「录制」。
const controlsGroupStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 8,
marginLeft: alignRecordButton ? 'auto' : undefined,
};
+ const presetChipStyle: CSSProperties = {
+ fontSize: 11,
+ padding: '4px 10px',
+ borderRadius: 999,
+ border: '0.5px solid var(--ol-line-strong)',
+ background: 'transparent',
+ color: 'var(--ol-ink-3)',
+ fontFamily: 'inherit',
+ cursor: disabled || recording ? 'default' : 'pointer',
+ };
+
+ const presetTriggers = modifierPresets.filter(t => t !== 'custom' && t !== 'mediaPlayPause');
return (
@@ -181,7 +210,7 @@ export function ShortcutRecorder({
if (disabled) return;
setRecording(true);
setError(null);
- clearPendingModifier();
+ resetRecordingState();
}}
disabled={recording || disabled}
style={recordButtonStyle}
@@ -190,6 +219,24 @@ export function ShortcutRecorder({
+ {!comboOnly && presetTriggers.length > 0 && (
+
+
+ {t('settings.recording.modifierPresetsLabel', '常用单键:')}
+
+ {presetTriggers.map(trigger => (
+
+ ))}
+
+ )}
{recording && (
, string> = {
+ rightOption: 'RightOption',
+ leftOption: 'LeftOption',
+ rightControl: 'RightControl',
+ leftControl: 'LeftControl',
+ rightCommand: 'RightCommand',
+ leftCommand: 'LeftCommand',
+ leftShift: 'LeftShift',
+ rightShift: 'RightShift',
+ fn: 'Fn',
+ rightAlt: 'RightOption',
+ mediaPlayPause: 'MediaPlayPause',
+ };
+ return {
+ primary: map[trigger as Exclude] ?? 'RightOption',
+ modifiers: [],
+ };
+}
+
/** 把 ComboBinding 或 QaHotkeyBinding 格式化为可读标签,如 "⌘⇧D" / "Ctrl+Shift+D"。 */
export function formatComboLabel(binding: ComboBinding | QaHotkeyBinding | ShortcutBinding): string {
const parts: string[] = [];
const platform = currentPlatform();
// 固定输出顺序:Ctrl/Cmd → Alt/Option → Shift → Super
- const modifierOrder = ['cmd', 'ctrl', 'alt', 'shift', 'super'] as const;
+ const modifierOrder = [
+ 'cmd-left', 'cmd-right', 'cmd', 'ctrl-left', 'ctrl-right', 'ctrl',
+ 'alt-left', 'alt-right', 'alt', 'shift-left', 'shift-right', 'shift',
+ 'super-left', 'super-right', 'super',
+ ] as const;
for (const tag of modifierOrder) {
if (binding.modifiers.some(m => m.toLowerCase() === tag)) {
- parts.push(modifierDisplayName(tag, platform));
+ parts.push(sideModifierDisplayName(tag, platform));
}
}
@@ -217,7 +247,80 @@ export function currentPlatform(): { isMac: boolean; isWindows: boolean } {
};
}
+/** Build side-specific modifier tags from Web KeyboardEvent.code values. */
+export function sideModifiersFromPressedCodes(codes: Iterable): string[] {
+ const set = codes instanceof Set ? codes : new Set(codes);
+ const modifiers: string[] = [];
+ if (set.has('MetaLeft')) modifiers.push('cmd-left');
+ else if (set.has('MetaRight')) modifiers.push('cmd-right');
+ if (set.has('ControlLeft')) modifiers.push('ctrl-left');
+ else if (set.has('ControlRight')) modifiers.push('ctrl-right');
+ if (set.has('AltLeft')) modifiers.push('alt-left');
+ else if (set.has('AltRight')) modifiers.push('alt-right');
+ if (set.has('ShiftLeft')) modifiers.push('shift-left');
+ else if (set.has('ShiftRight')) modifiers.push('shift-right');
+ return modifiers;
+}
+
+/** Build generic modifier tags (cmd/super/ctrl/alt/shift) from pressed key codes. */
+export function genericModifiersFromPressedCodes(codes: Iterable): string[] {
+ const set = codes instanceof Set ? codes : new Set(codes);
+ const modifiers: string[] = [];
+ const { isMac } = currentPlatform();
+ if (isMac) {
+ if (set.has('MetaLeft') || set.has('MetaRight')) modifiers.push('cmd');
+ } else if (
+ set.has('MetaLeft')
+ || set.has('MetaRight')
+ || set.has('OSLeft')
+ || set.has('OSRight')
+ ) {
+ modifiers.push('super');
+ }
+ if (set.has('ControlLeft') || set.has('ControlRight')) modifiers.push('ctrl');
+ if (set.has('AltLeft') || set.has('AltRight')) modifiers.push('alt');
+ if (set.has('ShiftLeft') || set.has('ShiftRight')) modifiers.push('shift');
+ return modifiers;
+}
+
+export function modifiersFromPressedCodes(
+ codes: Iterable,
+ sideSpecific = false,
+): string[] {
+ return sideSpecific
+ ? sideModifiersFromPressedCodes(codes)
+ : genericModifiersFromPressedCodes(codes);
+}
+
function modifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string {
+ return sideModifierDisplayName(tag, platform);
+}
+
+function sideModifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string {
+ const sideLabels: Record = platform.isMac
+ ? {
+ 'cmd-left': '左 ⌘',
+ 'cmd-right': '右 ⌘',
+ 'ctrl-left': '左 ⌃',
+ 'ctrl-right': '右 ⌃',
+ 'alt-left': '左 ⌥',
+ 'alt-right': '右 ⌥',
+ 'shift-left': '左 ⇧',
+ 'shift-right': '右 ⇧',
+ }
+ : {
+ 'cmd-left': platform.isWindows ? '左 Win' : '左 Super',
+ 'cmd-right': platform.isWindows ? '右 Win' : '右 Super',
+ 'ctrl-left': '左 Ctrl',
+ 'ctrl-right': '右 Ctrl',
+ 'alt-left': '左 Alt',
+ 'alt-right': '右 Alt',
+ 'shift-left': '左 Shift',
+ 'shift-right': '右 Shift',
+ 'super-left': platform.isWindows ? '左 Win' : '左 Super',
+ 'super-right': platform.isWindows ? '右 Win' : '右 Super',
+ };
+ if (sideLabels[tag]) return sideLabels[tag];
if (platform.isMac) {
switch (tag) {
case 'cmd': return '\u2318';
@@ -274,6 +377,9 @@ function formatPrimary(primary: string): string {
case 'rightcontrol': return isMac ? 'Right ⌃' : 'Right Ctrl';
case 'leftcontrol': return isMac ? 'Left ⌃' : 'Left Ctrl';
case 'rightcommand': return isMac ? 'Right ⌘' : (currentPlatform().isWindows ? 'Right Win' : 'Right Super');
+ case 'leftcommand': return isMac ? 'Left ⌘' : (currentPlatform().isWindows ? 'Left Win' : 'Left Super');
+ case 'leftshift': return isMac ? 'Left ⇧' : 'Left Shift';
+ case 'rightshift': return isMac ? 'Right ⇧' : 'Right Shift';
case 'fn': return 'Fn';
case 'mediaplaypause': return '⏯ Media';
case 'shift': return isMac ? '⇧' : 'Shift';
diff --git a/openless-all/app/src/lib/hotkeySideModifiers.test.ts b/openless-all/app/src/lib/hotkeySideModifiers.test.ts
new file mode 100644
index 00000000..7b9e85ce
--- /dev/null
+++ b/openless-all/app/src/lib/hotkeySideModifiers.test.ts
@@ -0,0 +1,75 @@
+import {
+ genericModifiersFromPressedCodes,
+ modifiersFromPressedCodes,
+ shortcutFromLegacyTrigger,
+ sideModifiersFromPressedCodes,
+} from './hotkey';
+
+function assertEqual(actual: T, expected: T, message: string) {
+ if (actual !== expected) {
+ throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
+ }
+}
+
+function assertDeepEqual(actual: string[], expected: string[], message: string) {
+ assertEqual(actual.join(','), expected.join(','), message);
+}
+
+function mockNavigator(platform: string, userAgent = '') {
+ Object.defineProperty(globalThis, 'navigator', {
+ value: { platform, userAgent },
+ configurable: true,
+ });
+}
+
+assertEqual(
+ shortcutFromLegacyTrigger('leftCommand').primary,
+ 'LeftCommand',
+ 'maps leftCommand trigger primary',
+);
+
+assertDeepEqual(
+ sideModifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])),
+ ['cmd-left', 'shift-right'],
+ 'MetaLeft+ShiftRight produces side tags',
+);
+
+assertDeepEqual(
+ modifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight']), true),
+ ['cmd-left', 'shift-right'],
+ 'side-specific mode uses side tags',
+);
+
+mockNavigator('MacIntel', 'Macintosh');
+assertDeepEqual(
+ genericModifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])),
+ ['cmd', 'shift'],
+ 'macOS generic mode maps Meta to cmd',
+);
+
+mockNavigator('Win32', 'Windows');
+assertDeepEqual(
+ genericModifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])),
+ ['super', 'shift'],
+ 'non-mac generic mode maps Meta to super',
+);
+
+assertDeepEqual(
+ modifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])),
+ ['super', 'shift'],
+ 'default recording on non-mac uses super',
+);
+
+assertDeepEqual(
+ sideModifiersFromPressedCodes(new Set(['MetaRight', 'KeyD'])),
+ ['cmd-right'],
+ 'MetaRight+D binding uses cmd-right only',
+);
+
+assertDeepEqual(
+ sideModifiersFromPressedCodes(new Set(['MetaLeft', 'MetaRight'])),
+ ['cmd-left'],
+ 'left cmd wins when both meta keys are tracked',
+);
+
+console.log('hotkeySideModifiers.test.ts passed');
diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts
index 17b52bd7..10793ce2 100644
--- a/openless-all/app/src/lib/types.ts
+++ b/openless-all/app/src/lib/types.ts
@@ -81,6 +81,9 @@ export type HotkeyTrigger =
| 'rightControl'
| 'leftControl'
| 'rightCommand'
+ | 'leftCommand'
+ | 'leftShift'
+ | 'rightShift'
| 'fn'
| 'rightAlt'
| 'mediaPlayPause'
@@ -125,9 +128,9 @@ export interface HotkeyStatus {
}
export interface ShortcutBinding {
- /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "Shift" */
+ /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "LeftShift" */
primary: string;
- /** 修饰符列表,元素小写:"cmd" | "shift" | "alt" | "ctrl"。 */
+ /** 修饰符:泛化 tag(cmd/ctrl/…)或侧别 tag(cmd-left/ctrl-right/…)。 */
modifiers: string[];
}
diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx
index c66530a9..9d156e8d 100644
--- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx
+++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx
@@ -180,6 +180,8 @@ export function RecordingInputSection() {
{
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 0b8e9f8b..063d14b4 100644
--- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx
+++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx
@@ -66,6 +66,8 @@ export function ShortcutsSection() {
{
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });