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:
110
web/protocol/keymap.ts
Normal file
110
web/protocol/keymap.ts
Normal 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(" + ");
|
||||
}
|
||||
282
web/protocol/web-preferences.ts
Normal file
282
web/protocol/web-preferences.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user