From 49c174851ee30269401a8ddf03daf98bcd7e6fa4 Mon Sep 17 00:00:00 2001 From: hunter-dev Date: Mon, 24 Aug 2026 23:48:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BF=AB=E6=8D=B7=E9=94=AE=E5=BD=95?= =?UTF-8?q?=E5=85=A5=E9=87=8D=E7=BB=91=EF=BC=88FR-K3=EF=BC=8Ckeymap:load/s?= =?UTF-8?q?ave=20IPC+config.json=EF=BC=8CSettingsPanel=20=E5=BD=95?= =?UTF-8?q?=E5=85=A5=20UI+=E5=86=B2=E7=AA=81=E6=A3=80=E6=B5=8B=EF=BC=8Cuse?= =?UTF-8?q?-hotkeys=20=E8=AF=BB=20store=20=E5=AE=9E=E6=97=B6=E7=94=9F?= =?UTF-8?q?=E6=95=88=EF=BC=8C=E9=87=8D=E5=BC=80=E6=81=A2=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/ipc/handlers.ts | 2 + .../desktop/src/main/services/config-store.ts | 13 +++- .../src/main/services/vault-service.ts | 2 + apps/desktop/src/renderer/App.tsx | 3 +- .../features/keymap/keymap-service.ts | 61 ++++++++++++------- .../renderer/features/keymap/use-hotkeys.ts | 30 ++++++--- .../features/settings/SettingsPanel.tsx | 46 +++++++++++++- apps/desktop/tests/e2e/keymap.spec.ts | 41 +++++++++++++ packages/shared/src/types/ipc.ts | 4 ++ 9 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/tests/e2e/keymap.spec.ts diff --git a/apps/desktop/src/main/ipc/handlers.ts b/apps/desktop/src/main/ipc/handlers.ts index 7bb6813..69a4c46 100644 --- a/apps/desktop/src/main/ipc/handlers.ts +++ b/apps/desktop/src/main/ipc/handlers.ts @@ -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; 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 ''; diff --git a/apps/desktop/src/main/services/config-store.ts b/apps/desktop/src/main/services/config-store.ts index 614b197..7aef784 100644 --- a/apps/desktop/src/main/services/config-store.ts +++ b/apps/desktop/src/main/services/config-store.ts @@ -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; effect: string } } | null = null; + private cache: { vault?: VaultMeta; appearance?: { themeId: string; params: Record; 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; effect: string } }> { + private async read(): Promise<{ vault?: VaultMeta; appearance?: { themeId: string; params: Record; 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 { + (await this.read()).keymaps = data; + await this.write(); + } + async setDefaultRoot(root: string): Promise { const meta = await this.getVaultMeta(); meta.defaultRoot = root; diff --git a/apps/desktop/src/main/services/vault-service.ts b/apps/desktop/src/main/services/vault-service.ts index e4256cd..76d63a6 100644 --- a/apps/desktop/src/main/services/vault-service.ts +++ b/apps/desktop/src/main/services/vault-service.ts @@ -52,4 +52,6 @@ export class VaultService { async setDefaultRoot(root: string): Promise { await this.config.setDefaultRoot(root); } getAppearance() { return this.config.getAppearance(); } saveAppearance(data: { themeId: string; params: Record; effect: string }) { return this.config.saveAppearance(data); } + getKeymaps() { return this.config.getKeymaps(); } + saveKeymaps(data: { id: string; combo: string }[]) { return this.config.saveKeymaps(data); } } \ No newline at end of file diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 218fe92..071bb49 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -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) }); diff --git a/apps/desktop/src/renderer/features/keymap/keymap-service.ts b/apps/desktop/src/renderer/features/keymap/keymap-service.ts index d5f9b75..8e7438d 100644 --- a/apps/desktop/src/renderer/features/keymap/keymap-service.ts +++ b/apps/desktop/src/renderer/features/keymap/keymap-service.ts @@ -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(); +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 { +interface KeymapState { + bindings: Binding[]; + setCombo: (id: string, combo: string) => void; + resetAll: () => void; + load: () => Promise; + save: () => Promise; + conflicts: () => Record; +} + +export const useKeymap = create((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(); - 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 = {}; 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]; - } -} + }, +})); diff --git a/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts b/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts index 7958ac6..de0f189 100644 --- a/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts +++ b/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts @@ -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 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]); } diff --git a/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx b/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx index 464cd7f..c312d9d 100644 --- a/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx +++ b/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx @@ -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(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 (
{ 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 }}>
@@ -30,7 +55,24 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) { } {page === 'other' &&
删除前确认
} {page === 'path' &&
文件路径设置将在 M9 完善(默认库路径配置)
} - {page === 'keymap' &&
快捷键自定义与冲突检测已就绪(见速查面板 ?)。键位录入 UI 在此页完善。
} + {page === 'keymap' && <> +
点击「录入」后按下新组合键(需含 Ctrl/Meta/Alt);Esc 取消
+ {bindings.map(b => { + const cf = conflicts(); + const inConflict = Object.values(cf).some(ids => ids.includes(b.id)); + return ( +
+ {b.label} + + {inConflict && 冲突} + {recId === b.id ? '按下组合键…' : b.combo} + + +
+ ); + })} + + }
设置已自动保存
diff --git a/apps/desktop/tests/e2e/keymap.spec.ts b/apps/desktop/tests/e2e/keymap.spec.ts new file mode 100644 index 0000000..87489ad --- /dev/null +++ b/apps/desktop/tests/e2e/keymap.spec.ts @@ -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(); +}); diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index 33081ca..afd4363 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -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; effect: string } | null }; [IPC.appearanceSave]: { args: { themeId: string; params: Record; 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 暴露 + 渲染层使用)