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>
111 lines
5.7 KiB
TypeScript
111 lines
5.7 KiB
TypeScript
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(" + ");
|
||
}
|