Files
MyNote/apps/desktop/src/renderer/features/keymap/keymap-service.ts
T

131 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { create } from 'zustand';
import { IPC } from '@hunter/shared';
// 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; }
// 完整 Typora 快捷键列表(用户指定为标准)——所有 combo 唯一、无冲突
// 全局/应用级绑定由 use-hotkeys 真正驱动;编辑器绑定由 Tiptap(TyporaKeys) 以同样默认值驱动
const DEFAULTS: Binding[] = [
// ── 📁 文件操作(全局)──
{ id: 'newDoc', scope: 'global', label: '新建文件', combo: 'ctrl+n', defaultCombo: 'ctrl+n' },
{ id: 'newWindow', scope: 'global', label: '新建窗口', combo: 'ctrl+shift+n', defaultCombo: 'ctrl+shift+n' },
{ id: 'openFile', scope: 'global', label: '打开文件', combo: 'ctrl+o', defaultCombo: 'ctrl+o' },
{ id: 'quickOpen', scope: 'global', label: '快速打开历史文件', combo: 'ctrl+p', defaultCombo: 'ctrl+p' },
{ id: 'save', scope: 'global', label: '保存', combo: 'ctrl+s', defaultCombo: 'ctrl+s' },
{ id: 'saveAs', scope: 'global', label: '另存为', combo: 'ctrl+shift+s', defaultCombo: 'ctrl+shift+s' },
{ id: 'toggleSettings', scope: 'app', label: '偏好设置', combo: 'ctrl+,', defaultCombo: 'ctrl+,' },
{ id: 'closeTab', scope: 'global', label: '关闭标签页', combo: 'ctrl+w', defaultCombo: 'ctrl+w' },
{ id: 'reopenTab', scope: 'global', label: '重新打开关闭的文档', combo: 'ctrl+shift+t', defaultCombo: 'ctrl+shift+t' },
// ── ✏️ 基础编辑(编辑器)──
{ id: 'find', scope: 'editor', label: '查找', combo: 'ctrl+f', defaultCombo: 'ctrl+f' },
{ id: 'replace', scope: 'editor', label: '替换', combo: 'ctrl+h', defaultCombo: 'ctrl+h' },
{ id: 'copyMd', scope: 'global', label: '复制为 Markdown 源码', combo: 'ctrl+shift+c', defaultCombo: 'ctrl+shift+c' },
{ id: 'pastePlain', scope: 'editor', label: '粘贴纯文本', combo: 'ctrl+shift+v', defaultCombo: 'ctrl+shift+v' },
{ id: 'selectLine', scope: 'editor', label: '选中当前行', combo: 'ctrl+l', defaultCombo: 'ctrl+l' },
{ id: 'selectWord', scope: 'editor', label: '选中当前单词', combo: 'ctrl+d', defaultCombo: 'ctrl+d' },
{ id: 'selectSameFormat', scope: 'editor', label: '选中相同格式文本', combo: 'ctrl+e', defaultCombo: 'ctrl+e' },
{ id: 'deleteWord', scope: 'editor', label: '删除当前单词', combo: 'ctrl+shift+d', defaultCombo: 'ctrl+shift+d' },
{ id: 'docStart', scope: 'editor', label: '跳到文档开头', combo: 'ctrl+home', defaultCombo: 'ctrl+home' },
{ id: 'docEnd', scope: 'editor', label: '跳到文档末尾', combo: 'ctrl+end', defaultCombo: 'ctrl+end' },
// ── 📝 Markdown 格式(编辑器)──
{ id: 'bold', scope: 'editor', label: '加粗', combo: 'ctrl+b', defaultCombo: 'ctrl+b' },
{ id: 'italic', scope: 'editor', label: '斜体', combo: 'ctrl+i', defaultCombo: 'ctrl+i' },
{ id: 'underline', scope: 'editor', label: '下划线', combo: 'ctrl+u', defaultCombo: 'ctrl+u' },
{ id: 'strike', scope: 'editor', label: '删除线', combo: 'alt+shift+5', defaultCombo: 'alt+shift+5' },
{ id: 'inlineCode', scope: 'editor', label: '行内代码', combo: 'ctrl+shift+`', defaultCombo: 'ctrl+shift+`' },
{ id: 'link', scope: 'editor', label: '插入链接', combo: 'ctrl+k', defaultCombo: 'ctrl+k' },
{ id: 'insertImage', scope: 'editor', label: '插入图片', combo: 'ctrl+shift+i', defaultCombo: 'ctrl+shift+i' },
{ id: 'clearFormat', scope: 'editor', label: '清除格式', combo: 'ctrl+\\', defaultCombo: 'ctrl+\\' },
// ── 📑 段落、标题、块元素(编辑器)──
{ id: 'heading1', scope: 'editor', label: '一级标题', combo: 'ctrl+1', defaultCombo: 'ctrl+1' },
{ id: 'heading2', scope: 'editor', label: '二级标题', combo: 'ctrl+2', defaultCombo: 'ctrl+2' },
{ id: 'heading3', scope: 'editor', label: '三级标题', combo: 'ctrl+3', defaultCombo: 'ctrl+3' },
{ id: 'heading4', scope: 'editor', label: '四级标题', combo: 'ctrl+4', defaultCombo: 'ctrl+4' },
{ id: 'heading5', scope: 'editor', label: '五级标题', combo: 'ctrl+5', defaultCombo: 'ctrl+5' },
{ id: 'heading6', scope: 'editor', label: '六级标题', combo: 'ctrl+6', defaultCombo: 'ctrl+6' },
{ id: 'paragraph', scope: 'editor', label: '转为普通段落', combo: 'ctrl+0', defaultCombo: 'ctrl+0' },
{ id: 'headingUp', scope: 'editor', label: '标题级别升高', combo: 'ctrl+=', defaultCombo: 'ctrl+=' },
{ id: 'headingDown', scope: 'editor', label: '标题级别降低', combo: 'ctrl+-', defaultCombo: 'ctrl+-' },
{ id: 'blockquote', scope: 'editor', label: '引用块', combo: 'ctrl+shift+q', defaultCombo: 'ctrl+shift+q' },
{ id: 'bulletList', scope: 'editor', label: '无序列表', combo: 'ctrl+shift+]', defaultCombo: 'ctrl+shift+]' },
{ id: 'orderedList', scope: 'editor', label: '有序列表', combo: 'ctrl+shift+[', defaultCombo: 'ctrl+shift+[' },
{ id: 'codeBlock', scope: 'editor', label: '代码块', combo: 'ctrl+shift+k', defaultCombo: 'ctrl+shift+k' },
{ id: 'mathBlock', scope: 'editor', label: '公式块', combo: 'ctrl+shift+m', defaultCombo: 'ctrl+shift+m' },
{ id: 'table', scope: 'editor', label: '插入表格', combo: 'ctrl+t', defaultCombo: 'ctrl+t' },
{ id: 'indent', scope: 'editor', label: '增加缩进', combo: 'ctrl+]', defaultCombo: 'ctrl+]' },
{ id: 'outdent', scope: 'editor', label: '减少缩进', combo: 'ctrl+[', defaultCombo: 'ctrl+[' },
{ id: 'moveUp', scope: 'editor', label: '段落上移', combo: 'ctrl+shift+up', defaultCombo: 'ctrl+shift+up' },
{ id: 'moveDown', scope: 'editor', label: '段落下移', combo: 'ctrl+shift+down', defaultCombo: 'ctrl+shift+down' },
// ── 👁️ 视图(全局/应用)──
{ id: 'toggleSidebar', scope: 'global', label: '显示/隐藏侧边栏', combo: 'ctrl+shift+l', defaultCombo: 'ctrl+shift+l' },
{ id: 'toggleOutline', scope: 'global', label: '大纲视图', combo: 'ctrl+shift+1', defaultCombo: 'ctrl+shift+1' },
{ id: 'toggleInfo', scope: 'global', label: '信息面板', combo: 'ctrl+shift+2', defaultCombo: 'ctrl+shift+2' },
{ id: 'fileTree', scope: 'global', label: '文件树视图', combo: 'ctrl+shift+3', defaultCombo: 'ctrl+shift+3' },
{ id: 'focusMode', scope: 'global', label: '专注模式', combo: 'f8', defaultCombo: 'f8' },
{ id: 'typewriter', scope: 'global', label: '打字机模式', combo: 'f9', defaultCombo: 'f9' },
{ id: 'fullscreen', scope: 'global', label: '全屏', combo: 'f11', defaultCombo: 'f11' },
{ id: 'focusSearch', scope: 'global', label: '全库搜索', combo: 'ctrl+shift+f', defaultCombo: 'ctrl+shift+f' },
// FR-K3 应用级——快捷键速查
{ id: 'toggleCheat', scope: 'app', label: '快捷键速查', combo: 'ctrl+/', defaultCombo: 'ctrl+/' },
];
interface KeymapState {
bindings: Binding[];
setCombo: (id: string, combo: string) => void;
resetAll: () => void;
load: () => Promise<void>;
save: () => Promise<void>;
conflicts: () => Record<string, string[]>;
conflictCount: () => number; // FR-K4:冲突条目总数
// FR-K1:作用域裁决——返回当前焦点应生效的绑定
resolveActive: (editorFocused: boolean) => Binding[];
}
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 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: () => {
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;