feat: 快捷键整体重构优化

This commit is contained in:
alger
2026-03-04 20:28:38 +08:00
parent 36917a979d
commit 19092647d1
16 changed files with 2168 additions and 663 deletions

1
.gitignore vendored
View File

@@ -32,6 +32,7 @@ android/app/release
.cursor
.windsurf
.agent
.agents
.claude
.kiro
CLAUDE.md

View File

@@ -383,28 +383,61 @@ export default {
title: 'Shortcut Settings',
shortcut: 'Shortcut',
shortcutDesc: 'Customize global shortcuts',
summaryReady: 'Shortcut configuration is ready to save',
summaryRecording: 'Recording a new shortcut combination',
summaryBlocked: 'Fix conflicts or invalid entries before saving',
platformHintMac: 'On macOS, CommandOrControl is displayed as Cmd',
platformHintWindows: 'On Windows, CommandOrControl is displayed as Ctrl',
platformHintLinux: 'On Linux, CommandOrControl is displayed as Ctrl',
platformHintGeneric: 'CommandOrControl is adapted per operating system',
enabledCount: 'Enabled',
recordingTip: 'Click a shortcut field, press combination. Esc cancels, Delete disables',
shortcutConflict: 'Shortcut Conflict',
inputPlaceholder: 'Click to input shortcut',
clickToRecord: 'Click then press a shortcut',
recording: 'Recording...',
resetShortcuts: 'Reset',
restoreSingle: 'Restore',
disableAll: 'Disable All',
enableAll: 'Enable All',
groups: {
playback: 'Playback',
sound: 'Volume & Favorite',
window: 'Window'
},
togglePlay: 'Play/Pause',
togglePlayDesc: 'Toggle current playback state',
prevPlay: 'Previous',
prevPlayDesc: 'Play the previous track',
nextPlay: 'Next',
nextPlayDesc: 'Play the next track',
volumeUp: 'Volume Up',
volumeUpDesc: 'Increase player volume',
volumeDown: 'Volume Down',
volumeDownDesc: 'Decrease player volume',
toggleFavorite: 'Favorite/Unfavorite',
toggleFavoriteDesc: 'Favorite or unfavorite current track',
toggleWindow: 'Show/Hide Window',
toggleWindowDesc: 'Quickly show or hide the main window',
scopeGlobal: 'Global',
scopeApp: 'App Only',
enabled: 'Enabled',
disabled: 'Disabled',
issueInvalid: 'Invalid combo',
issueReserved: 'System reserved',
registrationWarningTitle: 'These shortcuts could not be registered',
registrationOccupied: 'Occupied by system or another app',
registrationInvalid: 'Invalid shortcut format',
messages: {
resetSuccess: 'Shortcuts reset successfully, please save',
conflict: 'Shortcut conflict, please reset',
saveSuccess: 'Shortcuts saved successfully',
saveError: 'Failed to save shortcuts',
saveValidationError: 'Shortcut validation failed, please review and try again',
partialRegistered: 'Saved, but some global shortcuts were not registered',
cancelEdit: 'Edit cancelled',
clearToDisable: 'Shortcut disabled',
invalidShortcut: 'Invalid shortcut, please use a valid combination',
disableAll: 'All shortcuts disabled, please save to apply',
enableAll: 'All shortcuts enabled, please save to apply'
}

View File

@@ -382,28 +382,61 @@ export default {
title: 'ショートカット設定',
shortcut: 'ショートカット',
shortcutDesc: 'ショートカットをカスタマイズ',
summaryReady: 'ショートカット設定は保存可能です',
summaryRecording: '新しいショートカットを記録中です',
summaryBlocked: '競合または無効な項目を修正してください',
platformHintMac: 'macOS では CommandOrControl は Cmd と表示されます',
platformHintWindows: 'Windows では CommandOrControl は Ctrl と表示されます',
platformHintLinux: 'Linux では CommandOrControl は Ctrl と表示されます',
platformHintGeneric: 'CommandOrControl はOSに応じて自動変換されます',
enabledCount: '有効',
recordingTip: '欄をクリックしてキー入力。Escでキャンセル、Deleteで無効化',
shortcutConflict: 'ショートカットの競合',
inputPlaceholder: 'クリックしてショートカットを入力',
clickToRecord: 'クリックしてキーを入力',
recording: '記録中...',
resetShortcuts: 'デフォルトに戻す',
restoreSingle: '復元',
disableAll: 'すべて無効',
enableAll: 'すべて有効',
groups: {
playback: '再生操作',
sound: '音量とお気に入り',
window: 'ウィンドウ'
},
togglePlay: '再生/一時停止',
togglePlayDesc: '現在の再生状態を切り替えます',
prevPlay: '前の曲',
prevPlayDesc: '前の曲に切り替えます',
nextPlay: '次の曲',
nextPlayDesc: '次の曲に切り替えます',
volumeUp: '音量を上げる',
volumeUpDesc: 'プレイヤー音量を上げます',
volumeDown: '音量を下げる',
volumeDownDesc: 'プレイヤー音量を下げます',
toggleFavorite: 'お気に入り/お気に入り解除',
toggleFavoriteDesc: '現在の曲をお気に入り切り替えします',
toggleWindow: 'ウィンドウ表示/非表示',
toggleWindowDesc: 'メインウィンドウを表示/非表示にします',
scopeGlobal: 'グローバル',
scopeApp: 'アプリ内',
enabled: '有効',
disabled: '無効',
issueInvalid: '無効な組み合わせ',
issueReserved: 'システム予約',
registrationWarningTitle: '以下のショートカットは登録できませんでした',
registrationOccupied: 'システムまたは他アプリで使用中',
registrationInvalid: 'ショートカット形式が無効',
messages: {
resetSuccess: 'デフォルトのショートカットに戻しました。保存を忘れずに',
conflict: '競合するショートカットがあります。再設定してください',
saveSuccess: 'ショートカット設定を保存しました',
saveError: 'ショートカットの保存に失敗しました。再試行してください',
saveValidationError: 'ショートカット検証に失敗しました。内容を確認してください',
partialRegistered: '保存しましたが、一部のグローバルショートカットは登録されませんでした',
cancelEdit: '変更をキャンセルしました',
clearToDisable: 'このショートカットを無効にしました',
invalidShortcut: '無効なショートカットです。有効な組み合わせを入力してください',
disableAll: 'すべてのショートカットを無効にしました。保存を忘れずに',
enableAll: 'すべてのショートカットを有効にしました。保存を忘れずに'
}

View File

@@ -383,28 +383,61 @@ export default {
title: '단축키 설정',
shortcut: '단축키',
shortcutDesc: '단축키 사용자 정의',
summaryReady: '단축키 구성이 저장 가능한 상태입니다',
summaryRecording: '새 단축키 조합을 입력 중입니다',
summaryBlocked: '충돌 또는 잘못된 항목을 먼저 수정하세요',
platformHintMac: 'macOS에서는 CommandOrControl이 Cmd로 표시됩니다',
platformHintWindows: 'Windows에서는 CommandOrControl이 Ctrl로 표시됩니다',
platformHintLinux: 'Linux에서는 CommandOrControl이 Ctrl로 표시됩니다',
platformHintGeneric: 'CommandOrControl은 운영체제에 맞게 자동 변환됩니다',
enabledCount: '활성화됨',
recordingTip: '필드를 클릭 후 조합키 입력, Esc 취소, Delete 비활성화',
shortcutConflict: '단축키 충돌',
inputPlaceholder: '클릭하여 단축키 입력',
clickToRecord: '클릭 후 단축키 입력',
recording: '입력 중...',
resetShortcuts: '기본값 복원',
restoreSingle: '복원',
disableAll: '모두 비활성화',
enableAll: '모두 활성화',
groups: {
playback: '재생 제어',
sound: '볼륨 및 즐겨찾기',
window: '창 제어'
},
togglePlay: '재생/일시정지',
togglePlayDesc: '현재 재생 상태를 전환합니다',
prevPlay: '이전 곡',
prevPlayDesc: '이전 곡으로 이동합니다',
nextPlay: '다음 곡',
nextPlayDesc: '다음 곡으로 이동합니다',
volumeUp: '볼륨 증가',
volumeUpDesc: '플레이어 볼륨을 높입니다',
volumeDown: '볼륨 감소',
volumeDownDesc: '플레이어 볼륨을 낮춥니다',
toggleFavorite: '즐겨찾기/즐겨찾기 취소',
toggleFavoriteDesc: '현재 곡 즐겨찾기를 전환합니다',
toggleWindow: '창 표시/숨기기',
toggleWindowDesc: '메인 창을 빠르게 표시/숨김합니다',
scopeGlobal: '전역',
scopeApp: '앱 내',
enabled: '활성화',
disabled: '비활성화',
issueInvalid: '잘못된 조합',
issueReserved: '시스템 예약',
registrationWarningTitle: '다음 단축키는 등록되지 않았습니다',
registrationOccupied: '시스템 또는 다른 앱에서 사용 중',
registrationInvalid: '단축키 형식이 잘못됨',
messages: {
resetSuccess: '기본 단축키로 복원되었습니다. 저장을 잊지 마세요',
conflict: '충돌하는 단축키가 있습니다. 다시 설정하세요',
saveSuccess: '단축키 설정이 저장되었습니다',
saveError: '단축키 저장 실패, 다시 시도하세요',
saveValidationError: '단축키 검증에 실패했습니다. 설정을 확인하세요',
partialRegistered: '저장되었지만 일부 전역 단축키는 등록되지 않았습니다',
cancelEdit: '수정이 취소되었습니다',
clearToDisable: '해당 단축키가 비활성화되었습니다',
invalidShortcut: '잘못된 단축키입니다. 유효한 조합을 입력하세요',
disableAll: '모든 단축키가 비활성화되었습니다. 저장을 잊지 마세요',
enableAll: '모든 단축키가 활성화되었습니다. 저장을 잊지 마세요'
}

View File

@@ -380,28 +380,61 @@ export default {
title: '快捷键设置',
shortcut: '快捷键',
shortcutDesc: '自定义快捷键',
summaryReady: '当前快捷键配置可保存',
summaryRecording: '正在录制新的快捷键组合',
summaryBlocked: '存在冲突或无效项,请先修正',
platformHintMac: 'macOS 下 CommandOrControl 会显示为 Cmd',
platformHintWindows: 'Windows 下 CommandOrControl 会显示为 Ctrl',
platformHintLinux: 'Linux 下 CommandOrControl 会显示为 Ctrl',
platformHintGeneric: '不同系统下 CommandOrControl 会自动适配',
enabledCount: '已启用',
recordingTip: '点击快捷键框后按下组合键Esc 取消Delete 可禁用该项',
shortcutConflict: '快捷键冲突',
inputPlaceholder: '点击输入快捷键',
clickToRecord: '点击后按下组合键',
recording: '录制中...',
resetShortcuts: '恢复默认',
restoreSingle: '恢复',
disableAll: '全部禁用',
enableAll: '全部启用',
groups: {
playback: '播放控制',
sound: '音量与收藏',
window: '窗口控制'
},
togglePlay: '播放/暂停',
togglePlayDesc: '切换当前歌曲播放状态',
prevPlay: '上一首',
prevPlayDesc: '切换到上一首歌曲',
nextPlay: '下一首',
nextPlayDesc: '切换到下一首歌曲',
volumeUp: '音量增加',
volumeUpDesc: '提高播放器音量',
volumeDown: '音量减少',
volumeDownDesc: '降低播放器音量',
toggleFavorite: '收藏/取消收藏',
toggleFavoriteDesc: '收藏或取消当前歌曲',
toggleWindow: '显示/隐藏窗口',
toggleWindowDesc: '快速显示或隐藏主窗口',
scopeGlobal: '全局',
scopeApp: '应用内',
enabled: '启用',
disabled: '禁用',
issueInvalid: '组合无效',
issueReserved: '系统保留',
registrationWarningTitle: '以下快捷键未能注册,请更换组合后重试',
registrationOccupied: '被系统或其他应用占用',
registrationInvalid: '键位格式无效',
messages: {
resetSuccess: '已恢复默认快捷键,请记得保存',
conflict: '存在冲突的快捷键,请重新设置',
saveSuccess: '快捷键设置已保存',
saveError: '保存快捷键失败,请重试',
saveValidationError: '快捷键校验未通过,请检查后重试',
partialRegistered: '已保存,但部分全局快捷键未注册成功',
cancelEdit: '已取消修改',
clearToDisable: '已禁用该快捷键',
invalidShortcut: '快捷键无效,请输入有效组合',
disableAll: '已禁用所有快捷键,请记得保存',
enableAll: '已启用所有快捷键,请记得保存'
}

View File

@@ -377,28 +377,61 @@ export default {
title: '快捷鍵設定',
shortcut: '快捷鍵',
shortcutDesc: '自訂快捷鍵',
summaryReady: '目前快捷鍵設定可直接儲存',
summaryRecording: '正在錄製新的快捷鍵組合',
summaryBlocked: '存在衝突或無效項目,請先修正',
platformHintMac: 'macOS 下 CommandOrControl 會顯示為 Cmd',
platformHintWindows: 'Windows 下 CommandOrControl 會顯示為 Ctrl',
platformHintLinux: 'Linux 下 CommandOrControl 會顯示為 Ctrl',
platformHintGeneric: 'CommandOrControl 會依系統自動適配',
enabledCount: '已啟用',
recordingTip: '點擊快捷鍵欄位後輸入組合鍵Esc 取消Delete 可停用',
shortcutConflict: '快捷鍵衝突',
inputPlaceholder: '點擊輸入快捷鍵',
clickToRecord: '點擊後輸入快捷鍵',
recording: '錄製中...',
resetShortcuts: '恢復預設',
restoreSingle: '恢復',
disableAll: '全部停用',
enableAll: '全部啟用',
groups: {
playback: '播放控制',
sound: '音量與收藏',
window: '視窗控制'
},
togglePlay: '播放/暫停',
togglePlayDesc: '切換目前歌曲播放狀態',
prevPlay: '上一首',
prevPlayDesc: '切換到上一首歌曲',
nextPlay: '下一首',
nextPlayDesc: '切換到下一首歌曲',
volumeUp: '增加音量',
volumeUpDesc: '提高播放器音量',
volumeDown: '減少音量',
volumeDownDesc: '降低播放器音量',
toggleFavorite: '收藏/取消收藏',
toggleFavoriteDesc: '收藏或取消目前歌曲',
toggleWindow: '顯示/隱藏視窗',
toggleWindowDesc: '快速顯示或隱藏主視窗',
scopeGlobal: '全域',
scopeApp: '應用程式內',
enabled: '已啟用',
disabled: '已停用',
issueInvalid: '組合無效',
issueReserved: '系統保留',
registrationWarningTitle: '以下快捷鍵未能註冊,請改用其他組合',
registrationOccupied: '被系統或其他應用程式占用',
registrationInvalid: '鍵位格式無效',
messages: {
resetSuccess: '已恢復預設快捷鍵,請記得儲存',
conflict: '存在快捷鍵衝突,請重新設定',
saveSuccess: '快捷鍵設定已儲存',
saveError: '快捷鍵儲存失敗,請重試',
saveValidationError: '快捷鍵校驗未通過,請檢查後重試',
partialRegistered: '已儲存,但部分全域快捷鍵未註冊成功',
cancelEdit: '已取消修改',
clearToDisable: '已停用該快捷鍵',
invalidShortcut: '快捷鍵無效,請輸入有效組合',
disableAll: '已停用所有快捷鍵,請記得儲存',
enableAll: '已啟用所有快捷鍵,請記得儲存'
}

View File

@@ -13,7 +13,7 @@ import { initializeLoginWindow } from './modules/loginWindow';
import { initLxMusicHttp } from './modules/lxMusicHttp';
import { initializeOtherApi } from './modules/otherApi';
import { initializeRemoteControl } from './modules/remoteControl';
import { initializeShortcuts, registerShortcuts } from './modules/shortcuts';
import { initializeShortcuts } from './modules/shortcuts';
import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray';
import { setupUpdateHandlers } from './modules/update';
import { createMainWindow, initializeWindowManager, setAppQuitting } from './modules/window';
@@ -149,11 +149,6 @@ if (!isSingleInstance) {
});
});
// 监听快捷键更新
ipcMain.on('update-shortcuts', () => {
registerShortcuts(mainWindow);
});
// 监听语言切换
ipcMain.on('change-language', (_, locale: Language) => {
// 更新主进程的语言设置

View File

@@ -1,8 +1,8 @@
import { app, ipcMain } from 'electron';
import Store from 'electron-store';
import { createDefaultShortcuts, type ShortcutsConfig } from '../../shared/shortcuts';
import set from '../set.json';
import { defaultShortcuts } from './shortcuts';
type SetConfig = {
isProxy: boolean;
@@ -29,7 +29,7 @@ type SetConfig = {
};
interface StoreType {
set: SetConfig;
shortcuts: typeof defaultShortcuts;
shortcuts: ShortcutsConfig;
}
let store: Store<StoreType>;
@@ -42,7 +42,7 @@ export function initializeConfig() {
name: 'config',
defaults: {
set: set as SetConfig,
shortcuts: defaultShortcuts
shortcuts: createDefaultShortcuts()
}
});

View File

@@ -1,122 +1,398 @@
import { globalShortcut, ipcMain } from 'electron';
import { type BrowserWindow, globalShortcut, ipcMain } from 'electron';
import {
defaultShortcuts,
getReservedAccelerators,
getShortcutConflicts,
hasShortcutAction,
isModifierOnlyShortcut,
normalizeShortcutAccelerator,
normalizeShortcutsConfig,
type ShortcutAction,
shortcutActionOrder,
type ShortcutPlatform,
type ShortcutsConfig,
type ShortcutScope
} from '../../shared/shortcuts';
import { getStore } from './config';
// 添加获取平台信息的 IPC 处理程序
ipcMain.on('get-platform', (event) => {
event.returnValue = process.platform;
});
type ShortcutRegistrationFailureReason = 'invalid' | 'occupied';
// 定义快捷键配置接口
export interface ShortcutConfig {
type ShortcutRegistrationFailure = {
action: ShortcutAction;
key: string;
enabled: boolean;
scope: 'global' | 'app';
}
export interface ShortcutsConfig {
[key: string]: ShortcutConfig;
}
// 定义默认快捷键
export const defaultShortcuts: ShortcutsConfig = {
togglePlay: { key: 'CommandOrControl+Alt+P', enabled: true, scope: 'global' },
prevPlay: { key: 'Alt+Left', enabled: true, scope: 'global' },
nextPlay: { key: 'Alt+Right', enabled: true, scope: 'global' },
volumeUp: { key: 'Alt+Up', enabled: true, scope: 'app' },
volumeDown: { key: 'Alt+Down', enabled: true, scope: 'app' },
toggleFavorite: { key: 'CommandOrControl+Alt+L', enabled: true, scope: 'app' },
toggleWindow: { key: 'CommandOrControl+Alt+Shift+M', enabled: true, scope: 'global' }
reason: ShortcutRegistrationFailureReason;
};
let mainWindowRef: Electron.BrowserWindow | null = null;
type ShortcutRegistrationResult = {
success: boolean;
failed: ShortcutRegistrationFailure[];
};
// 注册快捷键
export function registerShortcuts(
mainWindow: Electron.BrowserWindow,
shortcutsConfig?: ShortcutsConfig
) {
mainWindowRef = mainWindow;
const store = getStore();
const shortcuts =
shortcutsConfig || (store.get('shortcuts') as ShortcutsConfig) || defaultShortcuts;
type ShortcutValidationReason = 'invalid' | 'conflict' | 'reserved';
// 注销所有已注册的快捷键
globalShortcut.unregisterAll();
type ShortcutValidationIssue = {
action: ShortcutAction;
key: string;
scope: ShortcutScope;
reason: ShortcutValidationReason;
conflictWith?: ShortcutAction;
};
// 对旧格式数据进行兼容处理
if (shortcuts && typeof shortcuts.togglePlay === 'string') {
// 将 shortcuts 强制转换为 unknown再转为 Record<string, string>
const oldShortcuts = { ...shortcuts } as unknown as Record<string, string>;
const newShortcuts: ShortcutsConfig = {};
type ShortcutValidationResult = {
shortcuts: ShortcutsConfig;
hasBlockingIssue: boolean;
issues: ShortcutValidationIssue[];
};
Object.entries(oldShortcuts).forEach(([key, value]) => {
newShortcuts[key] = {
key: value,
enabled: true,
scope: ['volumeUp', 'volumeDown', 'toggleFavorite'].includes(key) ? 'app' : 'global'
};
type ShortcutSaveResult = {
ok: boolean;
validation: ShortcutValidationResult;
registration: ShortcutRegistrationResult;
};
let mainWindowRef: BrowserWindow | null = null;
let shortcutsEnabled = true;
let shortcutIpcReady = false;
const managedGlobalShortcuts = new Map<ShortcutAction, string>();
function currentPlatform(): ShortcutPlatform {
if (
process.platform === 'darwin' ||
process.platform === 'win32' ||
process.platform === 'linux'
) {
return process.platform;
}
return 'linux';
}
function hasAvailableMainWindow(): boolean {
return Boolean(mainWindowRef && !mainWindowRef.isDestroyed());
}
function isShortcutsConfigEqual(left: ShortcutsConfig, right: ShortcutsConfig): boolean {
return shortcutActionOrder.every((action) => {
const leftConfig = left[action];
const rightConfig = right[action];
return (
leftConfig.key === rightConfig.key &&
leftConfig.enabled === rightConfig.enabled &&
leftConfig.scope === rightConfig.scope
);
});
}
store.set('shortcuts', newShortcuts);
registerShortcuts(mainWindow, newShortcuts);
function getStoredShortcuts(): ShortcutsConfig {
const store = getStore();
const rawShortcuts = store.get('shortcuts');
const normalizedShortcuts = normalizeShortcutsConfig(rawShortcuts);
const serializedRaw = JSON.stringify(rawShortcuts ?? null);
const serializedNormalized = JSON.stringify(normalizedShortcuts);
if (serializedRaw !== serializedNormalized) {
store.set('shortcuts', normalizedShortcuts);
}
return normalizedShortcuts;
}
function persistShortcuts(shortcuts: ShortcutsConfig) {
const store = getStore();
const currentShortcuts = normalizeShortcutsConfig(store.get('shortcuts'));
if (!isShortcutsConfigEqual(currentShortcuts, shortcuts)) {
store.set('shortcuts', shortcuts);
}
}
function emitShortcutsChanged(
shortcuts: ShortcutsConfig,
registration: ShortcutRegistrationResult
): void {
if (!hasAvailableMainWindow()) {
return;
}
// 注册全局快捷键
Object.entries(shortcuts).forEach(([action, config]) => {
const { key, enabled, scope } = config as ShortcutConfig;
mainWindowRef!.webContents.send('update-app-shortcuts', shortcuts);
mainWindowRef!.webContents.send('shortcuts-updated', shortcuts, registration);
}
// 只注册启用且作用域为全局的快捷键
if (!enabled || scope !== 'global') return;
function unregisterManagedGlobalShortcuts() {
managedGlobalShortcuts.forEach((accelerator) => {
try {
globalShortcut.unregister(accelerator);
} catch (error) {
console.error(`[Shortcuts] 注销快捷键失败: ${accelerator}`, error);
}
});
managedGlobalShortcuts.clear();
}
function handleShortcutAction(action: ShortcutAction) {
if (!hasAvailableMainWindow()) {
return;
}
const mainWindow = mainWindowRef!;
if (action === 'toggleWindow') {
if (mainWindow.isVisible() && mainWindow.isFocused()) {
mainWindow.hide();
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
return;
}
mainWindow.webContents.send('global-shortcut', action);
}
function registerManagedGlobalShortcuts(shortcuts: ShortcutsConfig): ShortcutRegistrationResult {
unregisterManagedGlobalShortcuts();
const failed: ShortcutRegistrationFailure[] = [];
if (!shortcutsEnabled) {
return {
success: true,
failed
};
}
shortcutActionOrder.forEach((action) => {
const config = shortcuts[action];
if (!config.enabled || config.scope !== 'global') {
return;
}
const accelerator = normalizeShortcutAccelerator(config.key);
if (!accelerator || isModifierOnlyShortcut(accelerator)) {
failed.push({
action,
key: config.key,
reason: 'invalid'
});
return;
}
try {
switch (action) {
case 'toggleWindow':
globalShortcut.register(key, () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
const registered = globalShortcut.register(accelerator, () => {
handleShortcutAction(action);
});
break;
default:
globalShortcut.register(key, () => {
mainWindow.webContents.send('global-shortcut', action);
if (!registered) {
failed.push({
action,
key: accelerator,
reason: 'occupied'
});
break;
return;
}
managedGlobalShortcuts.set(action, accelerator);
} catch (error) {
console.error(`注册快捷键 ${key} 失败:`, error);
console.error(`[Shortcuts] 注册快捷键失败: ${accelerator}`, error);
failed.push({
action,
key: accelerator,
reason: 'invalid'
});
}
});
// 通知渲染进程更新应用内快捷键
mainWindow.webContents.send('update-app-shortcuts', shortcuts);
return {
success: failed.length === 0,
failed
};
}
// 初始化快捷键
export function initializeShortcuts(mainWindow: Electron.BrowserWindow) {
mainWindowRef = mainWindow;
registerShortcuts(mainWindow);
function validateShortcuts(rawShortcuts: unknown): ShortcutValidationResult {
const shortcuts = normalizeShortcutsConfig(rawShortcuts);
const issues: ShortcutValidationIssue[] = [];
const issueKeys = new Set<string>();
const rawShortcutMap =
rawShortcuts && typeof rawShortcuts === 'object'
? (rawShortcuts as Record<string, unknown>)
: {};
const pushIssue = (issue: ShortcutValidationIssue) => {
const issueKey = `${issue.reason}:${issue.action}:${issue.scope}:${issue.key}:${issue.conflictWith ?? ''}`;
if (issueKeys.has(issueKey)) {
return;
}
issueKeys.add(issueKey);
issues.push(issue);
};
shortcutActionOrder.forEach((action) => {
const rawActionConfig = rawShortcutMap[action];
if (!rawActionConfig) {
return;
}
const rawKey =
typeof rawActionConfig === 'string'
? rawActionConfig
: typeof rawActionConfig === 'object' && rawActionConfig !== null
? (rawActionConfig as { key?: unknown }).key
: null;
if (typeof rawKey !== 'string') {
return;
}
const normalizedKey = normalizeShortcutAccelerator(rawKey);
if (!normalizedKey || isModifierOnlyShortcut(rawKey)) {
pushIssue({
action,
key: rawKey,
scope: shortcuts[action].scope,
reason: 'invalid'
});
}
});
const conflicts = getShortcutConflicts(shortcuts);
conflicts.forEach((conflict) => {
conflict.actions.forEach((action, index) => {
const conflictWith = conflict.actions[(index + 1) % conflict.actions.length];
pushIssue({
action,
key: conflict.key,
scope: conflict.scope,
reason: 'conflict',
conflictWith
});
});
});
const reservedAccelerators = new Set(getReservedAccelerators(currentPlatform()));
shortcutActionOrder.forEach((action) => {
const config = shortcuts[action];
if (!config.enabled || config.scope !== 'global') {
return;
}
const accelerator = normalizeShortcutAccelerator(config.key);
if (accelerator && reservedAccelerators.has(accelerator)) {
pushIssue({
action,
key: accelerator,
scope: config.scope,
reason: 'reserved'
});
}
});
return {
shortcuts,
hasBlockingIssue: issues.length > 0,
issues
};
}
function applyShortcuts(shortcuts: ShortcutsConfig): ShortcutRegistrationResult {
const registration = registerManagedGlobalShortcuts(shortcuts);
emitShortcutsChanged(shortcuts, registration);
return registration;
}
function saveShortcuts(rawShortcuts: unknown): ShortcutSaveResult {
const validation = validateShortcuts(rawShortcuts);
if (validation.hasBlockingIssue) {
return {
ok: false,
validation,
registration: {
success: false,
failed: []
}
};
}
persistShortcuts(validation.shortcuts);
const registration = applyShortcuts(validation.shortcuts);
return {
ok: true,
validation,
registration
};
}
function setupShortcutIpcHandlers() {
if (shortcutIpcReady) {
return;
}
shortcutIpcReady = true;
ipcMain.on('get-platform', (event) => {
event.returnValue = process.platform;
});
// 监听禁用快捷键事件
ipcMain.on('disable-shortcuts', () => {
globalShortcut.unregisterAll();
shortcutsEnabled = false;
unregisterManagedGlobalShortcuts();
});
// 监听启用快捷键事件
ipcMain.on('enable-shortcuts', () => {
if (mainWindowRef) {
registerShortcuts(mainWindowRef);
}
shortcutsEnabled = true;
const shortcuts = getStoredShortcuts();
applyShortcuts(shortcuts);
});
// 监听快捷键更新事件
ipcMain.on('update-shortcuts', (_, shortcutsConfig: ShortcutsConfig) => {
if (mainWindowRef) {
registerShortcuts(mainWindowRef, shortcutsConfig);
}
ipcMain.on('update-shortcuts', (_, shortcutsConfig: unknown) => {
saveShortcuts(shortcutsConfig);
});
ipcMain.handle('shortcuts:get-config', () => {
return getStoredShortcuts();
});
ipcMain.handle('shortcuts:validate', (_, shortcutsConfig: unknown) => {
return validateShortcuts(shortcutsConfig);
});
ipcMain.handle('shortcuts:save', (_, shortcutsConfig: unknown) => {
return saveShortcuts(shortcutsConfig);
});
}
export function registerShortcuts(mainWindow: BrowserWindow, shortcutsConfig?: ShortcutsConfig) {
mainWindowRef = mainWindow;
const shortcuts = shortcutsConfig
? normalizeShortcutsConfig(shortcutsConfig)
: getStoredShortcuts();
if (shortcutsConfig) {
persistShortcuts(shortcuts);
}
return applyShortcuts(shortcuts);
}
export function initializeShortcuts(mainWindow: BrowserWindow) {
mainWindowRef = mainWindow;
setupShortcutIpcHandlers();
const shortcuts = getStoredShortcuts();
applyShortcuts(shortcuts);
}
export function isShortcutActionSupported(action: string): action is ShortcutAction {
return hasShortcutAction(action);
}
export { defaultShortcuts };

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ import pinia from '@/store';
import App from './App.vue';
import directives from './directive';
import { initAppShortcuts } from './utils/appShortcuts';
const app = createApp(App);
@@ -23,6 +22,3 @@ app.use(pinia);
app.use(router);
app.use(i18n as any);
app.mount('#app');
// 初始化应用内快捷键
initAppShortcuts();

View File

@@ -3,68 +3,61 @@ import { onMounted, onUnmounted } from 'vue';
import i18n from '@/../i18n/renderer';
import { usePlayerStore, useSettingsStore } from '@/store';
import {
hasShortcutAction,
normalizeShortcutAccelerator,
normalizeShortcutsConfig,
type ShortcutAction,
shortcutActionOrder,
type ShortcutsConfig
} from '../../shared/shortcuts';
import { isElectron } from '.';
import { isEditableTarget, keyboardEventToAccelerator } from './shortcutKeyboard';
import { showShortcutToast } from './shortcutToast';
// 添加一个简单的防抖机制
let actionTimeout: NodeJS.Timeout | null = null;
const ACTION_DELAY = 300; // 毫秒
const ACTION_DELAY = 260;
// 添加一个操作锁,记录最后一次操作的时间和动作
let lastActionInfo = {
action: '',
timestamp: 0
};
interface ShortcutConfig {
key: string;
enabled: boolean;
scope: 'global' | 'app';
}
interface ShortcutsConfig {
[key: string]: ShortcutConfig;
}
const actionTimestamps = new Map<ShortcutAction, number>();
const { t } = i18n.global;
// 全局存储快捷键配置
let appShortcuts: ShortcutsConfig = {};
let appShortcuts: ShortcutsConfig = normalizeShortcutsConfig(null);
let appShortcutsSuspended = false;
let appShortcutsInitialized = false;
const onGlobalShortcut = (_event: unknown, action: string) => {
if (!hasShortcutAction(action)) {
return;
}
void handleShortcutAction(action);
};
const onUpdateAppShortcuts = (_event: unknown, shortcuts: unknown) => {
updateAppShortcuts(shortcuts);
};
function shouldSkipAction(action: ShortcutAction): boolean {
const now = Date.now();
const lastTimestamp = actionTimestamps.get(action) ?? 0;
if (now - lastTimestamp < ACTION_DELAY) {
return true;
}
actionTimestamps.set(action, now);
return false;
}
/**
* 处理快捷键动作
* @param action 快捷键动作
*/
export async function handleShortcutAction(action: string) {
const now = Date.now();
// 如果存在未完成的动作,则忽略当前请求
if (actionTimeout) {
console.log('[AppShortcuts] 忽略快速连续的动作请求:', action);
export async function handleShortcutAction(action: ShortcutAction) {
if (shouldSkipAction(action)) {
return;
}
// 检查是否是同一个动作的重复触发300ms内
if (lastActionInfo.action === action && now - lastActionInfo.timestamp < ACTION_DELAY) {
console.log(
`[AppShortcuts] 忽略重复的 ${action} 动作,距上次仅 ${now - lastActionInfo.timestamp}ms`
);
return;
}
// 更新最后一次操作信息
lastActionInfo = {
action,
timestamp: now
};
// 设置防抖锁
actionTimeout = setTimeout(() => {
actionTimeout = null;
}, ACTION_DELAY);
console.log(`[AppShortcuts] 执行动作: ${action}, 时间戳: ${now}`);
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
@@ -81,12 +74,10 @@ export async function handleShortcutAction(action: string) {
if (playerStore.play) {
await playerStore.handlePause();
showToast(t('player.playBar.pause'), 'ri-pause-circle-line');
} else {
if (playerStore.playMusic?.id) {
} else if (playerStore.playMusic?.id) {
await playerStore.setPlay({ ...playerStore.playMusic });
showToast(t('player.playBar.play'), 'ri-play-circle-line');
}
}
break;
case 'prevPlay':
await playerStore.prevPlay();
@@ -115,16 +106,19 @@ export async function handleShortcutAction(action: string) {
}
break;
case 'toggleFavorite': {
const isFavorite = playerStore.favoriteList.includes(Number(playerStore.playMusic.id));
const numericId = Number(playerStore.playMusic.id);
console.log(`[AppShortcuts] toggleFavorite 当前状态: ${isFavorite}, ID: ${numericId}`);
if (isFavorite) {
playerStore.removeFromFavorite(numericId);
console.log(`[AppShortcuts] 已从收藏中移除: ${numericId}`);
} else {
playerStore.addToFavorite(numericId);
console.log(`[AppShortcuts] 已添加到收藏: ${numericId}`);
if (!playerStore.playMusic?.id) {
return;
}
const currentSongId = Number(playerStore.playMusic.id);
const isFavorite = playerStore.favoriteList.includes(currentSongId);
if (isFavorite) {
playerStore.removeFromFavorite(currentSongId);
} else {
playerStore.addToFavorite(currentSongId);
}
showToast(
isFavorite
? t('player.playBar.unFavorite', { name: playerStore.playMusic.name })
@@ -134,119 +128,91 @@ export async function handleShortcutAction(action: string) {
break;
}
default:
console.log('未知的快捷键动作:', action);
break;
}
} catch (error) {
console.error(`执行快捷键动作 ${action} 时出错:`, error);
} finally {
// 确保在出错时也能清除超时
clearTimeout(actionTimeout);
actionTimeout = null;
console.log(
`[AppShortcuts] 动作完成: ${action}, 时间戳: ${Date.now()}, 耗时: ${Date.now() - now}ms`
);
console.error(`[AppShortcuts] 执行动作失败: ${action}`, error);
}
}
/**
* 检查按键是否匹配快捷键
* @param e KeyboardEvent
* @param shortcutKey 快捷键字符串
* @returns 是否匹配
*/
function matchShortcut(e: KeyboardEvent, shortcutKey: string): boolean {
const keys = shortcutKey.split('+');
const pressedKey = e.key.length === 1 ? e.key.toUpperCase() : e.key;
// 检查修饰键
const hasCommandOrControl = keys.includes('CommandOrControl');
const hasAlt = keys.includes('Alt');
const hasShift = keys.includes('Shift');
// 检查主键
let mainKey = keys.find((k) => !['CommandOrControl', 'Alt', 'Shift'].includes(k));
if (!mainKey) return false;
// 处理特殊键
if (mainKey === 'Left' && pressedKey === 'ArrowLeft') mainKey = 'ArrowLeft';
if (mainKey === 'Right' && pressedKey === 'ArrowRight') mainKey = 'ArrowRight';
if (mainKey === 'Up' && pressedKey === 'ArrowUp') mainKey = 'ArrowUp';
if (mainKey === 'Down' && pressedKey === 'ArrowDown') mainKey = 'ArrowDown';
// 检查是否所有条件都匹配
return (
hasCommandOrControl === (e.ctrlKey || e.metaKey) &&
hasAlt === e.altKey &&
hasShift === e.shiftKey &&
mainKey === pressedKey
);
}
/**
* 全局键盘事件处理函数
* @param e KeyboardEvent
*/
function handleKeyDown(e: KeyboardEvent) {
// 如果在输入框中则不处理快捷键
if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) {
function handleKeyDown(event: KeyboardEvent) {
if (appShortcutsSuspended) {
return;
}
Object.entries(appShortcuts).forEach(([action, config]) => {
if (config.enabled && config.scope === 'app' && matchShortcut(e, config.key)) {
e.preventDefault();
handleShortcutAction(action);
if (isEditableTarget(event.target)) {
return;
}
const accelerator = keyboardEventToAccelerator(event);
if (!accelerator) {
return;
}
for (const action of shortcutActionOrder) {
const config = appShortcuts[action];
if (!config.enabled || config.scope !== 'app') {
continue;
}
const shortcutKey = normalizeShortcutAccelerator(config.key);
if (!shortcutKey || shortcutKey !== accelerator) {
continue;
}
event.preventDefault();
void handleShortcutAction(action);
break;
}
});
}
/**
* 更新应用内快捷键
* @param shortcuts 快捷键配置
*/
export function updateAppShortcuts(shortcuts: ShortcutsConfig) {
appShortcuts = shortcuts;
export function updateAppShortcuts(shortcuts: unknown) {
appShortcuts = normalizeShortcutsConfig(shortcuts);
}
export function setAppShortcutsSuspended(suspended: boolean) {
appShortcutsSuspended = suspended;
}
/**
* 初始化应用内快捷键
*/
export function initAppShortcuts() {
if (isElectron) {
// 监听全局快捷键事件
window.electron.ipcRenderer.on('global-shortcut', async (_, action: string) => {
handleShortcutAction(action);
});
if (!isElectron || appShortcutsInitialized) {
return;
}
// 监听应用内快捷键更新
window.electron.ipcRenderer.on('update-app-shortcuts', (_, shortcuts: ShortcutsConfig) => {
updateAppShortcuts(shortcuts);
});
appShortcutsInitialized = true;
window.electron.ipcRenderer.on('global-shortcut', onGlobalShortcut);
window.electron.ipcRenderer.on('update-app-shortcuts', onUpdateAppShortcuts);
// 获取初始快捷键配置
const storedShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts');
if (storedShortcuts) {
updateAppShortcuts(storedShortcuts);
}
// 添加键盘事件监听
document.addEventListener('keydown', handleKeyDown);
}
}
/**
* 清理应用内快捷键
*/
export function cleanupAppShortcuts() {
if (isElectron) {
// 移除全局事件监听
window.electron.ipcRenderer.removeAllListeners('global-shortcut');
window.electron.ipcRenderer.removeAllListeners('update-app-shortcuts');
// 移除键盘事件监听
document.removeEventListener('keydown', handleKeyDown);
if (!isElectron || !appShortcutsInitialized) {
return;
}
appShortcutsInitialized = false;
window.electron.ipcRenderer.removeListener('global-shortcut', onGlobalShortcut);
window.electron.ipcRenderer.removeListener('update-app-shortcuts', onUpdateAppShortcuts);
document.removeEventListener('keydown', handleKeyDown);
}
/**

View File

@@ -0,0 +1,118 @@
import { normalizeShortcutAccelerator } from '../../shared/shortcuts';
const modifierOnlyKeys = new Set(['Control', 'Meta', 'Alt', 'Shift']);
const keyAliases: Record<string, string> = {
ArrowLeft: 'Left',
ArrowRight: 'Right',
ArrowUp: 'Up',
ArrowDown: 'Down',
Escape: 'Escape',
Enter: 'Enter',
Tab: 'Tab',
Backspace: 'Backspace',
Delete: 'Delete',
Insert: 'Insert',
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
' ': 'Space',
Spacebar: 'Space',
MediaPlayPause: 'MediaPlayPause',
MediaNextTrack: 'MediaNextTrack',
MediaPreviousTrack: 'MediaPreviousTrack',
MediaStop: 'MediaStop'
};
function resolveMainKey(event: KeyboardEvent): string | null {
if (event.isComposing || modifierOnlyKeys.has(event.key)) {
return null;
}
const { code, key } = event;
if (/^Key[A-Z]$/.test(code)) {
return code.slice(3);
}
if (/^Digit[0-9]$/.test(code)) {
return code.slice(5);
}
if (/^Numpad[0-9]$/.test(code)) {
return code.slice(6);
}
if (/^F([1-9]|1\d|2[0-4])$/.test(code)) {
return code;
}
if (code === 'NumpadAdd') {
return 'Plus';
}
if (code === 'NumpadSubtract' || code === 'Minus') {
return 'Minus';
}
if (code === 'Equal' && event.shiftKey) {
return 'Plus';
}
if (key === '+' || key === '=') {
return 'Plus';
}
if (key === '-' || key === '_') {
return 'Minus';
}
if (keyAliases[key]) {
return keyAliases[key];
}
if (/^[A-Za-z0-9]$/.test(key)) {
return key.toUpperCase();
}
return null;
}
export function keyboardEventToAccelerator(event: KeyboardEvent): string | null {
const mainKey = resolveMainKey(event);
if (!mainKey) {
return null;
}
const parts: string[] = [];
if (event.ctrlKey || event.metaKey) {
parts.push('CommandOrControl');
}
if (event.altKey) {
parts.push('Alt');
}
if (event.shiftKey) {
parts.push('Shift');
}
return normalizeShortcutAccelerator([...parts, mainKey].join('+'));
}
export function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
const tagName = target.tagName;
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
return true;
}
return Boolean(target.closest('[contenteditable="true"]'));
}

368
src/shared/shortcuts.ts Normal file
View File

@@ -0,0 +1,368 @@
export const shortcutActionOrder = [
'togglePlay',
'prevPlay',
'nextPlay',
'volumeUp',
'volumeDown',
'toggleFavorite',
'toggleWindow'
] as const;
export type ShortcutAction = (typeof shortcutActionOrder)[number];
export type ShortcutScope = 'global' | 'app';
export type ShortcutConfig = {
key: string;
enabled: boolean;
scope: ShortcutScope;
};
export type ShortcutsConfig = Record<ShortcutAction, ShortcutConfig>;
export type ShortcutPlatform = 'darwin' | 'win32' | 'linux' | 'web';
export type ShortcutConflict = {
key: string;
scope: ShortcutScope;
actions: ShortcutAction[];
};
type ShortcutModifier = 'CommandOrControl' | 'Alt' | 'Shift';
const shortcutModifierOrder: ShortcutModifier[] = ['CommandOrControl', 'Alt', 'Shift'];
const shortcutScopeDefaults: Record<ShortcutAction, ShortcutScope> = {
togglePlay: 'global',
prevPlay: 'global',
nextPlay: 'global',
volumeUp: 'app',
volumeDown: 'app',
toggleFavorite: 'app',
toggleWindow: 'global'
};
const shortcutKeyDefaults: Record<ShortcutAction, string> = {
togglePlay: 'CommandOrControl+Alt+P',
prevPlay: 'CommandOrControl+Alt+Left',
nextPlay: 'CommandOrControl+Alt+Right',
volumeUp: 'CommandOrControl+Alt+Up',
volumeDown: 'CommandOrControl+Alt+Down',
toggleFavorite: 'CommandOrControl+Alt+L',
toggleWindow: 'CommandOrControl+Alt+Shift+M'
};
const modifierAliases: Record<string, ShortcutModifier> = {
commandorcontrol: 'CommandOrControl',
cmdorctrl: 'CommandOrControl',
cmd: 'CommandOrControl',
command: 'CommandOrControl',
control: 'CommandOrControl',
ctrl: 'CommandOrControl',
meta: 'CommandOrControl',
super: 'CommandOrControl',
win: 'CommandOrControl',
windows: 'CommandOrControl',
alt: 'Alt',
option: 'Alt',
shift: 'Shift'
};
const namedKeyAliases: Record<string, string> = {
left: 'Left',
arrowleft: 'Left',
right: 'Right',
arrowright: 'Right',
up: 'Up',
arrowup: 'Up',
down: 'Down',
arrowdown: 'Down',
esc: 'Escape',
escape: 'Escape',
enter: 'Enter',
return: 'Enter',
tab: 'Tab',
space: 'Space',
spacebar: 'Space',
' ': 'Space',
backspace: 'Backspace',
delete: 'Delete',
del: 'Delete',
insert: 'Insert',
ins: 'Insert',
home: 'Home',
end: 'End',
pageup: 'PageUp',
pagedown: 'PageDown',
plus: 'Plus',
'+': 'Plus',
equal: 'Plus',
'=': 'Plus',
minus: 'Minus',
'-': 'Minus',
mediaplaypause: 'MediaPlayPause',
mediaplay: 'MediaPlayPause',
medianexttrack: 'MediaNextTrack',
mediaprevioustrack: 'MediaPreviousTrack',
mediastop: 'MediaStop'
};
const allowedNamedKeys = new Set([
'Left',
'Right',
'Up',
'Down',
'Escape',
'Enter',
'Tab',
'Space',
'Backspace',
'Delete',
'Insert',
'Home',
'End',
'PageUp',
'PageDown',
'Plus',
'Minus',
'MediaPlayPause',
'MediaNextTrack',
'MediaPreviousTrack',
'MediaStop'
]);
const functionKeyRegExp = /^F([1-9]|1\d|2[0-4])$/i;
const shortcutActionGroups: Record<'playback' | 'sound' | 'window', ShortcutAction[]> = {
playback: ['togglePlay', 'prevPlay', 'nextPlay'],
sound: ['volumeUp', 'volumeDown', 'toggleFavorite'],
window: ['toggleWindow']
};
export const shortcutGroups = shortcutActionGroups;
const sharedReservedAccelerators = ['CommandOrControl+Shift+I', 'F5'];
const platformReservedAccelerators: Record<Exclude<ShortcutPlatform, 'web'>, string[]> = {
darwin: ['CommandOrControl+Q', 'CommandOrControl+W', 'CommandOrControl+H'],
win32: ['Alt+F4'],
linux: ['Alt+F4']
};
function normalizeModifierToken(token: string): ShortcutModifier | null {
return modifierAliases[token.trim().toLowerCase()] ?? null;
}
function normalizeKeyToken(token: string): string | null {
const normalizedToken = token.trim();
if (!normalizedToken) {
return null;
}
if (normalizedToken.length === 1 && /[A-Za-z0-9]/.test(normalizedToken)) {
return normalizedToken.toUpperCase();
}
const functionKeyMatch = normalizedToken.match(functionKeyRegExp);
if (functionKeyMatch) {
return `F${functionKeyMatch[1]}`;
}
const aliasKey = namedKeyAliases[normalizedToken.toLowerCase()];
if (aliasKey) {
return aliasKey;
}
if (allowedNamedKeys.has(normalizedToken)) {
return normalizedToken;
}
return null;
}
function createDefaultShortcutConfig(action: ShortcutAction): ShortcutConfig {
return {
key: shortcutKeyDefaults[action],
enabled: true,
scope: shortcutScopeDefaults[action]
};
}
export function createDefaultShortcuts(): ShortcutsConfig {
return shortcutActionOrder.reduce((result, action) => {
result[action] = createDefaultShortcutConfig(action);
return result;
}, {} as ShortcutsConfig);
}
export const defaultShortcuts = createDefaultShortcuts();
export function normalizeShortcutAccelerator(raw: string): string | null {
if (!raw || typeof raw !== 'string') {
return null;
}
const segments = raw
.split('+')
.map((item) => item.trim())
.filter(Boolean);
if (segments.length === 0) {
return null;
}
const modifiers = new Set<ShortcutModifier>();
let mainKey: string | null = null;
for (const segment of segments) {
const modifier = normalizeModifierToken(segment);
if (modifier) {
modifiers.add(modifier);
continue;
}
const normalizedKey = normalizeKeyToken(segment);
if (!normalizedKey || mainKey) {
return null;
}
mainKey = normalizedKey;
}
if (!mainKey) {
return null;
}
const orderedModifiers = shortcutModifierOrder.filter((modifier) => modifiers.has(modifier));
return [...orderedModifiers, mainKey].join('+');
}
function normalizeShortcutScope(scope: unknown, action: ShortcutAction): ShortcutScope {
return scope === 'global' || scope === 'app' ? scope : shortcutScopeDefaults[action];
}
function normalizeShortcutConfig(action: ShortcutAction, value: unknown): ShortcutConfig {
if (typeof value === 'string') {
const normalizedKey = normalizeShortcutAccelerator(value);
return {
key: normalizedKey ?? shortcutKeyDefaults[action],
enabled: true,
scope: shortcutScopeDefaults[action]
};
}
if (!value || typeof value !== 'object') {
return createDefaultShortcutConfig(action);
}
const config = value as Partial<ShortcutConfig>;
const normalizedKey = normalizeShortcutAccelerator(config.key ?? '');
return {
key: normalizedKey ?? shortcutKeyDefaults[action],
enabled: config.enabled !== false,
scope: normalizeShortcutScope(config.scope, action)
};
}
export function normalizeShortcutsConfig(input: unknown): ShortcutsConfig {
const result = createDefaultShortcuts();
if (!input || typeof input !== 'object') {
return result;
}
const rawConfig = input as Record<string, unknown>;
shortcutActionOrder.forEach((action) => {
result[action] = normalizeShortcutConfig(action, rawConfig[action]);
});
return result;
}
export function getShortcutConflicts(shortcuts: ShortcutsConfig): ShortcutConflict[] {
const shortcutBuckets = new Map<string, ShortcutAction[]>();
shortcutActionOrder.forEach((action) => {
const config = shortcuts[action];
if (!config.enabled) {
return;
}
const normalizedKey = normalizeShortcutAccelerator(config.key);
if (!normalizedKey) {
return;
}
const bucketKey = `${config.scope}::${normalizedKey}`;
const actions = shortcutBuckets.get(bucketKey) ?? [];
actions.push(action);
shortcutBuckets.set(bucketKey, actions);
});
const conflicts: ShortcutConflict[] = [];
shortcutBuckets.forEach((actions, key) => {
if (actions.length < 2) {
return;
}
const [scope, accelerator] = key.split('::') as [ShortcutScope, string];
conflicts.push({
key: accelerator,
scope,
actions
});
});
return conflicts;
}
export function getReservedAccelerators(platform: ShortcutPlatform): string[] {
if (platform === 'web') {
return [...sharedReservedAccelerators];
}
return [...sharedReservedAccelerators, ...platformReservedAccelerators[platform]];
}
export function formatShortcutForDisplay(shortcut: string, platform: ShortcutPlatform): string {
const accelerator = normalizeShortcutAccelerator(shortcut) ?? shortcut;
const isMac = platform === 'darwin';
return accelerator
.split('+')
.map((segment) => {
if (segment === 'CommandOrControl') {
return isMac ? 'Cmd' : 'Ctrl';
}
if (segment === 'Alt') {
return isMac ? 'Option' : 'Alt';
}
if (segment === 'Shift') {
return 'Shift';
}
if (segment === 'Plus') {
return '+';
}
if (segment === 'Minus') {
return '-';
}
return segment;
})
.join(' + ');
}
export function hasShortcutAction(action: string): action is ShortcutAction {
return shortcutActionOrder.includes(action as ShortcutAction);
}
export function isModifierOnlyShortcut(shortcut: string): boolean {
const normalized = normalizeShortcutAccelerator(shortcut);
if (!normalized) {
return true;
}
const segments = normalized.split('+');
const mainKey = segments[segments.length - 1];
return shortcutModifierOrder.includes(mainKey as ShortcutModifier);
}

View File

@@ -4,6 +4,7 @@
"electron.vite.config.*",
"src/main/**/*",
"src/preload/**/*",
"src/shared/**/*",
"src/i18n/**/*"
],
"compilerOptions": {

View File

@@ -4,6 +4,7 @@
"src/preload/*.d.ts",
"src/renderer/**/*",
"src/renderer/**/*.vue",
"src/shared/**/*",
"src/i18n/**/*",
"src/main/modules/config.ts",
"src/main/modules/shortcuts.ts"