diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index f2fc53a..369393a 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -1,5 +1,9 @@ import { useState, useEffect } from 'react'; import { useStore } from './store'; +import { invoke } from './ipc-client'; +import { IPC } from '@hunter/shared'; +import { useDialog } from './features/dialog/dialog-store'; +import { useEditorShared } from './features/editor/editor-shared-store'; import { FileTree } from './features/file-tree/FileTree'; import { Editor } from './features/editor/Editor'; import { Toolbar } from './features/toolbar/Toolbar'; @@ -47,7 +51,26 @@ export function App() { window.addEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload); }, []); - 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')); } }); + useGlobalHotkeys({ + onNewDoc: async () => { + const { showInput } = useDialog.getState(); + const name = await showInput('新建文档', '输入文档标题…'); + if (!name) return; + const { path } = await invoke(IPC.fileCreate, { basePath: '', name, isFolder: false }); + await useStore.getState().loadTree(); + await useStore.getState().openDoc(path); + }, + 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')); }, + onClearFormat: () => { + const ed = useEditorShared.getState().editor; + ed?.chain().focus().clearNodes().unsetAllMarks().run(); + }, + }); // 编辑器样式随设置生效(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/keymap/use-hotkeys.ts b/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts index 01c41f9..50313c8 100644 --- a/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts +++ b/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts @@ -1,9 +1,8 @@ import { useEffect, useRef } from 'react'; -import hotkeys from 'hotkeys-js'; -import { useKeymap, type Binding } from './keymap-service'; +import { useKeymap } from './keymap-service'; -// 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 }) { +// FR-K1/K3/K7:从 keymap store 读绑定,统一用捕获阶段 keydown 拦截(先于 ProseMirror / Electron 菜单) +export function useGlobalHotkeys(opts: { onNewDoc: () => void; onSave: () => void; onFocusSearch: () => void; onToggleOutline: () => void; onCheat: () => void; onSettings: () => void; onFind: () => void; onClearFormat: () => void }) { const optsRef = useRef(opts); optsRef.current = opts; const bindings = useKeymap(s => s.bindings); @@ -17,33 +16,37 @@ export function useGlobalHotkeys(opts: { onNewDoc: () => void; onSave: () => voi toggleCheat: () => optsRef.current.onCheat(), toggleSettings: () => optsRef.current.onSettings(), find: () => optsRef.current.onFind(), + clearFormat: () => optsRef.current.onClearFormat(), }; - // 将 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'), - }; + return { key, ctrl: parts.includes('ctrl'), shift: parts.includes('shift'), alt: parts.includes('alt'), meta: parts.includes('meta') }; }; + const codeToKey: Record = { Comma: ',', Period: '.', Slash: '/', Backslash: '\\', Semicolon: ';', Quote: "'", BracketLeft: '[', BracketRight: ']', Equal: '=', Minus: '-', Backquote: '`' }; 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; + const key = codeToKey[e.code] ?? e.key.toLowerCase(); + return key === 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]); + // 判断编辑器是否聚焦(编辑器作用域键仅编辑器聚焦时生效) + // 统一捕获阶段 keydown——处理所有作用域绑定 const onCaptureKeydown = (e: KeyboardEvent) => { - for (const b of editorBindings) { + if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { + const tag = document.activeElement?.tagName; + if (tag !== 'INPUT' && tag !== 'TEXTAREA') { + e.preventDefault(); + optsRef.current.onCheat(); + return; + } + } + const tag = document.activeElement?.tagName; + const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + for (const b of bindings) { + if (!actions[b.id]) continue; + if (b.scope === 'editor' && inInput && b.id !== 'find') continue; if (matchCombo(e, b.combo)) { e.preventDefault(); e.stopPropagation(); @@ -53,23 +56,6 @@ export function useGlobalHotkeys(opts: { onNewDoc: () => void; onSave: () => voi } }; 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); - } - } - hotkeys('?', (e) => { if (document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') { e.preventDefault(); optsRef.current.onCheat(); } }); - bound.push('?'); - - return () => { - window.removeEventListener('keydown', onCaptureKeydown, true); - for (const c of bound) hotkeys.unbind(c); - }; - }, [sig]); + return () => window.removeEventListener('keydown', onCaptureKeydown, true); + }, [sig, bindings]); } diff --git a/apps/desktop/tests/e2e/keymap-all.spec.ts b/apps/desktop/tests/e2e/keymap-all.spec.ts new file mode 100644 index 0000000..95899a6 --- /dev/null +++ b/apps/desktop/tests/e2e/keymap-all.spec.ts @@ -0,0 +1,185 @@ +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 }); +} + +// 1. Ctrl+N 新建文档(全局) +test('快捷键 Ctrl+N 新建文档', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-kn-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await a.page.getByPlaceholder(/搜索/).click(); // 聚焦到非编辑器区域 + await a.page.keyboard.press('Control+n'); + await expect(a.page.getByPlaceholder(/文档标题/)).toBeVisible({ timeout: 5000 }); + await a.page.keyboard.press('Escape'); + await a.app.close(); +}); + +// 2. Ctrl+S 保存(全局) +test('快捷键 Ctrl+S 保存', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-ks-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await newDoc(a, 'SaveTest'); + await a.page.locator('.ProseMirror').click(); + await a.page.keyboard.type('hello'); + await a.page.keyboard.press('Control+s'); + await a.page.waitForTimeout(1000); + // 检查 store 中 saveState 应为 saved + const saveState = await a.page.evaluate(() => (window as any).__STORE__?.getState?.().saveState); + expect(saveState).toBe('saved'); + const content = await fs.readFile(nodePath.join(v, 'SaveTest.md'), 'utf8'); + expect(content).toContain('hello'); + await a.app.close(); +}); + +// 3. Ctrl+Shift+F 全库搜索(全局) +test('快捷键 Ctrl+Shift+F 聚焦搜索框', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-ksf-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await newDoc(a, 'SearchHK'); + await a.page.locator('.ProseMirror').click(); // 聚焦编辑器 + await a.page.keyboard.press('Control+Shift+f'); + await a.page.waitForTimeout(300); + const focused = await a.page.evaluate(() => { + const el = document.activeElement; + return el?.getAttribute('placeholder') ?? ''; + }); + expect(focused).toContain('搜索'); + await a.app.close(); +}); + +// 4. Ctrl+Shift+L 大纲面板切换(全局) +test('快捷键 Ctrl+Shift+L 大纲面板切换', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-ksl-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await newDoc(a, 'OutlineTest'); + await a.page.locator('.ProseMirror').click(); + await a.page.keyboard.type('# Heading 1'); + await a.page.waitForTimeout(300); + // 切换大纲面板——检查 layout store 的 outlineVisible 状态 + const before = await a.page.evaluate(() => { + // layout store is not exposed, check if outline tab buttons exist + return document.querySelectorAll('button').length > 0; // just a placeholder + }); + // Use evaluate to check the outline panel visibility via DOM + const beforePanel = await a.page.evaluate(() => { + const btns = Array.from(document.querySelectorAll('button')); + return btns.some(b => b.textContent === '大纲'); + }); + await a.page.keyboard.press('Control+Shift+l'); + await a.page.waitForTimeout(500); + const afterPanel = await a.page.evaluate(() => { + const btns = Array.from(document.querySelectorAll('button')); + return btns.some(b => b.textContent === '大纲'); + }); + expect(beforePanel).not.toBe(afterPanel); + await a.app.close(); +}); + +// 5. Ctrl+/ 快捷键速查(应用级) +test('快捷键 Ctrl+/ 速查面板', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-kslash-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await a.page.keyboard.press('Control+/'); + await expect(a.page.getByText('快捷键速查').first()).toBeVisible({ timeout: 5000 }); + await a.page.keyboard.press('Escape'); + await a.app.close(); +}); + +// 6. Ctrl+Shift+, 设置(应用级) +test('快捷键 Ctrl+Shift+, 设置面板', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-ksc-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await a.page.keyboard.press('Control+Shift+Comma'); + await expect(a.page.getByText('设置', { exact: true }).first()).toBeVisible({ timeout: 5000 }); + await a.page.keyboard.press('Escape'); + await a.app.close(); +}); + +// 7. 编辑器快捷键:Ctrl+B 加粗 / Ctrl+I 斜体 / Ctrl+E 行内代码 / Alt+Shift+5 删除线 / Ctrl+\ 清除格式 +test('编辑器快捷键:加粗+斜体+行内代码+删除线+清除格式', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-ke-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await newDoc(a, 'FmtHK'); + await a.page.locator('.ProseMirror').click(); + await a.page.keyboard.type('format test'); + // 全选 + await a.page.keyboard.press('Control+a'); + // 加粗 + await a.page.keyboard.press('Control+b'); + await a.page.waitForTimeout(100); + let html = await a.page.locator('.ProseMirror').innerHTML(); + expect(html).toContain('strong'); + // 斜体 + await a.page.keyboard.press('Control+i'); + await a.page.waitForTimeout(100); + html = await a.page.locator('.ProseMirror').innerHTML(); + expect(html).toContain('em'); + // 行内代码 + await a.page.keyboard.press('Control+a'); + await a.page.keyboard.press('Control+e'); + await a.page.waitForTimeout(100); + html = await a.page.locator('.ProseMirror').innerHTML(); + expect(html).toContain('code'); + // 清除格式 (Ctrl+\) + await a.page.keyboard.press('Control+a'); + await a.page.keyboard.press('Control+\\'); + await a.page.waitForTimeout(300); + html = await a.page.locator('.ProseMirror').innerHTML(); + expect(html).not.toContain(''); + await a.app.close(); +}); + +// 8. 编辑器快捷键:Ctrl+F 查找(已有测试覆盖,这里快速验证) +test('编辑器快捷键 Ctrl+F 查找面板', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-kf-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await newDoc(a, 'FindHK'); + await a.page.locator('.ProseMirror').click(); + await a.page.keyboard.press('Control+f'); + await expect(a.page.getByPlaceholder('查找')).toBeVisible({ timeout: 5000 }); + await a.page.keyboard.press('Control+f'); + await expect(a.page.getByPlaceholder('查找')).not.toBeVisible({ timeout: 5000 }); + await a.app.close(); +});