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:
@@ -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(); }}>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
130
web/app/src/app/ui/PreferencesWindow.tsx
Normal file
130
web/app/src/app/ui/PreferencesWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
web/app/src/storage/web-settings.ts
Normal file
199
web/app/src/storage/web-settings.ts
Normal 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
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;
|
||||
}
|
||||
65
web/tests/e2e/web-preferences.spec.ts
Normal file
65
web/tests/e2e/web-preferences.spec.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user