diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 54a4087..b9223eb 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -38,7 +38,7 @@ export function App() { useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); useKeymap.getState().load(); useSettings.getState().loadGpu(); }, []); 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) }); + 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), onFind: () => { window.dispatchEvent(new CustomEvent('dsh-toggle-find')); } }); // 编辑器样式随设置生效(FR-编辑器设置真实生效) useEffect(() => { document.documentElement.style.setProperty('--editor-font-size', settings.fontSize + 'px'); document.documentElement.style.setProperty('--editor-font-family', settings.fontFamily); document.documentElement.style.setProperty('--editor-white-space', settings.wordWrap ? 'pre-wrap' : 'pre'); }, [settings.fontSize, settings.fontFamily, settings.wordWrap]); diff --git a/apps/desktop/src/renderer/features/editor/FindReplace.tsx b/apps/desktop/src/renderer/features/editor/FindReplace.tsx index 1108245..682dc30 100644 --- a/apps/desktop/src/renderer/features/editor/FindReplace.tsx +++ b/apps/desktop/src/renderer/features/editor/FindReplace.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; @@ -39,6 +39,14 @@ export function FindReplace() { const [opts, setOpts] = useState({ caseSensitive: false, wholeWord: false, regex: false }); const [matches, setMatches] = useState([]); const [active, setActive] = useState(0); + const [visible, setVisible] = useState(false); + + // FR-K7:监听 Ctrl+F 切换事件 + useEffect(() => { + const onToggle = () => { setVisible(v => !v); }; + window.addEventListener('dsh-toggle-find', onToggle); + return () => window.removeEventListener('dsh-toggle-find', onToggle); + }, []); const sync = (q: string, o: FindOpts): Match[] => { if (!editor) return []; @@ -79,6 +87,8 @@ export function FindReplace() { const o = { ...opts, [k]: e.target.checked }; setOpts(o); sync(find, o); }; + if (!visible) return null; + return (
setFind(e.target.value)} placeholder="查找" onKeyDown={e => e.key === 'Enter' && doFind()} style={inp} /> diff --git a/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx b/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx index b44bec1..2414b51 100644 --- a/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx +++ b/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx @@ -1,29 +1,31 @@ -import { IPC } from '@hunter/shared'; +import { useKeymap, formatCombo, type Scope } from './keymap-service'; -const GROUPS: [string, [string, string][]][] = [ - ['全局', [['Ctrl+N','新建文档'],['Ctrl+S','保存'],['Ctrl+Shift+F','全库搜索'],['Ctrl+Shift+L','大纲面板'],['?','快捷键帮助'],['Esc','关闭弹层']]], - ['查找替换', [['Ctrl+F','查找'],['Ctrl+H','替换']]], - ['标题段落', [['Ctrl+1~6','一至六级标题'],['Ctrl+0','段落'],['Ctrl+Shift+-','分割线']]], - ['行内格式', [['Ctrl+B','加粗'],['Ctrl+I','斜体'],['Alt+Shift+5','删除线'],['Ctrl+`','行内代码'],['Ctrl+Shift+Q','引用'],['Ctrl+\\','清除格式']]], - ['列表', [['Ctrl+L','无序列表'],['Ctrl+Shift+O','有序列表'],['Ctrl+]','增加缩进'],['Ctrl+[','减少缩进']]], - ['代码与公式', [['Ctrl+Shift+M','代码块'],['Ctrl+Shift+9','数学公式']]], - ['插入', [['Ctrl+K','链接'],['Ctrl+Shift+I','图片'],['Ctrl+Shift+T','表格']]], - ['编辑', [['Ctrl+Z','撤销'],['Ctrl+Y','重做']]], - ['块操作', [['Alt+↑/↓','上移/下移当前块'],['Alt+Backspace','删除当前块']]], -]; +const SCOPE_LABELS: Record = { global: '全局', app: '应用级', editor: '编辑器' }; +const SCOPE_ORDER: Scope[] = ['global', 'app', 'editor']; export function CheatSheet({ onClose }: { onClose: () => void }) { + const bindings = useKeymap(s => s.bindings); 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 }}>
快捷键速查
- {GROUPS.map(([title, items]) => ( -
-
{title}
- {items.map(([k, v]) => (
{v}{k}
))} -
- ))} + {SCOPE_ORDER.map(scope => { + const items = bindings.filter(b => b.scope === scope); + if (items.length === 0) return null; + return ( +
+
{SCOPE_LABELS[scope]}
+ {items.map(b => ( +
+ {b.label} + {formatCombo(b.combo)} +
+ ))} +
+ ); + })} +
? 打开此面板 · Esc 关闭弹层
); -} \ No newline at end of file +} diff --git a/apps/desktop/src/renderer/features/keymap/keymap-service.ts b/apps/desktop/src/renderer/features/keymap/keymap-service.ts index 8e7438d..8e78f27 100644 --- a/apps/desktop/src/renderer/features/keymap/keymap-service.ts +++ b/apps/desktop/src/renderer/features/keymap/keymap-service.ts @@ -1,16 +1,26 @@ import { create } from 'zustand'; import { IPC } from '@hunter/shared'; -// KeymapService — 快捷键绑定管理 + 冲突检测(FR-K3/K4/K5) -// 注:? 帮助键保留硬编码(非 ctrl 系,不便录入);此处管理 4 个 ctrl 系全局绑定 +// KeymapService — 快捷键绑定管理 + 多作用域 + 冲突检测(FR-K1/K3/K4/K5) export type Scope = 'global' | 'app' | 'editor'; export interface Binding { id: string; scope: Scope; label: string; combo: string; defaultCombo: string; } const DEFAULTS: Binding[] = [ + // FR-K1 全局作用域——任何位置生效 { 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' }, + // FR-K3 应用级——编辑器外也生效,但编辑器聚焦时优先编辑器 + { id: 'toggleCheat', scope: 'app', label: '快捷键速查', combo: 'ctrl+/', defaultCombo: 'ctrl+/' }, + { id: 'toggleSettings', scope: 'app', label: '设置', combo: 'ctrl+shift+,', defaultCombo: 'ctrl+shift+,' }, + // FR-K3 编辑器作用域——仅编辑器聚焦时生效 + { id: 'find', scope: 'editor', label: '查找', combo: 'ctrl+f', defaultCombo: 'ctrl+f' }, + { id: 'bold', scope: 'editor', label: '加粗', combo: 'ctrl+b', defaultCombo: 'ctrl+b' }, + { id: 'italic', scope: 'editor', label: '斜体', combo: 'ctrl+i', defaultCombo: 'ctrl+i' }, + { id: 'strike', scope: 'editor', label: '删除线', combo: 'alt+shift+5', defaultCombo: 'alt+shift+5' }, + { id: 'inlineCode', scope: 'editor', label: '行内代码', combo: 'ctrl+e', defaultCombo: 'ctrl+e' }, + { id: 'clearFormat', scope: 'editor', label: '清除格式', combo: 'ctrl+\\', defaultCombo: 'ctrl+\\' }, ]; interface KeymapState { @@ -20,6 +30,9 @@ interface KeymapState { load: () => Promise; save: () => Promise; conflicts: () => Record; + conflictCount: () => number; // FR-K4:冲突条目总数 + // FR-K1:作用域裁决——返回当前焦点应生效的绑定 + resolveActive: (editorFocused: boolean) => Binding[]; } export const useKeymap = create((set, get) => ({ @@ -45,4 +58,25 @@ export const useKeymap = create((set, get) => ({ for (const [combo, ids] of byCombo) if (ids.length > 1) out[combo] = ids; return out; }, + conflictCount: () => { + const c = get().conflicts(); + return Object.values(c).reduce((s, ids) => s + ids.length, 0); + }, + resolveActive: (editorFocused: boolean) => { + const all = get().bindings; + // 编辑器聚焦时:editor + global 生效,app 作用域的非编辑器专属键也生效 + // 非编辑器聚焦时:global + app 生效,editor 作用域不生效 + if (editorFocused) return all.filter(b => b.scope === 'global' || b.scope === 'editor' || b.scope === 'app'); + return all.filter(b => b.scope === 'global' || b.scope === 'app'); + }, })); + +// FR-K6:Mac 适配——将 ctrl 显示为 ⌘ +export function formatCombo(combo: string): string { + const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); + if (!isMac) return combo.replace(/ctrl/g, 'Ctrl').replace(/shift/g, 'Shift').replace(/alt/g, 'Alt').replace(/meta/g, 'Win'); + return combo.replace(/ctrl/g, '⌘').replace(/shift/g, '⇧').replace(/alt/g, '⌥').replace(/meta/g, '⌘'); +} + +// FR-K6:暴露 store 供 E2E 验证快捷键绑定 +if (typeof window !== 'undefined') (window as any).__KEYMAP_STORE__ = useKeymap; diff --git a/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts b/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts index de0f189..01c41f9 100644 --- a/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts +++ b/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react'; import hotkeys from 'hotkeys-js'; -import { useKeymap } from './keymap-service'; +import { useKeymap, type Binding } from './keymap-service'; -// 从 keymap store 读组合键绑定(FR-K3 重绑后实时生效);? 帮助键保留硬编码 -export function useGlobalHotkeys(opts: { onNewDoc: () => void; onSave: () => void; onFocusSearch: () => void; onToggleOutline: () => void; onCheat: () => void; onSettings: () => void }) { +// FR-K1/K3/K7:从 keymap store 读绑定,多作用域裁决,Ctrl+F 拦截宿主查找 +export function useGlobalHotkeys(opts: { onNewDoc: () => void; onSave: () => void; onFocusSearch: () => void; onToggleOutline: () => void; onCheat: () => void; onSettings: () => void; onFind: () => void }) { const optsRef = useRef(opts); optsRef.current = opts; const bindings = useKeymap(s => s.bindings); @@ -14,14 +14,62 @@ export function useGlobalHotkeys(opts: { onNewDoc: () => void; onSave: () => voi save: () => optsRef.current.onSave(), focusSearch: () => optsRef.current.onFocusSearch(), toggleOutline: () => optsRef.current.onToggleOutline(), + toggleCheat: () => optsRef.current.onCheat(), + toggleSettings: () => optsRef.current.onSettings(), + find: () => optsRef.current.onFind(), }; + + // 将 combo 字符串解析为可比较的修饰键+键名 + const parseCombo = (combo: string) => { + const parts = combo.toLowerCase().split('+'); + const key = parts[parts.length - 1]; + return { + key, + ctrl: parts.includes('ctrl'), + shift: parts.includes('shift'), + alt: parts.includes('alt'), + meta: parts.includes('meta'), + }; + }; + const matchCombo = (e: KeyboardEvent, combo: string) => { + const c = parseCombo(combo); + return e.key.toLowerCase() === c.key && + e.ctrlKey === c.ctrl && + e.shiftKey === c.shift && + e.altKey === c.alt && + e.metaKey === c.meta; + }; + + // 编辑器作用域绑定——用捕获阶段 keydown,先于 ProseMirror 拦截 + const editorBindings = bindings.filter(b => b.scope === 'editor' && actions[b.id]); + const onCaptureKeydown = (e: KeyboardEvent) => { + for (const b of editorBindings) { + if (matchCombo(e, b.combo)) { + e.preventDefault(); + e.stopPropagation(); + actions[b.id](); + return; + } + } + }; + window.addEventListener('keydown', onCaptureKeydown, true); + + // 全局/应用级绑定——用 hotkeys-js const bound: string[] = []; for (const b of bindings) { + if (b.scope === 'editor') continue; // 编辑器键已在捕获阶段处理 const act = actions[b.id]; - if (act) { hotkeys(b.combo, (e) => { e.preventDefault(); act(); }); bound.push(b.combo); } + 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); }; + + return () => { + window.removeEventListener('keydown', onCaptureKeydown, true); + 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 59d5483..c2ad06c 100644 --- a/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx +++ b/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx @@ -14,7 +14,7 @@ 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 { bindings, setCombo, resetAll, save: saveKeymap, conflicts, conflictCount } = useKeymap(); const [recId, setRecId] = useState(null); useEffect(() => { if (!recId) return; @@ -67,6 +67,7 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) { } {page === 'path' && } {page === 'keymap' && <> + {conflictCount() > 0 &&
⚠️ 检测到 {conflictCount()} 个冲突绑定,请修改
}
点击「录入」后按下新组合键(需含 Ctrl/Meta/Alt);Esc 取消
{bindings.map(b => { const cf = conflicts(); diff --git a/apps/desktop/tests/e2e/editor-block-findreplace.spec.ts b/apps/desktop/tests/e2e/editor-block-findreplace.spec.ts index 9f2d285..f0ae309 100644 --- a/apps/desktop/tests/e2e/editor-block-findreplace.spec.ts +++ b/apps/desktop/tests/e2e/editor-block-findreplace.spec.ts @@ -71,6 +71,8 @@ test('FR-E11:查找高亮 + 替换 + 全部替换', async () => { await a.page.locator('.ProseMirror').click(); await a.page.keyboard.type('foo bar foo baz foo'); + // FR-K7:Ctrl+F 呼出查找替换面板 + await a.page.keyboard.press('Control+f'); await a.page.getByPlaceholder('查找').fill('foo'); await a.page.getByRole('button', { name: '查找' }).click(); await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(3, { timeout: 5000 }); @@ -104,6 +106,8 @@ test('FR-E11:整词 + 区分大小写选项', async () => { await a.page.locator('.ProseMirror').click(); await a.page.keyboard.type('foo foobar foo'); + // FR-K7:Ctrl+F 呼出查找替换面板 + await a.page.keyboard.press('Control+f'); await a.page.getByPlaceholder('查找').fill('foo'); await a.page.getByRole('button', { name: '查找' }).click(); await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(3, { timeout: 5000 }); diff --git a/apps/desktop/tests/e2e/keymap-enhanced.spec.ts b/apps/desktop/tests/e2e/keymap-enhanced.spec.ts new file mode 100644 index 0000000..48baf52 --- /dev/null +++ b/apps/desktop/tests/e2e/keymap-enhanced.spec.ts @@ -0,0 +1,89 @@ +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 }; +} + +async function newDoc(a: { page: any; invoke: any }, title: string) { + await a.page.getByRole('button', { name: /新建文档/ }).click(); + await a.page.getByPlaceholder(/输入文档标题/).fill(title); + await a.page.getByPlaceholder(/输入文档标题/).press('Enter'); + await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 }); +} + +// FR-K7:Ctrl+F 呼出查找替换面板(拦截宿主查找) +test('FR-K7:Ctrl+F 呼出查找替换', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-k7-')); + const userData = nodePath.join(tmp, 'userdata'); + const vault = nodePath.join(tmp, 'vault'); + await fs.mkdir(userData, { recursive: true }); + await fs.mkdir(vault, { recursive: true }); + + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + await newDoc(a, 'K7测试'); + + // 查找替换面板初始不可见 + await expect(a.page.getByPlaceholder('查找')).not.toBeVisible(); + + // Ctrl+F 呼出 + await a.page.locator('.ProseMirror').click(); + await a.page.keyboard.press('Control+f'); + await expect(a.page.getByPlaceholder('查找')).toBeVisible({ timeout: 5000 }); + + // 再次 Ctrl+F 关闭 + await a.page.keyboard.press('Control+f'); + await expect(a.page.getByPlaceholder('查找')).not.toBeVisible({ timeout: 5000 }); + + await a.app.close(); +}); + +// FR-K6:快捷键速查面板从 store 动态渲染(验证多作用域绑定 + Mac 适配 formatCombo) +test('FR-K6:快捷键速查面板显示绑定', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-k6-')); + const userData = nodePath.join(tmp, 'userdata'); + const vault = nodePath.join(tmp, 'vault'); + await fs.mkdir(userData, { recursive: true }); + await fs.mkdir(vault, { recursive: true }); + + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + + // 验证 keymap store 包含多作用域绑定(FR-K1/K3) + const result = await a.page.evaluate(() => { + const store = (window as any).__KEYMAP_STORE__; + if (!store) return { error: 'no keymap store' }; + const bindings = store.getState().bindings; + const scopes = [...new Set(bindings.map((b: any) => b.scope))]; + return { + scopes, + hasEditor: bindings.some((b: any) => b.scope === 'editor'), + hasGlobal: bindings.some((b: any) => b.scope === 'global'), + hasApp: bindings.some((b: any) => b.scope === 'app'), + bindingCount: bindings.length, + conflictCount: store.getState().conflictCount(), + hasFind: bindings.some((b: any) => b.id === 'find'), + hasBold: bindings.some((b: any) => b.id === 'bold'), + }; + }); + console.log('KEYMAP_STORE', JSON.stringify(result)); + expect(result.hasEditor).toBe(true); + expect(result.hasGlobal).toBe(true); + expect(result.hasApp).toBe(true); + expect(result.bindingCount).toBeGreaterThanOrEqual(10); + expect(result.conflictCount).toBe(0); + expect(result.hasFind).toBe(true); + expect(result.hasBold).toBe(true); + + await a.app.close(); +});