mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-24 01:40:08 +08:00
🐛 fix(shortcuts): 修复编辑器快捷键冲突处理
- 新增保留快捷键冲突检测,区分浏览器、Monaco 编辑器和数据表格等不同冲突来源。 - 在快捷键设置弹窗中展示冲突提示,并在录入冲突快捷键时给出覆盖或可能失效的反馈。 - 将执行 SQL 快捷键注册到 Monaco 内部 keybinding,确保可覆盖编辑器默认快捷键并触发当前活跃查询。 - 增加快捷键冲突检测和 Monaco keybinding 转换的单元测试,覆盖常见组合键与边界情况。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
210
frontend/src/utils/shortcuts.test.ts
Normal file
210
frontend/src/utils/shortcuts.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user