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>
This commit is contained in:
mes123456
2026-11-13 09:12:03 +08:00
parent 9150815cbb
commit ebf1e8e873
7 changed files with 941 additions and 52 deletions

110
web/protocol/keymap.ts Normal file
View File

@@ -0,0 +1,110 @@
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(" + ");
}