feat: 快捷键录入重绑(FR-K3,keymap:load/save IPC+config.json,SettingsPanel 录入 UI+冲突检测,use-hotkeys 读 store 实时生效,重开恢复)
This commit is contained in:
@@ -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 '';
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user