diff --git a/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx b/packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx index f346d8b3c53..aec0c796807 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, @@ -25,7 +27,7 @@ import { TreeItemLayout, treeItemLevelToken, } from "@fluentui/react-components"; -import { ArrowExpandAllRegular, createFluentIcon, FilterRegular, GlobeRegular } from "@fluentui/react-icons"; +import { ArrowCollapseAllRegular, ArrowExpandAllRegular, createFluentIcon, FilterRegular, GlobeRegular } from "@fluentui/react-icons"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ToggleButton } from "shared-ui-components/fluent/primitives/toggleButton"; @@ -103,25 +105,33 @@ export type SceneExplorerSection = Readonly<{ getEntityMovedObservables?: () => readonly IReadonlyObservable[]; }>; -type Command = Partial & - Readonly<{ - /** - * The display name of the command (e.g. "Delete", "Rename", etc.). - */ - displayName: string; +type InlineCommand = { + /** + * An icon component to render for the command. Required for inline commands. + */ + icon: ComponentType; - /** - * An icon component to render for the command. - */ - icon: ComponentType; + /** + * The mode of the command. Inline commands are shown directly in the tree item layout. Inline by default. + */ + mode?: "inline"; +}; - /** - * An observable that notifies when the command state changes. - */ - onChange?: IReadonlyObservable; - }>; +type ContextMenuCommand = { + /** + * An icon component to render for the command. Optional for context menu commands. + */ + icon?: ComponentType; + + /** + * The mode of the command. Context menu commands are shown in the context menu for the tree item. + */ + mode: "contextMenu"; +}; -type ActionCommand = Command & { +type CommandMode = (InlineCommand | ContextMenuCommand)["mode"]; + +type ActionCommand = { readonly type: "action"; /** @@ -130,7 +140,7 @@ type ActionCommand = Command & { execute(): void; }; -type ToggleCommand = Command & { +type ToggleCommand = { readonly type: "toggle"; /** @@ -139,7 +149,22 @@ type ToggleCommand = Command & { isEnabled: boolean; }; -export type SceneExplorerCommand = ActionCommand | ToggleCommand; +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<{ /** @@ -224,7 +249,7 @@ const useStyles = makeStyles({ }, }); -const ActionCommand: FunctionComponent<{ command: ActionCommand }> = (props) => { +const ActionCommand: FunctionComponent<{ command: SceneExplorerCommand<"inline", "action"> }> = (props) => { const { command } = props; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -240,7 +265,7 @@ const ActionCommand: FunctionComponent<{ command: ActionCommand }> = (props) => ); }; -const ToggleCommand: FunctionComponent<{ command: ToggleCommand }> = (props) => { +const ToggleCommand: FunctionComponent<{ command: SceneExplorerCommand<"inline", "toggle"> }> = (props) => { const { command } = props; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -256,7 +281,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: SceneExplorerCommand<"inline", "action"> = { type: "action", displayName: "", icon: createFluentIcon("Placeholder", "1em", ""), @@ -265,7 +290,7 @@ const PlaceHolderCommand: ActionCommand = { }, }; -function MakeCommandElement(command: SceneExplorerCommand, 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. @@ -409,9 +434,11 @@ const EntityTreeItem: FunctionComponent<{ }, [entityItem.entity, commandProviders]) ); + 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: SceneExplorerCommand[] = []; + const defaultCommands: SceneExplorerCommand<"inline">[] = []; if (hasChildren) { defaultCommands.push({ type: "action", @@ -421,8 +448,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 +460,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 +471,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 +481,50 @@ const EntityTreeItem: FunctionComponent<{ observer.remove(); } }; - }, [commands]); + }, [inlineCommands]); + + const contextMenuCommands = useMemo(() => commands.filter((command): command is SceneExplorerCommand<"contextMenu"> => command.mode === "contextMenu"), [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 ( - + - 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",