diff --git a/packages/dev/inspector-v2/src/inspector.tsx b/packages/dev/inspector-v2/src/inspector.tsx index c4130c517dc..5581ab8ce0d 100644 --- a/packages/dev/inspector-v2/src/inspector.tsx +++ b/packages/dev/inspector-v2/src/inspector.tsx @@ -6,6 +6,8 @@ import type { ISceneContext } from "./services/sceneContext"; import type { IShellService } from "./services/shellService"; import { makeStyles } from "@fluentui/react-components"; +import { AsyncLock } from "core/Misc/asyncLock"; +import { Logger } from "core/Misc/logger"; import { Observable } from "core/Misc/observable"; import { useEffect, useRef } from "react"; import { DefaultInspectorExtensionFeed } from "./extensibility/defaultInspectorExtensionFeed"; @@ -66,222 +68,255 @@ export type InspectorOptions = Omit & { autoR // switch the inspector instance to that scene (once this is supported). const InspectorTokens = new WeakMap(); -export function ShowInspector(scene: Scene, options: Partial = {}): IDisposable { - options = { - autoResizeEngine: true, - ...options, - }; - - const inspectorToken = { - dispose: () => {}, - }; - - let parentElement = options.containerElement ?? null; - if (!parentElement) { - parentElement = scene.getEngine().getRenderingCanvas()?.parentElement ?? null; - while (parentElement) { - const rootNode = parentElement.getRootNode(); - // TODO: Right now we never parent the inspector within a ShadowRoot because we need to do more work to get FluentProvider to work correctly in this context. - if (!(rootNode instanceof ShadowRoot)) { - break; - } - parentElement = rootNode.host.parentElement; - } - } +// This async lock is used to sequentialize all calls to ShowInspector and dispose of existing inspectors. +// This is needed because each time Inspector is shown or hidden, it is potentially mutating the same DOM element. +const InspectorLock = new AsyncLock(); - if (!parentElement) { - return inspectorToken; - } - - const existingToken = InspectorTokens.get(scene); - if (existingToken) { - existingToken.dispose(); - InspectorTokens.delete(scene); - } - - InspectorTokens.set(scene, inspectorToken); - - const disposeActions: (() => void)[] = []; - - const canvasContainerDisplay = parentElement.style.display; - const canvasContainerChildren: ChildNode[] = []; - - canvasContainerChildren.push(...parentElement.childNodes); - - disposeActions.push(() => { - canvasContainerChildren.forEach((child) => parentElement.appendChild(child)); - }); +export function ShowInspector(scene: Scene, options: Partial = {}): IDisposable { + // Dispose of any existing inspector for this scene. + InspectorTokens.get(scene)?.dispose(); - // This service is responsible for injecting the passed in canvas as the "central content" of the shell UI (the main area between the side panes and toolbars). - const canvasInjectorServiceDefinition: ServiceDefinition<[], [IShellService]> = { - friendlyName: "Canvas Injector", - consumes: [ShellServiceIdentity], - factory: (shellService) => { - const useStyles = makeStyles({ - canvasContainer: { - display: canvasContainerDisplay, - width: "100%", - height: "100%", - }, - }); + // Default the dispose logic to a no-op until we know that we are actually going + // to show the Inspector and there will be cleanup work to do. + let disposeAsync = async () => await Promise.resolve(); - const registration = shellService.addCentralContent({ - key: "Canvas Injector", - component: () => { - const classes = useStyles(); - const canvasContainerRef = useRef(null); - useEffect(() => { - if (canvasContainerRef.current) { - for (const child of canvasContainerChildren) { - canvasContainerRef.current.appendChild(child); - } - } - }, []); - - return
; - }, + // Create an inspector dispose token. The dispose will use the same async lock to + // make sure async dispose (hide) does not actually start until async show is finished. + const inspectorToken = { + dispose: () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + InspectorLock.lockAsync(async () => { + await disposeAsync(); }); - - return { - dispose: () => { - registration.dispose(); - }, - }; }, - }; + } as const; - // This service exposes the scene that was passed into Inspector through ISceneContext, which is used by other services that may be used in other contexts outside of Inspector. - const sceneContextServiceDefinition: ServiceDefinition<[ISceneContext], []> = { - friendlyName: "Inspector Scene Context", - produces: [SceneContextIdentity], - factory: () => { - return { - currentScene: scene, - currentSceneObservable: new Observable>(), - }; - }, + // Track the inspector token for the scene. + InspectorTokens.set(scene, inspectorToken); + + // Set default options. + options = { + autoResizeEngine: true, + ...options, }; - if (options.autoResizeEngine) { - const observer = scene.onBeforeRenderObservable.add(() => scene.getEngine().resize()); - disposeActions.push(() => observer.remove()); - } - - const modularTool = MakeModularTool({ - containerElement: parentElement, - serviceDefinitions: [ - // Injects the canvas the scene is rendering to into the central "content" area of the shell UI. - canvasInjectorServiceDefinition, - - // Provides access to the scene in a generic way (other tools might provide a scene in a different way). - sceneContextServiceDefinition, - - // Helps with managing gizmos and a shared utility layer. - GizmoServiceDefinition, - - // Scene explorer tab and related services. - SceneExplorerServiceDefinition, - NodeExplorerServiceDefinition, - SkeletonExplorerServiceDefinition, - MaterialExplorerServiceDefinition, - TextureExplorerServiceDefinition, - PostProcessExplorerServiceDefinition, - RenderingPipelineExplorerServiceDefinition, - EffectLayerExplorerServiceDefinition, - ParticleSystemExplorerServiceDefinition, - SpriteManagerExplorerServiceDefinition, - AnimationGroupExplorerServiceDefinition, - GuiExplorerServiceDefinition, - FrameGraphExplorerServiceDefinition, - AtmosphereExplorerServiceDefinition, - - // Properties pane tab and related services. - ScenePropertiesServiceDefinition, - PropertiesServiceDefinition, - TexturePropertiesServiceDefinition, - CommonPropertiesServiceDefinition, - TransformPropertiesServiceDefinition, - AnimationPropertiesServiceDefinition, - NodePropertiesServiceDefinition, - PhysicsPropertiesServiceDefinition, - SkeletonPropertiesServiceDefinition, - MaterialPropertiesServiceDefinition, - LightPropertiesServiceDefinition, - SpritePropertiesServiceDefinition, - ParticleSystemPropertiesServiceDefinition, - CameraPropertiesServiceDefinition, - PostProcessPropertiesServiceDefinition, - RenderingPipelinePropertiesServiceDefinition, - EffectLayerPropertiesServiceDefinition, - FrameGraphPropertiesServiceDefinition, - AnimationGroupPropertiesServiceDefinition, - MetadataPropertiesServiceDefinition, - AtmospherePropertiesServiceDefinition, - - // Debug pane tab and related services. - DebugServiceDefinition, - - // Stats pane tab and related services. - StatsServiceDefinition, - - // Tools pane tab and related services. - ToolsServiceDefinition, - - // Settings pane tab and related services. - SettingsServiceDefinition, - - // Tracks entity selection state (e.g. which Mesh or Material or other entity is currently selected in scene explorer and bound to the properties pane, etc.). - SelectionServiceDefinition, - - // Gizmos for manipulating objects in the scene. - GizmoToolbarServiceDefinition, - - // Allows picking objects from the scene to select them. - PickingServiceDefinition, - - // Adds entry points for user feedback on Inspector v2 (probably eventually will be removed). - UserFeedbackServiceDefinition, - - // Adds always present "mini stats" (like fps) to the toolbar, etc. - MiniStatsServiceDefinition, - - // Legacy service to support custom inspectable properties on objects. - LegacyInspectableObjectPropertiesServiceDefinition, - - // Additional services passed in to the Inspector. - ...(options.serviceDefinitions ?? []), - ], - themeMode: options.themeMode, - showThemeSelector: options.showThemeSelector, - extensionFeeds: [DefaultInspectorExtensionFeed, ...(options.extensionFeeds ?? [])], - layoutMode: options.layoutMode, - toolbarMode: "compact", - sidePaneRemapper: options.sidePaneRemapper, - }); - disposeActions.push(() => modularTool.dispose()); + // Sequentialize showing the inspector (e.g. don't start showing until after a previous hide (for example) is finished). + // eslint-disable-next-line @typescript-eslint/no-floating-promises + InspectorLock.lockAsync(async () => { + let parentElement = options.containerElement ?? null; + // If a container element was not found, find an appropriate one above the engine's rendering canvas. + if (!parentElement) { + parentElement = scene.getEngine().getRenderingCanvas()?.parentElement ?? null; + while (parentElement) { + const rootNode = parentElement.getRootNode(); + // TODO: Right now we never parent the inspector within a ShadowRoot because we need to do more work to get FluentProvider to work correctly in this context. + if (!(rootNode instanceof ShadowRoot)) { + break; + } + parentElement = rootNode.host.parentElement; + } + } - let disposed = false; - inspectorToken.dispose = () => { - if (disposed) { + // If we couldn't find a parent element, we can't show the inspector. + if (!parentElement) { + Logger.Warn("Unable to find a parent element to host the Inspector."); return; } - disposeActions.reverse().forEach((dispose) => dispose()); - if (options.autoResizeEngine) { - scene.getEngine().resize(); - } + // This will keep track of all the cleanup work we need to do when hiding the inspector. + const disposeActions: (() => void | Promise)[] = []; - disposed = true; - }; + // Update the disposeAsync function to walk the dispose actions in reverse order + // and call each one. + let disposed = false; + disposeAsync = async () => { + if (disposed) { + return; + } + disposed = true; + + for (const disposeAction of disposeActions.reverse()) { + const result = disposeAction(); + if (result) { + // eslint-disable-next-line no-await-in-loop + await result; + } + } + }; - const sceneDisposedObserver = scene.onDisposeObservable.addOnce(() => { - inspectorToken.dispose(); - }); + // If we were responsible for resizing the engine, resize one more after the inspector UI is hidden. + disposeActions.push(() => { + if (options.autoResizeEngine) { + scene.getEngine().resize(); + } + }); + + // Remove all the existing children from the parent element. + const canvasContainerDisplay = parentElement.style.display; + const canvasContainerChildren = [...parentElement.childNodes]; + parentElement.replaceChildren(); + + disposeActions.push(async () => { + // When the ModularTool token is disposed, it unmounts the react element, which asynchronously + // removes all children from the parentElement. We need to wait for that to complete before + // re-adding the canvas children back to the parentElement. + await new Promise((resolve) => setTimeout(resolve)); + parentElement.replaceChildren(...canvasContainerChildren); + }); + + // This service is responsible for injecting the passed in canvas as the "central content" of the shell UI (the main area between the side panes and toolbars). + const canvasInjectorServiceDefinition: ServiceDefinition<[], [IShellService]> = { + friendlyName: "Canvas Injector", + consumes: [ShellServiceIdentity], + factory: (shellService) => { + const useStyles = makeStyles({ + canvasContainer: { + display: canvasContainerDisplay, + width: "100%", + height: "100%", + }, + }); + + const registration = shellService.addCentralContent({ + key: "Canvas Injector", + component: () => { + const classes = useStyles(); + const canvasContainerRef = useRef(null); + useEffect(() => { + canvasContainerRef.current?.replaceChildren(...canvasContainerChildren); + }, []); + + return
; + }, + }); + + return { + dispose: () => { + registration.dispose(); + }, + }; + }, + }; + + // This service exposes the scene that was passed into Inspector through ISceneContext, which is used by other services that may be used in other contexts outside of Inspector. + const sceneContextServiceDefinition: ServiceDefinition<[ISceneContext], []> = { + friendlyName: "Inspector Scene Context", + produces: [SceneContextIdentity], + factory: () => { + return { + currentScene: scene, + currentSceneObservable: new Observable>(), + }; + }, + }; - disposeActions.push(() => sceneDisposedObserver.remove()); + if (options.autoResizeEngine) { + const observer = scene.onBeforeRenderObservable.add(() => scene.getEngine().resize()); + disposeActions.push(() => observer.remove()); + } - disposeActions.push(() => { - InspectorTokens.delete(scene); + const modularTool = MakeModularTool({ + containerElement: parentElement, + serviceDefinitions: [ + // Injects the canvas the scene is rendering to into the central "content" area of the shell UI. + canvasInjectorServiceDefinition, + + // Provides access to the scene in a generic way (other tools might provide a scene in a different way). + sceneContextServiceDefinition, + + // Helps with managing gizmos and a shared utility layer. + GizmoServiceDefinition, + + // Scene explorer tab and related services. + SceneExplorerServiceDefinition, + NodeExplorerServiceDefinition, + SkeletonExplorerServiceDefinition, + MaterialExplorerServiceDefinition, + TextureExplorerServiceDefinition, + PostProcessExplorerServiceDefinition, + RenderingPipelineExplorerServiceDefinition, + EffectLayerExplorerServiceDefinition, + ParticleSystemExplorerServiceDefinition, + SpriteManagerExplorerServiceDefinition, + AnimationGroupExplorerServiceDefinition, + GuiExplorerServiceDefinition, + FrameGraphExplorerServiceDefinition, + AtmosphereExplorerServiceDefinition, + + // Properties pane tab and related services. + ScenePropertiesServiceDefinition, + PropertiesServiceDefinition, + TexturePropertiesServiceDefinition, + CommonPropertiesServiceDefinition, + TransformPropertiesServiceDefinition, + AnimationPropertiesServiceDefinition, + NodePropertiesServiceDefinition, + PhysicsPropertiesServiceDefinition, + SkeletonPropertiesServiceDefinition, + MaterialPropertiesServiceDefinition, + LightPropertiesServiceDefinition, + SpritePropertiesServiceDefinition, + ParticleSystemPropertiesServiceDefinition, + CameraPropertiesServiceDefinition, + PostProcessPropertiesServiceDefinition, + RenderingPipelinePropertiesServiceDefinition, + EffectLayerPropertiesServiceDefinition, + FrameGraphPropertiesServiceDefinition, + AnimationGroupPropertiesServiceDefinition, + MetadataPropertiesServiceDefinition, + AtmospherePropertiesServiceDefinition, + + // Debug pane tab and related services. + DebugServiceDefinition, + + // Stats pane tab and related services. + StatsServiceDefinition, + + // Tools pane tab and related services. + ToolsServiceDefinition, + + // Settings pane tab and related services. + SettingsServiceDefinition, + + // Tracks entity selection state (e.g. which Mesh or Material or other entity is currently selected in scene explorer and bound to the properties pane, etc.). + SelectionServiceDefinition, + + // Gizmos for manipulating objects in the scene. + GizmoToolbarServiceDefinition, + + // Allows picking objects from the scene to select them. + PickingServiceDefinition, + + // Adds entry points for user feedback on Inspector v2 (probably eventually will be removed). + UserFeedbackServiceDefinition, + + // Adds always present "mini stats" (like fps) to the toolbar, etc. + MiniStatsServiceDefinition, + + // Legacy service to support custom inspectable properties on objects. + LegacyInspectableObjectPropertiesServiceDefinition, + + // Additional services passed in to the Inspector. + ...(options.serviceDefinitions ?? []), + ], + themeMode: options.themeMode, + showThemeSelector: options.showThemeSelector, + extensionFeeds: [DefaultInspectorExtensionFeed, ...(options.extensionFeeds ?? [])], + layoutMode: options.layoutMode, + toolbarMode: "compact", + sidePaneRemapper: options.sidePaneRemapper, + }); + disposeActions.push(() => modularTool.dispose()); + + const sceneDisposedObserver = scene.onDisposeObservable.addOnce(() => { + inspectorToken.dispose(); + }); + + disposeActions.push(() => sceneDisposedObserver.remove()); + + disposeActions.push(() => { + InspectorTokens.delete(scene); + }); }); return inspectorToken; diff --git a/packages/dev/inspector-v2/src/legacy/debugLayer.ts b/packages/dev/inspector-v2/src/legacy/debugLayer.ts index db285c9259d..b318d942e6c 100644 --- a/packages/dev/inspector-v2/src/legacy/debugLayer.ts +++ b/packages/dev/inspector-v2/src/legacy/debugLayer.ts @@ -18,10 +18,6 @@ Object.defineProperty(Scene.prototype, "debugLayer", { }); class DebugLayerEx extends DebugLayer { - override get openedPanes() { - throw new Error("Not Implemented"); - } - // eslint-disable-next-line @typescript-eslint/naming-convention override async show(config?: IInspectorOptions): Promise { // If a custom inspector URL is not provided, default to a lazy dynamic import of the inspector module. @@ -30,8 +26,4 @@ class DebugLayerEx extends DebugLayer { } return await super.show(config); } - - override setAsActiveScene() { - throw new Error("Not Implemented"); - } } diff --git a/packages/dev/inspector-v2/src/legacy/inspector.tsx b/packages/dev/inspector-v2/src/legacy/inspector.tsx index 2b18f668fb1..3fcdab446e1 100644 --- a/packages/dev/inspector-v2/src/legacy/inspector.tsx +++ b/packages/dev/inspector-v2/src/legacy/inspector.tsx @@ -283,9 +283,15 @@ export function ConvertOptions(v1Options: Partial): Partial< * @deprecated This class only exists for backward compatibility. Use the module-level ShowInspector function instead. */ export class Inspector { - private static _CurrentInspectorToken: Nullable = null; + private static _CurrentInstance: Nullable<{ scene: Scene; options: Partial; disposeToken: IDisposable }> = null; private static _PopupToggler: Nullable<(side: "left" | "right") => void> = null; private static _SectionHighlighter: Nullable<(sectionIds: readonly string[]) => void> = null; + private static _SidePaneOpenCounter: Nullable<() => number> = null; + + // @ts-expect-error TS6133: This is private, but used by debugLayer (same as Inspector v1). + private static get _OpenedPane() { + return this._SidePaneOpenCounter?.() ?? 0; + } public static readonly OnSelectionChangeObservable = new Observable(); public static readonly OnPropertyChangedObservable = new Observable(); @@ -311,7 +317,7 @@ export class Inspector { } public static get IsVisible(): boolean { - return !!this._CurrentInspectorToken; + return !!this._CurrentInstance; } public static Show(scene: Scene, userOptions: Partial) { @@ -336,10 +342,12 @@ export class Inspector { factory: (shellService) => { this._PopupToggler = (side: "left" | "right") => { const sidePaneContainer = side === "left" ? shellService.leftSidePaneContainer : shellService.rightSidePaneContainer; - if (sidePaneContainer.isDocked) { - sidePaneContainer.undock(); - } else { - sidePaneContainer.dock(); + if (sidePaneContainer) { + if (sidePaneContainer.isDocked) { + sidePaneContainer.undock(); + } else { + sidePaneContainer.dock(); + } } }; @@ -411,16 +419,50 @@ export class Inspector { }; serviceDefinitions.push(sectionHighlighterServiceDefinition); + const openedPanesServiceDefinition: ServiceDefinition<[], [IShellService]> = { + friendlyName: "Opened Panes Service (Backward Compatibility)", + consumes: [ShellServiceIdentity], + factory: (shellService) => { + this._SidePaneOpenCounter = () => (shellService.leftSidePaneContainer ? 1 : 0) + (shellService.rightSidePaneContainer ? 1 : 0); + + return { + dispose: () => { + this._SidePaneOpenCounter = null; + }, + }; + }, + }; + serviceDefinitions.push(openedPanesServiceDefinition); + options = { ...options, serviceDefinitions: [...(options.serviceDefinitions ?? []), ...serviceDefinitions], }; - this._CurrentInspectorToken = ShowInspector(scene, options); + this._CurrentInstance = { + scene, + options, + disposeToken: ShowInspector(scene, options), + }; } public static Hide() { - this._CurrentInspectorToken?.dispose(); - this._CurrentInspectorToken = null; + this._CurrentInstance?.disposeToken.dispose(); + this._CurrentInstance = null; + } + + // @ts-expect-error TS6133: This is private, but used by debugLayer (same as Inspector v1). + private static _SetNewScene(scene: Scene) { + if (this._CurrentInstance && this._CurrentInstance.scene !== scene) { + // TODO: For now, just hide and re-show the Inspector. + // Need to think more about this when we work on multi-scene support in Inspector v2. + const options = this._CurrentInstance.options; + this.Hide(); + this._CurrentInstance = { + scene, + options, + disposeToken: ShowInspector(scene, options), + }; + } } } diff --git a/packages/dev/inspector-v2/src/modularTool.tsx b/packages/dev/inspector-v2/src/modularTool.tsx index f0224048264..fd0c8a2bb8e 100644 --- a/packages/dev/inspector-v2/src/modularTool.tsx +++ b/packages/dev/inspector-v2/src/modularTool.tsx @@ -287,11 +287,15 @@ export function MakeModularTool(options: ModularToolOptions): IDisposable { const reactRoot = createRoot(containerElement); reactRoot.render(createElement(modularToolRootComponent)); + let disposed = false; return { dispose: () => { // Unmount and restore the original container element display. - reactRoot.unmount(); - containerElement.style.display = originalContainerElementDisplay; + if (!disposed) { + disposed = true; + reactRoot.unmount(); + containerElement.style.display = originalContainerElementDisplay; + } }, }; } diff --git a/packages/dev/inspector-v2/src/services/shellService.tsx b/packages/dev/inspector-v2/src/services/shellService.tsx index 8260d471246..3de15f65dd4 100644 --- a/packages/dev/inspector-v2/src/services/shellService.tsx +++ b/packages/dev/inspector-v2/src/services/shellService.tsx @@ -232,12 +232,12 @@ export interface IShellService extends IService { /** * The left side pane container. */ - readonly leftSidePaneContainer: SidePaneContainer; + readonly leftSidePaneContainer: Nullable; /** * The right side pane container. */ - readonly rightSidePaneContainer: SidePaneContainer; + readonly rightSidePaneContainer: Nullable; /** * The side panes currently present in the shell. @@ -1167,15 +1167,17 @@ export function MakeShellServiceDefinition({ const onDockChanged = new Observable<{ location: HorizontalLocation; dock: boolean }>(undefined, true); const leftSidePaneContainerState = { - isDocked: true as boolean, + isPresent: false, + isDocked: true, dock: () => onDockChanged.notifyObservers({ location: "left", dock: true }), undock: () => onDockChanged.notifyObservers({ location: "left", dock: false }), - } satisfies SidePaneContainer; + }; const rightSidePaneContainerState = { - isDocked: true as boolean, + isPresent: false, + isDocked: true, dock: () => onDockChanged.notifyObservers({ location: "right", dock: true }), undock: () => onDockChanged.notifyObservers({ location: "right", dock: false }), - } satisfies SidePaneContainer; + }; const rootComponent: FunctionComponent = () => { const classes = useStyles(); @@ -1294,6 +1296,16 @@ export function MakeShellServiceDefinition({ const hasLeftPanes = coercedSidePanes.some((entry) => entry.horizontalLocation === "left"); const hasRightPanes = coercedSidePanes.some((entry) => entry.horizontalLocation === "right"); + useEffect(() => { + leftSidePaneContainerState.isPresent = hasLeftPanes; + rightSidePaneContainerState.isPresent = hasRightPanes; + + return () => { + leftSidePaneContainerState.isPresent = false; + rightSidePaneContainerState.isPresent = false; + }; + }, [hasLeftPanes, hasRightPanes]); + // If we are in compact toolbar mode, we may need to move toolbar items from the left to the right or vice versa, // depending on whether there are any side panes on that side. const coerceToolBarItemHorizontalLocation = useMemo( @@ -1465,8 +1477,12 @@ export function MakeShellServiceDefinition({ }, addCentralContent: (entry) => centralContentCollection.add(entry), resetSidePaneLayout: () => localStorage.removeItem("Babylon/Settings/SidePaneDockOverrides"), - leftSidePaneContainer: leftSidePaneContainerState, - rightSidePaneContainer: rightSidePaneContainerState, + get leftSidePaneContainer() { + return leftSidePaneContainerState.isPresent ? leftSidePaneContainerState : null; + }, + get rightSidePaneContainer() { + return rightSidePaneContainerState.isPresent ? rightSidePaneContainerState : null; + }, onDockChanged, get sidePanes() { return [...sidePaneCollection.items].map((sidePaneDefinition) => {