Files
workinf_Blender_Wasm/web/protocol/keymap.ts
mes123456 ebf1e8e873 feat(web): add OPFS-backed preferences and editable top-level keymap
Preferences now live in settings/web-interface.json (OPFS canonical,
localStorage mirror/migration) and render as a tabbed React window with
desktop-source annotations for unsupported items. Top-level shortcuts
move to a central registry that supports rebinding, conflict detection
and persistence; F3 and other migrated handlers honor the new keymap.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-13 09:12:03 +08:00

111 lines
5.7 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.
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: "X" },
{ 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<KeyboardEvent, "key" | "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey">): 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<string, string>): Record<string, string> {
const bindings: Record<string, string> = {};
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<string, string>): Array<{ chord: string; actions: string[] }> {
const byChord = new Map<string, string[]>();
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<string, string>, 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<string, string>): { 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(" + ");
}