Files
workinf_Blender_Wasm/web/protocol/web-preferences.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

283 lines
16 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.
import type { EditorType } from "./ui-schema";
export const WEB_PREFERENCES_SCHEMA_VERSION = 1 as const;
export const WEB_SETTINGS_STORAGE_KEY = "web-blender-interface-v2";
export const LEGACY_SETTINGS_STORAGE_KEY = "web-blender-interface-v1";
export const WEB_SETTINGS_PATH = "settings/web-interface.json";
export type PreferenceTab = "interface" | "viewport" | "animation" | "input" | "keymap" | "saveLoad" | "files" | "themes" | "system";
export interface WebPreferencesIR {
schemaVersion: typeof WEB_PREFERENCES_SCHEMA_VERSION;
interface: {
showStatus: boolean;
quickTools: boolean;
showAreaSwitcher: boolean;
showObjectInfo: boolean;
showViewName: boolean;
showPlaybackFps: boolean;
playbackFpsSamples: number;
recentFiles: number;
statusbarVersion: boolean;
statusbarExtensions: boolean;
startupWorkspace: string;
};
viewport: {
gizmoSize: number;
gizmoSizeNavigate: number;
miniAxis: "MINIMAL" | "GIZMO";
viewportAA: number;
overlaySmoothWire: boolean;
navigationSensitivity: number;
defaultShading: "SOLID" | "MATERIAL" | "RENDERED";
};
animation: {
useAutoKeying: boolean;
autoKeyingMode: "ADD_REPLACE_KEYS" | "REPLACE_KEYS";
keyframeInterpolation: "BEZIER" | "LINEAR" | "CONSTANT";
keyframeHandle: "AUTO_CLAMPED" | "AUTO" | "VECTOR" | "ALIGNED" | "FREE";
};
navigation: {
orbitSensitivity: number;
zoomSensitivity: number;
invertZoom: boolean;
};
keymap: {
activeKeyconfig: string;
bindings: Record<string, string>;
};
saveLoad: {
autoSaveTimeMinutes: number;
useAutoSaveTemporaryFiles: boolean;
saveVersion: number;
usePreferencesSave: boolean;
};
files: {
note: string;
};
}
export interface AreaLayoutRecord {
layoutMode: "custom";
areas: Array<{
id: string;
editor: EditorType;
contentKey: string | null;
rect: { x: number; y: number; width: number; height: number };
view3d?: { cameraLock: boolean };
}>;
activeAreaId: string;
focusAreaId?: string | null;
maximizedAreaId?: string | null;
}
export interface WebSettingsFile {
schemaVersion: typeof WEB_PREFERENCES_SCHEMA_VERSION;
updatedAt: string;
preferences: WebPreferencesIR;
areaLayouts: Record<string, AreaLayoutRecord>;
areaEditorOverrides: Record<string, EditorType>;
}
export const DEFAULT_WEB_PREFERENCES: WebPreferencesIR = {
schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION,
interface: {
showStatus: true,
quickTools: false,
showAreaSwitcher: true,
showObjectInfo: true,
showViewName: true,
showPlaybackFps: true,
playbackFpsSamples: 8,
recentFiles: 200,
statusbarVersion: true,
statusbarExtensions: true,
startupWorkspace: "Layout",
},
viewport: {
gizmoSize: 75,
gizmoSizeNavigate: 80,
miniAxis: "GIZMO",
viewportAA: 8,
overlaySmoothWire: false,
navigationSensitivity: 1,
defaultShading: "MATERIAL",
},
animation: {
useAutoKeying: false,
autoKeyingMode: "ADD_REPLACE_KEYS",
keyframeInterpolation: "BEZIER",
keyframeHandle: "AUTO_CLAMPED",
},
navigation: { orbitSensitivity: 1, zoomSensitivity: 1, invertZoom: false },
keymap: { activeKeyconfig: "Blender", bindings: {} },
saveLoad: {
autoSaveTimeMinutes: 2,
useAutoSaveTemporaryFiles: true,
saveVersion: 1,
usePreferencesSave: true,
},
files: { note: "OPFS 存储;本地 .blend 仅通过“下载 .blend”手动导出。" },
};
export interface PreferenceDefinition {
path: string;
tab: PreferenceTab;
label: string;
description?: string;
kind: "boolean" | "number" | "text" | "select" | "readonly";
min?: number;
max?: number;
step?: number;
options?: Array<{ value: string; label: string }>;
supported: boolean;
desktopSource: string;
}
export const PREFERENCE_DEFINITIONS: PreferenceDefinition[] = [
{ path: "interface.showStatus", tab: "interface", label: "显示状态栏", kind: "boolean", supported: true, desktopSource: "preferences.view.show_statusbar_stats" },
{ path: "interface.quickTools", tab: "interface", label: "显示 Web 快捷操作栏", kind: "boolean", supported: true, desktopSource: "Web 扩展;对应 SpaceView3D 快捷工具" },
{ path: "interface.showAreaSwitcher", tab: "interface", label: "显示区域编辑器切换器", kind: "boolean", supported: true, desktopSource: "Area header space type button" },
{ path: "interface.showObjectInfo", tab: "interface", label: "显示对象信息", kind: "boolean", supported: false, desktopSource: "preferences.view.show_object_info" },
{ path: "interface.showViewName", tab: "interface", label: "显示视图名称", kind: "boolean", supported: false, desktopSource: "preferences.view.show_view_name" },
{ path: "interface.showPlaybackFps", tab: "interface", label: "显示播放 FPS", kind: "boolean", supported: false, desktopSource: "preferences.view.show_playback_fps" },
{ path: "interface.playbackFpsSamples", tab: "interface", label: "FPS 采样数", kind: "number", min: 0, max: 5000, step: 1, supported: false, desktopSource: "preferences.view.playback_fps_samples (8)" },
{ path: "interface.recentFiles", tab: "interface", label: "最近项目数量", kind: "number", min: 0, max: 1000, step: 1, supported: false, desktopSource: "preferences.filepaths.recent_files (200)" },
{ path: "interface.statusbarVersion", tab: "interface", label: "状态栏显示版本", kind: "boolean", supported: true, desktopSource: "preferences.view.show_statusbar_version" },
{ path: "interface.statusbarExtensions", tab: "interface", label: "状态栏显示扩展更新", kind: "boolean", supported: false, desktopSource: "preferences.view.show_extensions_updates" },
{ path: "interface.startupWorkspace", tab: "interface", label: "启动工作区", kind: "select", options: [
{ value: "Layout", label: "Layout" }, { value: "Modeling", label: "Modeling" }, { value: "Shading", label: "Shading" },
{ value: "Rendering", label: "Rendering" }, { value: "Compositing", label: "Compositing" }, { value: "Geometry Nodes", label: "Geometry Nodes" },
{ value: "Animation", label: "Animation" }, { value: "Scripting", label: "Scripting" },
], supported: true, desktopSource: "Web 启动行为;桌面 SpaceLink 恢复语义" },
{ path: "viewport.gizmoSize", tab: "viewport", label: "变换 Gizmo 大小", kind: "number", min: 20, max: 200, step: 1, supported: false, desktopSource: "preferences.view.gizmo_size (75)" },
{ path: "viewport.gizmoSizeNavigate", tab: "viewport", label: "导航 Gizmo 大小", kind: "number", min: 20, max: 200, step: 1, supported: false, desktopSource: "preferences.view.gizmo_size_navigate_v3d (80)" },
{ path: "viewport.miniAxis", tab: "viewport", label: "迷你坐标轴", kind: "select", options: [{ value: "GIZMO", label: "Gizmo" }, { value: "MINIMAL", label: "Minimal" }], supported: false, desktopSource: "preferences.view.mini_axis_type (GIZMO)" },
{ path: "viewport.viewportAA", tab: "viewport", label: "视口抗锯齿", kind: "number", min: 0, max: 32, step: 1, supported: false, desktopSource: "preferences.system.viewport_aa (8)" },
{ path: "viewport.overlaySmoothWire", tab: "viewport", label: "平滑线框", kind: "boolean", supported: false, desktopSource: "preferences.system.use_overlay_smooth_wire" },
{ path: "viewport.navigationSensitivity", tab: "viewport", label: "导航灵敏度", kind: "number", min: 0.2, max: 4, step: 0.1, supported: false, desktopSource: "preferences.inputs.* 导航速度" },
{ path: "viewport.defaultShading", tab: "viewport", label: "默认着色", kind: "select", options: [{ value: "SOLID", label: "Solid" }, { value: "MATERIAL", label: "Material" }, { value: "RENDERED", label: "Rendered" }], supported: false, desktopSource: "SpaceView3D.shading(每视口)" },
{ path: "animation.useAutoKeying", tab: "animation", label: "自动关键帧", kind: "boolean", supported: false, desktopSource: "preferences.edit.use_auto_keying (false)" },
{ path: "animation.autoKeyingMode", tab: "animation", label: "自动关键帧模式", kind: "select", options: [{ value: "ADD_REPLACE_KEYS", label: "Add/Replace" }, { value: "REPLACE_KEYS", label: "Replace" }], supported: false, desktopSource: "preferences.edit.auto_keying_mode" },
{ path: "animation.keyframeInterpolation", tab: "animation", label: "新建关键帧插值", kind: "select", options: [{ value: "BEZIER", label: "Bezier" }, { value: "LINEAR", label: "Linear" }, { value: "CONSTANT", label: "Constant" }], supported: false, desktopSource: "preferences.edit.keyframe_new_interpolation_type (BEZIER)" },
{ path: "animation.keyframeHandle", tab: "animation", label: "新建关键帧手柄", kind: "select", options: [{ value: "AUTO_CLAMPED", label: "Auto Clamped" }, { value: "AUTO", label: "Auto" }, { value: "VECTOR", label: "Vector" }, { value: "ALIGNED", label: "Aligned" }, { value: "FREE", label: "Free" }], supported: false, desktopSource: "preferences.edit.keyframe_new_handle_type (AUTO_CLAMPED)" },
{ path: "navigation.orbitSensitivity", tab: "input", label: "轨道旋转灵敏度", kind: "number", min: 0.2, max: 4, step: 0.1, supported: false, desktopSource: "preferences.inputs.ndof/view rotation speed" },
{ path: "navigation.zoomSensitivity", tab: "input", label: "滚轮缩放灵敏度", kind: "number", min: 0.2, max: 4, step: 0.1, supported: false, desktopSource: "preferences.inputs.view_zoom" },
{ path: "navigation.invertZoom", tab: "input", label: "反转滚轮缩放", kind: "boolean", supported: false, desktopSource: "preferences.inputs.invert_zoom" },
{ path: "keymap.activeKeyconfig", tab: "keymap", label: "键位配置", kind: "text", supported: false, desktopSource: "preferences.keymap.active_keyconfig" },
{ path: "saveLoad.autoSaveTimeMinutes", tab: "saveLoad", label: "自动保存间隔(分钟)", kind: "number", min: 1, max: 60, step: 1, supported: false, desktopSource: "preferences.filepaths.auto_save_time (2)" },
{ path: "saveLoad.useAutoSaveTemporaryFiles", tab: "saveLoad", label: "自动保存临时文件", kind: "boolean", supported: false, desktopSource: "preferences.filepaths.use_auto_save_temporary_files" },
{ path: "saveLoad.saveVersion", tab: "saveLoad", label: "保存版本数", kind: "number", min: 0, max: 32, step: 1, supported: false, desktopSource: "preferences.filepaths.save_version (1)" },
{ path: "saveLoad.usePreferencesSave", tab: "saveLoad", label: "自动保存偏好设置", kind: "boolean", supported: true, desktopSource: "preferences.use_preferences_save (true)" },
{ path: "themes.note", tab: "themes", label: "主题", kind: "readonly", supported: false, desktopSource: "preferences.themes;Web 当前固定 dark" },
{ path: "system.note", tab: "system", label: "系统 / 输入设备 / 扩展", kind: "readonly", supported: false, desktopSource: "preferences.system / inputs / addons / extensions" },
];
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function bool(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function number(value: unknown, fallback: number, min: number, max: number): number {
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
}
function text(value: unknown, fallback: string, max = 200): string {
return typeof value === "string" && value.length > 0 && value.length <= max ? value : fallback;
}
export function validateWebPreferences(value: unknown): WebPreferencesIR {
if (!record(value)) return structuredClone(DEFAULT_WEB_PREFERENCES);
const d = DEFAULT_WEB_PREFERENCES;
const i = record(value.interface) ? value.interface : {};
const v = record(value.viewport) ? value.viewport : {};
const a = record(value.animation) ? value.animation : {};
const n = record(value.navigation) ? value.navigation : {};
const k = record(value.keymap) ? value.keymap : {};
const s = record(value.saveLoad) ? value.saveLoad : {};
const bindings: Record<string, string> = {};
if (record(k.bindings)) {
for (const [key, chord] of Object.entries(k.bindings)) {
if (typeof chord === "string" && chord.length <= 80) bindings[key.slice(0, 120)] = chord;
}
}
return {
schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION,
interface: {
showStatus: bool(i.showStatus, d.interface.showStatus),
quickTools: bool(i.quickTools, d.interface.quickTools),
showAreaSwitcher: bool(i.showAreaSwitcher, d.interface.showAreaSwitcher),
showObjectInfo: bool(i.showObjectInfo, d.interface.showObjectInfo),
showViewName: bool(i.showViewName, d.interface.showViewName),
showPlaybackFps: bool(i.showPlaybackFps, d.interface.showPlaybackFps),
playbackFpsSamples: number(i.playbackFpsSamples, d.interface.playbackFpsSamples, 0, 5000),
recentFiles: number(i.recentFiles, d.interface.recentFiles, 0, 1000),
statusbarVersion: bool(i.statusbarVersion, d.interface.statusbarVersion),
statusbarExtensions: bool(i.statusbarExtensions, d.interface.statusbarExtensions),
startupWorkspace: text(i.startupWorkspace, d.interface.startupWorkspace, 40),
},
viewport: {
gizmoSize: number(v.gizmoSize, d.viewport.gizmoSize, 20, 200),
gizmoSizeNavigate: number(v.gizmoSizeNavigate, d.viewport.gizmoSizeNavigate, 20, 200),
miniAxis: v.miniAxis === "MINIMAL" ? "MINIMAL" : "GIZMO",
viewportAA: number(v.viewportAA, d.viewport.viewportAA, 0, 32),
overlaySmoothWire: bool(v.overlaySmoothWire, d.viewport.overlaySmoothWire),
navigationSensitivity: number(v.navigationSensitivity, d.viewport.navigationSensitivity, 0.2, 4),
defaultShading: v.defaultShading === "SOLID" || v.defaultShading === "RENDERED" ? v.defaultShading : "MATERIAL",
},
animation: {
useAutoKeying: bool(a.useAutoKeying, d.animation.useAutoKeying),
autoKeyingMode: a.autoKeyingMode === "REPLACE_KEYS" ? "REPLACE_KEYS" : "ADD_REPLACE_KEYS",
keyframeInterpolation: a.keyframeInterpolation === "LINEAR" || a.keyframeInterpolation === "CONSTANT" ? a.keyframeInterpolation : "BEZIER",
keyframeHandle: ["AUTO", "VECTOR", "ALIGNED", "FREE"].includes(String(a.keyframeHandle)) ? a.keyframeHandle as WebPreferencesIR["animation"]["keyframeHandle"] : "AUTO_CLAMPED",
},
navigation: {
orbitSensitivity: number(n.orbitSensitivity, d.navigation.orbitSensitivity, 0.2, 4),
zoomSensitivity: number(n.zoomSensitivity, d.navigation.zoomSensitivity, 0.2, 4),
invertZoom: bool(n.invertZoom, d.navigation.invertZoom),
},
keymap: { activeKeyconfig: text(k.activeKeyconfig, d.keymap.activeKeyconfig, 80), bindings },
saveLoad: {
autoSaveTimeMinutes: number(s.autoSaveTimeMinutes, d.saveLoad.autoSaveTimeMinutes, 1, 60),
useAutoSaveTemporaryFiles: bool(s.useAutoSaveTemporaryFiles, d.saveLoad.useAutoSaveTemporaryFiles),
saveVersion: number(s.saveVersion, d.saveLoad.saveVersion, 0, 32),
usePreferencesSave: bool(s.usePreferencesSave, d.saveLoad.usePreferencesSave),
},
files: { note: d.files.note },
};
}
export function migrateLegacyPreferences(value: unknown): WebPreferencesIR | null {
if (!record(value)) return null;
const preferences = structuredClone(DEFAULT_WEB_PREFERENCES);
if (typeof value.showStatus === "boolean") preferences.interface.showStatus = value.showStatus;
if (typeof value.quickTools === "boolean") preferences.interface.quickTools = value.quickTools;
if (typeof value.showAreaSwitcher === "boolean") preferences.interface.showAreaSwitcher = value.showAreaSwitcher;
return preferences;
}
export function getPreferencePath(preferences: WebPreferencesIR, path: string): unknown {
return path.split(".").reduce<unknown>((current, key) => record(current) ? current[key] : undefined, preferences);
}
export function setPreferencePath(preferences: WebPreferencesIR, path: string, value: unknown): WebPreferencesIR {
const next = structuredClone(preferences);
const keys = path.split(".");
let target: Record<string, unknown> = next as unknown as Record<string, unknown>;
for (const key of keys.slice(0, -1)) {
if (!record(target[key])) target[key] = {};
target = target[key] as Record<string, unknown>;
}
target[keys[keys.length - 1]] = value;
return next;
}