Area headers now expose a real ⋮ menu with split, join, swap, duplicate, maximize/focus, close and reset; the first operation measures the CSS grid into normalized rects and switches that workspace to an OPFS-persisted custom layout while reusing the existing React content per slot. ViewportNavigation gains Numpad0 camera view with last-view restore, framing-only navigation when unlocked, Camera to View lock that writes the camera object (rotation-mode-aware native setObjectTransform), and Ctrl+Alt+Numpad0 camera-to-view. Camera lock persists per area. Co-Authored-By: Claude Code <noreply@anthropic.com>
284 lines
16 KiB
TypeScript
284 lines
16 KiB
TypeScript
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>;
|
||
cameraLocks: Record<string, boolean>;
|
||
}
|
||
|
||
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;
|
||
}
|