From ebf1e8e873a5d902f5df0883a4f6a1c45179c362 Mon Sep 17 00:00:00 2001 From: mes123456 Date: Fri, 13 Nov 2026 09:12:03 +0800 Subject: [PATCH] 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 --- web/app/src/app/App.tsx | 178 +++++++++----- web/app/src/app/app-shell.css | 29 +++ web/app/src/app/ui/PreferencesWindow.tsx | 130 +++++++++++ web/app/src/storage/web-settings.ts | 199 ++++++++++++++++ web/protocol/keymap.ts | 110 +++++++++ web/protocol/web-preferences.ts | 282 +++++++++++++++++++++++ web/tests/e2e/web-preferences.spec.ts | 65 ++++++ 7 files changed, 941 insertions(+), 52 deletions(-) create mode 100644 web/app/src/app/ui/PreferencesWindow.tsx create mode 100644 web/app/src/storage/web-settings.ts create mode 100644 web/protocol/keymap.ts create mode 100644 web/protocol/web-preferences.ts create mode 100644 web/tests/e2e/web-preferences.spec.ts diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index 86867b43a..cde216c5d 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -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>;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(() => 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>({}); - 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>(() => initialWebSettings?.areaEditorOverrides ?? {}); + const settingsRef = useRef(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 => { + 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("canvas.viewport-canvas"); + const mapping: Record = { "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: :null} + {preferencesOpen ? void savePreferencesNow()} onReset={resetPreferences} onClose={() => setPreferencesOpen(false)} /> : null} {headerDialog?{if(!headerPending)setHeaderDialog(null);}}> {headerDialog==='obj-import'?:null} {headerDialog==='save-copy'&&

下载当前工程的副本,并继续编辑当前项目;未保存标记将保留。

} @@ -8489,7 +8563,7 @@ else: {headerDialog==='history'?<>
{historyView?.history.entries.map(entry=>)}
{(historyError||!historyView||!historyView.sourceHash)?:null}{headerPending?正在读取工程历史…:null}{historyError?{historyError}:null}:null} {headerDialog==='new'?<>

{dirtyState.dirty?'当前工程有未保存修改。新建会替换当前场景。':'创建 Blender 默认常规场景。'}

:null} {isFileNameDialog||headerDialog==='rename'?
{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);}}>{isFileNameDialog?

另存为保存到浏览器 OPFS;保存副本会下载到本地。需要本地 .blend 时请使用“文件 → 下载 .blend 到本地”。

:null}<>{headerDialog==='rename' && renameError ? {renameError} : null}
:null} - {headerDialog==='preferences'?<>

Web 界面

{preferencesError?{preferencesError}:null}:null} + {headerDialog==='preferences'?<>

偏好设置

Web 偏好设置使用 OPFS 保存;原生 Blender 偏好仍可从本机桌面会话打开。

{preferencesError?{preferencesError}:null}:null} {headerDialog==='about'?<>

Blender 5.2.0 LTS · Web

当前工程使用本机 Blender 源码编译的 WebEngine 与原生工作流服务。菜单中的灰色命令尚未实现等价入口。

GLB 位于文件 → 导出。机器人及原生桌面会话位于窗口 → Web 扩展。

菜单对齐不代表桌面全部编辑器、偏好设置和功能已完全移植。

:null}
:null} {robotToolOpen?saveProjectOpfs()} onClose={()=>setRobotToolOpen(false)} onStart={async()=>{ @@ -8501,7 +8575,7 @@ else: await webClientRef.current.snapshot(); }}/>:null} {interactiveDesktop?
Native Blender
{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"}));});}}}/>
:null} -
LMB Select · MMB Rotate View · Wheel ZoomActive {snapshot?.nodes.find(node => node.id === snapshot?.activeObjectId)?.name ?? '—'} · Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}{openProgress ? 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} />{openProgress.message ?? "Opening"}{canCancelOpenRead ? : null} : null}{manifestStatus}{wasmStatus}{displayedEngineStatus}{localInvokePending || localInvokeActive ? : null}{localRetryPending ? : null}{storageStatus} void cleanupProjectAssets()} />5.2.0 LTS
+
LMB Select · MMB Rotate View · Wheel ZoomActive {snapshot?.nodes.find(node => node.id === snapshot?.activeObjectId)?.name ?? '—'} · Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}{openProgress ? 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} />{openProgress.message ?? "Opening"}{canCancelOpenRead ? : null} : null}{manifestStatus}{wasmStatus}{displayedEngineStatus}{localInvokePending || localInvokeActive ? : null}{localRetryPending ? : null}{storageStatus} void cleanupProjectAssets()} />{webPreferences.interface.statusbarVersion ? 5.2.0 LTS : null}
{createPortal(lastNativeOperator ?
{!lastOperatorOpen ? :
{ event.preventDefault(); void applyAdjustedLastOperation(); }}> diff --git a/web/app/src/app/app-shell.css b/web/app/src/app/app-shell.css index 29a727be0..ce828df5d 100644 --- a/web/app/src/app/app-shell.css +++ b/web/app/src/app/app-shell.css @@ -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}} diff --git a/web/app/src/app/ui/PreferencesWindow.tsx b/web/app/src/app/ui/PreferencesWindow.tsx new file mode 100644 index 000000000..2def77bd8 --- /dev/null +++ b/web/app/src/app/ui/PreferencesWindow.tsx @@ -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 ( + + ); +} + +export function PreferencesWindow({ preferences, bindings, dirty, saveStatus, settingsPath, onChange, onBind, onSave, onReset, onClose }: { + preferences: WebPreferencesIR; + bindings: Record; + 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("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 ( +
+
+ 偏好设置 + {settingsPath} + { + 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); + }} /> + {saveStatus || (dirty ? "有未保存更改" : "已保存")} + +
+
+ +
+ {definitions.map((definition) => ( + onChange(setPreferencePath(preferences, definition.path, value))} /> + ))} + {tab === "keymap" ? ( +
+

只覆盖 Web 顶层操作;输入框、模态和编辑器内部快捷键优先。点击“录制”后按下组合键。

+ + + + {KEYMAP_ACTIONS.map((action) => ( + + + + + + ))} + +
操作键位
{action.label}{action.scope}{effective[action.id] ? describeChord(effective[action.id]) : "未绑定"}
+ {recording ? 正在为 {KEYMAP_ACTIONS.find((action) => action.id === recording)?.label} 录制键位… : null} +
+ ) : null} + {tab === "files" ?

工程、恢复检查点和偏好设置均保存在浏览器 OPFS。需要本地 .blend 时,请使用顶栏“下载 .blend”手动导出;保存流程不会自动下载。

: null} + {tab === "themes" ?

Web 当前固定 dark 主题;完整 Theme 编辑器与预设导入尚未实现(桌面 preferences.themes)。

: null} + {tab === "system" ?

系统、输入设备、Add-ons/Extensions 页面在 Web 中不适用;NDOF 通过浏览器 Gamepad/NDOF 桥接,完整设备配置未实现。

: null} +
+
+
+ + + 桌面语义:use_preferences_save={String(preferences.saveLoad.usePreferencesSave)} +
+
+ ); +} diff --git a/web/app/src/storage/web-settings.ts b/web/app/src/storage/web-settings.ts new file mode 100644 index 000000000..cc2555b22 --- /dev/null +++ b/web/app/src/storage/web-settings.ts @@ -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; + 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + const layouts: Record = {}; + for (const [workspaceId, entry] of Object.entries(value)) { + if (typeof entry !== "object" || entry === null) continue; + const record = entry as Record; + 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; + 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).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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + const overrides: Record = {}; + 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; + 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 { + 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 { + 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 { + 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 { + 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 { + if (pendingSave !== null) { window.clearTimeout(pendingSave); pendingSave = null; } + const settings = pendingSettings; + pendingSettings = null; + if (!settings) return true; + return saveWebSettings(settings); +} diff --git a/web/protocol/keymap.ts b/web/protocol/keymap.ts new file mode 100644 index 000000000..5a9e02cc9 --- /dev/null +++ b/web/protocol/keymap.ts @@ -0,0 +1,110 @@ +export type KeymapScope = "global" | "viewport" | "outliner" | "timeline" | "graph"; + +export interface KeymapActionDefinition { + id: string; + label: string; + scope: KeymapScope; + defaultChord: string; + description?: string; +} + +export const KEYMAP_ACTIONS: KeymapActionDefinition[] = [ + { id: "file.open", label: "打开 .blend", scope: "global", defaultChord: "Ctrl+O" }, + { id: "file.save", label: "保存到 OPFS", scope: "global", defaultChord: "Ctrl+S" }, + { id: "file.saveAs", label: "另存为(OPFS)", scope: "global", defaultChord: "Ctrl+Shift+S" }, + { id: "edit.undo", label: "撤销", scope: "global", defaultChord: "Ctrl+Z" }, + { id: "edit.redo", label: "重做", scope: "global", defaultChord: "Ctrl+Y" }, + { id: "edit.operatorSearch", label: "操作搜索", scope: "global", defaultChord: "F3" }, + { id: "edit.rename", label: "重命名活动项", scope: "global", defaultChord: "F2" }, + { id: "object.delete", label: "删除对象", scope: "viewport", defaultChord: "X" }, + { id: "object.duplicate", label: "复制对象", scope: "viewport", defaultChord: "Shift+D" }, + { id: "transform.translate", label: "移动", scope: "viewport", defaultChord: "G" }, + { id: "transform.rotate", label: "旋转", scope: "viewport", defaultChord: "R" }, + { id: "transform.scale", label: "缩放", scope: "viewport", defaultChord: "S" }, + { id: "view.camera", label: "相机视图(切换)", scope: "viewport", defaultChord: "Numpad0" }, + { id: "view.cameraToView", label: "对齐相机到视图", scope: "viewport", defaultChord: "Ctrl+Alt+Numpad0" }, + { id: "view.cameraLock", label: "锁定相机到视图(Camera to View)", scope: "viewport", defaultChord: "" }, + { id: "view.frameAll", label: "显示全部", scope: "viewport", defaultChord: "Home" }, + { id: "view.frameSelected", label: "框显所选", scope: "viewport", defaultChord: "NumpadDecimal" }, +]; + +const ACTION_BY_ID = new Map(KEYMAP_ACTIONS.map((action) => [action.id, action])); +const RESERVED = new Set(["Meta+W", "Meta+T", "Meta+N", "Meta+Q", "Ctrl+W", "Ctrl+T", "Ctrl+N", "Alt+F4"]); + +export function actionDefinition(id: string): KeymapActionDefinition | undefined { + return ACTION_BY_ID.get(id); +} + +export function chordFromEvent(event: Pick): string { + const parts: string[] = []; + if (event.ctrlKey) parts.push("Ctrl"); + if (event.altKey) parts.push("Alt"); + if (event.shiftKey) parts.push("Shift"); + if (event.metaKey) parts.push("Meta"); + let key = event.key; + if (event.code.startsWith("Numpad") && event.code !== "NumpadEnter") key = event.code; + else if (key === " " || key === "Spacebar") key = "Space"; + else if (key.length === 1) key = key.toUpperCase(); + if (!["Control", "Alt", "Shift", "Meta"].includes(key)) parts.push(key); + return parts.join("+"); +} + +export function normalizeChord(chord: string): string { + const parts = chord.split("+").map((part) => part.trim()).filter(Boolean); + if (parts.length === 0) return ""; + const modifiers = new Set(parts.filter((part) => ["Ctrl", "Alt", "Shift", "Meta"].includes(part))); + const keys = parts.filter((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part)); + if (keys.length !== 1) return chord.trim(); + const ordered = ["Ctrl", "Alt", "Shift", "Meta"].filter((modifier) => modifiers.has(modifier)); + let key = keys[0]; + if (key.length === 1) key = key.toUpperCase(); + if (key === " ") key = "Space"; + return [...ordered, key].join("+"); +} + +export function effectiveBindings(overrides: Record): Record { + const bindings: Record = {}; + for (const action of KEYMAP_ACTIONS) { + const override = overrides[action.id]; + bindings[action.id] = normalizeChord(typeof override === "string" ? override : action.defaultChord); + } + return bindings; +} + +export function findKeymapConflicts(bindings: Record): Array<{ chord: string; actions: string[] }> { + const byChord = new Map(); + for (const action of KEYMAP_ACTIONS) { + const chord = normalizeChord(bindings[action.id] ?? action.defaultChord); + if (!chord) continue; + const list = byChord.get(chord) ?? []; + list.push(action.id); + byChord.set(chord, list); + } + return [...byChord.entries()].filter(([, actions]) => actions.length > 1).map(([chord, actions]) => ({ chord, actions })); +} + +export function resolveKeymapAction(event: KeyboardEvent, bindings: Record, scope: KeymapScope): KeymapActionDefinition | null { + const chord = chordFromEvent(event); + for (const action of KEYMAP_ACTIONS) { + if (action.scope !== scope && action.scope !== "global") continue; + if (normalizeChord(bindings[action.id] ?? action.defaultChord) === chord) return action; + } + return null; +} + +export function validateKeymapBinding(id: string, chord: string, bindings: Record): { ok: boolean; reason?: string } { + const action = ACTION_BY_ID.get(id); + if (!action) return { ok: false, reason: "未知操作" }; + const normalized = normalizeChord(chord); + if (!normalized) return { ok: true }; + if (RESERVED.has(normalized)) return { ok: false, reason: `${normalized} 被浏览器保留` }; + if (action.defaultChord && normalized === normalizeChord(action.defaultChord)) return { ok: true }; + const conflict = KEYMAP_ACTIONS.find((candidate) => candidate.id !== id && + ["global", action.scope].includes(candidate.scope) && + normalizeChord(bindings[candidate.id] ?? candidate.defaultChord) === normalized); + return conflict ? { ok: false, reason: `与“${conflict.label}”冲突` } : { ok: true }; +} + +export function describeChord(chord: string): string { + return chord.split("+").map((part) => part === "Ctrl" ? "Ctrl" : part === "Alt" ? "Alt" : part === "Shift" ? "Shift" : part === "Meta" ? "Meta" : part).join(" + "); +} diff --git a/web/protocol/web-preferences.ts b/web/protocol/web-preferences.ts new file mode 100644 index 000000000..a53903838 --- /dev/null +++ b/web/protocol/web-preferences.ts @@ -0,0 +1,282 @@ +import type { EditorType } from "./ui-schema"; + +export const WEB_PREFERENCES_SCHEMA_VERSION = 1 as const; +export const WEB_SETTINGS_STORAGE_KEY = "web-blender-interface-v2"; +export const LEGACY_SETTINGS_STORAGE_KEY = "web-blender-interface-v1"; +export const WEB_SETTINGS_PATH = "settings/web-interface.json"; + +export type PreferenceTab = "interface" | "viewport" | "animation" | "input" | "keymap" | "saveLoad" | "files" | "themes" | "system"; + +export interface WebPreferencesIR { + schemaVersion: typeof WEB_PREFERENCES_SCHEMA_VERSION; + interface: { + showStatus: boolean; + quickTools: boolean; + showAreaSwitcher: boolean; + showObjectInfo: boolean; + showViewName: boolean; + showPlaybackFps: boolean; + playbackFpsSamples: number; + recentFiles: number; + statusbarVersion: boolean; + statusbarExtensions: boolean; + startupWorkspace: string; + }; + viewport: { + gizmoSize: number; + gizmoSizeNavigate: number; + miniAxis: "MINIMAL" | "GIZMO"; + viewportAA: number; + overlaySmoothWire: boolean; + navigationSensitivity: number; + defaultShading: "SOLID" | "MATERIAL" | "RENDERED"; + }; + animation: { + useAutoKeying: boolean; + autoKeyingMode: "ADD_REPLACE_KEYS" | "REPLACE_KEYS"; + keyframeInterpolation: "BEZIER" | "LINEAR" | "CONSTANT"; + keyframeHandle: "AUTO_CLAMPED" | "AUTO" | "VECTOR" | "ALIGNED" | "FREE"; + }; + navigation: { + orbitSensitivity: number; + zoomSensitivity: number; + invertZoom: boolean; + }; + keymap: { + activeKeyconfig: string; + bindings: Record; + }; + saveLoad: { + autoSaveTimeMinutes: number; + useAutoSaveTemporaryFiles: boolean; + saveVersion: number; + usePreferencesSave: boolean; + }; + files: { + note: string; + }; +} + +export interface AreaLayoutRecord { + layoutMode: "custom"; + areas: Array<{ + id: string; + editor: EditorType; + contentKey: string | null; + rect: { x: number; y: number; width: number; height: number }; + view3d?: { cameraLock: boolean }; + }>; + activeAreaId: string; + focusAreaId?: string | null; + maximizedAreaId?: string | null; +} + +export interface WebSettingsFile { + schemaVersion: typeof WEB_PREFERENCES_SCHEMA_VERSION; + updatedAt: string; + preferences: WebPreferencesIR; + areaLayouts: Record; + areaEditorOverrides: Record; +} + +export const DEFAULT_WEB_PREFERENCES: WebPreferencesIR = { + schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION, + interface: { + showStatus: true, + quickTools: false, + showAreaSwitcher: true, + showObjectInfo: true, + showViewName: true, + showPlaybackFps: true, + playbackFpsSamples: 8, + recentFiles: 200, + statusbarVersion: true, + statusbarExtensions: true, + startupWorkspace: "Layout", + }, + viewport: { + gizmoSize: 75, + gizmoSizeNavigate: 80, + miniAxis: "GIZMO", + viewportAA: 8, + overlaySmoothWire: false, + navigationSensitivity: 1, + defaultShading: "MATERIAL", + }, + animation: { + useAutoKeying: false, + autoKeyingMode: "ADD_REPLACE_KEYS", + keyframeInterpolation: "BEZIER", + keyframeHandle: "AUTO_CLAMPED", + }, + navigation: { orbitSensitivity: 1, zoomSensitivity: 1, invertZoom: false }, + keymap: { activeKeyconfig: "Blender", bindings: {} }, + saveLoad: { + autoSaveTimeMinutes: 2, + useAutoSaveTemporaryFiles: true, + saveVersion: 1, + usePreferencesSave: true, + }, + files: { note: "OPFS 存储;本地 .blend 仅通过“下载 .blend”手动导出。" }, +}; + +export interface PreferenceDefinition { + path: string; + tab: PreferenceTab; + label: string; + description?: string; + kind: "boolean" | "number" | "text" | "select" | "readonly"; + min?: number; + max?: number; + step?: number; + options?: Array<{ value: string; label: string }>; + supported: boolean; + desktopSource: string; +} + +export const PREFERENCE_DEFINITIONS: PreferenceDefinition[] = [ + { path: "interface.showStatus", tab: "interface", label: "显示状态栏", kind: "boolean", supported: true, desktopSource: "preferences.view.show_statusbar_stats" }, + { path: "interface.quickTools", tab: "interface", label: "显示 Web 快捷操作栏", kind: "boolean", supported: true, desktopSource: "Web 扩展;对应 SpaceView3D 快捷工具" }, + { path: "interface.showAreaSwitcher", tab: "interface", label: "显示区域编辑器切换器", kind: "boolean", supported: true, desktopSource: "Area header space type button" }, + { path: "interface.showObjectInfo", tab: "interface", label: "显示对象信息", kind: "boolean", supported: false, desktopSource: "preferences.view.show_object_info" }, + { path: "interface.showViewName", tab: "interface", label: "显示视图名称", kind: "boolean", supported: false, desktopSource: "preferences.view.show_view_name" }, + { path: "interface.showPlaybackFps", tab: "interface", label: "显示播放 FPS", kind: "boolean", supported: false, desktopSource: "preferences.view.show_playback_fps" }, + { path: "interface.playbackFpsSamples", tab: "interface", label: "FPS 采样数", kind: "number", min: 0, max: 5000, step: 1, supported: false, desktopSource: "preferences.view.playback_fps_samples (8)" }, + { path: "interface.recentFiles", tab: "interface", label: "最近项目数量", kind: "number", min: 0, max: 1000, step: 1, supported: false, desktopSource: "preferences.filepaths.recent_files (200)" }, + { path: "interface.statusbarVersion", tab: "interface", label: "状态栏显示版本", kind: "boolean", supported: true, desktopSource: "preferences.view.show_statusbar_version" }, + { path: "interface.statusbarExtensions", tab: "interface", label: "状态栏显示扩展更新", kind: "boolean", supported: false, desktopSource: "preferences.view.show_extensions_updates" }, + { path: "interface.startupWorkspace", tab: "interface", label: "启动工作区", kind: "select", options: [ + { value: "Layout", label: "Layout" }, { value: "Modeling", label: "Modeling" }, { value: "Shading", label: "Shading" }, + { value: "Rendering", label: "Rendering" }, { value: "Compositing", label: "Compositing" }, { value: "Geometry Nodes", label: "Geometry Nodes" }, + { value: "Animation", label: "Animation" }, { value: "Scripting", label: "Scripting" }, + ], supported: true, desktopSource: "Web 启动行为;桌面 SpaceLink 恢复语义" }, + + { path: "viewport.gizmoSize", tab: "viewport", label: "变换 Gizmo 大小", kind: "number", min: 20, max: 200, step: 1, supported: false, desktopSource: "preferences.view.gizmo_size (75)" }, + { path: "viewport.gizmoSizeNavigate", tab: "viewport", label: "导航 Gizmo 大小", kind: "number", min: 20, max: 200, step: 1, supported: false, desktopSource: "preferences.view.gizmo_size_navigate_v3d (80)" }, + { path: "viewport.miniAxis", tab: "viewport", label: "迷你坐标轴", kind: "select", options: [{ value: "GIZMO", label: "Gizmo" }, { value: "MINIMAL", label: "Minimal" }], supported: false, desktopSource: "preferences.view.mini_axis_type (GIZMO)" }, + { path: "viewport.viewportAA", tab: "viewport", label: "视口抗锯齿", kind: "number", min: 0, max: 32, step: 1, supported: false, desktopSource: "preferences.system.viewport_aa (8)" }, + { path: "viewport.overlaySmoothWire", tab: "viewport", label: "平滑线框", kind: "boolean", supported: false, desktopSource: "preferences.system.use_overlay_smooth_wire" }, + { path: "viewport.navigationSensitivity", tab: "viewport", label: "导航灵敏度", kind: "number", min: 0.2, max: 4, step: 0.1, supported: false, desktopSource: "preferences.inputs.* 导航速度" }, + { path: "viewport.defaultShading", tab: "viewport", label: "默认着色", kind: "select", options: [{ value: "SOLID", label: "Solid" }, { value: "MATERIAL", label: "Material" }, { value: "RENDERED", label: "Rendered" }], supported: false, desktopSource: "SpaceView3D.shading(每视口)" }, + + { path: "animation.useAutoKeying", tab: "animation", label: "自动关键帧", kind: "boolean", supported: false, desktopSource: "preferences.edit.use_auto_keying (false)" }, + { path: "animation.autoKeyingMode", tab: "animation", label: "自动关键帧模式", kind: "select", options: [{ value: "ADD_REPLACE_KEYS", label: "Add/Replace" }, { value: "REPLACE_KEYS", label: "Replace" }], supported: false, desktopSource: "preferences.edit.auto_keying_mode" }, + { path: "animation.keyframeInterpolation", tab: "animation", label: "新建关键帧插值", kind: "select", options: [{ value: "BEZIER", label: "Bezier" }, { value: "LINEAR", label: "Linear" }, { value: "CONSTANT", label: "Constant" }], supported: false, desktopSource: "preferences.edit.keyframe_new_interpolation_type (BEZIER)" }, + { path: "animation.keyframeHandle", tab: "animation", label: "新建关键帧手柄", kind: "select", options: [{ value: "AUTO_CLAMPED", label: "Auto Clamped" }, { value: "AUTO", label: "Auto" }, { value: "VECTOR", label: "Vector" }, { value: "ALIGNED", label: "Aligned" }, { value: "FREE", label: "Free" }], supported: false, desktopSource: "preferences.edit.keyframe_new_handle_type (AUTO_CLAMPED)" }, + + { path: "navigation.orbitSensitivity", tab: "input", label: "轨道旋转灵敏度", kind: "number", min: 0.2, max: 4, step: 0.1, supported: false, desktopSource: "preferences.inputs.ndof/view rotation speed" }, + { path: "navigation.zoomSensitivity", tab: "input", label: "滚轮缩放灵敏度", kind: "number", min: 0.2, max: 4, step: 0.1, supported: false, desktopSource: "preferences.inputs.view_zoom" }, + { path: "navigation.invertZoom", tab: "input", label: "反转滚轮缩放", kind: "boolean", supported: false, desktopSource: "preferences.inputs.invert_zoom" }, + + { path: "keymap.activeKeyconfig", tab: "keymap", label: "键位配置", kind: "text", supported: false, desktopSource: "preferences.keymap.active_keyconfig" }, + + { path: "saveLoad.autoSaveTimeMinutes", tab: "saveLoad", label: "自动保存间隔(分钟)", kind: "number", min: 1, max: 60, step: 1, supported: false, desktopSource: "preferences.filepaths.auto_save_time (2)" }, + { path: "saveLoad.useAutoSaveTemporaryFiles", tab: "saveLoad", label: "自动保存临时文件", kind: "boolean", supported: false, desktopSource: "preferences.filepaths.use_auto_save_temporary_files" }, + { path: "saveLoad.saveVersion", tab: "saveLoad", label: "保存版本数", kind: "number", min: 0, max: 32, step: 1, supported: false, desktopSource: "preferences.filepaths.save_version (1)" }, + { path: "saveLoad.usePreferencesSave", tab: "saveLoad", label: "自动保存偏好设置", kind: "boolean", supported: true, desktopSource: "preferences.use_preferences_save (true)" }, + + { path: "themes.note", tab: "themes", label: "主题", kind: "readonly", supported: false, desktopSource: "preferences.themes;Web 当前固定 dark" }, + { path: "system.note", tab: "system", label: "系统 / 输入设备 / 扩展", kind: "readonly", supported: false, desktopSource: "preferences.system / inputs / addons / extensions" }, +]; + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function bool(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function number(value: unknown, fallback: number, min: number, max: number): number { + return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback; +} + +function text(value: unknown, fallback: string, max = 200): string { + return typeof value === "string" && value.length > 0 && value.length <= max ? value : fallback; +} + +export function validateWebPreferences(value: unknown): WebPreferencesIR { + if (!record(value)) return structuredClone(DEFAULT_WEB_PREFERENCES); + const d = DEFAULT_WEB_PREFERENCES; + const i = record(value.interface) ? value.interface : {}; + const v = record(value.viewport) ? value.viewport : {}; + const a = record(value.animation) ? value.animation : {}; + const n = record(value.navigation) ? value.navigation : {}; + const k = record(value.keymap) ? value.keymap : {}; + const s = record(value.saveLoad) ? value.saveLoad : {}; + const bindings: Record = {}; + if (record(k.bindings)) { + for (const [key, chord] of Object.entries(k.bindings)) { + if (typeof chord === "string" && chord.length <= 80) bindings[key.slice(0, 120)] = chord; + } + } + return { + schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION, + interface: { + showStatus: bool(i.showStatus, d.interface.showStatus), + quickTools: bool(i.quickTools, d.interface.quickTools), + showAreaSwitcher: bool(i.showAreaSwitcher, d.interface.showAreaSwitcher), + showObjectInfo: bool(i.showObjectInfo, d.interface.showObjectInfo), + showViewName: bool(i.showViewName, d.interface.showViewName), + showPlaybackFps: bool(i.showPlaybackFps, d.interface.showPlaybackFps), + playbackFpsSamples: number(i.playbackFpsSamples, d.interface.playbackFpsSamples, 0, 5000), + recentFiles: number(i.recentFiles, d.interface.recentFiles, 0, 1000), + statusbarVersion: bool(i.statusbarVersion, d.interface.statusbarVersion), + statusbarExtensions: bool(i.statusbarExtensions, d.interface.statusbarExtensions), + startupWorkspace: text(i.startupWorkspace, d.interface.startupWorkspace, 40), + }, + viewport: { + gizmoSize: number(v.gizmoSize, d.viewport.gizmoSize, 20, 200), + gizmoSizeNavigate: number(v.gizmoSizeNavigate, d.viewport.gizmoSizeNavigate, 20, 200), + miniAxis: v.miniAxis === "MINIMAL" ? "MINIMAL" : "GIZMO", + viewportAA: number(v.viewportAA, d.viewport.viewportAA, 0, 32), + overlaySmoothWire: bool(v.overlaySmoothWire, d.viewport.overlaySmoothWire), + navigationSensitivity: number(v.navigationSensitivity, d.viewport.navigationSensitivity, 0.2, 4), + defaultShading: v.defaultShading === "SOLID" || v.defaultShading === "RENDERED" ? v.defaultShading : "MATERIAL", + }, + animation: { + useAutoKeying: bool(a.useAutoKeying, d.animation.useAutoKeying), + autoKeyingMode: a.autoKeyingMode === "REPLACE_KEYS" ? "REPLACE_KEYS" : "ADD_REPLACE_KEYS", + keyframeInterpolation: a.keyframeInterpolation === "LINEAR" || a.keyframeInterpolation === "CONSTANT" ? a.keyframeInterpolation : "BEZIER", + keyframeHandle: ["AUTO", "VECTOR", "ALIGNED", "FREE"].includes(String(a.keyframeHandle)) ? a.keyframeHandle as WebPreferencesIR["animation"]["keyframeHandle"] : "AUTO_CLAMPED", + }, + navigation: { + orbitSensitivity: number(n.orbitSensitivity, d.navigation.orbitSensitivity, 0.2, 4), + zoomSensitivity: number(n.zoomSensitivity, d.navigation.zoomSensitivity, 0.2, 4), + invertZoom: bool(n.invertZoom, d.navigation.invertZoom), + }, + keymap: { activeKeyconfig: text(k.activeKeyconfig, d.keymap.activeKeyconfig, 80), bindings }, + saveLoad: { + autoSaveTimeMinutes: number(s.autoSaveTimeMinutes, d.saveLoad.autoSaveTimeMinutes, 1, 60), + useAutoSaveTemporaryFiles: bool(s.useAutoSaveTemporaryFiles, d.saveLoad.useAutoSaveTemporaryFiles), + saveVersion: number(s.saveVersion, d.saveLoad.saveVersion, 0, 32), + usePreferencesSave: bool(s.usePreferencesSave, d.saveLoad.usePreferencesSave), + }, + files: { note: d.files.note }, + }; +} + +export function migrateLegacyPreferences(value: unknown): WebPreferencesIR | null { + if (!record(value)) return null; + const preferences = structuredClone(DEFAULT_WEB_PREFERENCES); + if (typeof value.showStatus === "boolean") preferences.interface.showStatus = value.showStatus; + if (typeof value.quickTools === "boolean") preferences.interface.quickTools = value.quickTools; + if (typeof value.showAreaSwitcher === "boolean") preferences.interface.showAreaSwitcher = value.showAreaSwitcher; + return preferences; +} + +export function getPreferencePath(preferences: WebPreferencesIR, path: string): unknown { + return path.split(".").reduce((current, key) => record(current) ? current[key] : undefined, preferences); +} + +export function setPreferencePath(preferences: WebPreferencesIR, path: string, value: unknown): WebPreferencesIR { + const next = structuredClone(preferences); + const keys = path.split("."); + let target: Record = next as unknown as Record; + for (const key of keys.slice(0, -1)) { + if (!record(target[key])) target[key] = {}; + target = target[key] as Record; + } + target[keys[keys.length - 1]] = value; + return next; +} diff --git a/web/tests/e2e/web-preferences.spec.ts b/web/tests/e2e/web-preferences.spec.ts new file mode 100644 index 000000000..c43604e0a --- /dev/null +++ b/web/tests/e2e/web-preferences.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; + +test.use({ viewport: { width: 1600, height: 900 } }); + +async function openPreferences(page: import("@playwright/test").Page): Promise { + await page.getByRole("button", { name: "编辑", exact: true }).click(); + await page.getByRole("menuitem", { name: "偏好设置", exact: true }).click(); + await page.getByTestId("preferences-window").waitFor({ timeout: 30_000 }); +} + +test("Preferences persist through OPFS and keymap rebinding takes effect", async ({ page }) => { + test.setTimeout(240_000); + await page.goto("/?ui=web"); + await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); + await openPreferences(page); + await expect(page.getByTestId("preferences-window").getByRole("button", { name: "界面", exact: true })).toHaveClass(/active/); + await expect(page.getByRole("dialog", { name: "Web Blender 偏好设置" }).locator("nav button")).toHaveCount(9); + const statusToggle = page.getByLabel("显示状态栏", { exact: true }); + if (await statusToggle.isChecked()) await statusToggle.uncheck(); + await page.getByRole("button", { name: "保存偏好", exact: true }).click(); + await expect(page.getByTestId("preferences-window").locator("header output")).toContainText("已保存到 OPFS", { timeout: 30_000 }); + + await page.getByRole("button", { name: "键位", exact: true }).click(); + const row = page.locator('tr[data-action="edit.operatorSearch"]'); + await row.getByRole("button", { name: "录制", exact: true }).click(); + await page.keyboard.press("Shift+F3"); + await expect(row.locator("code")).toHaveText("Shift + F3"); + await page.getByRole("button", { name: "保存偏好", exact: true }).click(); + await page.getByRole("button", { name: "关闭偏好设置", exact: true }).click(); + await expect(page.locator("main.blender-app")).toHaveAttribute("data-hide-status", "true"); + + await page.keyboard.press("F3"); + await expect(page.getByLabel("搜索 Blender 操作")).toHaveCount(0); + await page.keyboard.press("Shift+F3"); + await expect(page.getByLabel("搜索 Blender 操作")).toBeVisible({ timeout: 30_000 }); + await page.keyboard.press("Escape"); + + await page.reload(); + await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); + await openPreferences(page); + await expect(page.getByLabel("显示状态栏", { exact: true })).not.toBeChecked(); + await page.getByRole("button", { name: "键位", exact: true }).click(); + await expect(page.locator('tr[data-action="edit.operatorSearch"] code')).toHaveText("Shift + F3"); + const stored = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const directory = await root.getDirectoryHandle("settings"); + const file = await (await directory.getFileHandle("web-interface.json")).getFile(); + return JSON.parse(await file.text()); + }); + expect(stored.preferences.keymap.bindings["edit.operatorSearch"]).toBe("Shift+F3"); + expect(stored.preferences.interface.showStatus).toBe(false); +}); + +test("Keymap conflict is rejected and unsupported preference rows are marked", async ({ page }) => { + test.setTimeout(180_000); + await page.goto("/?ui=web"); + await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); + await openPreferences(page); + await page.getByRole("button", { name: "键位", exact: true }).click(); + await page.locator('tr[data-action="edit.undo"]').getByRole("button", { name: "录制", exact: true }).click(); + await page.keyboard.press("Control+S"); + await expect(page.getByTestId("preferences-window").locator("header output")).toContainText("冲突"); + await page.getByRole("button", { name: "视口", exact: true }).click(); + await expect(page.locator('.preference-row[data-setting="viewport.gizmoSize"]')).toHaveAttribute("data-unsupported", "true"); +});