feat(web): add OPFS-backed preferences and editable top-level keymap

Preferences now live in settings/web-interface.json (OPFS canonical,
localStorage mirror/migration) and render as a tabbed React window with
desktop-source annotations for unsupported items. Top-level shortcuts
move to a central registry that supports rebinding, conflict detection
and persistence; F3 and other migrated handlers honor the new keymap.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-13 09:12:03 +08:00
parent 9150815cbb
commit ebf1e8e873
7 changed files with 941 additions and 52 deletions

View File

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

View File

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

View File

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

View File

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