From 232404925746c0e2bedd981bd3c70971b8b9b6e2 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 17 Nov 2025 14:14:19 -0800 Subject: [PATCH 1/5] Allow for context menu commands in scene explorer --- .../src/components/scene/sceneExplorer.tsx | 123 ++++++++++++++---- 1 file changed, 101 insertions(+), 22 deletions(-) diff --git a/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx b/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx index f346d8b3c53..46b70a1cddc 100644 --- a/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx +++ b/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx @@ -1,5 +1,5 @@ import type { ScrollToInterface } from "@fluentui-contrib/react-virtualizer"; -import type { TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent } from "@fluentui/react-components"; +import type { MenuCheckedValueChangeData, MenuCheckedValueChangeEvent, TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent } from "@fluentui/react-components"; import type { FluentIcon } from "@fluentui/react-icons"; import type { ComponentType, FunctionComponent } from "react"; @@ -14,7 +14,9 @@ import { FlatTreeItem, makeStyles, Menu, + MenuDivider, MenuItem, + MenuItemCheckbox, MenuList, MenuPopover, MenuTrigger, @@ -103,6 +105,21 @@ export type SceneExplorerSection = Readonly<{ getEntityMovedObservables?: () => readonly IReadonlyObservable[]; }>; +type PrimaryCommand = { + /** + * An icon component to render for the command. + */ + icon: ComponentType; + + mode?: "primary" | "secondary"; +}; + +type SecondaryCommand = { + icon?: ComponentType; + + mode: "secondary"; +}; + type Command = Partial & Readonly<{ /** @@ -110,18 +127,13 @@ type Command = Partial & */ displayName: string; - /** - * An icon component to render for the command. - */ - icon: ComponentType; - /** * An observable that notifies when the command state changes. */ onChange?: IReadonlyObservable; }>; -type ActionCommand = Command & { +type ActionCommand = { readonly type: "action"; /** @@ -130,7 +142,7 @@ type ActionCommand = Command & { execute(): void; }; -type ToggleCommand = Command & { +type ToggleCommand = { readonly type: "toggle"; /** @@ -139,7 +151,7 @@ type ToggleCommand = Command & { isEnabled: boolean; }; -export type SceneExplorerCommand = ActionCommand | ToggleCommand; +export type SceneExplorerCommand = Command & (ActionCommand | ToggleCommand) & (PrimaryCommand | SecondaryCommand); export type SceneExplorerCommandProvider = Readonly<{ /** @@ -224,7 +236,7 @@ const useStyles = makeStyles({ }, }); -const ActionCommand: FunctionComponent<{ command: ActionCommand }> = (props) => { +const ActionCommand: FunctionComponent<{ command: Command & ActionCommand & PrimaryCommand }> = (props) => { const { command } = props; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -240,7 +252,7 @@ const ActionCommand: FunctionComponent<{ command: ActionCommand }> = (props) => ); }; -const ToggleCommand: FunctionComponent<{ command: ToggleCommand }> = (props) => { +const ToggleCommand: FunctionComponent<{ command: Command & ToggleCommand & PrimaryCommand }> = (props) => { const { command } = props; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -256,7 +268,7 @@ const ToggleCommand: FunctionComponent<{ command: ToggleCommand }> = (props) => // This "placeholder" command has a blank icon and is a no-op. It is used for aside // alignment when some toggle commands are enabled. See more details on the commands // for setting the aside state. -const PlaceHolderCommand: ActionCommand = { +const PlaceHolderCommand: Command & ActionCommand & PrimaryCommand = { type: "action", displayName: "", icon: createFluentIcon("Placeholder", "1em", ""), @@ -265,7 +277,7 @@ const PlaceHolderCommand: ActionCommand = { }, }; -function MakeCommandElement(command: SceneExplorerCommand, isPlaceholder: boolean): JSX.Element { +function MakeInlineCommandElement(command: Command & (ActionCommand | ToggleCommand) & PrimaryCommand, isPlaceholder: boolean): JSX.Element { if (isPlaceholder) { // Placeholders are not visible and not interacted with, so they are always ActionCommand // components, just to ensure the exact right amount of space is taken up. @@ -409,9 +421,14 @@ const EntityTreeItem: FunctionComponent<{ }, [entityItem.entity, commandProviders]) ); + const inlineCommands = useMemo( + () => commands.filter((command): command is Command & (ActionCommand | ToggleCommand) & PrimaryCommand => command.mode !== "secondary"), + [commands] + ); + // TreeItemLayout actions (totally unrelated to "Action" type commands) are only visible when the item is focused or has pointer hover. const actions = useMemo(() => { - const defaultCommands: SceneExplorerCommand[] = []; + const defaultCommands: (Command & (ActionCommand | ToggleCommand) & PrimaryCommand)[] = []; if (hasChildren) { defaultCommands.push({ type: "action", @@ -421,8 +438,8 @@ const EntityTreeItem: FunctionComponent<{ }); } - return [...defaultCommands, ...commands].map((command) => MakeCommandElement(command, false)); - }, [commands, hasChildren, expandAll]); + return [...defaultCommands, ...inlineCommands].map((command) => MakeInlineCommandElement(command, false)); + }, [inlineCommands, hasChildren, expandAll]); // TreeItemLayout asides are always visible. const [aside, setAside] = useState([]); @@ -433,10 +450,10 @@ const EntityTreeItem: FunctionComponent<{ const updateAside = () => { let isAnyCommandEnabled = false; const aside: JSX.Element[] = []; - for (const command of commands) { + for (const command of inlineCommands) { isAnyCommandEnabled ||= command.type === "toggle" && command.isEnabled; if (isAnyCommandEnabled) { - aside.push(MakeCommandElement(command, command.type !== "toggle" || !command.isEnabled)); + aside.push(MakeInlineCommandElement(command, command.type !== "toggle" || !command.isEnabled)); } } setAside(aside); @@ -444,7 +461,7 @@ const EntityTreeItem: FunctionComponent<{ updateAside(); - const observers = commands + const observers = inlineCommands .map((command) => command.onChange) .filter((onChange) => !!onChange) .map((onChange) => onChange.add(updateAside)); @@ -454,10 +471,53 @@ const EntityTreeItem: FunctionComponent<{ observer.remove(); } }; - }, [commands]); + }, [inlineCommands]); + + const contextMenuCommands = useMemo( + () => commands.filter((command): command is Command & (ActionCommand | ToggleCommand) & SecondaryCommand => command.mode === "secondary"), + [commands] + ); + + const [checkedContextMenuItems, setCheckedContextMenuItems] = useState({ toggleCommands: [] as string[] }); + + useEffect(() => { + const updateCheckedItems = () => { + const checkedItems: string[] = []; + for (const command of contextMenuCommands) { + if (command.type === "toggle" && command.isEnabled) { + checkedItems.push(command.displayName); + } + } + setCheckedContextMenuItems({ toggleCommands: checkedItems }); + }; + + updateCheckedItems(); + + const observers = contextMenuCommands + .map((command) => command.onChange) + .filter((onChange) => !!onChange) + .map((onChange) => onChange.add(updateCheckedItems)); + + return () => { + for (const observer of observers) { + observer.remove(); + } + }; + }, [contextMenuCommands]); + + const onContextMenuCheckedValueChange = useCallback( + (e: MenuCheckedValueChangeEvent, data: MenuCheckedValueChangeData) => { + for (const command of contextMenuCommands) { + if (command.type === "toggle") { + command.isEnabled = data.checkedItems.includes(command.displayName); + } + } + }, + [contextMenuCommands] + ); return ( - + - From f7499c07d4e41f2b46a102e11bdd1f9dd9bef261 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 17 Nov 2025 14:32:30 -0800 Subject: [PATCH 2/5] Clean up command related types --- .../src/components/scene/sceneExplorer.tsx | 69 ++++++++++--------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx b/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx index 46b70a1cddc..210a6c9ec37 100644 --- a/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx +++ b/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx @@ -105,33 +105,31 @@ export type SceneExplorerSection = Readonly<{ getEntityMovedObservables?: () => readonly IReadonlyObservable[]; }>; -type PrimaryCommand = { +type InlineCommand = { /** - * An icon component to render for the command. + * An icon component to render for the command. Required for inline commands. */ icon: ComponentType; - mode?: "primary" | "secondary"; + /** + * The mode of the command. Inline commands are shown directly in the tree item layout. Inline by default. + */ + mode?: "inline"; }; -type SecondaryCommand = { +type ContextMenuCommand = { + /** + * An icon component to render for the command. Optional for context menu commands. + */ icon?: ComponentType; - mode: "secondary"; + /** + * The mode of the command. Context menu commands are shown in the context menu for the tree item. + */ + mode: "contextMenu"; }; -type Command = Partial & - Readonly<{ - /** - * The display name of the command (e.g. "Delete", "Rename", etc.). - */ - displayName: string; - - /** - * An observable that notifies when the command state changes. - */ - onChange?: IReadonlyObservable; - }>; +type CommandMode = (InlineCommand | ContextMenuCommand)["mode"]; type ActionCommand = { readonly type: "action"; @@ -151,7 +149,22 @@ type ToggleCommand = { isEnabled: boolean; }; -export type SceneExplorerCommand = Command & (ActionCommand | ToggleCommand) & (PrimaryCommand | SecondaryCommand); +type CommandType = (ActionCommand | ToggleCommand)["type"]; + +export type SceneExplorerCommand = Partial & + Readonly<{ + /** + * The display name of the command (e.g. "Delete", "Rename", etc.). + */ + displayName: string; + + /** + * An observable that notifies when the command state changes. + */ + onChange?: IReadonlyObservable; + }> & + (ModeT extends "inline" ? InlineCommand : ContextMenuCommand) & + (TypeT extends "action" ? ActionCommand : ToggleCommand); export type SceneExplorerCommandProvider = Readonly<{ /** @@ -236,7 +249,7 @@ const useStyles = makeStyles({ }, }); -const ActionCommand: FunctionComponent<{ command: Command & ActionCommand & PrimaryCommand }> = (props) => { +const ActionCommand: FunctionComponent<{ command: SceneExplorerCommand<"inline", "action"> }> = (props) => { const { command } = props; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -252,7 +265,7 @@ const ActionCommand: FunctionComponent<{ command: Command & ActionCommand & Prim ); }; -const ToggleCommand: FunctionComponent<{ command: Command & ToggleCommand & PrimaryCommand }> = (props) => { +const ToggleCommand: FunctionComponent<{ command: SceneExplorerCommand<"inline", "toggle"> }> = (props) => { const { command } = props; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -268,7 +281,7 @@ const ToggleCommand: FunctionComponent<{ command: Command & ToggleCommand & Prim // This "placeholder" command has a blank icon and is a no-op. It is used for aside // alignment when some toggle commands are enabled. See more details on the commands // for setting the aside state. -const PlaceHolderCommand: Command & ActionCommand & PrimaryCommand = { +const PlaceHolderCommand: SceneExplorerCommand<"inline", "action"> = { type: "action", displayName: "", icon: createFluentIcon("Placeholder", "1em", ""), @@ -277,7 +290,7 @@ const PlaceHolderCommand: Command & ActionCommand & PrimaryCommand = { }, }; -function MakeInlineCommandElement(command: Command & (ActionCommand | ToggleCommand) & PrimaryCommand, isPlaceholder: boolean): JSX.Element { +function MakeInlineCommandElement(command: SceneExplorerCommand<"inline">, isPlaceholder: boolean): JSX.Element { if (isPlaceholder) { // Placeholders are not visible and not interacted with, so they are always ActionCommand // components, just to ensure the exact right amount of space is taken up. @@ -421,14 +434,11 @@ const EntityTreeItem: FunctionComponent<{ }, [entityItem.entity, commandProviders]) ); - const inlineCommands = useMemo( - () => commands.filter((command): command is Command & (ActionCommand | ToggleCommand) & PrimaryCommand => command.mode !== "secondary"), - [commands] - ); + const inlineCommands = useMemo(() => commands.filter((command): command is SceneExplorerCommand<"inline"> => command.mode !== "contextMenu"), [commands]); // TreeItemLayout actions (totally unrelated to "Action" type commands) are only visible when the item is focused or has pointer hover. const actions = useMemo(() => { - const defaultCommands: (Command & (ActionCommand | ToggleCommand) & PrimaryCommand)[] = []; + const defaultCommands: SceneExplorerCommand<"inline">[] = []; if (hasChildren) { defaultCommands.push({ type: "action", @@ -473,10 +483,7 @@ const EntityTreeItem: FunctionComponent<{ }; }, [inlineCommands]); - const contextMenuCommands = useMemo( - () => commands.filter((command): command is Command & (ActionCommand | ToggleCommand) & SecondaryCommand => command.mode === "secondary"), - [commands] - ); + const contextMenuCommands = useMemo(() => commands.filter((command): command is SceneExplorerCommand<"contextMenu"> => command.mode === "contextMenu"), [commands]); const [checkedContextMenuItems, setCheckedContextMenuItems] = useState({ toggleCommands: [] as string[] }); From 318b0683c9b8fa0df0502a8c0552c3821d5005df Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 17 Nov 2025 15:57:36 -0800 Subject: [PATCH 3/5] Respect explorerExtensibility v1 option --- .../dev/inspector-v2/src/legacy/inspector.tsx | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/dev/inspector-v2/src/legacy/inspector.tsx b/packages/dev/inspector-v2/src/legacy/inspector.tsx index c3382b33a75..9b9baf8cfb3 100644 --- a/packages/dev/inspector-v2/src/legacy/inspector.tsx +++ b/packages/dev/inspector-v2/src/legacy/inspector.tsx @@ -33,7 +33,6 @@ export function ConvertOptions(v1Options: Partial): Partial< // • skipDefaultFontLoading: Probably doesn't make sense for Inspector v2 using Fluent. // TODO: - // • explorerExtensibility // • contextMenu // • contextMenuOverride @@ -131,6 +130,38 @@ export function ConvertOptions(v1Options: Partial): Partial< serviceDefinitions.push(additionalNodesServiceDefinition); } + if (v1Options.explorerExtensibility && v1Options.explorerExtensibility.length > 0) { + const { explorerExtensibility } = v1Options; + const explorerExtensibilityServiceDefinition: ServiceDefinition<[], [ISceneExplorerService]> = { + friendlyName: "Explorer Extensibility", + consumes: [SceneExplorerServiceIdentity], + factory: (sceneExplorerService) => { + const sceneExplorerCommandRegistrations = explorerExtensibility.flatMap((command) => + command.entries.map((entry) => + sceneExplorerService.addCommand({ + predicate: (entity): entity is EntityBase => command.predicate(entity), + getCommand: (entity) => { + return { + displayName: entry.label, + type: "action", + mode: "contextMenu", + execute: () => entry.action(entity), + }; + }, + }) + ) + ); + + return { + dispose: () => { + sceneExplorerCommandRegistrations.forEach((registration) => registration.dispose()); + }, + }; + }, + }; + serviceDefinitions.push(explorerExtensibilityServiceDefinition); + } + const v2Options: Partial = { containerElement: v1Options.globalRoot, layoutMode: v1Options.overlay ? "overlay" : "inline", From 1436e117cb5d0ae93684609430ca20b611ffcf3a Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 17 Nov 2025 16:45:05 -0800 Subject: [PATCH 4/5] Add missing check for conditionally showing expand/collapse menu items --- .../src/components/scene/sceneExplorer.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx b/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx index 210a6c9ec37..5c9814d0854 100644 --- a/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx +++ b/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx @@ -561,13 +561,17 @@ const EntityTreeItem: FunctionComponent<{