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);
|
||||
}
|
||||
Reference in New Issue
Block a user