@@ -109,7 +109,8 @@ import { exportGLB } from "../../../protocol/glb-export";
import { RobotTool } from '../robot/RobotTool' ;
import { DesktopMenu , DesktopDialog , type DesktopMenuItem } from './DesktopMenus' ;
import { AutosaveRecoveryDialog , type RecoveryPointSelection } from './AutosaveRecoveryDialog' ;
import type { BlenderViewportCamera , ViewportNavigationCommand } from '../../../protocol/viewport-camera' ;
import { eulerFromQuaternion , type BlenderViewportCamera , type ViewportNavigationCommand } from '../../../protocol/viewport-camera' ;
import { sceneCameraFrame } from '../three-adapter/scene-camera' ;
import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export" ;
import { normalizeProjectAssetPath } from "../../../protocol/asset-path" ;
import { applyCurveGizmoDelta , curveGizmoAxisDelta , deriveCurveHandleGizmoFrame , type CurveGizmoFrameIR , type CurveGizmoHandleIR , type CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction" ;
@@ -138,7 +139,8 @@ import {
} from "../volume/nanovdb-viewport" ;
import { composePaintColorPatch , composePaintWeightPatch , createSculptMeshAttributes , createSculptStroke } from "../../../protocol/paint" ;
import type { SculptBrush , SculptStrokeSampleIR } from "../../../protocol/sculpt" ;
import { BLENDER_WORKSPACE_IDS , createDefaultWebWorkspaceState , reduceUICommand , type EditorType , type UICommand , type WorkspaceId } from "../../../protocol/ui-schema" ;
import { BLENDER_WORKSPACE_IDS , createDefaultWebWorkspaceState , reduceUICommand , type AreaIR , type EditorType , type UICommand , type WorkspaceId } from "../../../protocol/ui-schema" ;
import { applyAreaOperation , findAreaNeighbor , measureAreaRects , type AreaDirection , type AreaOperation , type AreaSplitDirection } from "../../../protocol/area-layout" ;
import { EDITOR_MODES , parseEditorWorkflow , type EditorWorkflowIR , type EditorAreaIR , type EditorContextEdit , type NativeTimelineEdit , type NativeUVEdit } from "../../../protocol/editor-workflow" ;
import { importExternalVFontIntoMain } from "../fonts/external-vfont-import" ;
import { createIMECompositionState , reduceIMEComposition , shouldBlockOperatorShortcuts , type IMECompositionEvent } from "../../../protocol/ime-composition" ;
@@ -208,7 +210,7 @@ function sceneCameraPose(snapshot: SceneSnapshotIR | null): BlenderViewportCamer
w = ( m10 - m01 ) / s ; x = ( m02 + m20 ) / s ; y = ( m12 + m21 ) / s ; z = 0.25 * s ;
}
const fov = camera . projection === "ORTHOGRAPHIC" ? 45 : Math.min ( 170 , Math . max ( 5 , 2 * Math . atan ( 18 / Math . max ( 1 , camera . lensMm ) ) * 180 / Math . PI ) ) ;
return { position , target , orientation : [ w , x, y , z ] , projection : camera.projection === "ORTHOGRAPHIC" ? "ORTHOGRAPHIC" : "PERSPECTIVE" , fov , near : Math.max ( 0.0001 , camera . near ) , far : Math.max ( camera . near + 0.001 , camera . far ) , zoom : 1 , orthoHeight : Math.max ( 0.001 , camera . orthoScale ? ? 5 ) } ;
return { position , target , orientation : [ x , y , z , w ], projection : camera.projection === "ORTHOGRAPHIC" ? "ORTHOGRAPHIC" : "PERSPECTIVE" , fov , near : Math.max ( 0.0001 , camera . near ) , far : Math.max ( camera . near + 0.001 , camera . far ) , zoom : 1 , orthoHeight : Math.max ( 0.001 , camera . orthoScale ? ? 5 ) } ;
}
const IO_FORMAT_UI_REGISTRY = parseIOFormatUIRegistry ( ioFormatUIRegistryJSON ) ;
@@ -1214,7 +1216,8 @@ function rememberLastProjectId(projectId: string): void {
interface AreaProps {
className? : string ;
editor : EditorType ;
children : React.ReactNode ;
children? : React.ReactNode ;
areaId? : string ;
}
const AREA_EDITOR_CHOICES : EditorType [ ] = [
@@ -1227,6 +1230,12 @@ interface AreaEditorContextValue {
overrides : Record < string , EditorType > ;
setOverride : ( areaKey : string , editor : EditorType | null ) = > void ;
render : ( editor : EditorType , areaKey : string ) = > React . ReactNode ;
layoutFor : ( areaKey : string ) = > AreaIR | null ;
customLayout : boolean ;
neighbors : ( areaId : string ) = > Record < AreaDirection , AreaIR | null > ;
areas : AreaIR [ ] ;
dispatchArea : ( areaId : string , operation : AreaOperation ) = > void ;
resetLayout : ( ) = > void ;
}
const AreaEditorContext = createContext < AreaEditorContextValue | null > ( null ) ;
@@ -1258,23 +1267,71 @@ const EDITOR_ICONS: Record<EditorType, string> = {
} ;
function Area ( { className = "" , editor , children } : AreaProps ) {
function Area ( { className = "" , editor , children , areaId } : AreaProps ) {
const context = useContext ( AreaEditorContext ) ;
const areaKey = className || editor ;
const override = className ? context ? . overrides [ areaKey ] : undefined ;
const activeEditor = override ? ? editor ;
const areaKey = areaId ? ? ( className || editor ) ;
const layout = context ? . layoutFor ( areaKey ) ? ? null ;
const activeDefault = layout ? . editor ? ? editor ;
const override = className ? context ? . overrides [ className ] : context ? . overrides [ areaKey ] ;
const activeEditor = override ? ? activeDefault ;
const custom = Boolean ( context ? . customLayout ) ;
if ( custom && ! layout ) return null ;
if ( custom && layout && ! layout . maximized && context ? . areas . some ( ( area ) = > area . maximized ) ) return null ;
const rect = layout ? . rect ;
const style = custom && layout ? . maximized ? { left : "0%" , top : "0%" , width : "100%" , height : "100%" } : custom && rect ? {
left : ` ${ rect . x * 100 } % ` ,
top : ` ${ ( 1 - rect . y - rect . height ) * 100 } % ` ,
width : ` ${ rect . width * 100 } % ` ,
height : ` ${ rect . height * 100 } % ` ,
} : undefined ;
const targetId = layout ? . id ? ? areaKey ;
const neighbors = context ? . neighbors ( targetId ) ? ? { LEFT : null , RIGHT : null , UP : null , DOWN : null } ;
const directionLabel : Record < AreaDirection , string > = { LEFT : "左" , RIGHT : "右" , UP : "上" , DOWN : "下" } ;
const areaMenuItems : DesktopMenuItem [ ] = context ? [
{ label : "水平分割(上下)" , action : ( ) = > context . dispatchArea ( targetId , { type : "SPLIT_AREA" , areaId : targetId , direction : "HORIZONTAL" as AreaSplitDirection , factor : 0.5 } ) } ,
{ label : "垂直分割(左右)" , action : ( ) = > context . dispatchArea ( targetId , { type : "SPLIT_AREA" , areaId : targetId , direction : "VERTICAL" as AreaSplitDirection , factor : 0.5 } ) } ,
{ label : "" , separator : true } ,
{ label : "合并 / 停靠" , children : ( Object . keys ( directionLabel ) as AreaDirection [ ] ) . map ( ( direction ) = > ( {
label : ` 合并到 ${ directionLabel [ direction ] } 侧 ` ,
disabled : ! neighbors [ direction ] ,
reason : "该方向没有相邻区域" ,
action : ( ) = > { const neighbor = neighbors [ direction ] ; if ( neighbor ) context . dispatchArea ( targetId , { type : "JOIN_AREA" , areaId : targetId , targetAreaId : neighbor.id } ) ; } ,
} ) ) } ,
{ label : "交换编辑器" , children : context.areas.filter ( ( area ) = > area . id !== targetId ) . map ( ( area ) = > ( {
label : ` ${ area . editor } · ${ area . id } ` ,
action : ( ) = > context . dispatchArea ( targetId , { type : "SWAP_AREAS" , areaId : targetId , targetAreaId : area.id } ) ,
} ) ) } ,
{ label : "复制区域" , action : ( ) = > context . dispatchArea ( targetId , { type : "DUPLICATE_AREA" , areaId : targetId } ) } ,
{ label : "" , separator : true } ,
{ label : layout?.maximized ? "还原大小" : "最大化区域" , action : ( ) = > context . dispatchArea ( targetId , { type : "TOGGLE_MAXIMIZE_AREA" , areaId : targetId } ) } ,
{ label : "聚焦模式" , action : ( ) = > context . dispatchArea ( targetId , { type : "TOGGLE_FULLSCREEN_AREA" , areaId : targetId } ) } ,
{ label : "关闭区域" , disabled : context.areas.length <= 1 , reason : "至少保留一个区域" , action : ( ) = > context . dispatchArea ( targetId , { type : "CLOSE_AREA" , areaId : targetId } ) } ,
{ label : "" , separator : true } ,
{ label : "重置工作区布局" , action : ( ) = > context . resetLayout ( ) } ,
] : [ ] ;
const newAreaContent = children === undefined && context ? context . render ( activeEditor , areaKey ) : children ;
const useDefaultContent = override ? false : layout ? ( layout . editor === editor && children !== undefined ) : children !== undefined ;
return (
< section className = { ` area-frame ${ className } ` } data-editor = { activeEditor } data-default-editor = { editor } >
{ className && context ? (
< label className = "area-type-switch" title = "切换该区域的编辑器类型" >
< section
className= { ` area-frame ${ className } ` }
data-editor = { activeEditor }
data-default-editor = { editor }
data-web-area-id = { targetId }
data-content-key = { className || undefined }
style = { style }
>
{ ( className || areaId ) && context ? (
< div className = "area-type-switch" title = "切换该区域的编辑器类型" >
< span aria-hidden = "true" > { EDITOR_ICONS [ activeEditor ] } < / span >
< select aria-label = { ` 切换 ${ editor } 编辑器 ` } value = { activeEditor } onChange = { ( event ) = > {
const next = event . target . value as EditorType ;
context . setOverride ( areaKey , next === editor ? null : next ) ;
if ( custom ) context . dispatchArea ( t argetId , { type : "SET_AREA_EDITOR" , areaId : targetId , editor : next } ) ;
else context . setOverride ( className , next === editor ? null : next ) ;
} } >
{ AREA_EDITOR_CHOICES . map ( ( choice ) = > < option key = { choice } value = { choice } > { choice } { choice === editor ? "(默认)" : "" } < / option > ) }
< / select >
< /label >
< DesktopMenu className = "area-menu" label = "⋮" items = { areaMenuItems } / >
< / div >
) : null }
< header className = "editor-header" >
< button className = "editor-selector" type = "button" aria-label = { ` 切换 ${ editor } 编辑器 ` } >
@@ -1284,7 +1341,7 @@ function Area({ className = "", editor, children }: AreaProps) {
< span className = "header-spacer" / >
< button className = "icon-button" type = "button" aria-label = { ` ${ editor } 菜单 ` } > ⋮ < / button >
< / header >
< div className = "area-content" > { override && c ontex t ? context. render ( override , areaKey ) : children } < / div >
< div className = "area-content" > { useDefaultContent ? newAreaC onten t : context ! . render ( activeEditor , areaKey ) } < / div >
< / section >
) ;
}
@@ -1355,7 +1412,7 @@ function sameGreasePencilDrawing(left: GreasePencilDrawingScopeIR | null | undef
left . frame === right . frame && left . drawingId === right . drawingId ) ;
}
function ViewportPlaceholder ( { snapshot , geometryBuffers , nonMeshGeometryBuffers , textureAssets , volumeProject , lodLevels , selectedObjectIds , editMode , meshSelection , greasePencilSelectionRevision , cameraPose , onSelect , onElementSelect , onGreasePencilPointSelect , onGreasePencilMarqueeSelect , onTransform , onDiagnostic } : {
function ViewportPlaceholder ( { snapshot , geometryBuffers , nonMeshGeometryBuffers , textureAssets , volumeProject , lodLevels , selectedObjectIds , editMode , meshSelection , greasePencilSelectionRevision , cameraPose , cameraLocked , showViewName , onSelect , onElementSelect , onGreasePencilPointSelect , onGreasePencilMarqueeSelect , onTransform , onCameraWrite , onCameraLockChange , onDiagnostic } : {
snapshot : SceneSnapshotIR | null ;
geometryBuffers : MeshGeometryBuffer [ ] ;
nonMeshGeometryBuffers : NonMeshGeometryChunk [ ] ;
@@ -1367,6 +1424,10 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
meshSelection : MeshEditSelection ;
greasePencilSelectionRevision : number ;
cameraPose? : BlenderViewportCamera | null ;
cameraLocked? : boolean ;
showViewName? : boolean ;
onCameraWrite ? : ( pose : BlenderViewportCamera & { objectId : string } ) = > void ;
onCameraLockChange ? : ( locked : boolean ) = > void ;
onSelect : ( id : string , additive : boolean ) = > void ;
onElementSelect : ( meshId : string , mode : MeshElementMode , index : number , additive : boolean , nonMeshKind? : NonMeshElementKind ) = > void ;
onGreasePencilPointSelect : ( point : GreasePencilPointRef , mode : "REPLACE" | "ADD" | "SUBTRACT" , baseSelectionRevision : number ) = > void ;
@@ -1377,6 +1438,10 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
const canvasRef = useRef < HTMLCanvasElement > ( null ) ;
const rendererRef = useRef < ViewportBackend | null > ( null ) ;
const onSelectRef = useRef ( onSelect ) ;
const onCameraWriteRef = useRef ( onCameraWrite ) ;
const onCameraLockChangeRef = useRef ( onCameraLockChange ) ;
onCameraWriteRef . current = onCameraWrite ;
onCameraLockChangeRef . current = onCameraLockChange ;
const onElementSelectRef = useRef ( onElementSelect ) ;
const onGreasePencilPointSelectRef = useRef ( onGreasePencilPointSelect ) ;
const onGreasePencilMarqueeSelectRef = useRef ( onGreasePencilMarqueeSelect ) ;
@@ -1518,6 +1583,7 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
? acquireOffscreenViewportRenderer ( canvas , selectObject , selectElement , selectGreasePencilPoint , selectGreasePencilMarquee )
: new ViewportRenderer ( canvas , selectObject , selectElement , selectGreasePencilPoint , selectGreasePencilMarquee ) ;
rendererRef . current = renderer ;
renderer . setCameraWriteHandler ? . ( ( pose ) = > onCameraWriteRef . current ? . ( pose ) ) ;
return ( ) = > {
rendererRef . current = null ;
if ( renderer instanceof OffscreenViewportRenderer ) releaseOffscreenViewportRenderer ( canvas , renderer ) ;
@@ -1529,6 +1595,14 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
}
} , [ ] ) ;
useEffect ( ( ) = > {
const renderer = rendererRef . current ;
renderer ? . setSceneCamera ? . ( sceneCameraFrame ( snapshot ) ) ;
} , [ snapshot ] ) ;
useEffect ( ( ) = > {
rendererRef . current ? . setCameraLock ? . ( Boolean ( cameraLocked ) ) ;
} , [ cameraLocked ] ) ;
useEffect ( ( ) = > {
const renderer = rendererRef . current ;
renderer ? . setSnapshot ( snapshot ? ? {
@@ -1657,7 +1731,7 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
return (
< div className = "viewport-placeholder" role = "region" aria-label = "Three.js 视口区域" >
< canvas ref = { canvasRef } className = "viewport-canvas" aria-label = "Three.js WebGL2 视口" / >
< div className = "viewport-view-label" > { ( { USER : '用户 ', FRONT : '前' , BACK : '后' , LEFT : '左' , RIGHT : '右' , TOP : '顶' , BOTTOM : '底' } as Record < string , string > ) [ navigationView . view ] ? ? navigationView . view } · { navigationView . projection === 'ORTHOGRAPHIC' ? '正交' : '透视' } < / div >
< div className = "viewport-view-label" > { ( showViewName === false ? null : ( { USER : '用户' , CAMERA : '相机 ', FRONT : '前' , BACK : '后' , LEFT : '左' , RIGHT : '右' , TOP : '顶' , BOTTOM : '底' } as Record < string , string > ) [ navigationView . view ] ? ? navigationView . view ) } · { navigationView . projection === 'ORTHOGRAPHIC' ? '正交' : '透视' } < / div >
< div className = "axis-gizmo" role = "group" aria-label = "视图方向" >
{ ( [ 'X' , 'Y' , 'Z' ] as const ) . flatMap ( ( axis , index ) = > [ 1 , - 1 ] . map ( sign = > ( { axis , index , sign , depth : ( navigationView . axes [ index ] ? . [ 2 ] ? ? 0 ) * sign } ) ) ) . sort ( ( a , b ) = > b . depth - a . depth ) . filter ( ( item , index , all ) = > {
const direction = navigationView . axes [ item . index ] ? ? [ 0 , 0 , 0 ] ;
@@ -1673,6 +1747,9 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
< button type = "button" aria-label = "放大视图" title = "放大视图" onClick = { ( ) = > navigate ( 'ZOOM_IN' ) } > + < / button > < button type = "button" aria-label = "缩小视图" title = "缩小视图" onClick = { ( ) = > navigate ( 'ZOOM_OUT' ) } > − < / button >
< button type = "button" aria-label = "显示全部" title = "显示全部 (Home)" onClick = { ( ) = > navigate ( 'FRAME_ALL' ) } > ⌂ < / button > < button type = "button" aria-label = "框显所选" title = "框显所选 (小键盘 .)" onClick = { ( ) = > navigate ( 'FRAME_SELECTED' ) } > ⊙ < / button >
< button type = "button" aria-label = "切换透视正交" title = "透视 / 正交 (小键盘 5)" aria-pressed = { navigationView . projection === 'ORTHOGRAPHIC' } onClick = { ( ) = > navigate ( 'PROJECTION' ) } > ▱ < / button >
< button type = "button" aria-label = "相机视图" title = "相机视图 (小键盘 0)" aria-pressed = { navigationView . view === 'CAMERA' } onClick = { ( ) = > navigate ( 'CAMERA' ) } > ◎ < / button >
< button type = "button" aria-label = "相机对齐视图" title = "对齐相机到视图 (Ctrl+Alt+小键盘 0)" onClick = { ( ) = > navigate ( 'CAMERA_TO_VIEW' ) } > ⌾ < / button >
< button type = "button" aria-label = "锁定相机" title = "Camera to View( 锁定相机到视图) " aria-pressed = { Boolean ( cameraLocked ) } onClick = { ( ) = > { const next = ! cameraLocked ; onCameraLockChangeRef . current ? . ( next ) ; rendererRef . current ? . setCameraLock ? . ( next ) ; } } > 🔒 < / button >
< / div >
{ viewportError ? < div className = "viewport-message" > < strong > 3D Viewport < / strong > < span > WebGL 不可用 , 已保留场景编辑界面 : { viewportError } < / span > < / div > : null }
{ editMode & & activeTool = = = "marquee" & & marqueeDrawing ? < div className = "grease-pencil-marquee-surface" data-testid = "grease-pencil-marquee-surface" data-drawing-id = { marqueeDrawing . drawingId } onPointerDown = { beginMarquee } onPointerMove = { moveMarquee } onPointerUp = { finishMarquee } onPointerCancel = { finishMarquee } > { marqueeBox ? < span className = "grease-pencil-marquee-box" style = { { left : ` ${ marqueeBox . left * 100 } % ` , top : ` ${ marqueeBox . top * 100 } % ` , width : ` ${ ( marqueeBox . right - marqueeBox . left ) * 100 } % ` , height : ` ${ ( marqueeBox . bottom - marqueeBox . top ) * 100 } % ` } } / > : null } < / div > : null }
@@ -4023,7 +4100,9 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
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 [cameraLocks,setCameraLocks]=useState<Record<string,boolean>>({ });
const [areaLayouts,setAreaLayouts]=useState<Record<string,AreaIR[]>>({});
const settingsRef = useRef<WebSettingsFile>(initialWebSettings ?? { schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION, updatedAt: new Date().toISOString(), preferences: structuredClone(DEFAULT_WEB_PREFERENCES), areaLayouts: {}, areaEditorOverrides: {}, cameraLocks: {} });
const settingsLoadedRef = useRef(false);
useEffect(() => {
let cancelled = false;
@@ -4036,6 +4115,10 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
setShowStatus(settings.preferences.interface.showStatus);
setShowAreaSwitcher(settings.preferences.interface.showAreaSwitcher);
setAreaEditorOverrides(settings.areaEditorOverrides);
setCameraLocks(settings.cameraLocks ?? {});
if (Object.keys(settings.areaLayouts).length > 0) {
setAreaLayouts(Object.fromEntries(Object.entries(settings.areaLayouts).map(([workspaceId, layout]) => [workspaceId, layout.areas.map((area) => ({ id: area.id, editor: area.editor as EditorType, contentKey: area.contentKey, regions: [], rect: area.rect, maximized: false, view3d: area.view3d }))])));
}
setPreferencesError("");
}).catch((error: unknown) => {
if (!cancelled) { settingsLoadedRef.current = true; setPreferencesError(String(error)); }
@@ -4047,10 +4130,15 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
...webPreferences,
interface: { ...webPreferences.interface, showStatus, quickTools, showAreaSwitcher },
};
const merged: WebSettingsFile = { ...settingsRef.current, preferences: mergedPreferences, areaEditorOverrides };
const layoutRecords = Object.fromEntries(Object.entries(areaLayouts).map(([workspaceId, areas]) => [workspaceId, {
layoutMode: "custom" as const,
areas: areas.map((area) => ({ id: area.id, editor: area.editor, contentKey: area.contentKey ?? null, rect: area.rect, view3d: area.view3d })),
activeAreaId: uiState.workspaces[workspaceId as WorkspaceId]?.activeAreaId ?? areas[0]?.id ?? "",
}]));
const merged: WebSettingsFile = { ...settingsRef.current, preferences: mergedPreferences, areaEditorOverrides, areaLayouts: layoutRecords, cameraLocks };
settingsRef.current = merged;
if (settingsLoadedRef.current && mergedPreferences.saveLoad.usePreferencesSave) scheduleWebSettingsSave(merged);
}, [webPreferences, quickTools, showStatus, showAreaSwitcher, areaEditorOverrides]);
}, [webPreferences, quickTools, showStatus, showAreaSwitcher, areaEditorOverrides, areaLayouts, cameraLocks, uiState.workspaces ]);
useEffect(() => {
const flush = (): void => { void flushWebSettingsSave(); };
const hide = (): void => { if (document.visibilityState === "hidden") flush(); };
@@ -4067,10 +4155,10 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
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 });
const saved = await saveWebSettings({ ...settingsRef.current, preferences: { ...settingsRef.current.preferences, interface: { ...settingsRef.current.preferences.interface, showStatus, quickTools, showAreaSwitcher } }, areaEditorOverrides, cameraLocks });
setSettingsDirty(false);
setPreferencesStatus(saved ? "已保存到 OPFS" : "已缓存( OPFS 不可用)");
}, [quickTools, showStatus, showAreaSwitcher, areaEditorOverrides]);
}, [quickTools, showStatus, showAreaSwitcher, areaEditorOverrides, cameraLocks ]);
const bindKeymap = useCallback((actionId: string, chord: string): string | null => {
const bindings = webPreferences.keymap.bindings;
const validation = validateKeymapBinding(actionId, chord, bindings);
@@ -5203,6 +5291,12 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
editCommandTailRef . current = result . then ( ( ) = > undefined , ( ) = > undefined ) . then ( ( ) = > { if ( client === webClientRef . current && project === projectIdRef . current && actionSequence === userActionSequenceRef . current ) setAutosaveEditEpoch ( value = > value + 1 ) ; } ) ;
return result ;
} ;
const handleCameraWrite = useCallback ( ( pose : BlenderViewportCamera & { objectId : string } ) : void = > {
const node = snapshot ? . nodes . find ( ( candidate ) = > candidate . id === pose . objectId ) ;
if ( ! node || node . sourceLibrary ) return ;
const rotationEuler = eulerFromQuaternion ( pose . orientation ) ;
void applyEditCommand ( { type : "setObjectTransform" , objectId : pose.objectId , translation : pose.position , rotationEuler , scale : node.transform.scale } ) ;
} , [ snapshot , applyEditCommand ] ) ;
const executeEditCommand = async ( command : WebEngineEditCommand , onApplied ? : ( snapshot : SceneSnapshotIR ) = > void , onFeedback ? : ( message : string ) = > void ) : Promise < boolean > = > {
const reportProject = projectIdRef . current , reportRevision = projectRevisionRef . current ;
if ( projectActionMutexRef . current . owner ) {
@@ -8278,6 +8372,49 @@ else:
renderPbr : requestPbrRender ,
downloadPbrRender : downloadPbrRenderForTutorial ,
} ;
const workspaceAreaLayout = areaLayouts [ uiState . context . workspaceId ] ? ? null ;
const measureCurrentWorkspaceAreas = useCallback ( ( ) : AreaIR [ ] = > {
const grid = document . querySelector < HTMLElement > ( ".workspace-grid" ) ;
if ( ! grid ) return [ ] ;
const frames = Array . from ( grid . querySelectorAll < HTMLElement > ( ":scope > .area-frame" ) )
. filter ( ( frame ) = > frame . getClientRects ( ) . length > 0 && frame . offsetWidth > 0 && frame . offsetHeight > 0 ) ;
const gridRect = grid . getBoundingClientRect ( ) ;
return measureAreaRects ( frames . map ( ( frame ) = > ( {
id : frame.dataset.contentKey || frame . dataset . webAreaId || "" ,
editor : ( frame . dataset . editor || "3D Viewport" ) as EditorType ,
contentKey : frame.dataset.contentKey || null ,
rect : frame.getBoundingClientRect ( ) ,
} ) ) . filter ( ( entry ) = > entry . id . length > 0 ) , gridRect ) ;
} , [ ] ) ;
const dispatchArea = useCallback ( ( areaId : string , operation : AreaOperation ) : void = > {
if ( localOnly ) return ;
const workspaceId = uiState . context . workspaceId ;
const current = areaLayouts [ workspaceId ] ? ? measureCurrentWorkspaceAreas ( ) ;
const workspace = { . . . uiState . workspaces [ workspaceId ] , areas : current , activeAreaId : areaId , layoutMode : "custom" as const } ;
const next = applyAreaOperation ( workspace , operation ) ;
if ( next . layoutMode === "default" ) {
setAreaLayouts ( ( layouts ) = > { const copy = { . . . layouts } ; delete copy [ workspaceId ] ; return copy ; } ) ;
return ;
}
setAreaLayouts ( ( layouts ) = > ( { . . . layouts , [ workspaceId ] : next . areas } ) ) ;
} , [ localOnly , uiState . context . workspaceId , uiState . workspaces , areaLayouts , measureCurrentWorkspaceAreas ] ) ;
const resetAreaLayout = useCallback ( ( ) : void = > {
const workspaceId = uiState . context . workspaceId ;
setAreaLayouts ( ( layouts ) = > { const copy = { . . . layouts } ; delete copy [ workspaceId ] ; return copy ; } ) ;
} , [ uiState . context . workspaceId ] ) ;
const areaLayoutFor = useCallback ( ( areaKey : string ) : AreaIR | null = > {
if ( ! workspaceAreaLayout ) return null ;
return workspaceAreaLayout . find ( ( area ) = > area . id === areaKey || area . contentKey === areaKey ) ? ? null ;
} , [ workspaceAreaLayout ] ) ;
const areaNeighbors = useCallback ( ( areaId : string ) : Record < AreaDirection , AreaIR | null > = > {
const areas = workspaceAreaLayout ? ? [ ] ;
return {
LEFT : findAreaNeighbor ( areas , areaId , "LEFT" ) ,
RIGHT : findAreaNeighbor ( areas , areaId , "RIGHT" ) ,
UP : findAreaNeighbor ( areas , areaId , "UP" ) ,
DOWN : findAreaNeighbor ( areas , areaId , "DOWN" ) ,
} ;
} , [ workspaceAreaLayout ] ) ;
const renderAreaEditor = ( editor : EditorType , areaKey : string ) : React . ReactNode = > {
switch ( editor ) {
case "3D Viewport" :
@@ -8321,7 +8458,7 @@ else:
const activeMeshSummary = snapshot ? . meshes . find ( ( mesh ) = > mesh . id === activeObjectSummary ? . dataId ) ;
const activeMaterialId = activeMeshSummary ? . materialSlotIds ? . [ activeMaterialSlot ] ? ? snapshot ? . materials [ 0 ] ? . id ;
return (
< main className = "blender-app" data-native-session-exit = { nativeSessionExit } data-native-canvas = { nativeCanvasReady && gpuSurfaceOpen ? "true" : undefined } data-quick-tools = { quickTools } data-show-area-switcher = { showAreaSwitcher } data-hide-status = { ! showStatus } data-workspace = { workspace } data-editor-workflow-status = { snapshot ? . editorWorkflowStatus ? ? "BLOCKED" } data-editor-workflow-workspace = { editorWorkflow ? . context . workspaceId } data-editor-workflow-area = { editorWorkflow ? . context . activeAreaId } data-editor-workflow-revision = { editorWorkflow ? . context . revision } data-ime-composing = { imeComposition . composing ? "true" : "false" } data-ime-revision = { imeComposition . revision } data-ime-last-event = { imeComposition . lastEvent } data-key-key = { keyboardObservation ? . key } data-key-code = { keyboardObservation ? . code } data-key-location = { keyboardObservation ? . location } data-key-modifiers = { ` ${ keyboardObservation ? . shiftKey ? "S" : "" } ${ keyboardObservation ? . ctrlKey ? "C" : "" } ${ keyboardObservation ? . altKey ? "A" : "" } ${ keyboardObservation ? . metaKey ? "M" : "" } ` } data-key-dead = { keyboardObservation ? . deadKey ? "true" : "false" } data-ui-revision = { uiState . context . revision }
< main className = "blender-app" data-native-session-exit = { nativeSessionExit } data-native-canvas = { nativeCanvasReady && gpuSurfaceOpen ? "true" : undefined } data-quick-tools = { quickTools } data-show-area-switcher = { showAreaSwitcher } data-area-layout = { workspaceAreaLayout ? "custom" : "default" } data-hide-status = { ! showStatus } data-workspace = { workspace } data-editor-workflow-status = { snapshot ? . editorWorkflowStatus ? ? "BLOCKED" } data-editor-workflow-workspace = { editorWorkflow ? . context . workspaceId } data-editor-workflow-area = { editorWorkflow ? . context . activeAreaId } data-editor-workflow-revision = { editorWorkflow ? . context . revision } data-ime-composing = { imeComposition . composing ? "true" : "false" } data-ime-revision = { imeComposition . revision } data-ime-last-event = { imeComposition . lastEvent } data-key-key = { keyboardObservation ? . key } data-key-code = { keyboardObservation ? . code } data-key-location = { keyboardObservation ? . location } data-key-modifiers = { ` ${ keyboardObservation ? . shiftKey ? "S" : "" } ${ keyboardObservation ? . ctrlKey ? "C" : "" } ${ keyboardObservation ? . altKey ? "A" : "" } ${ keyboardObservation ? . metaKey ? "M" : "" } ` } data-key-dead = { keyboardObservation ? . deadKey ? "true" : "false" } data-ui-revision = { uiState . context . revision }
data-user-action-import-status = { userActions . IMPORT . status } data-user-action-import-id = { userActions . IMPORT . identity ? . actionId } data-user-action-import-error = { userActions . IMPORT . errorCode ? ? undefined }
aria-busy = { projectActionLocked }
data-user-action-open-status = { userActions . OPEN . status } data-user-action-open-id = { userActions . OPEN . identity ? . actionId } data-user-action-open-error = { userActions . OPEN . errorCode ? ? undefined }
@@ -8355,7 +8492,7 @@ else:
data-grease-pencil-selection-source = { greasePencilSelection ? . lastSource ? ? "NONE" }
data-grease-pencil-selection-drawing-id = { greasePencilSelection ? . drawing . drawingId ? ? "" }
data-diagnostic-count = { diagnostics . length } data-last-diagnostic-code = { diagnostics . at ( - 1 ) ? . code } data-last-diagnostic-detail = { diagnostics . at ( - 1 ) ? . detail ? . slice ( 0 , 1200 ) } >
< AreaEditorContext.Provider value = { { overrides : areaEditorOverrides , setOverride : ( areaKey , editor ) = > setAreaEditorOverrides ( current = > { const next = { . . . current } ; if ( editor === null ) delete next [ areaKey ] ; else next [ areaKey ] = editor ; return next ; } ) , render : renderAreaEditor } } >
< AreaEditorContext.Provider value = { { overrides : areaEditorOverrides , setOverride : ( areaKey , editor ) = > setAreaEditorOverrides ( current = > { const next = { . . . current } ; if ( editor === null ) delete next [ areaKey ] ; else next [ areaKey ] = editor ; return next ; } ) , render : renderAreaEditor , layoutFor : areaLayoutFor , customLayout : Boolean ( workspaceAreaLayout ) , neighbors : areaNeighbors , areas : workspaceAreaLayout ? ? [ ] , dispatchArea , resetLayout : resetAreaLayout } } >
{ nativeRecoveryWarning ? < aside role = "alert" aria-label = "恢复存储提示" style = { { position : "fixed" , right : 12 , bottom : 32 , zIndex : 10000 , maxWidth : 560 , padding : 12 , background : "#332b16" , color : "#ffe4a3" , border : "1px solid #a88635" } } > < span > { nativeRecoveryWarning } < / span > < button type = "button" aria-label = "关闭恢复提示" onClick = { ( ) = > setNativeRecoveryWarning ( null ) } > 关 闭 提 示 < / button > < / aside > : null }
< header className = "topbar" >
< div className = "brand" title = "Blender 5.2.0 LTS Web" > < span className = "brand-mark" aria-hidden = "true" > < span className = "brand-orbit" / > < / span > < span className = "brand-name" > Blender < / span > < / div >
@@ -8427,12 +8564,12 @@ else:
onRepair = { repairAssetLibrary }
/ > }
< / Area > : null }
{ ! localOnly && workspace === "Animation" ? < Area className = "camera-view-area" editor = "3D Viewport" > < ViewportPlaceholder snapshot = { snapshot } geometryBuffers = { geometryBuffers } nonMeshGeometryBuffers = { nonMeshGeometryBuffers } textureAssets = { gpuTextureAssets } volumeProject = { volumeProject } lodLevels = { lodLevels } selectedObjectIds = { selectedObjectIds } editMode = { uiState . context . mode === "Edit" } meshSelection = { meshSelection } greasePencilSelectionRevision = { greasePencilSelection ? . revision ? ? 0 } cameraPose = { sceneCameraPose ( snapshot ) } onSelect = { selectObject } onElementSelect = { selectMeshElement } onGreasePencilPointSelect = { selectGreasePencilPoint } onGreasePencilMarqueeSelect = { selectGreasePencilMarquee } onTransform = { transformActive } onDiagnostic = { recordDiagnostic } / > < / Area > : null }
{ ! localOnly && workspace === "Animation" ? < Area className = "camera-view-area" editor = "3D Viewport" > < ViewportPlaceholder snapshot = { snapshot } geometryBuffers = { geometryBuffers } nonMeshGeometryBuffers = { nonMeshGeometryBuffers } textureAssets = { gpuTextureAssets } volumeProject = { volumeProject } lodLevels = { lodLevels } selectedObjectIds = { selectedObjectIds } editMode = { uiState . context . mode === "Edit" } meshSelection = { meshSelection } greasePencilSelectionRevision = { greasePencilSelection ? . revision ? ? 0 } cameraPose = { sceneCameraPose ( snapshot ) } cameraLocked = { Boolean ( cameraLocks [ "camera-view-area" ] ) } showViewName = { webPreferences . interface . showViewName } onCameraWrite = { handleCameraWrite } onCameraLockChange = { ( locked ) = > setCameraLocks ( current = > ( { . . . current , "camera-view-area" : locked } ) ) } onSelect = { selectObject } onElementSelect = { selectMeshElement } onGreasePencilPointSelect = { selectGreasePencilPoint } onGreasePencilMarqueeSelect = { selectGreasePencilMarquee } onTransform = { transformActive } onDiagnostic = { recordDiagnostic } / > < / Area > : null }
{ localOnly ? < Area className = "spreadsheet-area" editor = "Spreadsheet" > < WorkspaceEditorSurface editor = "Spreadsheet" snapshot = { snapshot } engineStatus = { engineStatus } onOpen = { ( ) = > fileInputRef . current ? . click ( ) } onSearch = { ( ) = > dispatchUI ( { type : "toggleOperatorSearch" , open : true } ) } / > < / Area > : workspace === "Geometry Nodes" ? < Area className = "spreadsheet-area" editor = "Spreadsheet" > < SpreadsheetView snapshot = { snapshot } geometryBuffers = { geometryBuffers } / > < / Area > : null }
{ scriptingMounted . current ? < ScriptingContainer { ... ( localOnly ? {} : { style : { display : scriptingVisible ? "contents" : "none" }} ) } > < Area className = "text-editor-area" editor = "Text Editor" >
< WasmTextEditor key = { ` ${ projectIdRef . current } : ${ textEditorEpoch } ` } snapshot = { snapshot } onCommand = { applyEditCommand } projectBusy = { projectActionLocked } canEditDraft = { ( ) = > ! projectActionMutexRef . current . owner } onDraftChanged = { dirty = > { if ( dirty ) textDraftAreas . current . add ( 'web-tools-text' ) ; else textDraftAreas . current . delete ( 'web-tools-text' ) ; textDraftDirty . current = textDraftAreas . current . size > 0 ; } } onNativeRun = { localOnly ? undefined : ( name , source ) = > void runNativeWorkflow ( { kind : 'PYTHON' , context :nativeContext ( ) , python : { source , textName :name , console :false } } ) } onPythonRun = { localOnly && pythonSession ? ( name , source ) = > pythonSession . run ( source , name , false ) : undefined } pythonBusy = { pythonState ? . status === 'running' || pythonState ? . status === 'stopping' } / >
< / Area > < Area className = "console-area" editor = "Python Console" > { localOnly ? ( pythonSession ? < WasmPythonConsole session = { pythonSession } enabled = { Boolean ( snapshot ) && ! headerPending } / > : null ) : < NativeSessionPanel key = { projectIdRef . current } client = { webClientRef . current } context = { nativeContext ( ) } onApplied = { applyNativeScene } / > } < / Area > < Area className = "info-area" editor = "Info" > < WorkspaceEditorSurface editor = "Info" snapshot = { snapshot } engineStatus = { engineStatus } onOpen = { ( ) = > fileInputRef . current ? . click ( ) } onSearch = { ( ) = > dispatchUI ( { type : "toggleOperatorSearch" , open : true } ) } / > < / Area > < Area className = "secondary-outliner-area" editor = "Outliner" > < Outliner key = { ` ${ projectIdRef . current } : ${ userActionSequenceRef . current } ` } snapshot = { snapshot } onToggleExpand = { toggleOutlinerExpand } onTreeSelect = { selectOutlinerRow } onRemap = { ( command , feedback ) = > applyEditCommand ( command , undefined , feedback ) } onReparent = { ( ids , parentId , keepTransform ) = > void applyEditCommand ( { type : 'setParents' , objectIds : ids , parentId , keepTransform } ) } onMoveToCollection = { ( ids , collectionId , keepExistingLinks ) = > { for ( const id of ids ) void applyEditCommand ( { type : 'moveObjectToCollection' , objectId : id , collectionId , keepExistingLinks } ) ; } } onMoveCollection = { ( collectionId , parentCollectionId ) = > void applyEditCommand ( coreSceneCommands . moveCollection ( collectionId , parentCollectionId ) ) } onSelect = { selectObject } onRefresh = { refreshOutliner } onSelectAll = { selectAllOutliner } onToggleVisibility = { ( id , visible ) = > void applyEditCommand ( { type : "setObjectVisibility" , objectId : id , visible } ) } / > < / Area > < / ScriptingContainer > : null }
< Area className = "viewport-area" editor = "3D Viewport" > < ViewportPlaceholder snapshot = { preview ? . snapshot ? ? snapshot } geometryBuffers = { preview ? . geometryBuffers ? ? geometryBuffers } nonMeshGeometryBuffers = { preview ? . nonMeshGeometryBuffers ? ? nonMeshGeometryBuffers } textureAssets = { gpuTextureAssets } volumeProject = { volumeProject } lodLevels = { preview ? null : lodLevels } selectedObjectIds = { selectedObjectIds } editMode = { uiState . context . mode === "Edit" } meshSelection = { meshSelection } greasePencilSelectionRevision = { greasePencilSelection ? . revision ? ? 0 } onSelect = { selectObject } onElementSelect = { selectMeshElement } onGreasePencilPointSelect = { selectGreasePencilPoint } onGreasePencilMarqueeSelect = { selectGreasePencilMarquee } onTransform = { transformActive } onDiagnostic = { recordDiagnostic } / > < / Area >
< Area className = "viewport-area" editor = "3D Viewport" > < ViewportPlaceholder snapshot = { preview ? . snapshot ? ? snapshot } geometryBuffers = { preview ? . geometryBuffers ? ? geometryBuffers } nonMeshGeometryBuffers = { preview ? . nonMeshGeometryBuffers ? ? nonMeshGeometryBuffers } textureAssets = { gpuTextureAssets } volumeProject = { volumeProject } lodLevels = { preview ? null : lodLevels } selectedObjectIds = { selectedObjectIds } editMode = { uiState . context . mode === "Edit" } meshSelection = { meshSelection } greasePencilSelectionRevision = { greasePencilSelection ? . revision ? ? 0 } cameraLocked = { Boolean ( cameraLocks [ "viewport-area" ] ) } showViewName = { webPreferences . interface . showViewName } onCameraWrite = { handleCameraWrite } onCameraLockChange = { ( locked ) = > setCameraLocks ( current = > ( { . . . current , "viewport-area" : locked } ) ) } onSelect = { selectObject } onElementSelect = { selectMeshElement } onGreasePencilPointSelect = { selectGreasePencilPoint } onGreasePencilMarqueeSelect = { selectGreasePencilMarquee } onTransform = { transformActive } onDiagnostic = { recordDiagnostic } / > < / Area >
< Area className = "outliner-area" editor = "Outliner" > < Outliner key = { ` ${ projectIdRef . current } : ${ userActionSequenceRef . current } ` } snapshot = { snapshot } onToggleExpand = { toggleOutlinerExpand } onTreeSelect = { selectOutlinerRow } onRemap = { ( command , feedback ) = > applyEditCommand ( command , undefined , feedback ) } onReparent = { ( ids , parentId , keepTransform ) = > void applyEditCommand ( { type : 'setParents' , objectIds : ids , parentId , keepTransform } ) } onMoveToCollection = { ( ids , collectionId , keepExistingLinks ) = > { for ( const id of ids ) void applyEditCommand ( { type : 'moveObjectToCollection' , objectId : id , collectionId , keepExistingLinks } ) ; } } onMoveCollection = { ( collectionId , parentCollectionId ) = > void applyEditCommand ( coreSceneCommands . moveCollection ( collectionId , parentCollectionId ) ) } onSelect = { selectObject } onRefresh = { refreshOutliner } onSelectAll = { selectAllOutliner } onToggleVisibility = { ( id , visible ) = > void applyEditCommand ( { type : "setObjectVisibility" , objectId : id , visible } ) } / > < / Area >
< Area className = "properties-area" editor = "Properties" > < Properties key = { mainReplacementEpoch } onSoftBodyCacheOperation = { localOnly ? queueSoftBodyCache : undefined } onFluidCacheOperation = { localOnly ? ( objectId , operation ) = > queueLocalDesktopOperator ( "fluid." + operation , { } , true , objectId , "OBJECT" ) : undefined } pointCacheBusy = { headerBusy || Boolean ( localInvokePending ) || localInvokeActive || nativeModalActive || projectActionLocked } onPointCacheOperation = { localOnly ? operation = > queueLocalDesktopOperator ( operation === "BAKE_ALL" ? "ptcache.bake_all" : "ptcache.free_bake_all" , operation === "BAKE_ALL" ? { bake : true } : { } , true ) : undefined } selectedEdgeIndices = { meshSelection . mode === "EDGE" ? [ . . . meshSelection . indices ] : [ ] } nonMeshGeometryBuffers = { nonMeshGeometryBuffers } client = { webClientRef . current } snapshot = { snapshot } editorWorkflow = { editorWorkflow } selectedFaceIndices = { meshSelection . mode === "FACE" ? [ . . . meshSelection . indices ] : [ ] } selectedVertexIndices = { meshSelection . mode === "VERT" ? [ . . . meshSelection . indices ] : [ ] } greasePencilSelection = { greasePencilSelection } onGreasePencilCanvasPointSelect = { ( point , mode , baseSelectionRevision ) = > { commitGreasePencilSelection ( "CANVAS_2D" , mode === "ADD" ? "TOGGLE" : mode , [ point ] , baseSelectionRevision , point ) ; } } onCommand = { ( command ) = > applyEditCommand ( command ) } onImportImage = { ( file ) = > void importImage ( file ) } onImportVFont = { ( file ) = > void importVFont ( file ) } onRequestAsset = { ( assetId ) = > { const client = webClientRef . current ; if ( ! client ) return ; void client . requestAsset ( assetId ) . then ( ( asset ) = > setEngineStatus ( ` Asset: ${ asset . assetId } ( ${ asset . status } ) ` ) ) . catch ( ( error : unknown ) = > setEngineStatus ( recordDiagnostic ( "ENGINE" , "PBR_ASSET_INVALID" , error , { assetId } ) ) ) ; } } onAppendLibraryObject = { async ( source , request ) = > {
const client = webClientRef . current ; if ( ! client ) return ; const appendProjectId = projectIdRef . current ; const isCurrent = ( ) = > client === webClientRef . current && appendProjectId === projectIdRef . current ;
@@ -8443,6 +8580,7 @@ else:
< Area className = "timeline-area" editor = { workspace === "Animation" ? "Dope Sheet" : "Timeline" } > < Timeline snapshot = { snapshot } frame = { frame } start = { frameRange . start } end = { frameRange . end } onFrameChange = { ( value ) = > void applyEditCommand ( { type : "setFrame" , frame : value } ) } onCommand = { ( command ) = > void applyEditCommand ( command ) } onNativeEdit = { localOnly ? applyNativeTimelineEdit : undefined } nativePlaying = { nativeTimelinePlaying } playbackAvailable = { ! localOnly || Boolean ( editorWorkflow ) } / > < / Area >
{ ! localOnly && workspace === "Animation" ? < Area className = "graph-editor-area" editor = "Graph Editor" > < GraphEditor snapshot = { snapshot } onCommand = { ( command ) = > void applyEditCommand ( command ) } / > < / Area > : null }
{ localOnly ? < Area editor = { workspace === 'Animation' ? 'Timeline' : 'Dope Sheet' } > < Timeline snapshot = { snapshot } frame = { frame } start = { frameRange . start } end = { frameRange . end } onFrameChange = { value = > void applyEditCommand ( { type : 'setFrame' , frame :value } ) } onCommand = { command = > void applyEditCommand ( command ) } onNativeEdit = { applyNativeTimelineEdit } nativePlaying = { nativeTimelinePlaying } playbackAvailable = { Boolean ( snapshot && editorWorkflow ) } / > < / Area > : null }
{ workspaceAreaLayout ? workspaceAreaLayout . filter ( ( area ) = > ! area . contentKey ) . map ( ( area ) = > < Area key = { area . id } areaId = { area . id } editor = { area . editor } / > ) : null }
< / WasmWorkspace > : null }
{ autosaveRecovery ? < AutosaveRecoveryDialog initialProjectId = { autosaveRecovery . projectId } storage = { autosaveRecovery . storage }
projects = { [ { projectId : autosaveRecovery.projectId , displayName : autosaveRecovery.displayName } , . . . recentProjects . filter ( project = > project . projectId !== autosaveRecovery . projectId ) ] }