🐛 fix(shortcuts): 修复编辑器快捷键冲突处理

- 新增保留快捷键冲突检测,区分浏览器、Monaco 编辑器和数据表格等不同冲突来源。
- 在快捷键设置弹窗中展示冲突提示,并在录入冲突快捷键时给出覆盖或可能失效的反馈。
- 将执行 SQL 快捷键注册到 Monaco 内部 keybinding,确保可覆盖编辑器默认快捷键并触发当前活跃查询。
- 增加快捷键冲突检测和 Monaco keybinding 转换的单元测试,覆盖常见组合键与边界情况。
This commit is contained in:
TonyJiangWJ
2026-05-10 18:41:51 +08:00
parent c0ae40c638
commit f3d325ddab
4 changed files with 497 additions and 11 deletions

View File

@@ -65,10 +65,13 @@ import {
ShortcutAction,
canRecordShortcutForAction,
eventToShortcut,
findReservedConflicts,
getShortcutDisplay,
isEditableElement,
isShortcutMatch,
normalizeShortcutCombo,
splitConflictsByContext,
type ConflictInfo,
} from './utils/shortcuts';
import { resolveTitleBarToggleIconKey, resolveWindowsScaleCheckDelayMs, shouldApplyWindowsScaleFix, shouldToggleMaximisedWindowForScaleFix, type WindowsScaleCheckTrigger } from './utils/windowStateUi';
import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds';
@@ -1889,6 +1892,18 @@ function App() {
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
const [isSnippetModalOpen, setIsSnippetModalOpen] = useState(false);
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
const shortcutConflictMap = useMemo(() => {
const map: Partial<Record<ShortcutAction, ConflictInfo[]>> = {};
for (const action of SHORTCUT_ACTION_ORDER) {
const binding = shortcutOptions[action];
if (!binding?.enabled || !binding.combo) continue;
const conflicts = findReservedConflicts(normalizeShortcutCombo(binding.combo));
if (conflicts.length > 0) {
map[action] = conflicts;
}
}
return map;
}, [shortcutOptions]);
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
const [isDataRootModalOpen, setIsDataRootModalOpen] = useState(false);
const [dataRootInfo, setDataRootInfo] = useState<any>(null);
@@ -2561,6 +2576,17 @@ function App() {
return;
}
const reservedConflicts = findReservedConflicts(normalizedCombo);
if (reservedConflicts.length > 0) {
const { hasMonaco, hasOther, monacoLabels, otherLabels, otherContexts } = splitConflictsByContext(reservedConflicts);
if (hasMonaco) {
void message.info(`已覆盖编辑器「${monacoLabels}」默认快捷键`, 4);
}
if (hasOther) {
void message.warning(`${otherContexts}${otherLabels}」冲突,可能失效`, 4);
}
}
updateShortcut(capturingShortcutAction, { combo: normalizedCombo, enabled: true });
setCapturingShortcutAction(null);
};
@@ -3580,6 +3606,8 @@ function App() {
}
const binding = shortcutOptions[action] ?? { combo: '', enabled: false };
const isCapturing = capturingShortcutAction === action;
const conflicts = shortcutConflictMap[action];
const conflictInfo = conflicts?.length ? splitConflictsByContext(conflicts) : null;
return (
<div
key={action}
@@ -3595,6 +3623,16 @@ function App() {
<div>
<div style={{ fontWeight: 500 }}>{meta.label}</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{meta.description}</div>
{conflictInfo && (
<div style={{ fontSize: 11, color: darkMode ? '#faad14' : '#d48806', marginTop: 2 }}>
{conflictInfo.hasMonaco && (
<> {conflictInfo.monacoLabels}</>
)}
{conflictInfo.hasOther && (
<> {conflictInfo.otherContexts}{conflictInfo.otherLabels}</>
)}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Input

View File

@@ -9,8 +9,8 @@ import { useStore } from '../store';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from '../utils/mongodb';
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb";
import { getShortcutDisplay, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding } from "../utils/shortcuts";
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
@@ -178,8 +178,13 @@ const SQL_FUNCTIONS: { name: string; detail: string }[] = [
{ name: 'SLEEP', detail: '工具 - 延时' },
];
// 模块级标志:确保 SQL completion provider 全局只注册一次
let sqlCompletionRegistered = false;
// HMR 重载时释放旧注册避免补全项重复
const _g = globalThis as any;
if (!_g.__gonaviSqlCompletionState) {
_g.__gonaviSqlCompletionState = { registered: false, disposables: [] as any[] };
}
let sqlCompletionRegistered = _g.__gonaviSqlCompletionState.registered;
let sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables;
// 模块级共享变量completion provider 从这些变量读取当前活跃 Tab 的状态。
// 每个 QueryEditor 实例在成为活跃 Tab 时更新这些变量,确保 provider 始终使用正确的上下文。
@@ -631,6 +636,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const [editorHeight, setEditorHeight] = useState(300);
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const runQueryActionRef = useRef<any>(null);
const lastExternalQueryRef = useRef<string>(tab.query || '');
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
const queryEditorRootRef = useRef<HTMLDivElement | null>(null);
@@ -918,10 +924,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
});
// 全局只注册一次 SQL completion provider避免多 tab 重复注册导致补全项重复
// Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding
const runBinding = shortcutOptions.runQuery;
if (runBinding?.enabled && runBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
runBinding.combo, monaco.KeyMod, monaco.KeyCode
);
if (keyBinding) {
runQueryActionRef.current = editor.addAction({
id: 'gonavi.runQuery',
label: 'GoNavi: 执行 SQL',
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
run: () => {
window.dispatchEvent(new CustomEvent('gonavi:run-active-query'));
},
});
}
}
// HMR 重载时释放旧注册避免补全项重复
if (!sqlCompletionRegistered) {
sqlCompletionRegistered = true;
monaco.languages.registerCompletionItemProvider('sql', {
_g.__gonaviSqlCompletionState.registered = true;
sqlCompletionDisposables.forEach((d: any) => d?.dispose?.());
sqlCompletionDisposables.length = 0;
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.'],
provideCompletionItems: async (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
@@ -1328,7 +1355,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
];
return { suggestions };
}
});
}));
// 注册 / 斜杠命令 AI 快捷补全
const slashCmdDefs = [
{ cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' },
@@ -1343,7 +1370,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// 全局变量存储命令定义,供 onDidChangeModelContent 使用
(window as any).__gonaviSlashCmdDefs = slashCmdDefs;
monaco.languages.registerCompletionItemProvider('sql', {
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['/'],
provideCompletionItems: (model: any, position: any) => {
const lineContent = model.getLineContent(position.lineNumber);
@@ -1370,7 +1397,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
})),
};
},
});
}));
// SQL snippet completion provider
monaco.languages.registerCompletionItemProvider('sql', {
@@ -2158,12 +2186,46 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
void handleRun();
};
window.addEventListener('keydown', handleRunShortcut);
window.addEventListener('keydown', handleRunShortcut, true);
return () => {
window.removeEventListener('keydown', handleRunShortcut);
window.removeEventListener('keydown', handleRunShortcut, true);
};
}, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]);
// Re-register Monaco internal keybinding when runQuery shortcut changes
useEffect(() => {
if (runQueryActionRef.current) {
runQueryActionRef.current.dispose();
runQueryActionRef.current = null;
}
const editor = editorRef.current;
const monaco = monacoRef.current;
if (!editor || !monaco) return;
const binding = shortcutOptions.runQuery;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
if (keyBinding) {
runQueryActionRef.current = editor.addAction({
id: 'gonavi.runQuery',
label: 'GoNavi: 执行 SQL',
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
run: () => {
window.dispatchEvent(new CustomEvent('gonavi:run-active-query'));
},
});
}
return () => {
if (runQueryActionRef.current) {
runQueryActionRef.current.dispose();
runQueryActionRef.current = null;
}
};
}, [shortcutOptions.runQuery]);
useEffect(() => {
const handleRunActiveQuery = () => {
if (activeTabId !== tab.id) {

View File

@@ -0,0 +1,210 @@
import { describe, expect, it } from 'vitest';
import {
findReservedConflict,
findReservedConflicts,
describeConflictContext,
normalizeShortcutCombo,
RESERVED_SHORTCUTS,
comboToMonacoKeyBinding,
} from './shortcuts';
import type { ConflictInfo } from './shortcuts';
// ─── findReservedConflict ────────────────────────────────────────────
describe('findReservedConflict', () => {
it('finds Ctrl+F conflict (Monaco Find)', () => {
const result = findReservedConflict('Ctrl+F');
expect(result).not.toBeNull();
expect(result!.label).toBe('编辑器查找');
expect(result!.context).toBe('monaco');
expect(result!.monacoCommandId).toBe('actions.find');
});
it('finds Ctrl+S conflict (browser save)', () => {
const result = findReservedConflict('Ctrl+S');
expect(result).not.toBeNull();
expect(result!.label).toBe('浏览器保存');
expect(result!.context).toBe('global');
});
it('returns null for non-reserved combo', () => {
expect(findReservedConflict('Ctrl+Shift+R')).toBeNull();
});
it('returns null for empty string', () => {
expect(findReservedConflict('')).toBeNull();
});
it('finds Meta+F (macOS variant)', () => {
const result = findReservedConflict('Meta+F');
expect(result).not.toBeNull();
expect(result!.label).toBe('编辑器查找');
expect(result!.context).toBe('monaco');
});
it('matches after normalization (ctrl+f → Ctrl+F)', () => {
const result = findReservedConflict(normalizeShortcutCombo('ctrl+f'));
expect(result).not.toBeNull();
expect(result!.label).toBe('编辑器查找');
});
it('finds F2 conflict', () => {
const result = findReservedConflict('F2');
expect(result).not.toBeNull();
expect(result!.context).toBe('monaco');
});
});
// ─── findReservedConflicts ───────────────────────────────────────────
describe('findReservedConflicts', () => {
it('returns multiple conflicts for Ctrl+Enter', () => {
const results = findReservedConflicts('Ctrl+Enter');
expect(results.length).toBeGreaterThanOrEqual(1);
const labels = results.map(r => r.label);
expect(labels).toContain('编辑器在下方插入行');
});
it('returns empty array for non-reserved combo', () => {
expect(findReservedConflicts('Ctrl+Shift+Q')).toEqual([]);
});
it('preserves monacoCommandId in results', () => {
const results = findReservedConflicts('Ctrl+F');
expect(results[0].monacoCommandId).toBe('actions.find');
});
});
// ─── describeConflictContext ─────────────────────────────────────────
describe('describeConflictContext', () => {
it('describes global context', () => {
expect(describeConflictContext('global')).toBe('浏览器');
});
it('describes monaco context', () => {
expect(describeConflictContext('monaco')).toBe('编辑器');
});
it('describes datagrid context', () => {
expect(describeConflictContext('datagrid')).toBe('数据表格');
});
});
// ─── RESERVED_SHORTCUTS sanity ───────────────────────────────────────
describe('RESERVED_SHORTCUTS', () => {
it('all combos are already normalized', () => {
for (const entry of RESERVED_SHORTCUTS) {
expect(entry.combo).toBe(normalizeShortcutCombo(entry.combo));
}
});
it('has at least 10 entries', () => {
expect(RESERVED_SHORTCUTS.length).toBeGreaterThanOrEqual(10);
});
it('every entry has a label and context', () => {
for (const entry of RESERVED_SHORTCUTS) {
expect(entry.label).toBeTruthy();
expect(['global', 'monaco', 'datagrid']).toContain(entry.context);
}
});
});
// ─── comboToMonacoKeyBinding ─────────────────────────────────────────
describe('comboToMonacoKeyBinding', () => {
const mockKeyMod = {
CtrlCmd: 2048,
WinCtrl: 256,
Alt: 512,
Shift: 1024,
};
const mockKeyCode = {
Enter: 3,
Tab: 2,
Escape: 9,
Space: 10,
Backspace: 1,
Delete: 20,
Home: 14,
End: 13,
PageUp: 11,
PageDown: 12,
UpArrow: 16,
DownArrow: 17,
LeftArrow: 15,
RightArrow: 18,
Insert: 19,
KeyA: 31, KeyB: 32, KeyC: 33, KeyD: 34, KeyE: 35,
KeyF: 41,
KeyG: 42, KeyH: 43, KeyK: 47, KeyN: 50, KeyP: 52, KeyR: 54, KeyS: 55,
Digit0: 21, Digit1: 22, Digit2: 23, Digit3: 24, Digit4: 25,
Digit5: 26, Digit6: 27, Digit7: 28, Digit8: 29, Digit9: 30,
F1: 61, F2: 62, F3: 63, F4: 64, F5: 65, F6: 66,
F7: 67, F8: 68, F9: 69, F10: 70, F11: 71, F12: 72,
Oem1: 80, Oem2: 81, Oem3: 82, Oem4: 83, Oem5: 84,
Oem6: 85, Oem7: 86, OemComma: 87, OemMinus: 88,
OemPlus: 89, OemPeriod: 90,
};
it('maps Ctrl+Enter correctly', () => {
expect(comboToMonacoKeyBinding('Ctrl+Enter', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd,
keyCode: mockKeyCode.Enter,
});
});
it('maps Ctrl+Shift+R correctly', () => {
expect(comboToMonacoKeyBinding('Ctrl+Shift+R', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Shift,
keyCode: mockKeyCode.KeyR,
});
});
it('maps Meta+Enter (macOS variant)', () => {
expect(comboToMonacoKeyBinding('Meta+Enter', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.WinCtrl,
keyCode: mockKeyCode.Enter,
});
});
it('maps F2 key', () => {
expect(comboToMonacoKeyBinding('F2', mockKeyMod, mockKeyCode)).toEqual({
keyMod: 0,
keyCode: mockKeyCode.F2,
});
});
it('maps Ctrl+, (comma)', () => {
expect(comboToMonacoKeyBinding('Ctrl+,', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd,
keyCode: mockKeyCode.OemComma,
});
});
it('returns null for empty combo', () => {
expect(comboToMonacoKeyBinding('', mockKeyMod, mockKeyCode)).toBeNull();
});
it('returns null for combo with only modifiers', () => {
expect(comboToMonacoKeyBinding('Ctrl+Shift', mockKeyMod, mockKeyCode)).toBeNull();
});
it('maps Ctrl+Digit1', () => {
expect(comboToMonacoKeyBinding('Ctrl+1', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd,
keyCode: mockKeyCode.Digit1,
});
});
it('maps Ctrl+Alt+Delete', () => {
expect(comboToMonacoKeyBinding('Ctrl+Alt+Delete', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Alt,
keyCode: mockKeyCode.Delete,
});
});
});

View File

@@ -312,3 +312,179 @@ export const getShortcutDisplay = (combo: string): string => {
return normalized || '-';
};
export type ConflictContext = 'global' | 'monaco' | 'datagrid';
export interface ReservedShortcut {
combo: string;
label: string;
context: ConflictContext;
monacoCommandId?: string;
}
export interface ConflictInfo {
label: string;
context: ConflictContext;
monacoCommandId?: string;
}
export const RESERVED_SHORTCUTS: ReservedShortcut[] = [
// Browser / WebView built-in shortcuts
{ combo: 'Ctrl+S', label: '浏览器保存', context: 'global' },
{ combo: 'Ctrl+P', label: '浏览器打印', context: 'global' },
{ combo: 'Ctrl+W', label: '浏览器关闭标签页', context: 'global' },
{ combo: 'Ctrl+T', label: '浏览器新建标签页', context: 'global' },
{ combo: 'Ctrl+N', label: '浏览器新建窗口', context: 'global' },
{ combo: 'Ctrl+Shift+N', label: '浏览器新建隐身窗口', context: 'global' },
// Monaco editor built-in shortcuts
{ combo: 'Ctrl+F', label: '编辑器查找', context: 'monaco', monacoCommandId: 'actions.find' },
{ combo: 'Meta+F', label: '编辑器查找', context: 'monaco', monacoCommandId: 'actions.find' },
{ combo: 'Ctrl+H', label: '编辑器替换', context: 'monaco', monacoCommandId: 'editor.action.startFindReplaceAction' },
{ combo: 'Meta+H', label: '编辑器替换', context: 'monaco', monacoCommandId: 'editor.action.startFindReplaceAction' },
{ combo: 'Ctrl+G', label: '编辑器跳转行', context: 'monaco', monacoCommandId: 'editor.action.gotoLine' },
{ combo: 'Meta+G', label: '编辑器跳转行', context: 'monaco', monacoCommandId: 'editor.action.gotoLine' },
{ combo: 'Ctrl+P', label: '编辑器快速打开', context: 'monaco', monacoCommandId: 'actions.quickOpen' },
{ combo: 'Meta+P', label: '编辑器快速打开', context: 'monaco', monacoCommandId: 'actions.quickOpen' },
{ combo: 'Ctrl+Shift+F', label: '编辑器全局查找', context: 'monaco', monacoCommandId: 'actions.quickOpenNavigate' },
{ combo: 'Meta+Shift+F', label: '编辑器全局查找', context: 'monaco', monacoCommandId: 'actions.quickOpenNavigate' },
{ combo: 'Ctrl+D', label: '编辑器添加选区', context: 'monaco', monacoCommandId: 'editor.action.addSelectionToNextFindMatch' },
{ combo: 'Meta+D', label: '编辑器添加选区', context: 'monaco', monacoCommandId: 'editor.action.addSelectionToNextFindMatch' },
{ combo: 'Ctrl+Shift+K', label: '编辑器删除行', context: 'monaco', monacoCommandId: 'editor.action.deleteLines' },
{ combo: 'Meta+Shift+K', label: '编辑器删除行', context: 'monaco', monacoCommandId: 'editor.action.deleteLines' },
{ combo: 'Ctrl+Enter', label: '编辑器在下方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineAfter' },
{ combo: 'Meta+Enter', label: '编辑器在下方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineAfter' },
{ combo: 'Ctrl+Shift+Enter', label: '编辑器在上方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineBefore' },
{ combo: 'Meta+Shift+Enter', label: '编辑器在上方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineBefore' },
{ combo: 'F2', label: '编辑器重命名符号', context: 'monaco', monacoCommandId: 'editor.action.rename' },
// DataGrid shortcuts
{ combo: 'Ctrl+C', label: '数据表格复制', context: 'datagrid' },
{ combo: 'Meta+C', label: '数据表格复制', context: 'datagrid' },
];
const CONTEXT_DESCRIPTION: Record<ConflictContext, string> = {
global: '浏览器',
monaco: '编辑器',
datagrid: '数据表格',
};
export const describeConflictContext = (context: ConflictContext): string => {
return CONTEXT_DESCRIPTION[context] || context;
};
export const splitConflictsByContext = (conflicts: ConflictInfo[]) => {
const monaco = conflicts.filter(c => c.context === 'monaco');
const other = conflicts.filter(c => c.context !== 'monaco');
const dedupe = (items: ConflictInfo[], fn: (c: ConflictInfo) => string) =>
[...new Set(items.map(fn))].join('、');
return {
monacoLabels: dedupe(monaco, c => c.label),
otherLabels: dedupe(other, c => c.label),
otherContexts: dedupe(other, c => describeConflictContext(c.context)),
hasMonaco: monaco.length > 0,
hasOther: other.length > 0,
};
};
export const findReservedConflict = (normalizedCombo: string): ConflictInfo | null => {
const conflict = RESERVED_SHORTCUTS.find((r) => r.combo === normalizedCombo);
if (!conflict) return null;
return { label: conflict.label, context: conflict.context, monacoCommandId: conflict.monacoCommandId };
};
export const findReservedConflicts = (normalizedCombo: string): ConflictInfo[] => {
return RESERVED_SHORTCUTS
.filter((r) => r.combo === normalizedCombo)
.map((r) => ({ label: r.label, context: r.context, monacoCommandId: r.monacoCommandId }));
};
export interface MonacoKeyBinding {
keyMod: number;
keyCode: number;
}
/** Map key token (after normalization) to a function that returns KeyCode.
* The function receives the KeyCode enum to avoid importing monaco at module level. */
type KeyCodeResolver = (kc: Record<string, number>) => number;
const MONACO_KEY_MAP: Record<string, KeyCodeResolver> = {
Enter: (kc) => kc.Enter,
Tab: (kc) => kc.Tab,
Esc: (kc) => kc.Escape,
Space: (kc) => kc.Space,
Backspace: (kc) => kc.Backspace,
Delete: (kc) => kc.Delete,
Home: (kc) => kc.Home,
End: (kc) => kc.End,
PageUp: (kc) => kc.PageUp,
PageDown: (kc) => kc.PageDown,
Up: (kc) => kc.UpArrow,
Down: (kc) => kc.DownArrow,
Left: (kc) => kc.LeftArrow,
Right: (kc) => kc.RightArrow,
Insert: (kc) => kc.Insert,
'/': (kc) => kc.Oem2,
',': (kc) => kc.OemComma,
'-': (kc) => kc.OemMinus,
'=': (kc) => kc.OemPlus,
'.': (kc) => kc.OemPeriod,
';': (kc) => kc.Oem1,
"'": (kc) => kc.Oem7,
'[': (kc) => kc.Oem4,
']': (kc) => kc.Oem6,
'\\': (kc) => kc.Oem5,
'`': (kc) => kc.Oem3,
};
function resolveKeyCode(token: string, kc: Record<string, number>): number | null {
// F1-F12
const fMatch = token.match(/^F([1-9]|1[0-2])$/);
if (fMatch) {
return kc['F' + fMatch[1]] ?? null;
}
// A-Z
if (/^[A-Z]$/.test(token)) {
return kc['Key' + token] ?? null;
}
// 0-9
if (/^[0-9]$/.test(token)) {
return kc['Digit' + token] ?? null;
}
// Special keys map
const resolver = MONACO_KEY_MAP[token];
if (resolver) {
return resolver(kc);
}
return null;
}
export const comboToMonacoKeyBinding = (
combo: string,
keyModEnum: Record<string, number>,
keyCodeEnum: Record<string, number>,
): MonacoKeyBinding | null => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized) return null;
const pieces = normalized.split('+');
let keyMod = 0;
let keyCode: number | null = null;
for (const piece of pieces) {
if (piece === 'Ctrl') {
keyMod |= keyModEnum.CtrlCmd ?? 0;
} else if (piece === 'Meta') {
keyMod |= keyModEnum.WinCtrl ?? 0;
} else if (piece === 'Alt') {
keyMod |= keyModEnum.Alt ?? 0;
} else if (piece === 'Shift') {
keyMod |= keyModEnum.Shift ?? 0;
} else {
keyCode = resolveKeyCode(piece, keyCodeEnum);
}
}
if (keyCode == null) return null;
return { keyMod, keyCode };
};