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

View File

@@ -7,7 +7,7 @@ import { PropertiesStackName } from "./PropertiesStackName";
import { PhysicsSettingsEditor, type PointCacheOperation, type SoftBodyCacheRequest, type FluidCacheOperation } from "./PhysicsSettingsEditor";
import { NativeRenderPanel, type NativeWorkflowFile } from './NativeWorkflowPanels';
import {NativeExportPanel,EXPORT_FORMATS} from './NativeExportPanel';
import {NATIVE_EDITORS,readWebInterfacePreferences} from './native-editors';
import {NATIVE_EDITORS} from './native-editors';
import {recoverDesktop,saveDesktop} from './desktop-workspace-storage';
import {NativeResourceClient} from '../engine-client/NativeResourceClient';
import { COURSES, type AvailableCourseId } from "../tutorial/course";
@@ -21,6 +21,8 @@ import {GeometryNodeCanvas} from './ui/GeometryNodeCanvas';
import {SpreadsheetView} from './ui/SpreadsheetView';
import {CompositorCanvas} from './ui/CompositorCanvas';
import {GraphEditor} from './ui/GraphEditor';
import {PreferencesWindow} from './ui/PreferencesWindow';
import { effectiveBindings, resolveKeymapAction, validateKeymapBinding } from '../../../protocol/keymap';
import { ensurePbrAssetLibrary, ensurePbrHighQualityLibrary, PBR_LIBRARY_PROJECT_ID, pbrLibraryWarnings, repairPbrAssetLibrary } from './pbr-library-seed';
import { createAssetPreviewIdentity } from '../../../protocol/asset-preview';
import { AssetLibraryPanel } from './AssetLibraryPanel';
@@ -89,6 +91,8 @@ import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { SimplifyAttributePolicy, SimplifyDelimit, SimplifyMode, SimplifyProfile } from "../../../protocol/simplify";
import { buildSimplifyReport, SimplifyReportPanel, type SimplifyWorkbenchReport } from './SimplifyReport';
import { StorageClient } from "../storage/StorageClient";
import { DEFAULT_WEB_PREFERENCES, WEB_PREFERENCES_SCHEMA_VERSION, type WebPreferencesIR, type WebSettingsFile } from "../../../protocol/web-preferences";
import { flushWebSettingsSave, loadWebSettings, readCachedWebSettings, saveWebSettings, scheduleWebSettingsSave } from "../storage/web-settings";
import {copyProjectAssets} from './copy-project-assets';
import type { StorageBudgetResult } from "../../../protocol/storage";
import { formatStorageBytes } from "../../../protocol/storage-budget";
@@ -4008,12 +4012,78 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
const [historyView,setHistoryView]=useState<{history:Awaited<ReturnType<WebEngineClient['history']>>;sourceHash:string|null}|null>(null);
const [historyError,setHistoryError]=useState('');
const [headerValue,setHeaderValue]=useState('');
const [quickTools,setQuickTools]=useState(()=>readWebInterfacePreferences().quickTools);
const [showStatus,setShowStatus]=useState(()=>readWebInterfacePreferences().showStatus);
const [showAreaSwitcher,setShowAreaSwitcher]=useState(()=>readWebInterfacePreferences().showAreaSwitcher);
const initialWebSettings = useMemo(() => readCachedWebSettings(), []);
const [webPreferences, setWebPreferences] = useState<WebPreferencesIR>(() => initialWebSettings?.preferences ?? structuredClone(DEFAULT_WEB_PREFERENCES));
const [quickTools,setQuickTools]=useState(()=> (initialWebSettings?.preferences ?? DEFAULT_WEB_PREFERENCES).interface.quickTools);
const [showStatus,setShowStatus]=useState(()=> (initialWebSettings?.preferences ?? DEFAULT_WEB_PREFERENCES).interface.showStatus);
const [showAreaSwitcher,setShowAreaSwitcher]=useState(()=> (initialWebSettings?.preferences ?? DEFAULT_WEB_PREFERENCES).interface.showAreaSwitcher);
const [preferencesError,setPreferencesError]=useState('');
const [areaEditorOverrides,setAreaEditorOverrides]=useState<Record<string,EditorType>>({});
useEffect(()=>{try{localStorage.setItem('web-blender-interface-v1',JSON.stringify({quickTools,showStatus,showAreaSwitcher}));setPreferencesError('');}catch(error){setPreferencesError(String(error));}},[quickTools,showStatus,showAreaSwitcher]);
const keymapBindings = useMemo(() => effectiveBindings(webPreferences.keymap.bindings), [webPreferences.keymap.bindings]);
const [preferencesOpen,setPreferencesOpen]=useState(false);
const [preferencesStatus,setPreferencesStatus]=useState('');
const [settingsDirty,setSettingsDirty]=useState(false);
const [areaEditorOverrides,setAreaEditorOverrides]=useState<Record<string,EditorType>>(() => initialWebSettings?.areaEditorOverrides ?? {});
const settingsRef = useRef<WebSettingsFile>(initialWebSettings ?? { schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION, updatedAt: new Date().toISOString(), preferences: structuredClone(DEFAULT_WEB_PREFERENCES), areaLayouts: {}, areaEditorOverrides: {} });
const settingsLoadedRef = useRef(false);
useEffect(() => {
let cancelled = false;
void loadWebSettings().then(({ settings }) => {
if (cancelled) return;
settingsRef.current = settings;
settingsLoadedRef.current = true;
setWebPreferences(settings.preferences);
setQuickTools(settings.preferences.interface.quickTools);
setShowStatus(settings.preferences.interface.showStatus);
setShowAreaSwitcher(settings.preferences.interface.showAreaSwitcher);
setAreaEditorOverrides(settings.areaEditorOverrides);
setPreferencesError("");
}).catch((error: unknown) => {
if (!cancelled) { settingsLoadedRef.current = true; setPreferencesError(String(error)); }
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
const mergedPreferences: WebPreferencesIR = {
...webPreferences,
interface: { ...webPreferences.interface, showStatus, quickTools, showAreaSwitcher },
};
const merged: WebSettingsFile = { ...settingsRef.current, preferences: mergedPreferences, areaEditorOverrides };
settingsRef.current = merged;
if (settingsLoadedRef.current && mergedPreferences.saveLoad.usePreferencesSave) scheduleWebSettingsSave(merged);
}, [webPreferences, quickTools, showStatus, showAreaSwitcher, areaEditorOverrides]);
useEffect(() => {
const flush = (): void => { void flushWebSettingsSave(); };
const hide = (): void => { if (document.visibilityState === "hidden") flush(); };
window.addEventListener("pagehide", flush);
document.addEventListener("visibilitychange", hide);
return () => { window.removeEventListener("pagehide", flush); document.removeEventListener("visibilitychange", hide); };
}, []);
const applyPreferences = useCallback((next: WebPreferencesIR): void => {
setWebPreferences(next);
setQuickTools(next.interface.quickTools);
setShowStatus(next.interface.showStatus);
setShowAreaSwitcher(next.interface.showAreaSwitcher);
setSettingsDirty(true);
setPreferencesStatus("");
}, []);
const savePreferencesNow = useCallback(async (): Promise<void> => {
const saved = await saveWebSettings({ ...settingsRef.current, preferences: { ...settingsRef.current.preferences, interface: { ...settingsRef.current.preferences.interface, showStatus, quickTools, showAreaSwitcher } }, areaEditorOverrides });
setSettingsDirty(false);
setPreferencesStatus(saved ? "已保存到 OPFS" : "已缓存(OPFS 不可用)");
}, [quickTools, showStatus, showAreaSwitcher, areaEditorOverrides]);
const bindKeymap = useCallback((actionId: string, chord: string): string | null => {
const bindings = webPreferences.keymap.bindings;
const validation = validateKeymapBinding(actionId, chord, bindings);
if (!validation.ok) { setPreferencesStatus(validation.reason ?? "键位无效"); return validation.reason ?? "键位无效"; }
applyPreferences({ ...webPreferences, keymap: { ...webPreferences.keymap, bindings: { ...bindings, [actionId]: chord } } });
setPreferencesStatus(`已更新键位:${actionId}`);
return null;
}, [webPreferences, applyPreferences]);
const resetPreferences = useCallback((): void => {
applyPreferences(structuredClone(DEFAULT_WEB_PREFERENCES));
setAreaEditorOverrides({});
setPreferencesStatus("已恢复默认(尚未保存)");
}, [applyPreferences]);
const [headerPending,setHeaderPending]=useState(false);
const [browserFullscreen,setBrowserFullscreen]=useState(()=>Boolean(document.fullscreenElement));
const [fullscreenPending,setFullscreenPending]=useState(false);
@@ -5968,13 +6038,6 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
if (!interactiveTarget) event.preventDefault();
return;
}
if (event.key === "F3") {
const textEntryTarget = target?.matches("textarea, select, [contenteditable='true'], input:not([type='file'])");
if (interactiveTarget && textEntryTarget) return;
event.preventDefault();
dispatchUI({ type: "toggleOperatorSearch", open: true });
return;
}
if (event.key === "Escape" && uiState.operatorSearchOpen) {
event.preventDefault();
closeOperatorSearch();
@@ -5991,41 +6054,58 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
cancelOpenFileRead();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s" && !target?.matches("input, textarea, select, [contenteditable='true']")) {
event.preventDefault();
void saveBlendRef.current();
return;
}
if (interactiveTarget) return;
const activeId = snapshot?.activeObjectId;
if ((event.key === "Delete" || event.key === "Backspace") && activeId) {
event.preventDefault();
void applyEditCommand(coreSceneCommands.delete(activeId));
}
else if (event.altKey && event.key.toLowerCase() === "d" && activeId) {
if (event.altKey && event.key.toLowerCase() === "d" && activeId) {
event.preventDefault();
void applyEditCommand(coreSceneCommands.duplicate(activeId, [0.5, 0.5, 0], true));
}
else if (event.shiftKey && event.key.toLowerCase() === "d" && activeId) {
event.preventDefault();
void applyEditCommand(coreSceneCommands.duplicate(activeId));
}
else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
event.preventDefault();
void applyEditCommand({ type: event.shiftKey ? "redo" : "undo" });
}
else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "y") {
event.preventDefault();
void applyEditCommand({ type: "redo" });
}
else if (["g", "r", "s"].includes(event.key.toLowerCase())) {
event.preventDefault();
transformActive(event.key.toLowerCase() === "g" ? "translate" : event.key.toLowerCase() === "r" ? "rotate" : "scale");
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [cancelOpenFileRead, openProgress, snapshot, uiState.context.mode, uiState.operatorSearchOpen, uiState.openMenu, userActions.OPEN.status]);
useEffect(() => {
const handler = (event: KeyboardEvent): void => {
if (event.isComposing || event.defaultPrevented) return;
const target = event.target as HTMLElement | null;
if (target?.matches('input, textarea, select, [contenteditable="true"]') || target?.closest('[role="dialog"], [role="menu"]')) return;
if (target?.matches('canvas[aria-label="Blender desktop"]')) return;
if (uiState.operatorSearchOpen || uiState.openMenu) return;
let action = resolveKeymapAction(event, keymapBindings, "global");
if (!action && (target?.matches("canvas.viewport-canvas") || !target?.closest("button, a, input, select, textarea"))) {
action = resolveKeymapAction(event, keymapBindings, "viewport");
}
if (!action) return;
const activeId = snapshot?.activeObjectId;
switch (action.id) {
case "file.open": fileInputRef.current?.click(); break;
case "file.save": void saveBlendRef.current(); break;
case "file.saveAs": if (snapshot) { setHeaderValue("blender-web.blend"); setHeaderDialog("save-as"); } break;
case "edit.undo": void applyEditCommand({ type: event.shiftKey ? "redo" : "undo" }); break;
case "edit.redo": void applyEditCommand({ type: "redo" }); break;
case "edit.operatorSearch": dispatchUI({ type: "toggleOperatorSearch", open: true }); break;
case "edit.rename": showRename(); break;
case "object.delete": if (activeId) void applyEditCommand(coreSceneCommands.delete(activeId)); break;
case "object.duplicate": if (activeId) void applyEditCommand(coreSceneCommands.duplicate(activeId)); break;
case "transform.translate": transformActive("translate"); break;
case "transform.rotate": transformActive("rotate"); break;
case "transform.scale": transformActive("scale"); break;
case "view.camera":
case "view.cameraToView":
case "view.cameraLock": {
const canvas = document.querySelector<HTMLCanvasElement>("canvas.viewport-canvas");
const mapping: Record<string, string> = { "view.camera": "CAMERA", "view.cameraToView": "CAMERA_TO_VIEW", "view.cameraLock": "CAMERA_LOCK" };
canvas?.dispatchEvent(new CustomEvent("viewport-command", { detail: mapping[action.id] }));
break;
}
default: break;
}
event.preventDefault();
event.stopImmediatePropagation();
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [keymapBindings, snapshot, uiState.operatorSearchOpen, uiState.openMenu, applyEditCommand, transformActive, dispatchUI]);
useEffect(() => {
if (!uiState.openMenu) return undefined;
const onPointerDown = (event: PointerEvent): void => {
@@ -7615,10 +7695,10 @@ else:
}catch{/* readback is best-effort; the next snapshot keeps the UI consistent */}
};
const canRenameObject = Boolean(activeHeaderNode && !activeHeaderNode.sourceLibrary);
const showRename=()=>{
function showRename(): void {
if (!canRenameObject || headerBusy) return;
setRenameError('');setHeaderValue(activeHeaderNode!.name);setHeaderDialog('rename');
};
}
const submitObjectRename = async (name: string) => {
if (!canRenameObject || headerPending || !activeHeaderNode) return;
setHeaderPending(true);setRenameError('');
@@ -7851,7 +7931,7 @@ else:
separator,menuAction('关闭',closeProject,!hasProject),menuAction('退出并保存会话',()=>requestNativeAutosaveOperator('wm.quit_blender'),!hasProject)
],
'编辑':[menuAction('撤销',()=>{void applyEditCommand({type:'undo'});},!hasProject,'Ctrl Z'),menuAction('重做',()=>{void applyEditCommand({type:'redo'});},!hasProject,'Shift Ctrl Z'),menuAction('撤销历史',()=>{void openHistory();},!hasProject),separator,
menuAction('菜单搜索',()=>dispatchUI({type:'toggleOperatorSearch',open:true}),false,'F3'),menuAction('重命名活动项',showRename,!canRenameObject,'F2'),menuAction('调整上一步操作',()=>openAdjustLastOperation(),!hasProject||(!lastNativeOperator&&(!localOnly||!nativeCanvasReady))),menuAction('重复上一步',()=>queueLocalDesktopOperator('screen.repeat_last'),!hasProject||!localOnly||!nativeCanvasReady),menuAction('重复历史',()=>queueLocalDesktopOperator('screen.repeat_history'),!hasProject||!localOnly||!nativeCanvasReady),separator,menuAction('偏好设置',()=>setHeaderDialog('preferences'))],
menuAction('菜单搜索',()=>dispatchUI({type:'toggleOperatorSearch',open:true}),false,'F3'),menuAction('重命名活动项',showRename,!canRenameObject,'F2'),menuAction('调整上一步操作',()=>openAdjustLastOperation(),!hasProject||(!lastNativeOperator&&(!localOnly||!nativeCanvasReady))),menuAction('重复上一步',()=>queueLocalDesktopOperator('screen.repeat_last'),!hasProject||!localOnly||!nativeCanvasReady),menuAction('重复历史',()=>queueLocalDesktopOperator('screen.repeat_history'),!hasProject||!localOnly||!nativeCanvasReady),separator,menuAction('偏好设置',()=>setPreferencesOpen(true))],
'渲染':[menuAction('渲染图像',()=>renderFromMenu(false),!hasProject,'F12'),menuAction('渲染动画',()=>renderFromMenu(true),!hasProject,'Ctrl F12'),unavailable('渲染音频'),separator,
menuAction('查看渲染结果',()=>dispatchUI({type:'switchWorkspace',workspaceId:'Rendering'}),!hasProject),menuAction('查看动画',()=>dispatchUI({type:'switchWorkspace',workspaceId:'Rendering'}),!nativeFiles.some(f=>f.artifact.mime.startsWith('video/')))],
'场景':[menuAction('新建场景',()=>queueLocalDesktopOperator('scene.new',{type:'NEW'}),!hasProject||!localOnly||!nativeCanvasReady),menuAction('复制场景设置',()=>queueLocalDesktopOperator('scene.new',{type:'COPY_SETTINGS'}),!hasProject||!localOnly||!nativeCanvasReady),separator,menuAction('删除场景',()=>queueLocalDesktopOperator('scene.delete'),!hasProject||!localOnly||!nativeCanvasReady),separator,
@@ -7966,18 +8046,11 @@ else:
const target=event.target as HTMLElement|null;
if(target?.matches('canvas[aria-label="Blender desktop"]'))return;
if(event.isComposing||target?.closest('input,textarea,select,[contenteditable=true],[role=dialog],[role=menu]')||headerBusy||headerDialog||robotToolOpen||interactiveDesktop)return;
if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='o'){event.preventDefault();event.stopImmediatePropagation();fileInputRef.current?.click();}
else if((event.ctrlKey||event.metaKey)&&event.shiftKey&&event.key.toLowerCase()==='s'){event.preventDefault();event.stopImmediatePropagation();if(snapshot){setHeaderValue('blender-web.blend');setHeaderDialog('save-as');}}
else if(event.key==='F1'&&event.shiftKey&&!event.ctrlKey&&!event.metaKey&&!event.altKey&&hasProject){event.preventDefault();event.stopImmediatePropagation();focusProperty('[data-testid="library-append-workbench"]');}
if(event.key==='F1'&&event.shiftKey&&!event.ctrlKey&&!event.metaKey&&!event.altKey&&hasProject){event.preventDefault();event.stopImmediatePropagation();focusProperty('[data-testid="library-append-workbench"]');}
else if(event.key==='F12'){event.preventDefault();event.stopImmediatePropagation();renderFromMenu(event.ctrlKey);}
else if(event.key==='F2'&&!target?.closest('.outliner-tree')&&canRenameObject){event.preventDefault();event.stopImmediatePropagation();showRename();}
else if(event.key==='Tab'&&target?.matches('canvas.viewport-canvas')&&canEdit){event.preventDefault();event.stopImmediatePropagation();dispatchUI({type:'setMode',mode:uiState.context.mode==='Edit'?'Object':'Edit'});}
else if(target?.matches('canvas.viewport-canvas')&&event.key.toLowerCase()==='a'&&!event.ctrlKey&&!event.metaKey){event.preventDefault();event.stopImmediatePropagation();selectHeader(event.altKey?'none':'all');}
else if(target?.matches('canvas.viewport-canvas')&&(event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='i'){event.preventDefault();event.stopImmediatePropagation();selectHeader('invert');}
else if(target?.matches('canvas.viewport-canvas')&&event.key.toLowerCase()==='x'&&hasObject&&uiState.context.mode==='Object'){event.preventDefault();event.stopImmediatePropagation();void applyEditCommand({type:'deleteObject',objectId:snapshot!.activeObjectId!});}
else if(target?.matches('canvas.viewport-canvas')&&!event.ctrlKey&&!event.altKey&&!event.metaKey&&['g','r','s'].includes(event.key.toLowerCase())&&hasObject&&uiState.context.mode==='Object'){
event.preventDefault();event.stopImmediatePropagation();openTransform(({g:'translate',r:'rotate',s:'resize'} as const)[event.key.toLowerCase() as 'g'|'r'|'s']);
}
};
window.addEventListener('keydown',handler,true);return()=>window.removeEventListener('keydown',handler,true);
},[snapshot,headerBusy,headerDialog,robotToolOpen,interactiveDesktop,uiState.context.mode,selectedObjectIds,meshSelection,gpuSurfaceOpen,localOnly]);
@@ -8480,6 +8553,7 @@ else:
<button type="button" disabled={replacementPrompt.hasTextDraft} onClick={()=>resolveReplacement('save')}>保存后继续</button>
</div>
</DesktopDialog>:null}
{preferencesOpen ? <PreferencesWindow preferences={webPreferences} bindings={webPreferences.keymap.bindings} dirty={settingsDirty} saveStatus={preferencesStatus || preferencesError} settingsPath="OPFS · settings/web-interface.json" onChange={applyPreferences} onBind={bindKeymap} onSave={() => void savePreferencesNow()} onReset={resetPreferences} onClose={() => setPreferencesOpen(false)} /> : null}
{headerDialog?<DesktopDialog title={{new:'新建工程','save-as':'另存为','save-copy':'保存副本',rename:'重命名',about:'关于 Web Blender',preferences:'偏好设置',history:'撤销历史',export:'导出模型','obj-import':'导入 OBJ 与材质'}[headerDialog]} onClose={()=>{if(!headerPending)setHeaderDialog(null);}}>
{headerDialog==='obj-import'?<OBJImportPanel initialFile={objImportFile} disabled={headerBusy || projectActionLocked || Boolean(localInvokePending) || localInvokeActive || nativeModalActive} onPrepare={prepareOBJImport}/>:null}
{headerDialog==='save-copy'&&<p>下载当前工程的副本,并继续编辑当前项目;未保存标记将保留。</p>}
@@ -8489,7 +8563,7 @@ else:
{headerDialog==='history'?<><div className="desktop-history-list" aria-busy={headerPending}>{historyView?.history.entries.map(entry=><button key={entry.index} aria-current={entry.index===historyView.history.cursor?'step':undefined} disabled={headerPending||Boolean(historyError)||!historyView.sourceHash||entry.index===historyView.history.cursor} onClick={()=>void jumpHistory(entry.index)}><span>{entry.index+1}. {entry.label}</span>{entry.index===historyView.history.cursor?<strong>当前</strong>:null}</button>)}</div>{(historyError||!historyView||!historyView.sourceHash)?<button disabled={headerPending} onClick={()=>void openHistory()}>重新读取历史</button>:null}{headerPending?<output>正在读取工程历史…</output>:null}{historyError?<output role="alert">{historyError}</output>:null}</>:null}
{headerDialog==='new'?<><p>{dirtyState.dirty?'当前工程有未保存修改。新建会替换当前场景。':'创建 Blender 默认常规场景。'}</p><div className="desktop-dialog-actions"><button onClick={()=>setHeaderDialog(null)}>取消</button><button onClick={()=>{setHeaderDialog(null);void newProject();}}>新建常规工程</button></div></>:null}
{isFileNameDialog||headerDialog==='rename'?<form onSubmit={event=>{event.preventDefault();const value=headerValue.trim();if(!value||headerPending)return;if(isFileNameDialog){const fileName=value.toLowerCase().endsWith('.blend')?value:value+'.blend';const copy=headerDialog==='save-copy';setHeaderDialog(null);void(copy?saveBlend(fileName,true):saveProjectOpfs(fileName));}else void submitObjectRename(value);}}><label>{isFileNameDialog?'文件名':'对象名称'}<input aria-label={headerDialog==='save-copy'?'副本文件名':isFileNameDialog?'另存为文件名':'新对象名称'} required disabled={headerPending} maxLength={isFileNameDialog?128:63} value={headerValue} onChange={event=>setHeaderValue(event.target.value)}/></label>{isFileNameDialog?<p>另存为保存到浏览器 OPFS;保存副本会下载到本地。需要本地 .blend 时请使用“文件 → 下载 .blend 到本地”。</p>:null}<>{headerDialog==='rename' && renameError ? <output role="alert">{renameError}</output> : null}</><div className="desktop-dialog-actions"><button type="button" disabled={headerPending} onClick={()=>setHeaderDialog(null)}>取消</button><button type="submit" disabled={headerPending || (headerDialog==='rename' && !canRenameObject)}>{headerPending ? '正在重命名…' : '确认'}</button></div></form>:null}
{headerDialog==='preferences'?<><h3>Web 界面</h3><label><input type="checkbox" checked={showStatus} onChange={event=>setShowStatus(event.target.checked)}/>显示状态栏</label><label><input type="checkbox" checked={quickTools} onChange={event=>setQuickTools(event.target.checked)}/>显示 Web 快捷操作栏</label><label><input type="checkbox" checked={showAreaSwitcher} onChange={event=>setShowAreaSwitcher(event.target.checked)}/>显示区域编辑器切换器</label>{preferencesError?<output role="alert">{preferencesError}</output>:null}<button disabled={headerPending} onClick={()=>{void (async()=>{if(!hasProject)await newProject();if(!webClientRef.current)return;setHeaderDialog(null);openNativeEditor('PREFERENCES');})();}}>Blender 偏好设置</button></>:null}
{headerDialog==='preferences'?<><h3>偏好设置</h3><p>Web 偏好设置使用 OPFS 保存;原生 Blender 偏好仍可从本机桌面会话打开。</p>{preferencesError?<output role="alert">{preferencesError}</output>:null}<button onClick={()=>{setHeaderDialog(null);setPreferencesOpen(true);}}>打开 Web 偏好设置</button><button disabled={headerPending} onClick={()=>{void (async()=>{if(!hasProject)await newProject();if(!webClientRef.current)return;setHeaderDialog(null);openNativeEditor('PREFERENCES');})();}}>Blender 原生偏好设置</button></>:null}
{headerDialog==='about'?<><h3>Blender 5.2.0 LTS · Web</h3><p>当前工程使用本机 Blender 源码编译的 WebEngine 与原生工作流服务。菜单中的灰色命令尚未实现等价入口。</p><p>GLB 位于文件 → 导出。机器人及原生桌面会话位于窗口 → Web 扩展。</p><p>菜单对齐不代表桌面全部编辑器、偏好设置和功能已完全移植。</p></>:null}
</DesktopDialog>:null}
{robotToolOpen?<RobotTool client={webClientRef.current} snapshot={snapshot} onApplied={applyNativeScene} onSave={()=>saveProjectOpfs()} onClose={()=>setRobotToolOpen(false)} onStart={async()=>{
@@ -8501,7 +8575,7 @@ else:
await webClientRef.current.snapshot();
}}/>:null}
{interactiveDesktop?<section className="native-desktop-dialog" role="dialog" aria-modal="true" aria-label="Native Blender Desktop"><header><strong>Native Blender</strong><button aria-label="Close native desktop" onClick={()=>{setInteractiveDesktop(null);activeViewportCanvas()?.focus({preventScroll:true});}}>×</button></header><NativeSessionPanel desktop client={webClientRef.current} context={interactiveDesktop.context} initialOperator={interactiveDesktop.command} onApplied={(scene)=>{applyNativeScene(scene);setInteractiveDesktop(null);activeViewportCanvas()?.focus({preventScroll:true});}} onCommandStatus={(status)=>{if(status==='CANCELLED'||status==='FAILED'){activeViewportCanvas()?.focus({preventScroll:true});setEngineStatus(status==='CANCELLED'?'Native Blender command cancelled':'Native Blender command failed');const client=webClientRef.current,project=projectIdRef.current;if(client)void client.snapshot().then(result=>{if(client!==webClientRef.current||project!==projectIdRef.current)return;const changed=result.snapshot.revision!==snapshot?.revision;applyNativeScene(result,changed);const recoveryRevision=projectRevisionRef.current;if(changed)void persistRecoveryCheckpoint(client,project,recoveryRevision).catch(error=>{if(client===webClientRef.current&&project===projectIdRef.current&&recoveryRevision===projectRevisionRef.current)setStorageStatus(recordDiagnostic("STORAGE","OPERATION_LOG_FAILED",error,{command:"native-session-recovery",recovery:"checkpoint"}));});}).catch(error=>{if(client===webClientRef.current&&project===projectIdRef.current)setEngineStatus(recordDiagnostic("ENGINE","COMMAND_FAILED",error,{workflow:"native-session-recovery",recovery:"snapshot"}));});}}}/></section>:null}
<footer className="status-bar"><span className="status-hint">LMB Select · MMB Rotate View · Wheel Zoom</span><span data-testid="scene-stats">Active {snapshot?.nodes.find(node => node.id === snapshot?.activeObjectId)?.name ?? '—'} · Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span className="runtime-status">{manifestStatus}</span><span className="runtime-status" data-testid="wasm-status">{wasmStatus}</span><span className="runtime-status" data-testid="engine-status" title={displayedEngineStatus}>{displayedEngineStatus}</span>{localInvokePending || localInvokeActive ? <button type="button" disabled={typeof SharedArrayBuffer === 'undefined' || !globalThis.crossOriginIsolated} data-testid="native-invoke-cancel" aria-label="取消原生操作" title={`取消 ${localInvokePending?.command.operator ?? '当前操作'}`} onClick={() => { localInvokeAbortRef.current?.abort(); activeViewportCanvas()?.dispatchEvent(new Event('native-cancel')); setLocalInvokePending(null); setLocalRetryPending(null); activeViewportCanvas()?.focus({ preventScroll: true }); setEngineStatus('已请求取消原生操作'); }}>取消原生操作</button> : null}{localRetryPending ? <button type="button" data-testid="native-retry" aria-label="重试原生操作" title={`重试 ${localRetryPending.command.operator}`} onClick={() => { const retry = localRetryPending; setLocalRetryPending(null); setLocalInvokePending(retry); setGPUSurfaceOpen(true); }}>{`重试 ${localRetryPending.command.operator}`}</button> : null}<span className="runtime-status">{storageStatus}</span><StorageBudgetPanel budget={storageBudget} onCleanup={() => void cleanupProjectAssets()} /><span className="blender-version">5.2.0 LTS</span></footer>
<footer className="status-bar"><span className="status-hint">LMB Select · MMB Rotate View · Wheel Zoom</span><span data-testid="scene-stats">Active {snapshot?.nodes.find(node => node.id === snapshot?.activeObjectId)?.name ?? '—'} · Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span className="runtime-status">{manifestStatus}</span><span className="runtime-status" data-testid="wasm-status">{wasmStatus}</span><span className="runtime-status" data-testid="engine-status" title={displayedEngineStatus}>{displayedEngineStatus}</span>{localInvokePending || localInvokeActive ? <button type="button" disabled={typeof SharedArrayBuffer === 'undefined' || !globalThis.crossOriginIsolated} data-testid="native-invoke-cancel" aria-label="取消原生操作" title={`取消 ${localInvokePending?.command.operator ?? '当前操作'}`} onClick={() => { localInvokeAbortRef.current?.abort(); activeViewportCanvas()?.dispatchEvent(new Event('native-cancel')); setLocalInvokePending(null); setLocalRetryPending(null); activeViewportCanvas()?.focus({ preventScroll: true }); setEngineStatus('已请求取消原生操作'); }}>取消原生操作</button> : null}{localRetryPending ? <button type="button" data-testid="native-retry" aria-label="重试原生操作" title={`重试 ${localRetryPending.command.operator}`} onClick={() => { const retry = localRetryPending; setLocalRetryPending(null); setLocalInvokePending(retry); setGPUSurfaceOpen(true); }}>{`重试 ${localRetryPending.command.operator}`}</button> : null}<span className="runtime-status">{storageStatus}</span><StorageBudgetPanel budget={storageBudget} onCleanup={() => void cleanupProjectAssets()} />{webPreferences.interface.statusbarVersion ? <span className="blender-version">5.2.0 LTS</span> : null}</footer>
{createPortal(lastNativeOperator ? <section className="last-operator-panel" aria-label="调整上一步操作">
{!lastOperatorOpen ? <button type="button" aria-label="调整上一步操作" onClick={() => openAdjustLastOperation()}>调整上一步操作<small>{lastNativeOperator.operator}</small></button>
: <form onSubmit={(event) => { event.preventDefault(); void applyAdjustedLastOperation(); }}>

View File

@@ -212,3 +212,32 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.timeline-content .key-dot.active { outline: 2px solid #6da8e2; outline-offset: 2px; }
.timeline-content .key-dot[data-selected-unknown="true"] { background: transparent; border-style: dashed; }
.blender-app[data-show-area-switcher="false"] .area-type-switch { display: none; }
.preferences-window { position: fixed; inset: 4vh 6vw; z-index: 12000; display: grid; grid-template-rows: auto minmax(0,1fr) auto; color: #ddd; background: #252525; border: 1px solid #5a5a5a; border-radius: 6px; box-shadow: 0 20px 60px #000b; }
.preferences-window > header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 12px; background: #303030; border-bottom: 1px solid #454545; }
.preferences-window > header strong { font-size: 14px; }
.preferences-window > header span { color: #9aa39c; font-family: monospace; font-size: 11px; }
.preferences-window > header input { flex: 1 1 180px; min-width: 100px; max-width: 320px; padding: 4px 7px; color: #ddd; background: #202020; border: 1px solid #505050; border-radius: 3px; }
.preferences-window > header output { color: #b8c7bb; font-size: 11px; }
.preferences-window > header button { width: 26px; height: 26px; color: #ddd; background: #454545; border-radius: 3px; }
.preferences-body { display: grid; grid-template-columns: 150px minmax(0,1fr); min-height: 0; }
.preferences-tabs { display: flex; flex-direction: column; gap: 2px; padding: 8px; overflow: auto; background: #202020; border-right: 1px solid #404040; }
.preferences-tabs button { padding: 7px 9px; text-align: left; color: #c8c8c8; background: transparent; border-radius: 3px; }
.preferences-tabs button.active { color: #fff; background: #4775a3; }
.preferences-content { min-height: 0; overflow: auto; padding: 10px 16px 24px; }
.preference-row { display: grid; grid-template-columns: minmax(0,1fr) minmax(120px, 240px); gap: 12px; align-items: center; padding: 7px 0; border-bottom: 1px solid #383838; }
.preference-row.unsupported { opacity: .6; }
.preference-label { display: flex; flex-direction: column; gap: 2px; color: #d2d2d2; font-size: 12px; }
.preference-label small, .preference-reason { color: #9a9a9a; font-size: 10px; font-weight: 400; }
.preference-reason { grid-column: 1 / 3; }
.preference-row input[type=number], .preference-row input[type=text], .preference-row select { min-width: 0; width: 100%; padding: 4px 6px; color: #ddd; background: #1f1f1f; border: 1px solid #505050; border-radius: 3px; }
.preference-row output { color: #a9b3aa; }
.preferences-keymap p, .preferences-note { color: #aaa; }
.preferences-keymap table { width: 100%; border-collapse: collapse; font-size: 12px; }
.preferences-keymap th, .preferences-keymap td { padding: 5px 6px; text-align: left; border-bottom: 1px solid #3c3c3c; }
.preferences-keymap td small { display: block; color: #8f9a8f; font-size: 9px; }
.preferences-keymap code { color: #e0c27a; }
.preferences-keymap button { margin-right: 4px; padding: 2px 6px; color: #ddd; background: #454545; border-radius: 3px; }
.preferences-window > footer { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 7px 12px; background: #2b2b2b; border-top: 1px solid #454545; }
.preferences-window > footer button { padding: 4px 10px; color: #ddd; background: #4775a3; border-radius: 3px; }
.preferences-window > footer span { margin-left: auto; color: #9aa39c; font-size: 11px; }
@media(max-width:700px){.preferences-window{inset:0}.preferences-body{grid-template-columns:110px}.preference-row{grid-template-columns:1fr}}

View File

@@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from "react";
import {
PREFERENCE_DEFINITIONS, getPreferencePath, setPreferencePath,
type PreferenceDefinition, type PreferenceTab, type WebPreferencesIR,
} from "../../../../protocol/web-preferences";
import { KEYMAP_ACTIONS, chordFromEvent, describeChord, effectiveBindings, normalizeChord } from "../../../../protocol/keymap";
const TABS: Array<{ id: PreferenceTab; label: string }> = [
{ id: "interface", label: "界面" },
{ id: "viewport", label: "视口" },
{ id: "animation", label: "动画" },
{ id: "input", label: "输入与导航" },
{ id: "keymap", label: "键位" },
{ id: "saveLoad", label: "保存与加载" },
{ id: "files", label: "文件" },
{ id: "themes", label: "主题" },
{ id: "system", label: "系统" },
];
function PreferenceRow({ definition, value, onChange }: {
definition: PreferenceDefinition;
value: unknown;
onChange: (value: unknown) => void;
}) {
const disabled = !definition.supported;
return (
<label className={`preference-row${disabled ? " unsupported" : ""}`} data-setting={definition.path} data-unsupported={disabled ? "true" : undefined} title={disabled ? `不支持:${definition.desktopSource}` : definition.desktopSource}>
<span className="preference-label">{definition.label}{definition.description ? <small>{definition.description}</small> : null}</span>
{definition.kind === "boolean" ? (
<input type="checkbox" aria-label={definition.label} checked={Boolean(value)} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
) : definition.kind === "number" ? (
<input type="number" aria-label={definition.label} value={typeof value === "number" ? value : 0} min={definition.min} max={definition.max} step={definition.step} disabled={disabled} onChange={(event) => onChange(Number(event.target.value))} />
) : definition.kind === "select" ? (
<select aria-label={definition.label} value={typeof value === "string" ? value : ""} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{(definition.options ?? []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
) : definition.kind === "readonly" || disabled ? (
<output>{disabled ? "Web 不支持" : String(value ?? "")}</output>
) : (
<input aria-label={definition.label} value={typeof value === "string" ? value : ""} disabled={disabled} onChange={(event) => onChange(event.target.value)} />
)}
{disabled ? <small className="preference-reason">{definition.desktopSource}</small> : null}
</label>
);
}
export function PreferencesWindow({ preferences, bindings, dirty, saveStatus, settingsPath, onChange, onBind, onSave, onReset, onClose }: {
preferences: WebPreferencesIR;
bindings: Record<string, string>;
dirty: boolean;
saveStatus: string;
settingsPath: string;
onChange: (preferences: WebPreferencesIR) => void;
onBind: (actionId: string, chord: string) => string | null;
onSave: () => void;
onReset: () => void;
onClose: () => void;
}) {
const [tab, setTab] = useState<PreferenceTab>("interface");
const [recording, setRecording] = useState("");
const effective = useMemo(() => effectiveBindings(bindings), [bindings]);
const definitions = PREFERENCE_DEFINITIONS.filter((definition) => definition.tab === tab);
useEffect(() => {
if (!recording) return;
const handler = (event: KeyboardEvent): void => {
if (["Control", "Alt", "Shift", "Meta"].includes(event.key)) return;
event.preventDefault();
event.stopImmediatePropagation();
const chord = chordFromEvent(event);
setRecording("");
onBind(recording, chord);
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [recording, onBind]);
return (
<section className="preferences-window" role="dialog" aria-modal="true" aria-label="Web Blender 偏好设置" data-testid="preferences-window">
<header>
<strong>偏好设置</strong>
<span>{settingsPath}</span>
<input aria-label="搜索偏好" placeholder="搜索偏好…" onChange={(event) => {
const query = event.target.value.trim().toLowerCase();
if (!query) return;
const match = PREFERENCE_DEFINITIONS.find((definition) => definition.label.toLowerCase().includes(query) || definition.path.toLowerCase().includes(query));
if (match) setTab(match.tab);
}} />
<output data-dirty={dirty ? "true" : "false"}>{saveStatus || (dirty ? "有未保存更改" : "已保存")}</output>
<button type="button" onClick={onClose} aria-label="关闭偏好设置">×</button>
</header>
<div className="preferences-body">
<nav className="preferences-tabs" aria-label="偏好分类">
{TABS.map((item) => <button key={item.id} type="button" className={tab === item.id ? "active" : undefined} onClick={() => setTab(item.id)}>{item.label}</button>)}
</nav>
<div className="preferences-content">
{definitions.map((definition) => (
<PreferenceRow key={definition.path} definition={definition} value={getPreferencePath(preferences, definition.path)} onChange={(value) => onChange(setPreferencePath(preferences, definition.path, value))} />
))}
{tab === "keymap" ? (
<div className="preferences-keymap">
<p>只覆盖 Web 顶层操作;输入框、模态和编辑器内部快捷键优先。点击“录制”后按下组合键。</p>
<table>
<thead><tr><th>操作</th><th>键位</th><th /></tr></thead>
<tbody>
{KEYMAP_ACTIONS.map((action) => (
<tr key={action.id} data-action={action.id}>
<td>{action.label}<small>{action.scope}</small></td>
<td><code>{effective[action.id] ? describeChord(effective[action.id]) : "未绑定"}</code></td>
<td><button type="button" onClick={() => setRecording(action.id)}>{recording === action.id ? "按下键位…" : "录制"}</button><button type="button" onClick={() => onBind(action.id, normalizeChord(action.defaultChord))}>默认</button></td>
</tr>
))}
</tbody>
</table>
{recording ? <output role="status">正在为 {KEYMAP_ACTIONS.find((action) => action.id === recording)?.label} 录制键位…</output> : null}
</div>
) : null}
{tab === "files" ? <p className="preferences-note">工程、恢复检查点和偏好设置均保存在浏览器 OPFS。需要本地 .blend 时,请使用顶栏“下载 .blend”手动导出;保存流程不会自动下载。</p> : null}
{tab === "themes" ? <p className="preferences-note">Web 当前固定 dark 主题;完整 Theme 编辑器与预设导入尚未实现(桌面 preferences.themes)。</p> : null}
{tab === "system" ? <p className="preferences-note">系统、输入设备、Add-ons/Extensions 页面在 Web 中不适用;NDOF 通过浏览器 Gamepad/NDOF 桥接,完整设备配置未实现。</p> : null}
</div>
</div>
<footer>
<button type="button" onClick={onSave} disabled={!preferences.saveLoad.usePreferencesSave}>保存偏好</button>
<button type="button" onClick={onReset}>恢复默认</button>
<span>桌面语义:use_preferences_save={String(preferences.saveLoad.usePreferencesSave)}</span>
</footer>
</section>
);
}

View File

@@ -0,0 +1,199 @@
import {
DEFAULT_WEB_PREFERENCES,
LEGACY_SETTINGS_STORAGE_KEY,
WEB_PREFERENCES_SCHEMA_VERSION,
WEB_SETTINGS_PATH,
WEB_SETTINGS_STORAGE_KEY,
migrateLegacyPreferences,
validateWebPreferences,
type AreaLayoutRecord,
type WebPreferencesIR,
type WebSettingsFile,
} from "../../../protocol/web-preferences";
import type { EditorType } from "../../../protocol/ui-schema";
const ROOT_DIRECTORY = "settings";
const SETTINGS_FILE = "web-interface.json";
const MAX_SETTINGS_BYTES = 2 * 1024 * 1024;
function emptySettings(preferences: WebPreferencesIR = structuredClone(DEFAULT_WEB_PREFERENCES)): WebSettingsFile {
return {
schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION,
updatedAt: new Date().toISOString(),
preferences,
areaLayouts: {},
areaEditorOverrides: {},
};
}
function validRect(value: unknown): value is AreaLayoutRecord["areas"][number]["rect"] {
if (typeof value !== "object" || value === null) return false;
const rect = value as Record<string, unknown>;
return ["x", "y", "width", "height"].every((key) => typeof rect[key] === "number" && Number.isFinite(rect[key])) &&
(rect.width as number) > 0 && (rect.height as number) > 0 &&
(rect.x as number) >= -0.001 && (rect.y as number) >= -0.001 &&
(rect.x as number) + (rect.width as number) <= 1.001 &&
(rect.y as number) + (rect.height as number) <= 1.001;
}
function validateLayouts(value: unknown): Record<string, AreaLayoutRecord> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
const layouts: Record<string, AreaLayoutRecord> = {};
for (const [workspaceId, entry] of Object.entries(value)) {
if (typeof entry !== "object" || entry === null) continue;
const record = entry as Record<string, unknown>;
if (record.layoutMode !== "custom" || !Array.isArray(record.areas)) continue;
const areas: AreaLayoutRecord["areas"] = [];
for (const rawArea of record.areas) {
if (typeof rawArea !== "object" || rawArea === null) continue;
const area = rawArea as Record<string, unknown>;
if (typeof area.id !== "string" || typeof area.editor !== "string" || !validRect(area.rect)) continue;
areas.push({
id: area.id.slice(0, 120),
editor: area.editor as EditorType,
contentKey: typeof area.contentKey === "string" ? area.contentKey.slice(0, 120) : null,
rect: { x: area.rect.x, y: area.rect.y, width: area.rect.width, height: area.rect.height },
view3d: typeof area.view3d === "object" && area.view3d !== null && typeof (area.view3d as Record<string, unknown>).cameraLock === "boolean"
? { cameraLock: (area.view3d as { cameraLock: boolean }).cameraLock }
: undefined,
});
}
if (areas.length === 0 || areas.length > 64) continue;
layouts[workspaceId.slice(0, 40)] = {
layoutMode: "custom",
areas,
activeAreaId: typeof record.activeAreaId === "string" ? record.activeAreaId.slice(0, 120) : areas[0].id,
focusAreaId: typeof record.focusAreaId === "string" ? record.focusAreaId.slice(0, 120) : null,
maximizedAreaId: typeof record.maximizedAreaId === "string" ? record.maximizedAreaId.slice(0, 120) : null,
};
}
return layouts;
}
function validateEditorOverrides(value: unknown): Record<string, EditorType> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
const overrides: Record<string, EditorType> = {};
for (const [key, editor] of Object.entries(value)) {
if (typeof editor === "string" && editor.length <= 80) overrides[key.slice(0, 120)] = editor as EditorType;
}
return overrides;
}
export function validateWebSettings(value: unknown): WebSettingsFile {
if (typeof value !== "object" || value === null) return emptySettings();
const record = value as Record<string, unknown>;
return {
schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString(),
preferences: validateWebPreferences(record.preferences),
areaLayouts: validateLayouts(record.areaLayouts),
areaEditorOverrides: validateEditorOverrides(record.areaEditorOverrides),
};
}
export function readCachedWebSettings(): WebSettingsFile | null {
if (typeof localStorage === "undefined") return null;
try {
const cached = localStorage.getItem(WEB_SETTINGS_STORAGE_KEY);
if (cached) return validateWebSettings(JSON.parse(cached));
const legacy = localStorage.getItem(LEGACY_SETTINGS_STORAGE_KEY);
if (!legacy) return null;
const preferences = migrateLegacyPreferences(JSON.parse(legacy));
return preferences ? emptySettings(preferences) : null;
}
catch { return null; }
}
function mirrorCache(settings: WebSettingsFile): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(WEB_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
localStorage.removeItem(LEGACY_SETTINGS_STORAGE_KEY);
}
catch { /* storage may be unavailable; OPFS remains canonical */ }
}
async function settingsDirectory(): Promise<FileSystemDirectoryHandle | null> {
if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) return null;
try {
const root = await navigator.storage.getDirectory();
return await root.getDirectoryHandle(ROOT_DIRECTORY, { create: true });
}
catch { return null; }
}
async function readOpfsSettings(): Promise<WebSettingsFile | null> {
const directory = await settingsDirectory();
if (!directory) return null;
try {
const handle = await directory.getFileHandle(SETTINGS_FILE);
const file = await handle.getFile();
if (file.size === 0 || file.size > MAX_SETTINGS_BYTES) return null;
return validateWebSettings(JSON.parse(await file.text()));
}
catch { return null; }
}
async function atomicWrite(directory: FileSystemDirectoryHandle, name: string, data: string): Promise<void> {
const temporary = `${name}.tmp-${crypto.randomUUID()}`;
const handle = await directory.getFileHandle(temporary, { create: true });
const writable = await handle.createWritable();
await writable.write(data);
await writable.close();
try {
const target = await directory.getFileHandle(name, { create: true });
const targetWritable = await target.createWritable();
await targetWritable.write(data);
await targetWritable.close();
await directory.removeEntry(temporary);
}
catch (error) {
try { await directory.removeEntry(temporary); } catch { /* ignore */ }
throw error;
}
}
export async function loadWebSettings(): Promise<{ settings: WebSettingsFile; source: "opfs" | "cache" | "defaults" }> {
const cached = readCachedWebSettings();
const opfs = await readOpfsSettings();
if (opfs) {
mirrorCache(opfs);
return { settings: opfs, source: "opfs" };
}
if (cached) return { settings: cached, source: "cache" };
return { settings: emptySettings(), source: "defaults" };
}
export async function saveWebSettings(settings: WebSettingsFile): Promise<boolean> {
const validated = validateWebSettings(settings);
const toWrite: WebSettingsFile = { ...validated, schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION, updatedAt: new Date().toISOString() };
mirrorCache(toWrite);
const directory = await settingsDirectory();
if (!directory) return false;
try {
await atomicWrite(directory, SETTINGS_FILE, JSON.stringify(toWrite));
return true;
}
catch { return false; }
}
export function settingsPathForDisplay(): string {
return WEB_SETTINGS_PATH;
}
let pendingSave: number | null = null;
let pendingSettings: WebSettingsFile | null = null;
export function scheduleWebSettingsSave(settings: WebSettingsFile, delayMs = 300): void {
pendingSettings = settings;
if (pendingSave !== null) window.clearTimeout(pendingSave);
pendingSave = window.setTimeout(() => { void flushWebSettingsSave(); }, delayMs);
}
export async function flushWebSettingsSave(): Promise<boolean> {
if (pendingSave !== null) { window.clearTimeout(pendingSave); pendingSave = null; }
const settings = pendingSettings;
pendingSettings = null;
if (!settings) return true;
return saveWebSettings(settings);
}

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(" + ");
}

View 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;
}

View File

@@ -0,0 +1,65 @@
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 1600, height: 900 } });
async function openPreferences(page: import("@playwright/test").Page): Promise<void> {
await page.getByRole("button", { name: "编辑", exact: true }).click();
await page.getByRole("menuitem", { name: "偏好设置", exact: true }).click();
await page.getByTestId("preferences-window").waitFor({ timeout: 30_000 });
}
test("Preferences persist through OPFS and keymap rebinding takes effect", async ({ page }) => {
test.setTimeout(240_000);
await page.goto("/?ui=web");
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await openPreferences(page);
await expect(page.getByTestId("preferences-window").getByRole("button", { name: "界面", exact: true })).toHaveClass(/active/);
await expect(page.getByRole("dialog", { name: "Web Blender 偏好设置" }).locator("nav button")).toHaveCount(9);
const statusToggle = page.getByLabel("显示状态栏", { exact: true });
if (await statusToggle.isChecked()) await statusToggle.uncheck();
await page.getByRole("button", { name: "保存偏好", exact: true }).click();
await expect(page.getByTestId("preferences-window").locator("header output")).toContainText("已保存到 OPFS", { timeout: 30_000 });
await page.getByRole("button", { name: "键位", exact: true }).click();
const row = page.locator('tr[data-action="edit.operatorSearch"]');
await row.getByRole("button", { name: "录制", exact: true }).click();
await page.keyboard.press("Shift+F3");
await expect(row.locator("code")).toHaveText("Shift + F3");
await page.getByRole("button", { name: "保存偏好", exact: true }).click();
await page.getByRole("button", { name: "关闭偏好设置", exact: true }).click();
await expect(page.locator("main.blender-app")).toHaveAttribute("data-hide-status", "true");
await page.keyboard.press("F3");
await expect(page.getByLabel("搜索 Blender 操作")).toHaveCount(0);
await page.keyboard.press("Shift+F3");
await expect(page.getByLabel("搜索 Blender 操作")).toBeVisible({ timeout: 30_000 });
await page.keyboard.press("Escape");
await page.reload();
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await openPreferences(page);
await expect(page.getByLabel("显示状态栏", { exact: true })).not.toBeChecked();
await page.getByRole("button", { name: "键位", exact: true }).click();
await expect(page.locator('tr[data-action="edit.operatorSearch"] code')).toHaveText("Shift + F3");
const stored = await page.evaluate(async () => {
const root = await navigator.storage.getDirectory();
const directory = await root.getDirectoryHandle("settings");
const file = await (await directory.getFileHandle("web-interface.json")).getFile();
return JSON.parse(await file.text());
});
expect(stored.preferences.keymap.bindings["edit.operatorSearch"]).toBe("Shift+F3");
expect(stored.preferences.interface.showStatus).toBe(false);
});
test("Keymap conflict is rejected and unsupported preference rows are marked", async ({ page }) => {
test.setTimeout(180_000);
await page.goto("/?ui=web");
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await openPreferences(page);
await page.getByRole("button", { name: "键位", exact: true }).click();
await page.locator('tr[data-action="edit.undo"]').getByRole("button", { name: "录制", exact: true }).click();
await page.keyboard.press("Control+S");
await expect(page.getByTestId("preferences-window").locator("header output")).toContainText("冲突");
await page.getByRole("button", { name: "视口", exact: true }).click();
await expect(page.locator('.preference-row[data-setting="viewport.gizmoSize"]')).toHaveAttribute("data-unsupported", "true");
});