export type KeymapScope = "global" | "viewport" | "outliner" | "timeline" | "graph"; export interface KeymapActionDefinition { id: string; label: string; scope: KeymapScope; defaultChord: string; description?: string; } export const KEYMAP_ACTIONS: KeymapActionDefinition[] = [ { id: "file.open", label: "打开 .blend", scope: "global", defaultChord: "Ctrl+O" }, { id: "file.save", label: "保存到 OPFS", scope: "global", defaultChord: "Ctrl+S" }, { id: "file.saveAs", label: "另存为(OPFS)", scope: "global", defaultChord: "Ctrl+Shift+S" }, { id: "edit.undo", label: "撤销", scope: "global", defaultChord: "Ctrl+Z" }, { id: "edit.redo", label: "重做", scope: "global", defaultChord: "Ctrl+Y" }, { id: "edit.operatorSearch", label: "操作搜索", scope: "global", defaultChord: "F3" }, { id: "edit.rename", label: "重命名活动项", scope: "global", defaultChord: "F2" }, { id: "object.delete", label: "删除对象", scope: "viewport", defaultChord: "Delete" }, { id: "object.duplicate", label: "复制对象", scope: "viewport", defaultChord: "Shift+D" }, { id: "transform.translate", label: "移动", scope: "viewport", defaultChord: "G" }, { id: "transform.rotate", label: "旋转", scope: "viewport", defaultChord: "R" }, { id: "transform.scale", label: "缩放", scope: "viewport", defaultChord: "S" }, { id: "view.camera", label: "相机视图(切换)", scope: "viewport", defaultChord: "Numpad0" }, { id: "view.cameraToView", label: "对齐相机到视图", scope: "viewport", defaultChord: "Ctrl+Alt+Numpad0" }, { id: "view.cameraLock", label: "锁定相机到视图(Camera to View)", scope: "viewport", defaultChord: "" }, { id: "view.frameAll", label: "显示全部", scope: "viewport", defaultChord: "Home" }, { id: "view.frameSelected", label: "框显所选", scope: "viewport", defaultChord: "NumpadDecimal" }, ]; const ACTION_BY_ID = new Map(KEYMAP_ACTIONS.map((action) => [action.id, action])); const RESERVED = new Set(["Meta+W", "Meta+T", "Meta+N", "Meta+Q", "Ctrl+W", "Ctrl+T", "Ctrl+N", "Alt+F4"]); export function actionDefinition(id: string): KeymapActionDefinition | undefined { return ACTION_BY_ID.get(id); } export function chordFromEvent(event: Pick): string { const parts: string[] = []; if (event.ctrlKey) parts.push("Ctrl"); if (event.altKey) parts.push("Alt"); if (event.shiftKey) parts.push("Shift"); if (event.metaKey) parts.push("Meta"); let key = event.key; if (event.code.startsWith("Numpad") && event.code !== "NumpadEnter") key = event.code; else if (key === " " || key === "Spacebar") key = "Space"; else if (key.length === 1) key = key.toUpperCase(); if (!["Control", "Alt", "Shift", "Meta"].includes(key)) parts.push(key); return parts.join("+"); } export function normalizeChord(chord: string): string { const parts = chord.split("+").map((part) => part.trim()).filter(Boolean); if (parts.length === 0) return ""; const modifiers = new Set(parts.filter((part) => ["Ctrl", "Alt", "Shift", "Meta"].includes(part))); const keys = parts.filter((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part)); if (keys.length !== 1) return chord.trim(); const ordered = ["Ctrl", "Alt", "Shift", "Meta"].filter((modifier) => modifiers.has(modifier)); let key = keys[0]; if (key.length === 1) key = key.toUpperCase(); if (key === " ") key = "Space"; return [...ordered, key].join("+"); } export function effectiveBindings(overrides: Record): Record { const bindings: Record = {}; for (const action of KEYMAP_ACTIONS) { const override = overrides[action.id]; bindings[action.id] = normalizeChord(typeof override === "string" ? override : action.defaultChord); } return bindings; } export function findKeymapConflicts(bindings: Record): Array<{ chord: string; actions: string[] }> { const byChord = new Map(); for (const action of KEYMAP_ACTIONS) { const chord = normalizeChord(bindings[action.id] ?? action.defaultChord); if (!chord) continue; const list = byChord.get(chord) ?? []; list.push(action.id); byChord.set(chord, list); } return [...byChord.entries()].filter(([, actions]) => actions.length > 1).map(([chord, actions]) => ({ chord, actions })); } export function resolveKeymapAction(event: KeyboardEvent, bindings: Record, scope: KeymapScope): KeymapActionDefinition | null { const chord = chordFromEvent(event); for (const action of KEYMAP_ACTIONS) { if (action.scope !== scope && action.scope !== "global") continue; if (normalizeChord(bindings[action.id] ?? action.defaultChord) === chord) return action; } return null; } export function validateKeymapBinding(id: string, chord: string, bindings: Record): { ok: boolean; reason?: string } { const action = ACTION_BY_ID.get(id); if (!action) return { ok: false, reason: "未知操作" }; const normalized = normalizeChord(chord); if (!normalized) return { ok: true }; if (RESERVED.has(normalized)) return { ok: false, reason: `${normalized} 被浏览器保留` }; if (action.defaultChord && normalized === normalizeChord(action.defaultChord)) return { ok: true }; const conflict = KEYMAP_ACTIONS.find((candidate) => candidate.id !== id && ["global", action.scope].includes(candidate.scope) && normalizeChord(bindings[candidate.id] ?? candidate.defaultChord) === normalized); return conflict ? { ok: false, reason: `与“${conflict.label}”冲突` } : { ok: true }; } export function describeChord(chord: string): string { return chord.split("+").map((part) => part === "Ctrl" ? "Ctrl" : part === "Alt" ? "Alt" : part === "Shift" ? "Shift" : part === "Meta" ? "Meta" : part).join(" + "); }