feat: 快捷键录入重绑(FR-K3,keymap:load/save IPC+config.json,SettingsPanel 录入 UI+冲突检测,use-hotkeys 读 store 实时生效,重开恢复)

This commit is contained in:
hunter-dev
2026-08-24 23:48:17 +08:00
parent cf2600901b
commit 49c174851e
9 changed files with 166 additions and 36 deletions
@@ -1,33 +1,48 @@
import { create } from 'zustand';
import { IPC } from '@hunter/shared';
// KeymapService — 快捷键绑定管理 + 冲突检测(FR-K3/K4/K5)
// 注:? 帮助键保留硬编码(非 ctrl 系,不便录入);此处管理 4 个 ctrl 系全局绑定
export type Scope = 'global' | 'app' | 'editor';
export interface Binding { id: string; scope: Scope; combo: string; defaultCombo: string; }
export interface Binding { id: string; scope: Scope; label: string; combo: string; defaultCombo: string; }
export class KeymapService {
private bindings = new Map<string, Binding>();
const DEFAULTS: Binding[] = [
{ id: 'newDoc', scope: 'global', label: '新建文档', combo: 'ctrl+n', defaultCombo: 'ctrl+n' },
{ id: 'save', scope: 'global', label: '保存', combo: 'ctrl+s', defaultCombo: 'ctrl+s' },
{ id: 'focusSearch', scope: 'global', label: '全库搜索', combo: 'ctrl+shift+f', defaultCombo: 'ctrl+shift+f' },
{ id: 'toggleOutline', scope: 'global', label: '大纲面板', combo: 'ctrl+shift+l', defaultCombo: 'ctrl+shift+l' },
];
register(b: Binding) { this.bindings.set(b.id, b); }
setCombo(id: string, combo: string) {
const b = this.bindings.get(id); if (b) this.bindings.set(id, { ...b, combo });
}
resetAll() { for (const [id, b] of this.bindings) this.bindings.set(id, { ...b, combo: b.defaultCombo }); }
getAll(): Binding[] { return [...this.bindings.values()]; }
// 冲突检测:同一 combo 绑定到多个 action(不同 id)即冲突
conflicts(): Record<string, string[]> {
interface KeymapState {
bindings: Binding[];
setCombo: (id: string, combo: string) => void;
resetAll: () => void;
load: () => Promise<void>;
save: () => Promise<void>;
conflicts: () => Record<string, string[]>;
}
export const useKeymap = create<KeymapState>((set, get) => ({
bindings: DEFAULTS.map(b => ({ ...b })),
setCombo: (id, combo) => set(s => ({ bindings: s.bindings.map(b => b.id === id ? { ...b, combo } : b) })),
resetAll: () => set({ bindings: DEFAULTS.map(b => ({ ...b })) }),
load: async () => {
try {
const saved = (await (globalThis as any).dsh.invoke(IPC.keymapLoad)) as { id: string; combo: string }[] | null;
if (saved) set(s => ({ bindings: s.bindings.map(b => ({ ...b, combo: saved.find(x => x.id === b.id)?.combo ?? b.combo })) }));
} catch { /* 首次启动 */ }
},
save: async () => {
const data = get().bindings.map(b => ({ id: b.id, combo: b.combo }));
await (globalThis as any).dsh.invoke(IPC.keymapSave, data);
},
conflicts: () => {
const byCombo = new Map<string, string[]>();
for (const b of this.bindings.values()) {
for (const b of get().bindings) {
const arr = byCombo.get(b.combo) ?? []; arr.push(b.id); byCombo.set(b.combo, arr);
}
const out: Record<string, string[]> = {};
for (const [combo, ids] of byCombo) if (ids.length > 1) out[combo] = ids;
return out;
}
conflictCount(): number { return Object.keys(this.conflicts()).length; }
// 作用域裁决:给定当前是否编辑器聚焦,返回某 combo 应触发哪个 binding(编辑器聚焦时 global 让位规则按需)
resolve(combo: string, editorFocused: boolean): Binding | null {
const matches = [...this.bindings.values()].filter(b => b.combo === combo);
if (matches.length === 0) return null;
// 优先 editor(编辑器聚焦时),再 app,再 global
if (editorFocused) { const e = matches.find(m => m.scope === 'editor'); if (e) return e; }
return matches.find(m => m.scope === 'global') ?? matches[0];
}
}
},
}));