fix(hotkeys): 快捷键系统性修复——1.Ctrl+N onNewDoc 原为空函数, 实现新建文档全流程(dialog+fileCreate+openDoc); 2.Ctrl+backslash clearFormat 原无处理器(actions 缺失), 加 onClearFormat 调用 editor.chain().clearNodes().unsetAllMarks(); 3.use-hotkeys 重写: 移除 hotkeys-js 依赖, 全部绑定统一用捕获阶段 keydown 拦截(先于 Electron 菜单), 加 e.code 符号键映射修复 Shift+符号键匹配; 4.编辑器作用域键在输入框聚焦时仍放行 find(Ctrl+F); keymap-all E2E 8 项覆盖全部 12 默认快捷键

This commit is contained in:
hunter-dev
2026-08-26 19:05:01 +08:00
parent a6458c03e9
commit 8c47d27979
3 changed files with 234 additions and 40 deletions
+24 -1
View File
@@ -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]);
@@ -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<string, string> = { 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]);
}