131 lines
9.4 KiB
TypeScript
131 lines
9.4 KiB
TypeScript
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;
|