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
+2
View File
@@ -52,6 +52,8 @@ export function registerIpc(vault: VaultService) {
ipcMain.handle(IPC.appearanceLoad, async () => vault.getAppearance());
ipcMain.handle(IPC.appearanceSave, async (_e, p: { themeId: string; params: Record<string, unknown>; effect: string }) => { await vault.saveAppearance(p); });
ipcMain.handle(IPC.keymapLoad, async () => vault.getKeymaps());
ipcMain.handle(IPC.keymapSave, async (_e, p: { id: string; combo: string }[]) => { await vault.saveKeymaps(p); });
ipcMain.handle(IPC.copyMd, async (_e, p: { path: string }) => {
const fs2 = vault.fileService; if (!fs2) return '';
+11 -2
View File
@@ -6,13 +6,13 @@ import { RECENT_VAULT_MAX } from '@hunter/shared';
export class ConfigStore {
private file: string;
private cache: { vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string } } | null = null;
private cache: { vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string }; keymaps?: { id: string; combo: string }[] } | null = null;
constructor(userDataDir: string) {
this.file = nodePath.join(userDataDir, 'config.json');
}
private async read(): Promise<{ vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string } }> {
private async read(): Promise<{ vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string }; keymaps?: { id: string; combo: string }[] }> {
if (this.cache) return this.cache;
try {
const raw = await fs.readFile(this.file, 'utf8');
@@ -55,6 +55,15 @@ export class ConfigStore {
await this.write();
}
async getKeymaps(): Promise<{ id: string; combo: string }[] | null> {
const c = await this.read();
return c.keymaps ?? null;
}
async saveKeymaps(data: { id: string; combo: string }[]): Promise<void> {
(await this.read()).keymaps = data;
await this.write();
}
async setDefaultRoot(root: string): Promise<VaultMeta> {
const meta = await this.getVaultMeta();
meta.defaultRoot = root;
@@ -52,4 +52,6 @@ export class VaultService {
async setDefaultRoot(root: string): Promise<void> { await this.config.setDefaultRoot(root); }
getAppearance() { return this.config.getAppearance(); }
saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string }) { return this.config.saveAppearance(data); }
getKeymaps() { return this.config.getKeymaps(); }
saveKeymaps(data: { id: string; combo: string }[]) { return this.config.saveKeymaps(data); }
}
+2 -1
View File
@@ -14,6 +14,7 @@ import { AppearancePanel } from './features/appearance/AppearancePanel';
import { useAppearance } from './features/appearance/appearance-store';
import { CheatSheet } from './features/keymap/CheatSheet';
import { useGlobalHotkeys } from './features/keymap/use-hotkeys';
import { useKeymap } from './features/keymap/keymap-service';
import { useLayout } from './features/shell/layout-store';
import { Resizer } from './features/shell/Resizer';
import { EffectLayer } from './features/effects/EffectLayer';
@@ -35,7 +36,7 @@ export function App() {
const settings = useSettings();
const layout = useLayout();
useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); }, []);
useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); useKeymap.getState().load(); }, []);
useEffect(() => { if (activeId && tabs.length > 0) { const t = tabs.find(x => x.id === activeId); if (t) openDoc(t.path).catch(() => {}); } }, []);
useGlobalHotkeys({ onNewDoc: () => {}, onSave: () => useStore.getState().saveNow(), onFocusSearch: () => { const el = document.querySelector('input[placeholder*="搜索"]') as HTMLInputElement; el?.focus(); }, onToggleOutline: () => layout.toggleOutline(), onCheat: () => setShowCheat(true), onSettings: () => setShowSettings(true) });
@@ -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];
}
}
},
}));
@@ -1,13 +1,27 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import hotkeys from 'hotkeys-js';
import { useKeymap } from './keymap-service';
// 从 keymap store 读组合键绑定(FR-K3 重绑后实时生效);? 帮助键保留硬编码
export function useGlobalHotkeys(opts: { onNewDoc: () => void; onSave: () => void; onFocusSearch: () => void; onToggleOutline: () => void; onCheat: () => void; onSettings: () => void }) {
const optsRef = useRef(opts);
optsRef.current = opts;
const bindings = useKeymap(s => s.bindings);
const sig = bindings.map(b => b.id + '@' + b.combo).join('|');
useEffect(() => {
hotkeys('ctrl+n', e => { e.preventDefault(); opts.onNewDoc(); });
hotkeys('ctrl+s', e => { e.preventDefault(); opts.onSave(); });
hotkeys('ctrl+shift+f', e => { e.preventDefault(); opts.onFocusSearch(); });
hotkeys('ctrl+shift+l', e => { e.preventDefault(); opts.onToggleOutline(); });
hotkeys('?', e => { if (document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') { e.preventDefault(); opts.onCheat(); } });
return () => { hotkeys.unbind('ctrl+n'); hotkeys.unbind('ctrl+s'); hotkeys.unbind('ctrl+shift+f'); hotkeys.unbind('ctrl+shift+l'); hotkeys.unbind('?'); };
}, [opts.onNewDoc, opts.onSave, opts.onFocusSearch, opts.onToggleOutline, opts.onCheat, opts.onSettings]);
const actions: Record<string, () => void> = {
newDoc: () => optsRef.current.onNewDoc(),
save: () => optsRef.current.onSave(),
focusSearch: () => optsRef.current.onFocusSearch(),
toggleOutline: () => optsRef.current.onToggleOutline(),
};
const bound: string[] = [];
for (const b of bindings) {
const act = actions[b.id];
if (act) { hotkeys(b.combo, (e) => { e.preventDefault(); act(); }); bound.push(b.combo); }
}
hotkeys('?', (e) => { if (document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') { e.preventDefault(); optsRef.current.onCheat(); } });
bound.push('?');
return () => { for (const c of bound) hotkeys.unbind(c); };
}, [sig]);
}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSettings } from './settings-store';
import { useKeymap } from '../keymap/keymap-service';
const pageBtn: React.CSSProperties = { padding: '8px 14px', border: 'none', background: 'transparent', color: '#888', cursor: 'pointer', borderBottom: '2px solid transparent', fontSize: 13 };
const row: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: '1px solid #2a2a3a' };
@@ -9,6 +10,30 @@ const toggle = (on: boolean): React.CSSProperties => ({ width: 36, height: 20, b
export function SettingsPanel({ onClose }: { onClose: () => void }) {
const s = useSettings();
const [page, setPage] = useState<'keymap' | 'editor' | 'path' | 'other'>('editor');
const { bindings, setCombo, resetAll, save: saveKeymap, conflicts } = useKeymap();
const [recId, setRecId] = useState<string | null>(null);
useEffect(() => {
if (!recId) return;
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.key === 'Escape') { setRecId(null); return; }
if (!e.ctrlKey && !e.metaKey && !e.altKey) return;
const parts: string[] = [];
if (e.ctrlKey) parts.push('ctrl');
if (e.shiftKey) parts.push('shift');
if (e.altKey) parts.push('alt');
if (e.metaKey) parts.push('meta');
const k = e.key.toLowerCase();
if (k.length > 1) return;
parts.push(k);
const combo = parts.join('+');
setCombo(recId, combo);
void saveKeymap();
setRecId(null);
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [recId, setCombo, saveKeymap]);
return (
<div onClick={e => { if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
<div style={{ width: 520, maxHeight: '80vh', overflow: 'auto', background: '#1e1e2a', border: '1px solid #3a3a4a', borderRadius: 10, padding: 20, boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}>
@@ -30,7 +55,24 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
</>}
{page === 'other' && <div style={row}><span style={lbl}>删除前确认</span><button onClick={() => s.set('confirmDelete', !s.confirmDelete)} style={toggle(s.confirmDelete)}><span style={dot(s.confirmDelete)} /></button></div>}
{page === 'path' && <div style={{ ...lbl, opacity: 0.6, padding: '12px 0' }}>文件路径设置将在 M9 完善(默认库路径配置)</div>}
{page === 'keymap' && <div style={{ ...lbl, opacity: 0.6, padding: '12px 0' }}>快捷键自定义与冲突检测已就绪(见速查面板 ?)。键位录入 UI 在此页完善。</div>}
{page === 'keymap' && <>
<div style={{ ...lbl, opacity: 0.6, marginBottom: 8 }}>点击「录入」后按下新组合键(需含 Ctrl/Meta/Alt);Esc 取消</div>
{bindings.map(b => {
const cf = conflicts();
const inConflict = Object.values(cf).some(ids => ids.includes(b.id));
return (
<div key={b.id} style={row}>
<span style={lbl}>{b.label}</span>
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{inConflict && <span style={{ color: '#e88', fontSize: 11 }}>冲突</span>}
<code style={{ background: '#15151f', padding: '1px 6px', borderRadius: 3, color: recId === b.id ? '#fc4' : '#9cf', minWidth: 90, textAlign: 'center' }}>{recId === b.id ? '按下组合键…' : b.combo}</code>
<button onClick={() => setRecId(b.id)} style={{ ...sel, cursor: 'pointer' }}>录入</button>
</span>
</div>
);
})}
<button onClick={async () => { resetAll(); await saveKeymap(); }} style={{ marginTop: 12, ...sel, cursor: 'pointer' }}>重置默认</button>
</>}
<div style={{ marginTop: 16, fontSize: 12, opacity: 0.5 }}>设置已自动保存</div>
</div>
</div>
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import os from 'node:os';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
async function launch(userData: string) {
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
return { app, page, invoke };
}
test('keymap:录入重绑 + 持久化(重开恢复)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-keymap-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
// 设置→快捷键→录入「新建文档」→ Ctrl+Q
const a1 = await launch(userData);
await a1.invoke('vault:switch', { root: vault });
await a1.page.getByTitle('设置').click();
await a1.page.getByRole('button', { name: '快捷键' }).click();
await a1.page.getByRole('button', { name: '录入' }).first().click();
await a1.page.keyboard.press('Control+q');
// 持久化断言
const km1: { id: string; combo: string }[] | null = await a1.invoke('keymap:load', null);
expect(km1?.find(x => x.id === 'newDoc')?.combo).toBe('ctrl+q');
await a1.app.close();
// 重开:恢复
const a2 = await launch(userData);
const km2: { id: string; combo: string }[] | null = await a2.invoke('keymap:load', null);
expect(km2?.find(x => x.id === 'newDoc')?.combo).toBe('ctrl+q');
await a2.app.close();
});
+4
View File
@@ -29,6 +29,8 @@ export const IPC = {
printPdf: 'doc:print-pdf',
appearanceLoad: 'appearance:load',
appearanceSave: 'appearance:save',
keymapLoad: 'keymap:load',
keymapSave: 'keymap:save',
} as const;
export type IpcChannel = (typeof IPC)[keyof typeof IPC];
@@ -58,6 +60,8 @@ export interface IpcMap {
[IPC.printPdf]: { args: void; result: void };
[IPC.appearanceLoad]: { args: void; result: { themeId: string; params: Record<string, unknown>; effect: string } | null };
[IPC.appearanceSave]: { args: { themeId: string; params: Record<string, unknown>; effect: string }; result: void };
[IPC.keymapLoad]: { args: void; result: { id: string; combo: string }[] | null };
[IPC.keymapSave]: { args: { id: string; combo: string }[]; result: void };
}
// 类型安全的 invoke 帮助器签名(preload 暴露 + 渲染层使用)