diff --git a/.azure-pipelines/ci-graph-tools.yml b/.azure-pipelines/ci-graph-tools.yml index b44d547d2ac..036acbd1af6 100644 --- a/.azure-pipelines/ci-graph-tools.yml +++ b/.azure-pipelines/ci-graph-tools.yml @@ -144,7 +144,7 @@ jobs: - script: | npx playwright install --with-deps - npx playwright test --project=graphTools + npx playwright test --project=graphTools --project=flowGraphEditor displayName: "Test tools" env: CI: "true" diff --git a/.github/skills/porting-tools-to-fluent/SKILL.md b/.github/skills/porting-tools-to-fluent/SKILL.md index 0d56a6db20f..c0030886a1d 100644 --- a/.github/skills/porting-tools-to-fluent/SKILL.md +++ b/.github/skills/porting-tools-to-fluent/SKILL.md @@ -100,7 +100,15 @@ MakeModularTool({ - `SettingsStore` (persisted user preferences) - `ThemeService` + optional `ThemeSelectorService` - `ShellService` (layout: central content, side panes, toolbars) -- `ToastProvider` for toast notifications +- `ToastProvider` + `IToastService` for toast notifications (consume `ToastServiceIdentity` — do not roll your own container) +- `IDialogService` (consume `DialogServiceIdentity`) for modal alert/confirm dialogs — replaces ad-hoc `MessageDialog` + +### Cross-window / popup hosting + +`MakeModularTool` derives `targetDocument` from `containerElement.ownerDocument`. If your tool's entry function (e.g. `Show(options)`) hosts the editor in a popup window, just pass the popup body as `containerElement` — Fluent/Griffel/`Theme` plumb cross-window automatically. + +- For a fully-Fluent popup, use `OpenPopupWindow` from `shared-ui-components/fluent/hoc/popupWindow`. +- If part of your tool still ships traditional CSS/SCSS (e.g. `shared-ui-components/nodeGraphSystem/`'s graph canvas), keep the legacy `CreatePopup` from `shared-ui-components/popupHelper` — it copies stylesheets into the popup. Fluent and `CreatePopup` coexist fine. --- @@ -477,3 +485,97 @@ For `satisfies` clauses in const option arrays, use `satisfies DropdownOption { + return { + friendlyName: "Global State Service", + produces: [GlobalStateServiceIdentity], + factory: () => { + const globalState = new GlobalState(options.scene); + // wire options into globalState... + return { globalState, dispose: () => { /* cleanup */ } }; + }, + }; +} + +// In Show(options): +MakeModularTool({ + serviceDefinitions: [MakeGlobalStateService(options, hostElement), CentralServiceDefinition, ...], + /* ... */ +}); +``` + +Prefer this over a `parentContainer` for instance-scoped data. + +### Bridge services for legacy observables + +When the existing codebase uses `globalState.on*Observable` to trigger UI (toasts, dialogs, etc.), don't rewrite all call sites. Add a small "bridge" service that consumes the framework service and forwards observable events: + +```ts +export const ToastBridgeServiceDefinition: ServiceDefinition<[], [IGlobalStateService, IToastService]> = { + friendlyName: "Toast Bridge Service", + consumes: [GlobalStateServiceIdentity, ToastServiceIdentity], + factory: (gs, toast) => { + const observer = gs.globalState.onToastNotification.add((d) => toast.showToast(d.message, { intent: d.severity })); + return { dispose: () => observer?.remove() }; + }, +}; +``` + +Same pattern works for `DialogBridge` → `IDialogService`, etc. Delete the legacy renderer (`ToastContainerComponent`, `MessageDialog`) once bridged. + +### `ToolContext` override per pane + +Dense property panels often want `size: "small"` independent of the user's tool-wide setting. Override `ToolContext` _inside_ the pane's content function (spread the parent first so other fields are inherited): + +```tsx +content: () => { + const parent = useContext(ToolContext); + const ctx = useMemo(() => ({ ...parent, size: "small" as const }), [parent]); + return ; +}, +``` + +### Toolbar hosts global actions + +Convert global buttons (Help, How-to-use, documentation links) to `shellService.addToolbarItem({ horizontalLocation: "right", verticalLocation: "bottom" })`. Existing top-of-canvas control bars (play/pause/undo/redo) fit naturally in `{ horizontalLocation: "left", verticalLocation: "top" }`, removing the need for a custom bar above the canvas. + +Buttons typically just notify an existing `globalState.on*Requested` observable so the dialog overlay logic stays where it is. + +### Side panes — `ExtensibleAccordion`, title + icon + +- Use `ExtensibleAccordion` (from `shared-ui-components/modularTool/components/extensibleAccordion`) for node lists and property tabs — gives filtering and pinning for free. +- Set `title` and `icon` on `addSidePane` so the shell renders the tool name/logo in the pane header (mirrors `viewer-configurator/configuratorService.tsx`). Use Fluent icons first; `createFluentIcon` only for Babylon-specific glyphs (logo, port markers). + +### `FileUploadLine` for file inputs + +Replace local `FileButtonLineComponent`-style components with `FileUploadLine` from `shared-ui-components/fluent/hoc/fileUploadLine`. Its callback receives a `FileList` — read `files[0]` if you previously took a single `File`. + +### Shared `Dialog` primitive + +Use `Dialog` from `shared-ui-components/fluent/primitives/dialog` (open + title + children + actions) for ad-hoc dialogs instead of composing `FluentDialog` + `DialogSurface` + `DialogBody` directly. + +### Keep `GraphCanvasComponent` out of scope + +The shared `shared-ui-components/nodeGraphSystem/` graph canvas is consumed by every node-graph editor and still ships SCSS. Don't try to port it during a tool-level Fluent migration — only port the surrounding shell, panes, dialogs, and property panels. diff --git a/package-lock.json b/package-lock.json index 51aa8562727..f63f05b0e81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19561,10 +19561,13 @@ "@dev/build-tools": "1.0.0", "@dev/core": "1.0.0", "@dev/shared-ui-components": "1.0.0", + "@fluentui-contrib/react-resize-handle": "^0.8.4", + "@fluentui/react-components": "^9.70.0", + "@fluentui/react-icons": "^2.0.310", + "@fluentui/react-positioning": "^9.22.0", "@tools/snippet-loader": "1.0.0", "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", - "split.js": "^1.6.5" + "@types/react-dom": "^18.0.0" } }, "packages/tools/guiEditor": { diff --git a/packages/dev/sharedUiComponents/src/fluent/hoc/childWindow.tsx b/packages/dev/sharedUiComponents/src/fluent/hoc/childWindow.tsx index bc0bb66a063..b9a8510cc17 100644 --- a/packages/dev/sharedUiComponents/src/fluent/hoc/childWindow.tsx +++ b/packages/dev/sharedUiComponents/src/fluent/hoc/childWindow.tsx @@ -1,30 +1,8 @@ import { type GriffelRenderer, createDOMRenderer, FluentProvider, Portal, RendererProvider } from "@fluentui/react-components"; import { type FunctionComponent, type PropsWithChildren, type Ref, useCallback, useEffect, useImperativeHandle, useState } from "react"; -import { Logger } from "core/Misc/logger"; import { ToastProvider } from "../primitives/toast"; - -function ToFeaturesString(options: ChildWindowOptions) { - const { defaultWidth, defaultHeight, defaultLeft, defaultTop } = options; - - const features: { key: string; value: string }[] = []; - - if (defaultWidth !== undefined) { - features.push({ key: "width", value: defaultWidth.toString() }); - } - if (defaultHeight !== undefined) { - features.push({ key: "height", value: defaultHeight.toString() }); - } - if (defaultLeft !== undefined) { - features.push({ key: "left", value: defaultLeft.toString() }); - } - if (defaultTop !== undefined) { - features.push({ key: "top", value: defaultTop.toString() }); - } - features.push({ key: "location", value: "no" }); - - return features.map((feature) => `${feature.key}=${feature.value}`).join(","); -} +import { OpenPopupWindow, type PopupWindowHandle } from "./popupWindow"; export type ChildWindowOptions = { /** @@ -99,150 +77,76 @@ export const ChildWindow: FunctionComponent> const { id, children, onOpenChange, imperativeRef: imperativeRef } = props; const [windowState, setWindowState] = useState<{ mountNode: HTMLElement; renderer: GriffelRenderer }>(); - const [childWindow, setChildWindow] = useState(); - - const storageKey = id ? `Babylon/Settings/ChildWindow/${id}/Bounds` : null; + const [popupHandle, setPopupHandle] = useState(); // This function is just for creating the child window itself. It is a function because // it must be called synchronously in response to a user interaction (e.g. button click), // otherwise the browser will block it as a scripted popup. const createWindow = useCallback( (options: ChildWindowOptions = {}) => { - if (storageKey) { - // If we are persisting window bounds, but the window is already open, just use the existing bounds. - // Otherwise, try to load bounds from storage. - if (childWindow) { - options.defaultLeft = childWindow.screenX; - options.defaultTop = childWindow.screenY; - options.defaultWidth = childWindow.innerWidth; - options.defaultHeight = childWindow.innerHeight; - } else { - const savedBounds = localStorage.getItem(storageKey); - if (savedBounds) { - try { - const bounds = JSON.parse(savedBounds); - options.defaultLeft = bounds.left; - options.defaultTop = bounds.top; - options.defaultWidth = bounds.width; - options.defaultHeight = bounds.height; - } catch { - Logger.Warn(`Could not parse saved bounds for child window with key ${storageKey}`); - } - } - } - } - - // Half width by default. - if (!options.defaultWidth) { - options.defaultWidth = window.innerWidth * (2 / 3); - } - // Half height by default. - if (!options.defaultHeight) { - options.defaultHeight = window.innerHeight * (2 / 3); - } - // Horizontally centered by default. - if (!options.defaultLeft) { - options.defaultLeft = window.screenX + (window.innerWidth - options.defaultWidth) * (2 / 3); - } - // Vertically centered by default. - if (!options.defaultTop) { - options.defaultTop = window.screenY + (window.innerHeight - options.defaultHeight) * (2 / 3); - } - - // Try to create the child window (can be null if popups are blocked). - const newChildWindow = window.open("", "", ToFeaturesString(options)); - if (newChildWindow) { - // Set the title if provided. - newChildWindow.document.title = options.title ?? id ?? ""; + const handle = OpenPopupWindow({ + id, + title: options.title ?? id, + defaultWidth: options.defaultWidth, + defaultHeight: options.defaultHeight, + defaultLeft: options.defaultLeft, + defaultTop: options.defaultTop, + onClose: () => { + // Triggered when the popup is closed for any reason (user dismissal, parent unload, + // or programmatic dispose). Clear the popup handle so the effect cleanup runs and + // `onOpenChange(false)` is propagated up the tree (which lets a parent shell re-dock + // a previously-undocked pane). teardown() is idempotent so calling dispose() again + // from the effect cleanup is a safe no-op. + setPopupHandle(undefined); + }, + }); - // Set the child window state. - setChildWindow((current) => { - // But first close any existing child window. - current?.close(); - return newChildWindow; + if (handle) { + setPopupHandle((current) => { + // Close any existing child window before adopting the new one. + current?.dispose(); + return handle; }); } }, - [childWindow, storageKey] + [id] ); useImperativeHandle(imperativeRef, () => { return { open: createWindow, - close: () => setChildWindow(undefined), + close: () => { + setPopupHandle((current) => { + current?.dispose(); + return undefined; + }); + }, }; }, [createWindow]); - // This side effect runs any time the child window instance changes. It does the rest of the child window + // This side effect runs any time the popup handle changes. It does the rest of the child window // setup work, including creating resources and state needed to properly render the content of the child window. useEffect(() => { - const disposeActions: (() => void)[] = []; - - if (childWindow) { - const body = childWindow.document.body; - body.style.width = "100%"; - body.style.height = "100%"; - body.style.margin = "0"; - body.style.padding = "0"; - body.style.display = "flex"; - body.style.overflow = "hidden"; - - // Setup the window state, including creating a Fluent/Griffel "renderer" for managing runtime styles/classes in the child window. - setWindowState({ mountNode: body, renderer: createDOMRenderer(childWindow.document) }); - onOpenChange?.(true); - - // Track the most recently observed window bounds. In some browsers (e.g. Firefox), accessing - // properties like screenX on a closed window throws, so we cache the last known good values - // to use as a fallback when the dispose runs after the window has already been closed. - const getBounds = () => ({ - left: childWindow.screenX, - top: childWindow.screenY, - width: childWindow.innerWidth, - height: childWindow.innerHeight, - }); - let lastBounds = getBounds(); - - // When the child window is closed for any reason, transition back to a closed state. - const onChildWindowUnload = () => { - setWindowState(undefined); - setChildWindow(undefined); - onOpenChange?.(false); - }; - childWindow.addEventListener("unload", onChildWindowUnload, { once: true }); - disposeActions.push(() => childWindow.removeEventListener("unload", onChildWindowUnload)); - - // Capture bounds before the window is unloaded, while its properties are still safe to read. - const onChildWindowBeforeUnload = () => { - lastBounds = getBounds(); - }; - childWindow.addEventListener("beforeunload", onChildWindowBeforeUnload); - disposeActions.push(() => childWindow.removeEventListener("beforeunload", onChildWindowBeforeUnload)); - - // If the main window closes, close any open child windows as well (don't leave them orphaned). - const onParentWindowUnload = () => { - childWindow.close(); - }; - window.addEventListener("unload", onParentWindowUnload, { once: true }); - disposeActions.push(() => window.removeEventListener("unload", onParentWindowUnload)); + if (!popupHandle) { + return undefined; + } - // On dispose, close the child window. - disposeActions.push(() => childWindow.close()); + const popupDocument = popupHandle.popupWindow.document; - // On dispose, save the window bounds. - disposeActions.push(() => { - if (storageKey) { - if (!childWindow.closed) { - lastBounds = getBounds(); - } - localStorage.setItem(storageKey, JSON.stringify(lastBounds)); - } - }); - } + // Use the popup's hostElement as the React mount point. OpenPopupWindow configures it as a + // flex column that fills the popup body, so the FluentProvider (also a flex column with + // flex-grow: 1) inside it can fill the available space. + setWindowState({ mountNode: popupHandle.hostElement, renderer: createDOMRenderer(popupDocument) }); + onOpenChange?.(true); return () => { - disposeActions.reverse().forEach((dispose) => dispose()); + // Tear down the popup. The cached handle's dispose() handles bounds saving and + // listener cleanup; React state is reset so the Portal/Provider tree unmounts. + popupHandle.dispose(); + setWindowState(undefined); + onOpenChange?.(false); }; - }, [childWindow]); + }, [popupHandle]); if (!windowState) { return null; diff --git a/packages/dev/sharedUiComponents/src/fluent/hoc/popupWindow.ts b/packages/dev/sharedUiComponents/src/fluent/hoc/popupWindow.ts new file mode 100644 index 00000000000..4c154e5b420 --- /dev/null +++ b/packages/dev/sharedUiComponents/src/fluent/hoc/popupWindow.ts @@ -0,0 +1,243 @@ +import { Logger } from "core/Misc/logger"; + +/** + * Options for opening a popup browser window via {@link OpenPopupWindow}. + */ +export type PopupWindowOptions = { + /** + * Title set on the popup document. + */ + title?: string; + + /** + * Default width of the popup in pixels. + * @remarks Ignored if `id` is provided and a previously saved width exists. + */ + defaultWidth?: number; + + /** + * Default height of the popup in pixels. + * @remarks Ignored if `id` is provided and a previously saved height exists. + */ + defaultHeight?: number; + + /** + * Default screen-X position of the popup in pixels. + * @remarks Ignored if `id` is provided and a previously saved position exists. + */ + defaultLeft?: number; + + /** + * Default screen-Y position of the popup in pixels. + * @remarks Ignored if `id` is provided and a previously saved position exists. + */ + defaultTop?: number; + + /** + * Optional unique identity. When provided, the popup's bounds are saved to and + * restored from `localStorage` under the key `Babylon/Settings/PopupWindow/{id}/Bounds`. + */ + id?: string; + + /** + * Optional callback invoked when the popup is closed externally — e.g. the user dismisses + * the popup, or the browser tab/window itself is unloaded. NOT called when the consumer + * closes the popup via {@link PopupWindowHandle.dispose} (the consumer already knows about + * that closure). + */ + onClose?: () => void; +}; + +/** + * Handle returned from {@link OpenPopupWindow}. + */ +export type PopupWindowHandle = { + /** + * The popup `Window` object. May become `closed` if the user dismisses the popup. + */ + readonly popupWindow: Window; + + /** + * A flex container element appended to the popup body. Render the tool into this element. + */ + readonly hostElement: HTMLDivElement; + + /** + * Closes the popup window and removes any listeners installed on the parent. + * Safe to call multiple times. + */ + dispose: () => void; +}; + +const StorageKeyPrefix = "Babylon/Settings/PopupWindow"; + +type SavedBounds = { left: number; top: number; width: number; height: number }; + +function LoadSavedBounds(id: string): Partial | null { + const stored = localStorage.getItem(`${StorageKeyPrefix}/${id}/Bounds`); + if (!stored) { + return null; + } + try { + const parsed = JSON.parse(stored) as Partial; + return parsed; + } catch { + Logger.Warn(`Could not parse saved bounds for popup window with id ${id}`); + return null; + } +} + +function SaveBounds(id: string, bounds: SavedBounds) { + try { + localStorage.setItem(`${StorageKeyPrefix}/${id}/Bounds`, JSON.stringify(bounds)); + } catch { + // Storage may be full / disabled — bounds simply won't persist. + } +} + +function ResolveBounds(options: PopupWindowOptions): SavedBounds { + const saved = options.id ? LoadSavedBounds(options.id) : null; + + const width = options.defaultWidth ?? saved?.width ?? Math.floor(window.innerWidth * (2 / 3)); + const height = options.defaultHeight ?? saved?.height ?? Math.floor(window.innerHeight * (2 / 3)); + const left = options.defaultLeft ?? saved?.left ?? Math.floor(window.screenX + (window.innerWidth - width) / 2); + const top = options.defaultTop ?? saved?.top ?? Math.floor(window.screenY + (window.innerHeight - height) / 2); + + // When the caller passes explicit width/height/left/top, always honour them; otherwise fall + // back to the (saved or computed) values above. The order above already gives explicit + // options precedence, so just build the resulting bounds now. + return { left, top, width, height }; +} + +function ToFeaturesString(bounds: SavedBounds): string { + return `width=${bounds.width},height=${bounds.height},left=${bounds.left},top=${bounds.top},location=no`; +} + +/** + * Opens a new browser popup window suitable for hosting a Fluent-based modular tool. + * + * The popup body is configured for full-bleed flex layout and a host `
` is appended + * for the tool to render into. Fluent style targeting (Griffel `RendererProvider`, + * `FluentProvider` with `targetDocument`) is the caller's responsibility — typically wired + * up by `MakeModularTool`, which derives `targetDocument` from `containerElement.ownerDocument`. + * + * **Must be called synchronously in response to a user interaction** (e.g. button click) — + * otherwise the browser will block the popup as a scripted popup. + * + * @param options Window options. See {@link PopupWindowOptions}. + * @returns A handle to the popup window and its host element, plus a `dispose` to close it. + * `null` if the popup was blocked by the browser. + */ +export function OpenPopupWindow(options: PopupWindowOptions = {}): PopupWindowHandle | null { + const bounds = ResolveBounds(options); + + const popupWindow = window.open("", "", ToFeaturesString(bounds)); + if (!popupWindow) { + return null; + } + + if (options.title) { + popupWindow.document.title = options.title; + } + + const popupBody = popupWindow.document.body; + popupBody.style.width = "100%"; + popupBody.style.height = "100%"; + popupBody.style.margin = "0"; + popupBody.style.padding = "0"; + popupBody.style.display = "flex"; + popupBody.style.overflow = "hidden"; + + const hostElement = popupWindow.document.createElement("div"); + hostElement.style.display = "flex"; + hostElement.style.flexDirection = "column"; + hostElement.style.flexGrow = "1"; + hostElement.style.width = "100%"; + hostElement.style.height = "100%"; + hostElement.style.margin = "0"; + hostElement.style.padding = "0"; + hostElement.style.overflow = "hidden"; + popupBody.appendChild(hostElement); + + // Track the most recently observed window bounds. In some browsers (e.g. Firefox), accessing + // properties like screenX on a closed window throws, so we cache the last known good values + // to use as a fallback when saving after the window has already been closed. + const getBounds = (): SavedBounds => ({ + left: popupWindow.screenX, + top: popupWindow.screenY, + width: popupWindow.innerWidth, + height: popupWindow.innerHeight, + }); + let lastBounds = bounds; + + const onPopupBeforeUnload = () => { + try { + lastBounds = getBounds(); + } catch { + // Use the cached lastBounds. + } + }; + popupWindow.addEventListener("beforeunload", onPopupBeforeUnload); + + let disposed = false; + // Internal cleanup: tears down listeners, persists bounds, closes the popup. + // Does NOT invoke `options.onClose` — that's reserved for popup unload events + // that originate from outside our own teardown (e.g. user dismissed the popup). + const cleanup = () => { + if (disposed) { + return; + } + disposed = true; + + if (options.id) { + try { + if (!popupWindow.closed) { + lastBounds = getBounds(); + } + } catch { + // Use the cached lastBounds. + } + SaveBounds(options.id, lastBounds); + } + + popupWindow.removeEventListener("beforeunload", onPopupBeforeUnload); + // Remove the unload listener so that any pending unload event triggered by our + // own popupWindow.close() below cannot reach back into onPopupUnload after we've + // already torn down. This avoids a race where, during a programmatic open-while-open + // swap, the old popup's unload would otherwise fire onClose and clear React state + // pointing at the new popup. + popupWindow.removeEventListener("unload", onPopupUnload); + window.removeEventListener("unload", onParentUnload); + + if (!popupWindow.closed) { + popupWindow.close(); + } + }; + + const onPopupUnload = () => { + if (disposed) { + // Already torn down programmatically; the popup is just finishing its unload. + return; + } + cleanup(); + // Notify the consumer only for externally-triggered closures (user dismissed the popup, + // tab/browser closed). Programmatic disposal calls cleanup() directly and intentionally + // skips this so callers don't get a re-entrant onClose for the close they themselves issued. + options.onClose?.(); + }; + popupWindow.addEventListener("unload", onPopupUnload, { once: true }); + + // If the parent window is unloaded (page refresh / navigation), don't leave the popup orphaned. + const onParentUnload = () => { + if (!popupWindow.closed) { + popupWindow.close(); + } + }; + window.addEventListener("unload", onParentUnload, { once: true }); + + return { + popupWindow, + hostElement, + dispose: cleanup, + }; +} diff --git a/packages/dev/sharedUiComponents/src/fluent/primitives/button.tsx b/packages/dev/sharedUiComponents/src/fluent/primitives/button.tsx index 59e98ddf1bf..0f67a670b1e 100644 --- a/packages/dev/sharedUiComponents/src/fluent/primitives/button.tsx +++ b/packages/dev/sharedUiComponents/src/fluent/primitives/button.tsx @@ -16,17 +16,23 @@ const useButtonStyles = makeStyles({ }); export type ButtonProps = BasePrimitiveProps & { + /** Callback invoked when the button is clicked. */ onClick?: (e?: MouseEvent) => unknown | Promise; + /** Optional icon rendered inside the button. */ icon?: FluentIcon; + /** Fluent button appearance. */ appearance?: "subtle" | "transparent" | "primary" | "secondary"; + /** Optional visible button label. */ label?: string; + /** Optional accessible label when the visible label is absent or insufficient. */ + ariaLabel?: string; }; export const Button = forwardRef((props, ref) => { const { size } = useContext(ToolContext); const classes = useButtonStyles(); // eslint-disable-next-line @typescript-eslint/naming-convention - const { icon: Icon, label, onClick, disabled, className, title, ...buttonProps } = props; + const { icon: Icon, label, onClick, disabled, className, title, ariaLabel, ...buttonProps } = props; const [isOnClickBusy, setIsOnClickBusy] = useState(false); const handleOnClick = useCallback( @@ -54,6 +60,7 @@ export const Button = forwardRef((props, ref) => {...buttonProps} className={className} size={size} + aria-label={ariaLabel ?? (!label ? title : undefined)} icon={isOnClickBusy ? : Icon && } onClick={handleOnClick} disabled={disabled || isOnClickBusy} diff --git a/packages/dev/sharedUiComponents/src/fluent/primitives/dialog.tsx b/packages/dev/sharedUiComponents/src/fluent/primitives/dialog.tsx index 506e9133584..d4092d566fd 100644 --- a/packages/dev/sharedUiComponents/src/fluent/primitives/dialog.tsx +++ b/packages/dev/sharedUiComponents/src/fluent/primitives/dialog.tsx @@ -71,7 +71,7 @@ export const Dialog: FC = (props) => { action={ onDismiss ? ( - + + + + }> - + + + @@ -411,6 +494,12 @@ export function MakeModularTool(options: ModularToolOptions) { } }; + // Derive the target document from the container element. When the container is in a popup + // window (or other document distinct from the main one), this is what makes Fluent inject + // styles and render portals into the correct document. + const containerOwnerDocument = containerElement.ownerDocument; + const targetDocument = containerOwnerDocument && containerOwnerDocument !== document ? containerOwnerDocument : undefined; + // Set the container element to be a flex container so that the tool can be displayed properly. const originalContainerElementDisplay = containerElement.style.display; containerElement.style.display = "flex"; diff --git a/packages/dev/sharedUiComponents/src/modularTool/services/dialogService.ts b/packages/dev/sharedUiComponents/src/modularTool/services/dialogService.ts new file mode 100644 index 00000000000..16c757dd6c2 --- /dev/null +++ b/packages/dev/sharedUiComponents/src/modularTool/services/dialogService.ts @@ -0,0 +1,26 @@ +import { type IService } from "../modularity/serviceDefinition"; + +import { type ToastIntent } from "@fluentui/react-components"; + +export type DialogOptions = { + type: "alert"; + intent?: ToastIntent; + title: string; + content?: JSX.Element; +}; + +/** + * The unique identity symbol for the dialog service. + */ +export const DialogServiceIdentity = Symbol("DialogService"); + +/** + * Provides the ability to show dialog from non-React code (e.g. Observable callbacks). + */ +export interface IDialogService extends IService { + /** + * Shows a dialog with the given content. + * @param options The dialog options to display. + */ + showDialog(options: DialogOptions): void; +} diff --git a/packages/dev/sharedUiComponents/src/modularTool/services/shellService.tsx b/packages/dev/sharedUiComponents/src/modularTool/services/shellService.tsx index 6b148415853..89f3ee38cd1 100644 --- a/packages/dev/sharedUiComponents/src/modularTool/services/shellService.tsx +++ b/packages/dev/sharedUiComponents/src/modularTool/services/shellService.tsx @@ -426,6 +426,7 @@ const useStyles = makeStyles({ paneCollapseButton: { padding: `0 0 0 ${tokens.spacingHorizontalXS}`, borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground2, }, paneCollapseButtonWithBorder: { borderLeft: `1px solid ${tokens.colorNeutralStroke2}`, @@ -449,6 +450,11 @@ const useStyles = makeStyles({ paneContainer: { display: "flex", flexDirection: "column", + // Side panes hold the width requested by their `style.width` (or saved/dragged value) + // and never give it up to a neighboring pane growing. Without this, dragging the left + // pane wider would also squeeze the right pane (proportional flex-shrink). The central + // content (`flex-grow: 1`) is the only flex item that absorbs the change. + flexShrink: 0, overflowX: "hidden", overflowY: "hidden", zIndex: 1, @@ -1058,11 +1064,19 @@ function usePane( // registered). The Fluent hook's setValue will silently fail when the element doesn't exist (in relative mode, // it measures the element before/after and reverts if unchanged, which always happens when the element is null). // By composing the ref callback, we ensure the stored value is applied immediately after the element mounts. + // + // We also set the CSS variable directly as a fallback. The hook preserves its internal currentValue across + // re-mounts, so when (for example) the user undocks a resized pane and then re-docks it, currentValue already + // equals the persisted setting and the hook's setValue short-circuits — but the freshly-mounted DOM node has no + // inline CSS variable, so the pane visually reverts to the default width until the user drags. Setting the + // variable directly closes that gap. The order is important: setValue first (handles the first-mount case where + // currentValue starts at 0), then the direct setProperty as a redundant fallback for the no-op case. const composedHorizontalElementRef = useCallback( (node: HTMLElement | null) => { paneHorizontalResizeElementRef(node); if (node) { setPaneWidthAdjust(paneWidthSettingRef.current); + node.style.setProperty(paneWidthAdjustCSSVar, `${paneWidthSettingRef.current}px`); } }, [paneHorizontalResizeElementRef, setPaneWidthAdjust] @@ -1072,6 +1086,7 @@ function usePane( paneVerticalResizeElementRef(node); if (node) { setPaneHeightAdjust(paneHeightSettingRef.current); + node.style.setProperty(paneHeightAdjustCSSVar, `${paneHeightSettingRef.current}px`); } }, [paneVerticalResizeElementRef, setPaneHeightAdjust] diff --git a/packages/dev/sharedUiComponents/src/nodeGraphSystem/graphCanvas.tsx b/packages/dev/sharedUiComponents/src/nodeGraphSystem/graphCanvas.tsx index bc8f7295a18..c947a8c5909 100644 --- a/packages/dev/sharedUiComponents/src/nodeGraphSystem/graphCanvas.tsx +++ b/packages/dev/sharedUiComponents/src/nodeGraphSystem/graphCanvas.tsx @@ -837,11 +837,15 @@ export class GraphCanvasComponent extends React.Component n.content.data === data)[0]; } - reset() { + /** + * Clears the canvas visuals. + * @param disposeContent - Whether to dispose the underlying graph data while clearing visuals. + */ + reset(disposeContent = true) { this._nodeDataContentList = []; for (const node of this._nodes) { - node.dispose(); + node.dispose(disposeContent); } const frames = this._frames.splice(0); diff --git a/packages/dev/sharedUiComponents/src/nodeGraphSystem/graphNode.ts b/packages/dev/sharedUiComponents/src/nodeGraphSystem/graphNode.ts index c55d7dd966b..3111fbb33e6 100644 --- a/packages/dev/sharedUiComponents/src/nodeGraphSystem/graphNode.ts +++ b/packages/dev/sharedUiComponents/src/nodeGraphSystem/graphNode.ts @@ -1072,7 +1072,11 @@ export class GraphNode { }; } - public dispose() { + /** + * Disposes this visual graph node. + * @param disposeContent - Whether to dispose the underlying node content and disconnect links. + */ + public dispose(disposeContent = true) { if (this._stateManager.activeNode === this) { this._stateManager.activeNode = null; } @@ -1080,8 +1084,10 @@ export class GraphNode { this._displayManager.onDispose(this.content, this._stateManager); } - // notify frame observers that this node is being deleted - this._stateManager.onGraphNodeRemovalObservable.notifyObservers(this); + if (disposeContent) { + // notify frame observers that this node is being deleted + this._stateManager.onGraphNodeRemovalObservable.notifyObservers(this); + } if (this._onSelectionChangedObserver) { this._stateManager.onSelectionChangedObservable.remove(this._onSelectionChangedObserver); @@ -1117,9 +1123,11 @@ export class GraphNode { const links = this._links.slice(0); for (const link of links) { - link.dispose(); + link.dispose(disposeContent, disposeContent); } - this.content.dispose(); + if (disposeContent) { + this.content.dispose(); + } } } diff --git a/packages/dev/sharedUiComponents/src/nodeGraphSystem/nodeLink.ts b/packages/dev/sharedUiComponents/src/nodeGraphSystem/nodeLink.ts index 0d6995dfce0..c768a4dc079 100644 --- a/packages/dev/sharedUiComponents/src/nodeGraphSystem/nodeLink.ts +++ b/packages/dev/sharedUiComponents/src/nodeGraphSystem/nodeLink.ts @@ -383,7 +383,12 @@ export class NodeLink { requestAnimationFrame(animate); } - public dispose(notify = true) { + /** + * Disposes this visual link. + * @param notify - Whether to notify observers that the link was disposed. + * @param disconnectPorts - Whether to disconnect the underlying port data. + */ + public dispose(notify = true, disconnectPorts = true) { this._graphCanvas.stateManager.onSelectionChangedObservable.remove(this._onSelectionChangedObserver); if (this._path.parentElement) { @@ -405,7 +410,9 @@ export class NodeLink { this._nodeB.links.splice(this._nodeB.links.indexOf(this), 1); this._graphCanvas.links.splice(this._graphCanvas.links.indexOf(this), 1); - this._portA.portData.disconnectFrom(this._portB!.portData); + if (disconnectPorts) { + this._portA.portData.disconnectFrom(this._portB!.portData); + } RefreshNode(this._nodeB, undefined, undefined, this._graphCanvas); } diff --git a/packages/tools/flowGraphEditor/package.json b/packages/tools/flowGraphEditor/package.json index 1c9344ad6b0..f2395b5f36a 100644 --- a/packages/tools/flowGraphEditor/package.json +++ b/packages/tools/flowGraphEditor/package.json @@ -27,10 +27,13 @@ "@dev/build-tools": "1.0.0", "@dev/core": "1.0.0", "@dev/shared-ui-components": "1.0.0", + "@fluentui-contrib/react-resize-handle": "^0.8.4", + "@fluentui/react-components": "^9.70.0", + "@fluentui/react-icons": "^2.0.310", + "@fluentui/react-positioning": "^9.22.0", "@tools/snippet-loader": "1.0.0", "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", - "split.js": "^1.6.5" + "@types/react-dom": "^18.0.0" }, "sideEffects": true } diff --git a/packages/tools/flowGraphEditor/port-to-fluent.md b/packages/tools/flowGraphEditor/port-to-fluent.md new file mode 100644 index 00000000000..bfd600c1232 --- /dev/null +++ b/packages/tools/flowGraphEditor/port-to-fluent.md @@ -0,0 +1,229 @@ +# Flow Graph Editor — Port to Fluent UI / MakeModularTool + +## Problem Statement + +`packages/tools/flowGraphEditor/` is one of the last large node-graph editors still on the legacy stack: + +- **Bootstrapping** — `FlowGraphEditor.Show()` calls `createRoot(hostElement).render()`. `hostElement` is either a caller-supplied DOM element or a popup window created via `CreatePopup` (no Fluent setup, no Griffel renderer for the popup document, no theme). +- **Layout** — `graphEditor.tsx` hand-rolls a 3-column split with `SplitContainer` / `Splitter`: + - **Left**: `NodeListComponent` + - **Center top**: `GraphTabBarComponent` + `GraphControlsComponent` + `VariablesPanelComponent` + `GraphCanvasComponent` + - **Center bottom**: `LogComponent` + - **Right top**: `PropertyTabComponent` + - **Right bottom**: `ScenePreviewComponent` +- **Components** — 14 component folders, each with its own `.scss`. Together: `propertyTab.scss` 23 KB, `scenePreview.scss` 4 KB, `graphControls.scss` 7.7 KB, `nodeList.scss` 5 KB, `variables.scss` 7 KB, `graphTabBar.scss` 2.9 KB, `helpDialog.scss` 3 KB, `howToUse.scss` 2.8 KB, `contextMenu.scss` 1.2 KB, `log.scss` 0.6 KB, `toast.scss` 1.4 KB, `main.scss` 2.4 KB, plus two `*.module.scss` files in `graphSystem/`. +- **Local legacy line components** in `src/sharedComponents/` (`autoCompleteInputComponent`, `checkBoxLineComponent`, `draggableLineComponent`, `fileButtonLineComponent`, `lineContainerComponent`). +- **Property panels** — 11 `*.tsx` files in `src/graphSystem/properties/` consume legacy `shared-ui-components/lines/*` (`TextInputLineComponent`, `OptionsLine`, `FloatLineComponent`, `Vector{2,3,4}LineComponent`, `Color{3,4}LineComponent`, `MatrixLineComponent`, `SliderLineComponent`, `ButtonLineComponent`, `LineContainerComponent`, `TextLineComponent`). +- **No FontAwesome** — icons are inline SVGs scattered across components (e.g. `nodeList`, `graphControls`). + +`GraphCanvasComponent` itself lives in `shared-ui-components/nodeGraphSystem/` and is consumed as-is by every node-graph editor; it is **out of scope** for this port. We only port the surrounding shell, side panes, dialogs, and property panels. + +## Approach + +Single coherent change set, executed in 5 ordered phases. Reference: `packages/tools/viewer-configurator/`. The plan keeps the user-facing UX of today (same overall layout, same controls inside `GraphControls`/`Variables`); only the underlying primitives, theming and bootstrap change. + +### Layout mapping + +The overall app layout stays the same as today. We move it onto `IShellService` like so: + +| Today's slot | Shell mapping | +|---|---| +| Root `SplitContainer` | `IShellService` (provided by `MakeModularTool`) | +| `NodeListComponent` (left) | `shellService.addSidePane({ horizontalLocation: "left", verticalLocation: "top", title: "Nodes" })` — implemented as an **`ExtensibleAccordion`** so future tools/extensions can register categories. | +| `GraphTabBarComponent` + `GraphControlsComponent` + `VariablesPanelComponent` + `GraphCanvasComponent` + **`LogComponent` at the bottom** | `shellService.addCentralContent(...)` rendering one `` component. The Log stays inside the central column (vertical stack), preserving today's UX. | +| `PropertyTabComponent` (right) | `shellService.addSidePane({ horizontalLocation: "right", verticalLocation: "top", title: "Flow Graph Editor", icon: BabylonLogo })` — the shell renders the title + icon at the pane header (mirrors `viewer-configurator/configuratorService.tsx`). Implemented as an **`ExtensibleAccordion`**. | +| `ScenePreviewComponent` (right-bottom) | `shellService.addSidePane({ horizontalLocation: "right", verticalLocation: "bottom", title: "Scene Preview" })` | +| Help button + How-to-use button (currently in `GraphControlsComponent`) | `shellService.addToolbarItem({ horizontalLocation: "right", verticalLocation: "bottom" })` — moved into the bottom-right toolbar slot. | +| Reset button + Start/Pause/Stop + Speed presets + breakpoint controls + variables button | **Stay in `GraphControlsComponent`** in the central column. Just rewritten in Fluent. | +| `MessageDialog`, `HelpDialogComponent`, `HowToUseDialogComponent`, `ContextMenuComponent` | Overlays inside the central content (or Fluent `Dialog`). | +| `ToastContainerComponent` | **Deleted.** Use `IToastService` exposed by `MakeModularTool`'s built-in `ToastProvider`. | +| `main.scss` + per-component `.scss` + two `.module.scss` | `makeStyles` from `@fluentui/react-components`. | + +The central content layout becomes a vertical flex container: `GraphTabBar` → `GraphControls` → `VariablesPanel` (collapsible, as today) → `GraphCanvas` → `Log` (collapsible / resizable, as today). + +### `Show()` popup hosting — solve at the framework level + +The user-facing `FlowGraphEditor.Show()` API stays unchanged. Internally it creates a host element either via `CreatePopup` (popup window) or uses the caller-supplied one, then calls `MakeModularTool({ containerElement: host, ... })`. + +Today, `MakeModularTool` doesn't render Fluent styles into the popup's document, so a popup-hosted tool would see unstyled content. Fix at the framework level so any future tool with the same pattern works automatically: + +- `MakeModularTool` derives `const targetDocument = containerElement.ownerDocument` (defaults to the main `document` when the host is in the main window — no behaviour change there). +- When `targetDocument !== document` (i.e. popup), wrap the tool tree with: + - `RendererProvider` using `createDOMRenderer(targetDocument)` + - `Theme` (`FluentProvider`) configured with `targetDocument={targetDocument}` + - The existing `ToastProvider` (the `Toaster` inside it already calls `useFluent()` to inherit `targetDocument`). +- Factor a small helper out of `shared-ui-components/fluent/hoc/childWindow.tsx` (the renderer-creation + provider-wrapping logic) so both `ChildWindow` and `MakeModularTool` use the same code path. Suggested name: `withCrossWindowFluentProviders(targetDocument, children)` or a small `` component. + +When `targetDocument === document`, no extra wrappers are added (skip `RendererProvider`); `FluentProvider` keeps its current default behaviour. + +This is a tightly-scoped change in `shared-ui-components/modularTool/modularTool.tsx` + a small helper in `shared-ui-components/fluent/hoc/`. + +### Service decomposition + +Define services in `src/services/`: + +- `globalStateService.ts` — produces the existing `GlobalState`. Exposed via a **factory function** `MakeGlobalStateService(options): ServiceDefinition` so `Show()` can pass `options.flowGraph`, `options.hostScene`, `options.customSave`, `options.customLoadObservable`, and the host `Document`/`Window` straight into the service definition. This pattern matches what's used elsewhere in the repo. The service factory builds the `GlobalState`, wires the existing observables, and exposes the state through an `IGlobalStateService` contract. +- `nodeListService.tsx` — left side pane. Renders `NodeListComponent` inside an `ExtensibleAccordion`. +- `propertyTabService.tsx` — right-top side pane. Renders `PropertyTabComponent` inside an `ExtensibleAccordion`. The pane is registered with `title: "Flow Graph Editor"` + `icon: BabylonLogo` so the tool name/logo render in the pane header (same pattern as viewer-configurator). +- `scenePreviewService.tsx` — right-bottom side pane (`ScenePreviewComponent`). +- `centralGraphService.tsx` — central content: tab bar + controls + variables + `GraphCanvasComponent` + log + dialogs/overlays. Owns global keyboard handling, drag-drop, and the history stack (or delegates to a small `editorActionsService` if it gets too big). +- `toolbarService.tsx` — adds Help (bottom-right) and How-to-use (bottom-right) buttons via `shellService.addToolbarItem`. Theme selector is provided automatically by `showThemeSelector: true` in `MakeModularTool`. + +All services consume `ShellServiceIdentity`, `GlobalStateServiceIdentity`, and (for actions that surface notifications) `ToastServiceIdentity`. + +The toast container is **not** something we need to add — `MakeModularTool` already wraps the tool in `ToastProvider` and exposes `IToastService`. Replace `ShowToast(...)` call sites with `toastService.showToast(...)`. + +### Component-level work + +For each component: + +1. Rewrite as functional component, using `useObservableState` from `shared-ui-components/modularTool/hooks/observableHooks` for any `globalState.on*Observable` subscriptions. +2. Replace SCSS with `makeStyles` (delete the `.scss`). +3. Replace local `sharedComponents/*` with the appropriate Fluent primitive. +4. Replace inline SVG icons with **Fluent icons first**; only fall back to `createFluentIcon` for genuinely Babylon-specific glyphs (e.g. the Babylon logo, signal/data port markers if no Fluent equivalent fits). + +Property panels (`src/graphSystem/properties/*.tsx`): + +| Legacy line | Fluent replacement | +|---|---| +| `TextInputLineComponent` | `TextInputPropertyLine` | +| `OptionsLine` | `StringDropdownPropertyLine` | +| `FloatLineComponent` | `NumberInputPropertyLine` (or `SyncedSliderPropertyLine` when min/max are known) | +| `SliderLineComponent` | `SyncedSliderPropertyLine` | +| `Vector2LineComponent` / `Vector3LineComponent` / `Vector4LineComponent` | `Vector2PropertyLine` / `Vector3PropertyLine` / `Vector4PropertyLine` | +| `Color3LineComponent` / `Color4LineComponent` | `Color3PropertyLine` / `Color4PropertyLine` | +| `MatrixLineComponent` | `MatrixPropertyLine` (verify it exists; if not, build one ad-hoc) | +| `ButtonLineComponent` | `Button` primitive (do **not** nest `ButtonLine` inside `PropertyLine`) | +| `LineContainerComponent` | `AccordionSection` inside an `Accordion` | +| `TextLineComponent` | `PropertyLine` with `` child or `LineContainer` with text | + +### Local `sharedComponents/*` mapping + +| Local component | Fluent replacement | +|---|---| +| `CheckBoxLineComponent` | `SwitchPropertyLine` | +| `LineContainerComponent` | `Accordion` + `AccordionSection` | +| `FileButtonLineComponent` | **`FileUploadLine`** from `shared-ui-components/fluent/hoc/fileUploadLine`. The shapes match (`label` + `accept` + `onClick(files)`). The local component currently calls `onClick(files[0])` whereas `FileUploadLine` exposes the full `FileList`; adapt the two existing call sites in `propertyTabComponent.tsx` to read `files[0]`. | +| `AutoCompleteInputComponent` | Fluent **`Combobox`** from `@fluentui/react-components` (free-text + dropdown suggestions). Used by `genericNodePropertyComponent` and `setVariablePropertyComponent` — preserve their current "type to filter, click to pick" UX. | +| `DraggableLineComponent` | Keep as a small **local component** (it's trivial — a `
` with `makeStyles` + a left-border accent strip). No need to take a `dnd-kit` dependency for this. | + +### Files to DELETE after port + +- `src/main.scss` +- `src/sharedComponents/*` (entire directory: `autoComplete.scss`, `autoCompleteInputComponent.tsx`, `checkBoxLineComponent.tsx`, `fileButtonLineComponent.tsx`, `lineContainerComponent.tsx`) +- All `*.scss` under `src/components/**` and `src/graphSystem/**/*.module.scss` +- `src/portal.tsx` (if dialogs no longer need it — Fluent `Dialog` portals to `document.body` itself) +- `src/custom.d.ts` (if it only declared SCSS modules — verify before removing) +- `src/imgs/downArrow.svg` (used only by local `lineContainerComponent`) +- `src/components/toast/` (directory) — replaced by `IToastService` + +### Files to CREATE + +- `src/services/globalStateService.ts` — exports `MakeGlobalStateService(options)` factory + `IGlobalStateService` contract + `GlobalStateServiceIdentity`. +- `src/services/nodeListService.tsx` +- `src/services/propertyTabService.tsx` +- `src/services/scenePreviewService.tsx` +- `src/services/centralGraphService.tsx` +- `src/services/toolbarService.tsx` +- `src/icons.ts` — `createFluentIcon` wrappers for any custom SVGs that survive (Babylon logo + any port-design glyphs that don't have a Fluent equivalent). +- `src/components/draggableLine.tsx` — small local Fluent-styled replacement for `DraggableLineComponent`. + +### Files to MODIFY + +- `src/flowGraphEditor.ts` — replace `createRoot(...).render()` with `MakeModularTool({ namespace: "FlowGraphEditor", containerElement, serviceDefinitions: [MakeGlobalStateService(options), CentralGraphServiceDefinition, NodeListServiceDefinition, PropertyTabServiceDefinition, ScenePreviewServiceDefinition, ToolbarServiceDefinition], toolbarMode: "compact", showThemeSelector: true })`. Popup hosting works automatically once `MakeModularTool` derives `targetDocument` from `containerElement.ownerDocument` (framework change). +- `src/graphEditor.tsx` — gutted: most of it dissolves into services. The class-component shape becomes hooks + service factories. Keyboard handler, drag-drop, history stack, smart-group/sticky-note/breakpoint actions migrate to `centralGraphService.tsx`. +- `src/components/**` — each rewritten in Fluent + `makeStyles`. +- `src/graphSystem/properties/*.tsx` — rewritten to use Fluent property lines. +- `src/graphSystem/display/debugDisplayManager.ts` — replace `.module.scss` import with `makeStyles` (or inline class strings). +- `src/graphSystem/blockNodeData.ts` — same treatment for `.module.scss`. +- `package.json` — add `@fluentui/react-components` and `@fluentui/react-icons`; remove `sass-loader`, `style-loader`, `mini-css-extract-plugin`, `css-loader`, `webpack`, `webpack-cli`, `webpack-merge`, `html-webpack-plugin`, `copy-webpack-plugin`, `@svgr/webpack`, `split.js` (verify each is unused before removing). +- `tsconfig.json` — confirm `shared-ui-components/*` path alias is present. +- `vite.config.ts` — no change expected; `cdnExternals` already wires `core` → `BABYLON`. + +Plus framework changes (out-of-package, but in scope for this work): + +- `packages/dev/sharedUiComponents/src/modularTool/modularTool.tsx` — derive `targetDocument` from `containerElement.ownerDocument`; conditionally wrap in `RendererProvider` + pass `targetDocument` through to `Theme/FluentProvider` when `targetDocument !== document`. +- `packages/dev/sharedUiComponents/src/fluent/hoc/childWindow.tsx` — extract the renderer/provider-wrapping logic into a small reusable helper that both `ChildWindow` and `MakeModularTool` import. + +### Files UNCHANGED + +- `src/legacy/legacy.ts` +- `src/main.ts`, `src/index.ts`, `src/index.html`, `src/public/`, `src/test/` +- `src/serializationTools.ts`, `src/variableUtils.ts`, `src/sceneContext.ts`, `src/blockTools.ts`, `src/allBlockNames.ts`, `src/compositeTemplates.ts` +- `src/graphSystem/registerToDisplayLedger.ts`, `registerToPropertyLedger.ts`, `registerToTypeLedger.ts`, `registerDebugSupport.ts`, `registerDefaultInput.ts`, `registerElbowSupport.ts`, `registerExportData.ts`, `registerNodePortDesign.ts`, `connectionPointPortData.ts`, `blockTypeColors.ts`, `blockDisplayUtils.ts`, `smartGroup.ts` + +### Verification + +After each phase: + +1. `npm run lint:check` (root) — ratchets must not regress. +2. `npm run format:check` (root). +3. `npm run build -w @tools/flow-graph-editor`. +4. `npm run serve -w @tools/flow-graph-editor` and exercise the editor at `http://localhost:1347` — verify all panels render in light AND dark themes. +5. `npm run test:e2e -w @tools/flow-graph-editor` — Playwright smoke tests must still pass. + +## Phased Execution + +The phases are ordered for buildability — `npm run build -w @tools/flow-graph-editor` should succeed at the end of each phase. + +### Phase 1 — Bootstrap & Shell + +1. Add `@fluentui/react-components` + `@fluentui/react-icons` to `package.json`; install. +2. Framework change: extend `MakeModularTool` to derive `targetDocument` from `containerElement.ownerDocument` and wire cross-window Fluent providers. Factor a small helper out of `ChildWindow.tsx`. +3. Create `src/services/globalStateService.ts` with `MakeGlobalStateService(options)` factory. The factory builds and owns a `GlobalState`; the service exposes the state and observables through `IGlobalStateService`. +4. Create `src/services/centralGraphService.tsx` that registers central content rendering the **existing** `` class component as-is (still wired to the same `GlobalState`). +5. Rewrite `src/flowGraphEditor.ts` to call `MakeModularTool` with `[MakeGlobalStateService(options), CentralGraphServiceDefinition]`. Popup hosting now works through the framework. Verify the editor still loads end-to-end (functionally identical to today). + +### Phase 2 — Decompose layout into services + +1. Create `nodeListService.tsx`, `propertyTabService.tsx`, `scenePreviewService.tsx` that each register a side pane wrapping the existing legacy component (no rewrite yet). The right-top pane registration uses `title: "Flow Graph Editor"` + `icon: BabylonLogo` (icon stub in `src/icons.ts`). +2. Trim the central content: `centralGraphService.tsx` renders only `GraphTabBar` + `GraphControls` + `VariablesPanel` + `GraphCanvasComponent` + `LogComponent` at the bottom + dialogs/overlays. NodeList, PropertyTab, ScenePreview move into their respective side panes. +3. Move global keyboard handling, drag-drop, history stack, and helper methods from `graphEditor.tsx` into `centralGraphService.tsx` (or a small `editorActionsService` if it gets too big). Delete `SplitContainer`/`Splitter` usage from `graphEditor.tsx`. +4. Verify layout parity (panes resizable, persisted widths sensible, log + variables panels still collapse/expand as today). + +### Phase 3 — Port surrounding components to Fluent + +For each of `GraphTabBarComponent`, `GraphControlsComponent` (preserving current UX — Reset, Start/Pause/Stop, speed presets, breakpoint controls all stay), `VariablesPanelComponent` (preserving current UX), `LogComponent`, `NodeListComponent` (rebuilt around `ExtensibleAccordion`), `PropertyTabComponent` (rebuilt around `ExtensibleAccordion`), `ScenePreviewComponent`, `ContextMenuComponent`, `HelpDialogComponent`, `HowToUseDialogComponent`: + +1. Rewrite as functional component (where applicable). +2. Replace local `sharedComponents/*` and any inline DOM with Fluent primitives (`TextInput`, `Switch`, `Dropdown`, `Combobox`, `MessageBar`, `Accordion`, `Button`, `Dialog`, `Toolbar`). +3. Convert SCSS → `makeStyles`. Delete the `.scss` file once the component's last consumer no longer references it. +4. Replace inline SVG icons with Fluent icons first; only use `createFluentIcon` for Babylon-specific glyphs. +5. Use `useObservableState` for any `globalState.on*Observable` subscriptions. + +For toast: delete `src/components/toast/`, replace `ShowToast(...)` call sites with `toastService.showToast(...)` consumed via `IToastService`. + +For Help and How-to-use buttons: remove from `GraphControls`, register via `toolbarService.tsx` at `horizontalLocation: "right"`, `verticalLocation: "bottom"`. The dialogs themselves stay; toolbar buttons just toggle them. + +For `ExtensibleAccordion` consumers (NodeList, PropertyTab): wire each existing section as an `addSection`/`addSectionContent` call so the editor gets the filtering/pinning UX for free. + +### Phase 4 — Port property panels + +For each `src/graphSystem/properties/*.tsx`: + +1. Replace `shared-ui-components/lines/*` imports with Fluent property lines (mapping table above). +2. Replace `LineContainerComponent` with `AccordionSection` inside an `Accordion` (or just stack sections directly inside the `ExtensibleAccordion` that hosts the panel — verify what fits the pinning UX best). +3. Convert any local SCSS / className strings to `makeStyles`. +4. Verify each block still renders its panel correctly when selected. Special focus: `genericNodePropertyComponent.tsx` (34 KB) and the dynamic-port mutators `dataSwitchPropertyComponent`, `customEventPropertyComponent`, `setVariablePropertyComponent`, `switchBlockPropertyComponent`. +5. Migrate `genericNodePropertyComponent`'s `_EDITABLE_TYPE_NAMES` rendering (Vector2/3/4, Color3/4, Matrix) to the corresponding Fluent property lines. +6. Replace `AutoCompleteInputComponent` usages with Fluent `Combobox` (used by `genericNodePropertyComponent` for variable-name input and by `setVariablePropertyComponent`). + +### Phase 5 — Cleanup + +1. Delete `src/sharedComponents/` entirely. +2. Delete every remaining `.scss` and `.module.scss` file (and `src/main.scss`); delete `src/imgs/downArrow.svg` if unused. +3. Delete `src/portal.tsx` if dialogs no longer need it. +4. Delete `src/custom.d.ts` if it only declared SCSS modules. +5. Delete `src/components/toast/`. +6. Remove obsolete devDependencies from `package.json` (`sass-loader`, `style-loader`, `mini-css-extract-plugin`, `css-loader`, `webpack*`, `html-webpack-plugin`, `copy-webpack-plugin`, `@svgr/webpack`, `split.js`). Keep anything still referenced by `vite.config.ts` or test harnesses (verify). +7. Run `npm run format:check`, `npm run lint:check`, `npm run build -w @tools/flow-graph-editor`, `npm run test:e2e -w @tools/flow-graph-editor`. +8. Manually exercise: light/dark themes, popup hosting, all 11 property panels, drag-drop of `.glb` onto editor, snippet save/load, undo/redo, Ctrl+F search, sticky notes, breakpoints, all toolbar actions. + +## Notes / Risks + +- `globalState.ts` (67 KB) and `graphEditor.tsx` (59 KB) are large but mostly observable wiring + event handlers; neither is Fluent-coupled. Decomposition into services will mostly be a relocation exercise. +- `GraphCanvasComponent` (in `shared-ui-components/nodeGraphSystem/`) is shared across all node-graph editors and is **out of scope**. +- The framework-level `targetDocument` plumbing in `MakeModularTool` is small but cross-cutting — every other tool that uses `MakeModularTool` benefits but must also be sanity-checked once for regressions. +- Property line HOCs (`MatrixPropertyLine`, `Vector4PropertyLine`, etc.) — verify they all exist in `shared-ui-components/fluent/hoc/propertyLines/` before relying on them; if any are missing, build a small ad-hoc replacement rather than blocking this port. +- The two `*.module.scss` files (`debugDisplayManager.module.scss`, `blockNodeData.module.scss`) feed CSS-Module class names into the `GraphCanvasComponent` DOM. `makeStyles` produces stable class strings that work the same way; convert in place. +- `ExtensibleAccordion` brings filtering and pinning for free, but the `NodeListComponent` today has bespoke search + collapse-by-category UX. Verify the `ExtensibleAccordion`'s built-in filter is functionally equivalent (it is — it's the same UX inspector-v2 uses), or fall back to a plain `Accordion` if a behaviour gap shows up during Phase 3. diff --git a/packages/tools/flowGraphEditor/src/components/common/draggableLine.tsx b/packages/tools/flowGraphEditor/src/components/common/draggableLine.tsx new file mode 100644 index 00000000000..2a48a41d3c9 --- /dev/null +++ b/packages/tools/flowGraphEditor/src/components/common/draggableLine.tsx @@ -0,0 +1,60 @@ +import { type FunctionComponent } from "react"; + +import { Body1, Tooltip, makeStyles, tokens } from "@fluentui/react-components"; + +const useStyles = makeStyles({ + line: { + display: "block", + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + margin: `2px 0`, + background: tokens.colorNeutralBackground2, + borderRadius: tokens.borderRadiusSmall, + color: tokens.colorNeutralForeground1, + cursor: "grab", + userSelect: "none", + ":hover": { + background: tokens.colorNeutralBackground2Hover, + }, + ":active": { + cursor: "grabbing", + }, + }, +}); + +export interface IDraggableLineProps { + /** The data string transferred via the `babylonjs-flow-graph-node` drag MIME type. */ + data: string; + /** Tooltip shown on hover. */ + tooltip: string; + /** Optional accent color shown as a left border strip. */ + color?: string; +} + +/** + * A small Fluent-styled line item that the user can drag onto the canvas to instantiate + * a flow graph block (or composite template). + * + * Replaces the legacy `sharedComponents/draggableLineComponent.tsx` and is intentionally + * kept local — `dnd-kit` would be overkill for this trivial native HTML5 drag interaction. + * @returns The rendered draggable line element. + */ +export const DraggableLine: FunctionComponent = ({ data, tooltip, color }) => { + const classes = useStyles(); + const borderStyle = color ? { borderLeft: `4px solid ${color}` } : undefined; + const display = data.startsWith("FlowGraph") ? data.slice(9).replace("Block", "") : data.replace("Block", ""); + + return ( + +
{ + event.dataTransfer.setData("babylonjs-flow-graph-node", data); + }} + > + {display} +
+
+ ); +}; diff --git a/packages/tools/flowGraphEditor/src/components/contextMenu/contextMenu.scss b/packages/tools/flowGraphEditor/src/components/contextMenu/contextMenu.scss deleted file mode 100644 index b07e1fe1e68..00000000000 --- a/packages/tools/flowGraphEditor/src/components/contextMenu/contextMenu.scss +++ /dev/null @@ -1,59 +0,0 @@ -.fge-context-menu-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 10000; -} - -.fge-context-menu { - position: fixed; - z-index: 10001; - background: #2a2a2a; - border: 1px solid #555; - border-radius: 4px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); - min-width: 180px; - padding: 4px 0; - font: - 13px "acumin-pro", - sans-serif; - color: #ccc; - - .fge-ctx-item { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 16px; - cursor: pointer; - white-space: nowrap; - user-select: none; - - &:hover { - background: #3a5a8a; - color: white; - } - - &.disabled { - opacity: 0.35; - cursor: not-allowed; - &:hover { - background: transparent; - color: #ccc; - } - } - - .fge-ctx-shortcut { - margin-left: auto; - font-size: 11px; - opacity: 0.6; - } - } - - .fge-ctx-separator { - height: 1px; - background: #555; - margin: 4px 0; - } -} diff --git a/packages/tools/flowGraphEditor/src/components/contextMenu/contextMenuComponent.tsx b/packages/tools/flowGraphEditor/src/components/contextMenu/contextMenuComponent.tsx deleted file mode 100644 index 5c720f6497b..00000000000 --- a/packages/tools/flowGraphEditor/src/components/contextMenu/contextMenuComponent.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import * as React from "react"; -import "./contextMenu.scss"; - -/** - * Describes one item in a context menu. - */ -export interface IContextMenuItem { - /** Display label */ - label: string; - /** Callback when clicked */ - action: () => void; - /** Keyboard shortcut hint (display only) */ - shortcut?: string; - /** Whether the item is disabled */ - disabled?: boolean; - /** Accessible description */ - ariaLabel?: string; -} - -/** - * A separator entry in a context menu. - */ -export interface IContextMenuSeparator { - /** Marker to distinguish separator from action items */ - isSeparator: true; -} - -export type ContextMenuEntry = IContextMenuItem | IContextMenuSeparator; - -function IsSeparator(entry: ContextMenuEntry): entry is IContextMenuSeparator { - return "isSeparator" in entry && entry.isSeparator === true; -} - -interface IContextMenuComponentProps { - /** Screen X position */ - x: number; - /** Screen Y position */ - y: number; - /** Menu entries */ - items: ContextMenuEntry[]; - /** Called when the menu should close */ - onClose: () => void; -} - -/** - * Generic right-click context menu shown as a fixed overlay. - */ -export class ContextMenuComponent extends React.Component { - private _menuRef = React.createRef(); - - /** @internal */ - override componentDidMount() { - this._clampToViewport(); - // Close on Escape - this._onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - this.props.onClose(); - } - }; - document.addEventListener("keydown", this._onKeyDown); - } - - /** @internal */ - override componentDidUpdate() { - this._clampToViewport(); - } - - /** @internal */ - override componentWillUnmount() { - if (this._onKeyDown) { - document.removeEventListener("keydown", this._onKeyDown); - } - } - - private _onKeyDown: ((e: KeyboardEvent) => void) | null = null; - - private _clampToViewport() { - const el = this._menuRef.current; - if (!el) { - return; - } - const rect = el.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - if (rect.right > vw) { - el.style.left = `${Math.max(0, vw - rect.width - 4)}px`; - } - if (rect.bottom > vh) { - el.style.top = `${Math.max(0, vh - rect.height - 4)}px`; - } - } - - /** @internal */ - override render() { - return ( -
this.props.onClose()} onContextMenu={(e) => e.preventDefault()}> -
e.stopPropagation()}> - {this.props.items.map((entry, idx) => { - if (IsSeparator(entry)) { - return
; - } - const item = entry; - return ( -
{ - if (!item.disabled) { - item.action(); - this.props.onClose(); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - if (!item.disabled) { - item.action(); - this.props.onClose(); - } - } - }} - > - {item.label} - {item.shortcut && {item.shortcut}} -
- ); - })} -
-
- ); - } -} diff --git a/packages/tools/flowGraphEditor/src/components/graphControls/graphControls.scss b/packages/tools/flowGraphEditor/src/components/graphControls/graphControls.scss deleted file mode 100644 index 561f7e16c0b..00000000000 --- a/packages/tools/flowGraphEditor/src/components/graphControls/graphControls.scss +++ /dev/null @@ -1,352 +0,0 @@ -.fge-graph-controls { - display: flex; - flex-direction: row; - align-items: center; - gap: 4px; - padding: 4px 8px; - background: #2a2a2a; - border-bottom: 1px solid #555; - flex-shrink: 0; - height: 36px; - box-sizing: border-box; - - .fge-ctrl-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 26px; - background: #464646; - color: #ccc; - border: 1px solid #666; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - line-height: 1; - transition: - background 0.15s, - color 0.15s, - border-color 0.15s; - - &:hover:not(:disabled) { - background: #555; - color: white; - border-color: #888; - } - - &:active:not(:disabled) { - background: #666; - } - - &:disabled { - opacity: 0.35; - cursor: not-allowed; - } - } - - .fge-ctrl-start:not(:disabled) { - color: rgb(51, 183, 102); - border-color: rgb(51, 183, 102); - - &:hover { - background: rgba(51, 183, 102, 0.2); - } - } - - .fge-ctrl-undo:not(:disabled), - .fge-ctrl-redo:not(:disabled) { - color: rgb(180, 180, 180); - border-color: rgb(120, 120, 120); - - &:hover { - background: rgba(180, 180, 180, 0.15); - } - } - - .fge-ctrl-pause:not(:disabled) { - color: rgb(220, 180, 60); - border-color: rgb(220, 180, 60); - - &:hover { - background: rgba(220, 180, 60, 0.2); - } - } - - .fge-ctrl-stop:not(:disabled) { - color: rgb(220, 80, 60); - border-color: rgb(220, 80, 60); - - &:hover { - background: rgba(220, 80, 60, 0.2); - } - } - - .fge-ctrl-reset:not(:disabled) { - color: rgb(100, 160, 220); - border-color: rgb(100, 160, 220); - - &:hover { - background: rgba(100, 160, 220, 0.2); - } - } - - .fge-ctrl-state { - margin-left: 8px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.03em; - text-transform: uppercase; - - &.state-stopped { - color: #888; - } - - &.state-running { - color: rgb(51, 183, 102); - } - - &.state-paused { - color: rgb(220, 180, 60); - } - - &.state-breakpoint { - color: rgb(231, 76, 60); - animation: breakpoint-pulse 1s ease-in-out infinite; - } - } - - .fge-ctrl-continue:not(:disabled) { - color: rgb(51, 183, 102); - border-color: rgb(51, 183, 102); - font-size: 11px; - - &:hover { - background: rgba(51, 183, 102, 0.2); - } - } - - .fge-ctrl-step:not(:disabled) { - color: rgb(100, 160, 220); - border-color: rgb(100, 160, 220); - font-size: 11px; - - &:hover { - background: rgba(100, 160, 220, 0.2); - } - } - - .fge-ctrl-separator { - width: 1px; - height: 20px; - background: #555; - margin: 0 4px; - } - - .fge-ctrl-debug { - color: #666; - border-color: #555; - opacity: 0.6; - - &.active { - color: rgb(180, 130, 255); - border-color: rgb(180, 130, 255); - background: rgba(180, 130, 255, 0.15); - opacity: 1; - box-shadow: 0 2px 0 0 rgb(180, 130, 255); - } - - &:not(.active):hover { - opacity: 0.85; - background: rgba(180, 130, 255, 0.1); - } - } - - .fge-ctrl-validate { - color: rgb(100, 200, 120); - border-color: rgb(100, 200, 120); - - &:hover:not(:disabled) { - background: rgba(100, 200, 120, 0.2); - } - } - - .fge-ctrl-live-validate { - color: #666; - border-color: #555; - opacity: 0.6; - - &.active { - color: rgb(243, 156, 18); - border-color: rgb(243, 156, 18); - background: rgba(243, 156, 18, 0.15); - opacity: 1; - box-shadow: 0 2px 0 0 rgb(243, 156, 18); - } - - &:not(.active):hover { - opacity: 0.85; - background: rgba(243, 156, 18, 0.1); - } - } - - .fge-validation-summary { - margin-left: 4px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.03em; - padding: 2px 6px; - border-radius: 3px; - - &.error { - color: #e74c3c; - background: rgba(231, 76, 60, 0.15); - } - - &.warning { - color: #f39c12; - background: rgba(243, 156, 18, 0.15); - } - } - - .fge-ctrl-help:not(:disabled) { - color: rgb(100, 180, 220); - border-color: rgb(100, 180, 220); - font-weight: 700; - - &:hover { - background: rgba(100, 180, 220, 0.2); - } - } - - .fge-time-scale { - display: flex; - align-items: center; - gap: 3px; - } - - .fge-time-scale-label { - font-size: 10px; - font-weight: 600; - color: #999; - text-transform: uppercase; - letter-spacing: 0.04em; - margin-right: 2px; - } - - .fge-time-scale-btn { - width: auto !important; - padding: 0 5px; - font-size: 11px !important; - font-weight: 600; - color: #666; - opacity: 0.6; - - &.active { - color: rgb(243, 156, 18); - border-color: rgb(243, 156, 18); - background: rgba(243, 156, 18, 0.15); - opacity: 1; - box-shadow: 0 2px 0 0 rgb(243, 156, 18); - } - - &:not(.active):hover { - opacity: 0.85; - background: rgba(243, 156, 18, 0.1); - } - } - .fge-context-selector { - display: flex; - align-items: center; - gap: 3px; - } - - .fge-context-label { - font-size: 10px; - font-weight: 600; - color: #999; - text-transform: uppercase; - letter-spacing: 0.04em; - margin-right: 2px; - } - - .fge-context-dropdown { - height: 24px; - min-width: 90px; - max-width: 150px; - padding: 0 4px; - font-size: 11px; - font-weight: 500; - color: #ddd; - background: #3a3a3a; - border: 1px solid #666; - border-radius: 4px; - cursor: pointer; - outline: none; - - &:hover { - border-color: #888; - } - - &:focus { - border-color: rgb(100, 160, 220); - } - - option { - background: #2a2a2a; - color: #ddd; - } - } - - .fge-ctx-add:not(:disabled) { - color: rgb(51, 183, 102); - border-color: rgb(51, 183, 102); - font-size: 16px; - font-weight: 700; - - &:hover { - background: rgba(51, 183, 102, 0.2); - } - } - - .fge-ctx-remove:not(:disabled) { - color: rgb(220, 80, 60); - border-color: rgb(220, 80, 60); - font-size: 16px; - font-weight: 700; - - &:hover { - background: rgba(220, 80, 60, 0.2); - } - } - - .fge-ctx-rename:not(:disabled) { - color: rgb(180, 180, 180); - border-color: rgb(120, 120, 120); - - &:hover { - background: rgba(180, 180, 180, 0.15); - } - } - - .fge-ctx-rename-input { - height: 22px; - width: 100px; - padding: 0 4px; - font-size: 11px; - color: #ddd; - background: #3a3a3a; - border: 1px solid rgb(100, 160, 220); - border-radius: 3px; - outline: none; - } -} - -@keyframes breakpoint-pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} diff --git a/packages/tools/flowGraphEditor/src/components/graphControls/graphControlsComponent.tsx b/packages/tools/flowGraphEditor/src/components/graphControls/graphControlsComponent.tsx index 0a2b578cdd2..91faf01b923 100644 --- a/packages/tools/flowGraphEditor/src/components/graphControls/graphControlsComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/graphControls/graphControlsComponent.tsx @@ -1,516 +1,577 @@ -import * as React from "react"; -import { type Nullable } from "core/types"; -import { type Observer } from "core/Misc/observable"; -import { FlowGraphState } from "core/FlowGraph/flowGraph"; -import { type IFlowGraphPendingActivation } from "core/FlowGraph/flowGraphContext"; -import { type GlobalState } from "../../globalState"; -import { LogEntry } from "../log/logComponent"; -import { type IFlowGraphValidationResult, FlowGraphValidationSeverity } from "core/FlowGraph/flowGraphValidator"; - -import "./graphControls.scss"; - -interface IGraphControlsProps { - globalState: GlobalState; -} - -interface IGraphControlsState { - graphState: FlowGraphState; - debugMode: boolean; - liveValidation: boolean; - validationResult: Nullable; - breakpointPaused: boolean; - timeScale: number; - contextList: Array<{ index: number; uniqueId: string; name: string }>; - selectedContextIndex: number; - editingContextIndex: number | null; - editingContextName: string; -} - -/** - * Toolbar component that provides Start / Pause / Stop / Reset controls for the flow graph. - */ -export class GraphControlsComponent extends React.Component { - private _stateObserver: Nullable> = null; - private _builtObserver: Nullable> = null; - private _debugModeObserver: Nullable> = null; - private _liveValidationObserver: Nullable> = null; - private _validationResultObserver: Nullable>> = null; - private _breakpointHitObserver: Nullable> = null; - private _timeScaleObserver: Nullable> = null; - private _contextListObserver: Nullable> = null; - private _selectedContextObserver: Nullable> = null; - - constructor(props: IGraphControlsProps) { - super(props); - this.state = { - graphState: props.globalState.flowGraph.state, - debugMode: props.globalState.isDebugMode, - liveValidation: props.globalState.liveValidation, - validationResult: props.globalState.validationResult, - breakpointPaused: false, - timeScale: props.globalState.timeScale, - contextList: props.globalState.getContextList(), - selectedContextIndex: props.globalState.selectedContextIndex, - editingContextIndex: null, - editingContextName: "", - }; - } - - override componentDidMount() { - this._subscribeToFlowGraph(); - - // When a new graph is loaded (deserialized), the flowGraph reference on - // globalState is replaced. Re-subscribe so we track the *new* graph's state. - this._builtObserver = this.props.globalState.onBuiltObservable.add(() => { - this._subscribeToFlowGraph(); - this.setState({ contextList: this.props.globalState.getContextList(), selectedContextIndex: this.props.globalState.selectedContextIndex }); - }); - - this._debugModeObserver = this.props.globalState.onDebugModeChanged.add((debugMode) => { - this.setState({ debugMode }); - }); - - this._liveValidationObserver = this.props.globalState.onLiveValidationChanged.add((liveValidation) => { - this.setState({ liveValidation }); - }); - - this._validationResultObserver = this.props.globalState.onValidationResultChanged.add((validationResult) => { - this.setState({ validationResult }); - }); - - this._breakpointHitObserver = this.props.globalState.onBreakpointHit.add((activation) => { - this.setState({ breakpointPaused: true }); - this.props.globalState.onLogRequiredObservable.notifyObservers( - new LogEntry(`Breakpoint hit: ${activation.block.getClassName()} (${activation.block.name ?? activation.block.uniqueId})`, false) - ); - }); - - this._timeScaleObserver = this.props.globalState.onTimeScaleChanged.add((timeScale) => { - this.setState({ timeScale }); - }); - - this._contextListObserver = this.props.globalState.onContextListChanged.add(() => { - this.setState({ contextList: this.props.globalState.getContextList() }); - }); - - this._selectedContextObserver = this.props.globalState.onSelectedContextChanged.add((index) => { - this.setState({ selectedContextIndex: index }); - }); - } - - override componentWillUnmount() { - this._stateObserver?.remove(); - this._stateObserver = null; - this._builtObserver?.remove(); - this._builtObserver = null; - this._debugModeObserver?.remove(); - this._debugModeObserver = null; - this._liveValidationObserver?.remove(); - this._liveValidationObserver = null; - this._validationResultObserver?.remove(); - this._validationResultObserver = null; - this._breakpointHitObserver?.remove(); - this._breakpointHitObserver = null; - this._timeScaleObserver?.remove(); - this._timeScaleObserver = null; - this._contextListObserver?.remove(); - this._contextListObserver = null; - this._selectedContextObserver?.remove(); - this._selectedContextObserver = null; - } - - /** - * (Re-)subscribe to the current flowGraph's onStateChangedObservable and - * sync the component state with the graph's current state. - */ - private _subscribeToFlowGraph() { - // Remove previous subscription (may point to an old FlowGraph instance) - this._stateObserver?.remove(); - this._stateObserver = null; - - const flowGraph = this.props.globalState.flowGraph; - if (!flowGraph) { - return; - } - - this._stateObserver = flowGraph.onStateChangedObservable.add((newState) => { - // When the graph stops or is paused externally, clear the breakpoint-paused state - if (newState === FlowGraphState.Stopped || newState === FlowGraphState.Paused) { - this.setState({ graphState: newState, breakpointPaused: false, contextList: this.props.globalState.getContextList() }); - } else { - this.setState({ graphState: newState, contextList: this.props.globalState.getContextList() }); - } - }); - - // Sync immediately – the new graph is likely in Stopped state - this.setState({ graphState: flowGraph.state }); - } - - private _log(message: string) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry(message, false)); - } - - private _onStart() { - try { - // Wire the flow graph to the preview scene so events (pick, tick, etc.) - // fire on the visible scene, not the editor's hidden host scene. - // setScene() clears stale execution contexts so start() creates - // a fresh one with the correct scene. - const previewScene = this.props.globalState.sceneContext?.scene; - if (previewScene) { - this.props.globalState.flowGraph.setScene(previewScene); - } - this.props.globalState.flowGraph.start(); - this._log("Flow graph started."); - } catch (err) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error starting graph: ${err}`, true)); - } - } - - private _onPause() { - try { - this.props.globalState.flowGraph.pause(); - this._log("Flow graph paused."); - } catch (err) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error pausing graph: ${err}`, true)); - } - } - - private _onStop() { - try { - // Snapshot user variables before stop() clears execution contexts - this.props.globalState.snapshotUserVariables(); - this.props.globalState.flowGraph.stop(); - this.setState({ breakpointPaused: false }); - this._log("Flow graph stopped."); - } catch (err) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error stopping graph: ${err}`, true)); - } - } - - private _onContinue() { - try { - this.props.globalState.continueExecution(); - this.setState({ breakpointPaused: false }); - this._log("Resuming from breakpoint."); - } catch (err) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error continuing: ${err}`, true)); - } - } - - private _onStep() { - try { - this.setState({ breakpointPaused: false }); - this.props.globalState.stepExecution(); - // stepExecution is synchronous — if another breakpoint was hit, the - // observer will have already fired and set breakpointPaused back to true. - } catch (err) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error stepping: ${err}`, true)); - } - } - - private async _onResetAsync() { - try { - this.props.globalState.flowGraph.stop(); - - // If a scene was loaded from a snippet, reload it - if (this.props.globalState.snippetId && this.props.globalState.sceneContext) { - this._log("Reloading scene snippet..."); - - // Wait for the scene context to be rebuilt after the snippet reloads - const sceneContextReady = new Promise((resolve, reject) => { - const observer = this.props.globalState.onSceneContextChanged.add((ctx) => { - this.props.globalState.onSceneContextChanged.remove(observer); - if (ctx) { - resolve(); - } else { - reject(new Error("Snippet reload failed")); - } - }); - - // Safety timeout so the reset never hangs indefinitely - setTimeout(() => { - this.props.globalState.onSceneContextChanged.remove(observer); - reject(new Error("Snippet reload timed out")); - }, 30_000); - }); - - // Request the snippet reload - this.props.globalState.onReloadSnippetRequested.notifyObservers(); - - // Wait for the new scene context to arrive - await sceneContextReady; - - // Wait for all assets in the new scene to finish loading - const scene = this.props.globalState.sceneContext!.scene; - if (!scene.isReady(true)) { - this._log("Waiting for scene assets to load..."); - await scene.whenReadyAsync(true); - } - } - - this._log("Flow graph reset. Press Start to run."); - } catch (err) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error resetting graph: ${err}`, true)); - } - } - - private _commitContextRename() { - const { editingContextIndex, editingContextName } = this.state; - if (editingContextIndex === null) { - return; - } - const trimmed = editingContextName.trim(); - if (trimmed) { - this.props.globalState.renameContext(editingContextIndex, trimmed); - } - this.setState({ editingContextIndex: null, editingContextName: "" }); - } - - private _renderContextSelector(): React.ReactNode { - const { contextList, selectedContextIndex, editingContextIndex, editingContextName } = this.state; - - return ( -
- Ctx - - - - {editingContextIndex !== null ? ( - this.setState({ editingContextName: e.target.value })} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - this._commitContextRename(); - } else if (e.key === "Escape") { - this.setState({ editingContextIndex: null, editingContextName: "" }); - } - }} - onBlur={() => this._commitContextRename()} - /> - ) : ( - - )} -
- ); - } - - private _renderValidationSummary(): React.ReactNode { - const result = this.state.validationResult; - if (!result || result.issues.length === 0) { - return null; - } - const hasErrors = result.errorCount > 0; - const cls = hasErrors ? "fge-validation-summary error" : "fge-validation-summary warning"; - const label = hasErrors ? `${result.errorCount}E ${result.warningCount}W` : `${result.warningCount}W`; - return ( - - {label} - - ); - } - - override render() { - const { graphState, breakpointPaused } = this.state; - const isStopped = graphState === FlowGraphState.Stopped; - const isStarted = graphState === FlowGraphState.Started; - const isPaused = graphState === FlowGraphState.Paused; - - const canStart = isStopped || isPaused; - const canPause = isStarted; - const canStop = isStarted || isPaused; - const canReset = true; // Always available — reloads the scene and stops the graph - const canContinue = breakpointPaused; - const canStep = breakpointPaused; - - const stateLabel = breakpointPaused ? "Breakpoint" : isStopped ? "Stopped" : isStarted ? "Running" : "Paused"; - const stateCls = breakpointPaused ? "state-breakpoint" : isStopped ? "state-stopped" : isStarted ? "state-running" : "state-paused"; - - return ( -
- - - - - - - - - - {stateLabel} - - {this._renderContextSelector()} - - - - - - {this._renderValidationSummary()} - -
- Speed - {[0.1, 0.25, 0.5, 1].map((s) => ( - - ))} -
- - - -
- ); - } -} +import { type FunctionComponent, type ReactNode, useCallback, useEffect, useRef, useState } from "react"; + +import { Body1, Caption1, Button, Divider, Dropdown, Input, Option, Tooltip, makeStyles, mergeClasses, tokens } from "@fluentui/react-components"; +import { + AddRegular, + ArrowRedoRegular, + ArrowResetRegular, + ArrowUndoRegular, + BugRegular, + CheckmarkRegular, + EditRegular, + FastForwardRegular, + FlashRegular, + NextRegular, + PauseRegular, + PlayRegular, + StopRegular, + SubtractRegular, +} from "@fluentui/react-icons"; + +import { type Nullable } from "core/types"; +import { FlowGraphState } from "core/FlowGraph/flowGraph"; +import { type IFlowGraphValidationResult, FlowGraphValidationSeverity } from "core/FlowGraph/flowGraphValidator"; + +import { type GlobalState } from "../../globalState"; +import { LogEntry } from "../log/logComponent"; + +interface IGraphControlsProps { + globalState: GlobalState; +} + +const useStyles = makeStyles({ + bar: { + display: "flex", + flexDirection: "row", + alignItems: "center", + // Use rowGap so that when items wrap onto a second row the rows are spaced sensibly. + columnGap: tokens.spacingHorizontalXS, + rowGap: tokens.spacingVerticalXXS, + flexShrink: 0, + boxSizing: "border-box", + flexWrap: "wrap", + }, + separator: { + // Fluent's `Divider` defaults to `flex-grow: 1`, which is fine in fixed-width toolbars + // (see inspector-v2's curve editor topBar). Our toolbar has `flex-wrap: wrap` so that + // slack does exist on the row - without `flexGrow: 0` each divider would expand to + // consume it. Width and height pin the visible line. + flexGrow: 0, + width: "1px", + height: "20px", + margin: `0 ${tokens.spacingHorizontalXS}`, + }, + label: { + fontSize: tokens.fontSizeBase100, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground3, + textTransform: "uppercase", + letterSpacing: "0.04em", + marginRight: tokens.spacingHorizontalXXS, + }, + state: { + marginLeft: tokens.spacingHorizontalS, + fontSize: tokens.fontSizeBase100, + fontWeight: tokens.fontWeightSemibold, + letterSpacing: "0.03em", + textTransform: "uppercase", + }, + stateStack: { + // CSS grid stack: all four labels share grid cell 1/1 so the container's intrinsic + // width equals the widest label. Only the currently-active label is visible - the + // others reserve the same space with `visibility: hidden`. This eliminates horizontal + // shift of subsequent toolbar items as the graph state changes. + display: "inline-grid", + marginLeft: tokens.spacingHorizontalS, + }, + stateStackChild: { + gridArea: "1 / 1", + marginLeft: 0, + }, + stateHidden: { + visibility: "hidden", + }, + stateStopped: { color: tokens.colorNeutralForeground3 }, + stateRunning: { color: tokens.colorPaletteGreenForeground1 }, + statePaused: { color: tokens.colorPaletteYellowForeground1 }, + stateBreakpoint: { + color: tokens.colorPaletteRedForeground1, + animationName: { from: { opacity: 1 }, to: { opacity: 0.5 } }, + animationDuration: "1s", + animationIterationCount: "infinite", + animationDirection: "alternate", + }, + validationSummary: { + marginLeft: tokens.spacingHorizontalXS, + fontSize: tokens.fontSizeBase100, + fontWeight: tokens.fontWeightSemibold, + letterSpacing: "0.03em", + padding: `2px ${tokens.spacingHorizontalXS}`, + borderRadius: tokens.borderRadiusSmall, + }, + validationSummaryError: { + color: tokens.colorPaletteRedForeground1, + background: tokens.colorPaletteRedBackground2, + }, + validationSummaryWarning: { + color: tokens.colorPaletteYellowForeground1, + background: tokens.colorPaletteYellowBackground2, + }, + contextGroup: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalXXS, + }, + contextDropdown: { + minWidth: "120px", + maxWidth: "160px", + }, + contextRenameInput: { width: "120px" }, + timeScale: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalXXS, + }, + speedButton: { + // Fluent's Button has a baseline min-width that makes short labels like "0.1x" much + // wider than they need to be. Collapse to the content's intrinsic width with a small + // pad so all four presets fit comfortably and don't overflow the toolbar. + minWidth: "auto", + paddingLeft: tokens.spacingHorizontalXS, + paddingRight: tokens.spacingHorizontalXS, + }, +}); + +const SpeedPresets = [0.1, 0.25, 0.5, 1] as const; + +/** + * Toolbar component that provides Start / Pause / Stop / Reset controls for the flow graph. + * + * Help and How-to-use buttons have moved to the shell's bottom-right toolbar (registered + * by `toolbarService.tsx`). Everything else stays in this in-canvas controls bar. + * @param props - The component props. + * @returns The rendered controls toolbar. + */ +export const GraphControlsComponent: FunctionComponent = (props) => { + const { globalState } = props; + const classes = useStyles(); + + const [graphState, setGraphState] = useState(globalState.flowGraph.state); + const [debugMode, setDebugMode] = useState(globalState.isDebugMode); + const [liveValidation, setLiveValidation] = useState(globalState.liveValidation); + const [validationResult, setValidationResult] = useState>(globalState.validationResult); + const [breakpointPaused, setBreakpointPaused] = useState(false); + const [timeScale, setTimeScale] = useState(globalState.timeScale); + const [contextList, setContextList] = useState(globalState.getContextList()); + const [selectedContextIndex, setSelectedContextIndex] = useState(globalState.selectedContextIndex); + const [editingContextIndex, setEditingContextIndex] = useState(null); + const [editingContextName, setEditingContextName] = useState(""); + const [, forceUpdate] = useState({}); + + // Re-subscribe to the active flow graph's state observable whenever the graph reference + // is replaced (e.g. after deserialization). + const stateObserverRef = useRef<{ remove: () => void } | null>(null); + useEffect(() => { + const subscribeToFlowGraph = () => { + stateObserverRef.current?.remove(); + const flowGraph = globalState.flowGraph; + if (!flowGraph) { + return; + } + const obs = flowGraph.onStateChangedObservable.add((newState) => { + if (newState === FlowGraphState.Stopped || newState === FlowGraphState.Paused) { + setGraphState(newState); + setBreakpointPaused(false); + } else { + setGraphState(newState); + } + setContextList(globalState.getContextList()); + }); + stateObserverRef.current = { remove: () => obs?.remove() }; + setGraphState(flowGraph.state); + }; + + subscribeToFlowGraph(); + const builtObs = globalState.onBuiltObservable.add(() => { + subscribeToFlowGraph(); + setContextList(globalState.getContextList()); + setSelectedContextIndex(globalState.selectedContextIndex); + }); + const debugObs = globalState.onDebugModeChanged.add((m) => setDebugMode(m)); + const liveObs = globalState.onLiveValidationChanged.add((l) => setLiveValidation(l)); + const validationObs = globalState.onValidationResultChanged.add((r) => setValidationResult(r)); + const breakpointObs = globalState.onBreakpointHit.add((activation) => { + setBreakpointPaused(true); + globalState.onLogRequiredObservable.notifyObservers( + new LogEntry(`Breakpoint hit: ${activation.block.getClassName()} (${activation.block.name ?? activation.block.uniqueId})`, false) + ); + }); + const timeScaleObs = globalState.onTimeScaleChanged.add((t) => setTimeScale(t)); + const contextListObs = globalState.onContextListChanged.add(() => setContextList(globalState.getContextList())); + const selectedContextObs = globalState.onSelectedContextChanged.add((index) => setSelectedContextIndex(index)); + + return () => { + stateObserverRef.current?.remove(); + stateObserverRef.current = null; + builtObs?.remove(); + debugObs?.remove(); + liveObs?.remove(); + validationObs?.remove(); + breakpointObs?.remove(); + timeScaleObs?.remove(); + contextListObs?.remove(); + selectedContextObs?.remove(); + }; + }, [globalState]); + + const log = useCallback( + (message: string) => { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(message, false)); + }, + [globalState] + ); + + const onStart = useCallback(() => { + try { + // Wire the flow graph to the preview scene so events fire on the visible scene. + const previewScene = globalState.sceneContext?.scene; + if (previewScene) { + globalState.snapshotUserVariables(); + globalState.flowGraph.setScene(previewScene); + globalState.restoreSavedContexts(); + const inputElement = previewScene.getEngine().getInputElement(); + inputElement?.focus(); + } + globalState.flowGraph.start(); + log("Flow graph started."); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error starting graph: ${err}`, true)); + } + }, [globalState, log]); + + const onPause = useCallback(() => { + try { + globalState.flowGraph.pause(); + log("Flow graph paused."); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error pausing graph: ${err}`, true)); + } + }, [globalState, log]); + + const onStop = useCallback(() => { + try { + globalState.snapshotUserVariables(); + globalState.flowGraph.stop(); + globalState.restoreSavedContexts(); + setBreakpointPaused(false); + log("Flow graph stopped."); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error stopping graph: ${err}`, true)); + } + }, [globalState, log]); + + const onContinue = useCallback(() => { + try { + globalState.continueExecution(); + setBreakpointPaused(false); + log("Resuming from breakpoint."); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error continuing: ${err}`, true)); + } + }, [globalState, log]); + + const onStep = useCallback(() => { + try { + setBreakpointPaused(false); + globalState.stepExecution(); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error stepping: ${err}`, true)); + } + }, [globalState]); + + const onResetAsync = useCallback(async () => { + try { + globalState.snapshotUserVariables(); + globalState.flowGraph.stop(); + globalState.restoreSavedContexts(); + const canReloadScene = globalState.sceneSource === "snippet" || globalState.sceneSource === "default" || (!globalState.sceneSource && !globalState.snippetId); + if (canReloadScene && globalState.sceneContext) { + log(globalState.sceneSource === "snippet" ? "Reloading scene snippet..." : "Recreating default scene..."); + const sceneContextReady = new Promise((resolve, reject) => { + const observer = globalState.onSceneContextChanged.add((ctx) => { + globalState.onSceneContextChanged.remove(observer); + if (ctx) { + resolve(); + } else { + reject(new Error("Snippet reload failed")); + } + }); + setTimeout(() => { + globalState.onSceneContextChanged.remove(observer); + reject(new Error("Scene reload timed out")); + }, 30_000); + }); + globalState.onReloadSnippetRequested.notifyObservers(); + await sceneContextReady; + const scene = globalState.sceneContext!.scene; + if (!scene.isReady(true)) { + log("Waiting for scene assets to load..."); + await scene.whenReadyAsync(true); + } + } + log("Flow graph reset. Press Start to run."); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Error resetting graph: ${err}`, true)); + } + }, [globalState, log]); + + const commitContextRename = useCallback(() => { + if (editingContextIndex === null) { + return; + } + const trimmed = editingContextName.trim(); + if (trimmed) { + globalState.renameContext(editingContextIndex, trimmed); + } + setEditingContextIndex(null); + setEditingContextName(""); + }, [editingContextIndex, editingContextName, globalState]); + + const onValidate = useCallback(() => { + globalState.runValidation(); + const result = globalState.validationResult; + if (result && result.issues.length > 0) { + const errorStr = result.errorCount > 0 ? `${result.errorCount} error(s)` : ""; + const warnStr = result.warningCount > 0 ? `${result.warningCount} warning(s)` : ""; + const parts = [errorStr, warnStr].filter(Boolean).join(", "); + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`Validation: ${parts}`, result.errorCount > 0)); + const maxIssues = 20; + for (let i = 0; i < Math.min(result.issues.length, maxIssues); i++) { + const issue = result.issues[i]; + const prefix = issue.severity === FlowGraphValidationSeverity.Error ? "[Error]" : "[Warn]"; + const blockName = issue.block?.name ?? "Graph"; + globalState.onLogRequiredObservable.notifyObservers( + new LogEntry(` ${prefix} ${blockName}: ${issue.message}`, issue.severity === FlowGraphValidationSeverity.Error, issue.block) + ); + } + if (result.issues.length > maxIssues) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(` ... and ${result.issues.length - maxIssues} more issue(s).`, false)); + } + } else { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Validation passed - no issues found.", false)); + } + }, [globalState]); + + const isStopped = graphState === FlowGraphState.Stopped; + const isStarted = graphState === FlowGraphState.Started; + const isPaused = graphState === FlowGraphState.Paused; + + const canStart = isStopped || isPaused; + const canPause = isStarted; + const canStop = isStarted || isPaused; + const canContinue = breakpointPaused; + const canStep = breakpointPaused; + + // Render all four state labels stacked in the same grid cell so the container reserves + // space for the widest one ("Breakpoint"). Only the active label is visible; the others are + // visibility: hidden so they still contribute to the cell's intrinsic width and prevent the + // surrounding toolbar items from shifting horizontally as state transitions happen. + const activeStateKey = breakpointPaused ? "Breakpoint" : isStopped ? "Stopped" : isStarted ? "Running" : "Paused"; + const stateEntries: { key: string; label: string; className: string }[] = [ + { key: "Stopped", label: "Stopped", className: classes.stateStopped }, + { key: "Running", label: "Running", className: classes.stateRunning }, + { key: "Paused", label: "Paused", className: classes.statePaused }, + { key: "Breakpoint", label: "Breakpoint", className: classes.stateBreakpoint }, + ]; + + const validationSummary: ReactNode = (() => { + if (!validationResult || validationResult.issues.length === 0) { + return null; + } + const hasErrors = validationResult.errorCount > 0; + const cls = hasErrors ? classes.validationSummaryError : classes.validationSummaryWarning; + const label = hasErrors ? `${validationResult.errorCount}E ${validationResult.warningCount}W` : `${validationResult.warningCount}W`; + return ( + + {label} + + ); + })(); + + const selectedContext = contextList.find((c) => c.index === selectedContextIndex); + + return ( +
+ +
+ + + + + ))} +
+
+ ); +}; diff --git a/packages/tools/flowGraphEditor/src/components/graphTabBar/graphTabBar.scss b/packages/tools/flowGraphEditor/src/components/graphTabBar/graphTabBar.scss deleted file mode 100644 index 4ccb9e032ac..00000000000 --- a/packages/tools/flowGraphEditor/src/components/graphTabBar/graphTabBar.scss +++ /dev/null @@ -1,124 +0,0 @@ -.fge-graph-tab-bar { - display: flex; - flex-direction: row; - align-items: center; - background: #1e1e1e; - border-bottom: 1px solid #555; - flex-shrink: 0; - height: 32px; - box-sizing: border-box; - overflow: hidden; - - .fge-tab-scroll-area { - display: flex; - flex-direction: row; - align-items: stretch; - overflow-x: auto; - flex: 1; - scrollbar-width: none; /* Firefox */ - - &::-webkit-scrollbar { - display: none; /* Chrome/Safari */ - } - } - - .fge-tab { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 0 12px; - height: 32px; - background: #2a2a2a; - color: #aaa; - border: none; - border-right: 1px solid #1e1e1e; - cursor: pointer; - font-size: 12px; - white-space: nowrap; - flex-shrink: 0; - transition: - background 0.15s, - color 0.15s; - - &:hover { - background: #333; - color: #ddd; - } - - &.fge-tab-active { - background: #3a3a3a; - color: white; - border-bottom: 2px solid #569cd6; - } - - .fge-tab-name { - pointer-events: none; - } - - .fge-tab-name-input { - background: #1e1e1e; - color: white; - border: 1px solid #569cd6; - border-radius: 2px; - font-size: 12px; - padding: 1px 4px; - width: 80px; - outline: none; - } - - .fge-tab-close { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border: none; - background: transparent; - color: #888; - cursor: pointer; - font-size: 12px; - border-radius: 2px; - padding: 0; - line-height: 1; - opacity: 0; - transition: - opacity 0.15s, - background 0.15s, - color 0.15s; - - &:hover { - background: rgba(255, 255, 255, 0.1); - color: #fff; - } - } - - &:hover .fge-tab-close, - &.fge-tab-active .fge-tab-close { - opacity: 1; - } - } - - .fge-tab-add { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - background: transparent; - color: #888; - border: none; - cursor: pointer; - font-size: 16px; - flex-shrink: 0; - border-radius: 4px; - margin: 0 4px; - transition: - background 0.15s, - color 0.15s; - - &:hover { - background: rgba(255, 255, 255, 0.1); - color: #ccc; - } - } -} diff --git a/packages/tools/flowGraphEditor/src/components/graphTabBar/graphTabBarComponent.tsx b/packages/tools/flowGraphEditor/src/components/graphTabBar/graphTabBarComponent.tsx index 85dd056fccb..53d1b3c719a 100644 --- a/packages/tools/flowGraphEditor/src/components/graphTabBar/graphTabBarComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/graphTabBar/graphTabBarComponent.tsx @@ -1,179 +1,213 @@ -import * as React from "react"; -import { type Nullable } from "core/types"; -import { type Observer } from "core/Misc/observable"; -import { type FlowGraph } from "core/FlowGraph/flowGraph"; -import { type GlobalState } from "../../globalState"; - -import "./graphTabBar.scss"; - -interface IGraphTabBarProps { - globalState: GlobalState; -} - -interface IGraphTabBarState { - graphs: Array<{ name: string; uniqueId: string }>; - activeIndex: number; - editingIndex: number | null; - editingName: string; -} - -/** - * Tab bar component for switching between multiple flow graphs in the coordinator. - */ -export class GraphTabBarComponent extends React.Component { - private _graphListObserver: Nullable> = null; - private _activeGraphObserver: Nullable> = null; - private _scrollRef = React.createRef(); - - constructor(props: IGraphTabBarProps) { - super(props); - this.state = { - graphs: this._getGraphList(), - activeIndex: props.globalState.activeGraphIndex, - editingIndex: null, - editingName: "", - }; - } - - private _getGraphList(): Array<{ name: string; uniqueId: string }> { - const coordinator = this.props.globalState.coordinator; - if (!coordinator) { - return []; - } - return coordinator.flowGraphs.map((g) => ({ name: g.name, uniqueId: g.uniqueId })); - } - - override componentDidMount() { - this._graphListObserver = this.props.globalState.onGraphListChanged.add(() => { - this.setState({ graphs: this._getGraphList() }); - }); - this._activeGraphObserver = this.props.globalState.onActiveGraphChanged.add(() => { - this.setState({ - activeIndex: this.props.globalState.activeGraphIndex, - graphs: this._getGraphList(), - }); - }); - } - - override componentWillUnmount() { - if (this._graphListObserver) { - this.props.globalState.onGraphListChanged.remove(this._graphListObserver); - } - if (this._activeGraphObserver) { - this.props.globalState.onActiveGraphChanged.remove(this._activeGraphObserver); - } - } - - private _onTabClick(index: number) { - if (index === this.state.activeIndex) { - return; - } - this.props.globalState.activeGraphIndex = index; - } - - private _onTabDoubleClick(index: number) { - const graph = this.state.graphs[index]; - if (!graph) { - return; - } - this.setState({ editingIndex: index, editingName: graph.name }); - } - - private _commitRename() { - const { editingIndex, editingName } = this.state; - if (editingIndex === null) { - return; - } - const trimmed = editingName.trim(); - if (trimmed) { - this.props.globalState.renameGraph(editingIndex, trimmed); - } - this.setState({ editingIndex: null, editingName: "" }); - } - - private _onAddGraph() { - this.props.globalState.addGraph(); - this.props.globalState.onResetRequiredObservable.notifyObservers(true); - this.props.globalState.onClearUndoStack.notifyObservers(); - } - - private _onCloseTab(index: number, evt: React.MouseEvent) { - evt.stopPropagation(); - if (this.state.graphs.length <= 1) { - return; - } - this.props.globalState.removeGraph(index); - this.props.globalState.onResetRequiredObservable.notifyObservers(true); - this.props.globalState.onClearUndoStack.notifyObservers(); - } - - override render() { - const { graphs, activeIndex, editingIndex, editingName } = this.state; - - if (graphs.length === 0) { - return null; - } - - return ( -
-
- {graphs.map((graph, index) => ( -
this._onTabClick(index)} - onDoubleClick={() => this._onTabDoubleClick(index)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - this._onTabClick(index); - } else if (e.key === "ArrowRight") { - e.preventDefault(); - const next = (index + 1) % graphs.length; - (e.currentTarget.parentElement?.children[next] as HTMLElement)?.focus(); - } else if (e.key === "ArrowLeft") { - e.preventDefault(); - const prev = (index - 1 + graphs.length) % graphs.length; - (e.currentTarget.parentElement?.children[prev] as HTMLElement)?.focus(); - } - }} - title={graph.name} - > - {editingIndex === index ? ( - this.setState({ editingName: e.target.value })} - onBlur={() => this._commitRename()} - onKeyDown={(e) => { - if (e.key === "Enter") { - this._commitRename(); - } else if (e.key === "Escape") { - this.setState({ editingIndex: null, editingName: "" }); - } - }} - autoFocus - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {graph.name} - )} - {graphs.length > 1 && ( - - )} -
- ))} -
- -
- ); - } -} +import { type FunctionComponent, type MouseEvent, useCallback, useEffect, useState } from "react"; + +import { Body1, Button, Input, Tab, TabList, Tooltip, makeStyles, mergeClasses, tokens } from "@fluentui/react-components"; +import { AddRegular, DismissRegular } from "@fluentui/react-icons"; + +import { type GlobalState } from "../../globalState"; + +interface IGraphTabBarProps { + globalState: GlobalState; +} + +const useStyles = makeStyles({ + bar: { + display: "flex", + flexDirection: "row", + alignItems: "center", + background: tokens.colorNeutralBackground3, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + flexShrink: 0, + boxSizing: "border-box", + overflow: "hidden", + }, + tabList: { + flex: 1, + overflowX: "auto", + // Hide horizontal scrollbar — the user navigates with arrows / clicks. + scrollbarWidth: "none", + "&::-webkit-scrollbar": { display: "none" }, + }, + tabContent: { + display: "inline-flex", + alignItems: "center", + gap: tokens.spacingHorizontalXS, + }, + closeButton: { + // Slim down the close button so it does not push the tab content too wide. + minWidth: "auto", + padding: 0, + opacity: 0, + transitionProperty: "opacity", + transitionDuration: tokens.durationFast, + }, + closeButtonVisible: { + opacity: 1, + }, + // When the tab is hovered, reveal the close button. + tabHover: { + ":hover .fge-tab-close": { + opacity: 1, + }, + }, + tabRenameInput: { + width: "120px", + }, + addButton: { + flexShrink: 0, + margin: `0 ${tokens.spacingHorizontalXS}`, + }, +}); + +/** + * Tab bar component for switching between multiple flow graphs in the coordinator. + * + * Built on Fluent's `TabList` for native keyboard navigation and roving-tabindex behaviour. + * Each `Tab` shows the graph name (or an inline rename `Input` when the user double-clicks) + * plus a close button. A trailing `+` button creates a new graph. + * @param props The component props. + * @returns The rendered tab bar. + */ +export const GraphTabBarComponent: FunctionComponent = (props) => { + const { globalState } = props; + const classes = useStyles(); + + const [graphs, setGraphs] = useState(() => globalState.coordinator?.flowGraphs.map((g) => ({ name: g.name, uniqueId: g.uniqueId })) ?? []); + const [activeIndex, setActiveIndex] = useState(globalState.activeGraphIndex); + const [editingIndex, setEditingIndex] = useState(null); + const [editingName, setEditingName] = useState(""); + + const refreshGraphs = useCallback(() => { + const list = globalState.coordinator?.flowGraphs.map((g) => ({ name: g.name, uniqueId: g.uniqueId })) ?? []; + setGraphs(list); + setActiveIndex(globalState.activeGraphIndex); + }, [globalState]); + + useEffect(() => { + const listObs = globalState.onGraphListChanged.add(refreshGraphs); + const activeObs = globalState.onActiveGraphChanged.add(refreshGraphs); + return () => { + listObs?.remove(); + activeObs?.remove(); + }; + }, [globalState, refreshGraphs]); + + const startEditing = useCallback( + (index: number) => { + const graph = graphs[index]; + if (!graph) { + return; + } + setEditingIndex(index); + setEditingName(graph.name); + }, + [graphs] + ); + + const commitRename = useCallback(() => { + if (editingIndex === null) { + return; + } + const trimmed = editingName.trim(); + if (trimmed) { + globalState.renameGraph(editingIndex, trimmed); + } + setEditingIndex(null); + setEditingName(""); + }, [editingIndex, editingName, globalState]); + + const cancelRename = useCallback(() => { + setEditingIndex(null); + setEditingName(""); + }, []); + + const onAddGraph = useCallback(() => { + globalState.addGraph(); + globalState.onClearUndoStack.notifyObservers(); + }, [globalState]); + + const onCloseTab = useCallback( + (index: number, evt: MouseEvent) => { + // Prevent the click from selecting the tab being closed. + evt.stopPropagation(); + if (graphs.length <= 1) { + return; + } + globalState.removeGraph(index); + globalState.onClearUndoStack.notifyObservers(); + }, + [graphs.length, globalState] + ); + + if (graphs.length === 0) { + return null; + } + + // TabList tracks selection by `value` strings — use the graph index as a stable string key. + return ( +
+ { + if (typeof data.value !== "string") { + return; + } + const index = parseInt(data.value, 10); + if (!Number.isNaN(index) && index !== globalState.activeGraphIndex) { + globalState.activeGraphIndex = index; + } + }} + > + {graphs.map((graph, index) => { + const isActive = index === activeIndex; + const isEditing = editingIndex === index; + return ( + startEditing(index)}> +
+ {isEditing ? ( + setEditingName(data.value)} + onBlur={commitRename} + onKeyDown={(e) => { + // Stop propagation so the keys don't reach TabList's keyboard handler. + e.stopPropagation(); + if (e.key === "Enter") { + commitRename(); + } else if (e.key === "Escape") { + cancelRename(); + } + }} + autoFocus + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {graph.name} + )} + {graphs.length > 1 && !isEditing && ( + +
+
+ ); + })} +
+ +
+ ); +}; diff --git a/packages/tools/flowGraphEditor/src/components/help/helpContent.ts b/packages/tools/flowGraphEditor/src/components/help/helpContent.ts index 7d94b91f4c5..7fcbd4062bc 100644 --- a/packages/tools/flowGraphEditor/src/components/help/helpContent.ts +++ b/packages/tools/flowGraphEditor/src/components/help/helpContent.ts @@ -85,7 +85,7 @@ export const HelpTopics: IHelpTopic[] = [ ▶StartStarts executing the flow graph. Enabled when the graph is stopped or paused. ⏸PausePauses execution. The graph can be resumed with Start. ⏹StopStops execution and resets execution state. -↺ResetStops execution and reloads the scene from its snippet (if one was loaded). +↺ResetStops execution and recreates the default scene or reloads the loaded snippet.

The state indicator next to the controls shows the current graph state: Stopped, Running, Paused, or Breakpoint.

`, }, @@ -456,19 +456,14 @@ export const HelpTopics: IHelpTopic[] = [ }, { id: "gltf-import-export", - title: "glTF Import / Export", + title: "glTF Import", sections: [ { heading: "Importing from glTF", html: `

Drop a .glb or .gltf file on the scene preview pane. If the file contains a KHR_interactivity extension, the flow graph is automatically loaded into the editor.

-

Files exported by this editor contain a BABYLON_flow_graph custom extension, which is also detected and imported on drop.

+

Files that contain a BABYLON_flow_graph custom extension are also detected and imported on drop.

Alternatively, use the Load glTF button in the FILE section to load only the flow graph (no scene).

`, }, - { - heading: "Exporting to glTF", - html: `

Click Export glTF (.glb) in the FILE section. The flow graph is embedded in the file as a BABYLON_flow_graph custom extension.

-

If a preview scene is loaded and the serializers package is available, the full scene + flow graph are exported together. Otherwise a minimal .glb containing only the flow graph data is created.

`, - }, ], }, { diff --git a/packages/tools/flowGraphEditor/src/components/help/helpDialog.scss b/packages/tools/flowGraphEditor/src/components/help/helpDialog.scss deleted file mode 100644 index 6602f2125ce..00000000000 --- a/packages/tools/flowGraphEditor/src/components/help/helpDialog.scss +++ /dev/null @@ -1,182 +0,0 @@ -.fge-help-overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - font-family: - "acumin-pro", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - sans-serif; -} - -.fge-help-dialog { - background: #2a2a2a; - border: 1px solid #555; - border-radius: 8px; - width: 640px; - max-width: 90%; - max-height: 80%; - display: flex; - flex-direction: column; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); -} - -.fge-help-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid #444; - flex-shrink: 0; - - h2 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: #e0e0e0; - } -} - -.fge-help-close { - background: none; - border: none; - color: #aaa; - font-size: 20px; - cursor: pointer; - padding: 0 4px; - line-height: 1; - - &:hover { - color: #fff; - } -} - -.fge-help-body { - overflow-y: auto; - padding: 8px 0; - flex: 1; -} - -.fge-help-topic { - border-bottom: 1px solid #333; - - &:last-child { - border-bottom: none; - } -} - -.fge-help-topic-header { - display: flex; - align-items: center; - padding: 10px 16px; - cursor: pointer; - color: #ccc; - font-size: 14px; - font-weight: 600; - user-select: none; - transition: background 0.15s; - - &:hover { - background: #333; - color: #fff; - } -} - -.fge-help-topic-arrow { - display: inline-block; - width: 16px; - font-size: 10px; - color: #888; - flex-shrink: 0; - transition: transform 0.2s; - - &.expanded { - transform: rotate(90deg); - } -} - -.fge-help-topic-title { - flex: 1; -} - -.fge-help-topic-content { - padding: 0 16px 12px 32px; - color: #bbb; - font-size: 13px; - line-height: 1.6; - - h4 { - margin: 12px 0 6px; - font-size: 13px; - font-weight: 600; - color: #ddd; - - &:first-child { - margin-top: 0; - } - } - - p { - margin: 6px 0; - } - - ul, - ol { - margin: 6px 0; - padding-left: 20px; - } - - li { - margin: 3px 0; - } - - code { - background: #1a1a1a; - padding: 1px 5px; - border-radius: 3px; - font-size: 12px; - color: #e0a050; - } - - em { - color: #999; - } - - table { - width: 100%; - border-collapse: collapse; - margin: 8px 0; - font-size: 12px; - } - - th { - text-align: left; - padding: 5px 8px; - background: #1e1e1e; - color: #ddd; - border-bottom: 1px solid #444; - font-weight: 600; - } - - td { - padding: 4px 8px; - border-bottom: 1px solid #333; - color: #bbb; - } - - tr:last-child td { - border-bottom: none; - } - - b { - color: #ddd; - } -} diff --git a/packages/tools/flowGraphEditor/src/components/help/helpDialogComponent.tsx b/packages/tools/flowGraphEditor/src/components/help/helpDialogComponent.tsx index 370ed10f926..91a5eabc2c3 100644 --- a/packages/tools/flowGraphEditor/src/components/help/helpDialogComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/help/helpDialogComponent.tsx @@ -1,7 +1,9 @@ -import * as React from "react"; -import { type HelpTopicId, HelpTopics } from "./helpContent"; +import { type FunctionComponent, useEffect, useMemo, useRef } from "react"; + +import { Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, Subtitle2, makeStyles, tokens } from "@fluentui/react-components"; +import { Accordion, AccordionSection } from "shared-ui-components/fluent/primitives/accordion"; -import "./helpDialog.scss"; +import { type HelpTopicId, HelpTopics } from "./helpContent"; interface IHelpDialogProps { /** When set, the dialog opens and scrolls to this topic. Null/undefined = closed. */ @@ -10,123 +12,128 @@ interface IHelpDialogProps { onClose: () => void; } -interface IHelpDialogState { - expandedTopics: Set; -} +const useStyles = makeStyles({ + surface: { + width: "640px", + maxWidth: "90%", + // Constrain the surface to 80vh so it doesn't fill the screen on tall content. + maxHeight: "80vh", + }, + body: { + // Constrain DialogContent to fit within the shrunk surface, accounting for + // DialogSurface's 24px top/bottom padding. This ensures content scrolls within + // the visible dialog area instead of overflowing. + maxHeight: "calc(80vh - 2 * 24px)", + minHeight: 0, + overflowY: "auto", + }, + topicContent: { + padding: `0 ${tokens.spacingHorizontalM} ${tokens.spacingVerticalM}`, + color: tokens.colorNeutralForeground2, + lineHeight: tokens.lineHeightBase300, + // Style the developer-authored HTML rendered via dangerouslySetInnerHTML. + "& h4": { + margin: `${tokens.spacingVerticalM} 0 ${tokens.spacingVerticalXS}`, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground1, + }, + "& h4:first-child": { + marginTop: 0, + }, + "& p": { + margin: `${tokens.spacingVerticalXS} 0`, + }, + "& ul, & ol": { + margin: `${tokens.spacingVerticalXS} 0`, + paddingLeft: tokens.spacingHorizontalXXL, + }, + "& li": { + margin: `${tokens.spacingVerticalXXS} 0`, + }, + "& code": { + background: tokens.colorNeutralBackground3, + padding: `1px ${tokens.spacingHorizontalXS}`, + borderRadius: tokens.borderRadiusSmall, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + color: tokens.colorPaletteDarkOrangeForeground1, + }, + "& table": { + width: "100%", + borderCollapse: "collapse", + margin: `${tokens.spacingVerticalS} 0`, + }, + "& th": { + textAlign: "left", + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + background: tokens.colorNeutralBackground3, + color: tokens.colorNeutralForeground1, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + fontWeight: tokens.fontWeightSemibold, + }, + "& td": { + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, + borderBottom: `1px solid ${tokens.colorNeutralStroke3}`, + color: tokens.colorNeutralForeground2, + }, + "& tr:last-child td": { + borderBottom: "none", + }, + }, +}); /** - * Modal help dialog with collapsible topic sections. + * Modal help dialog with collapsible topic sections, using Fluent UI primitives. + * @returns The rendered help dialog. */ -export class HelpDialogComponent extends React.Component { - private _topicRefs = new Map>(); +export const HelpDialogComponent: FunctionComponent = ({ initialTopicId, onClose }) => { + const classes = useStyles(); - /** @internal */ - constructor(props: IHelpDialogProps) { - super(props); + // Memoise the highlight set so the Accordion only opens the requested topic when it + // changes. Other topics start collapsed. + const highlightSections = useMemo(() => (initialTopicId ? [initialTopicId] : []), [initialTopicId]); - // Create refs for each topic so we can scroll to them - for (const topic of HelpTopics) { - this._topicRefs.set(topic.id, React.createRef()); - } - - // If an initial topic is specified, expand it; otherwise start all collapsed - const expanded = new Set(); - if (props.initialTopicId) { - expanded.add(props.initialTopicId); - } - this.state = { expandedTopics: expanded }; - } + // Track topic refs so we can scroll into view when an initial topic is requested. + const topicRefs = useRef(new Map()); - /** @internal */ - override componentDidMount() { - if (this.props.initialTopicId) { - this._scrollToTopic(this.props.initialTopicId); + useEffect(() => { + if (!initialTopicId) { + return; } - } - - /** @internal */ - override componentDidUpdate(prevProps: IHelpDialogProps) { - if (this.props.initialTopicId && this.props.initialTopicId !== prevProps.initialTopicId) { - this.setState( - (prev) => { - const expanded = new Set(prev.expandedTopics); - expanded.add(this.props.initialTopicId!); - return { expandedTopics: expanded }; - }, - () => { - this._scrollToTopic(this.props.initialTopicId!); - } - ); - } - } - - private _scrollToTopic(topicId: HelpTopicId) { - requestAnimationFrame(() => { - const ref = this._topicRefs.get(topicId); - if (ref?.current) { - ref.current.scrollIntoView({ behavior: "smooth", block: "start" }); - } - }); - } - - private _toggleTopic(topicId: HelpTopicId) { - this.setState((prev) => { - const expanded = new Set(prev.expandedTopics); - if (expanded.has(topicId)) { - expanded.delete(topicId); - } else { - expanded.add(topicId); - } - return { expandedTopics: expanded }; + // Defer to next frame so the Accordion has expanded the requested section. + const handle = requestAnimationFrame(() => { + const node = topicRefs.current.get(initialTopicId); + node?.scrollIntoView({ behavior: "smooth", block: "start" }); }); - } + return () => cancelAnimationFrame(handle); + }, [initialTopicId]); - private _onOverlayClick = (evt: React.MouseEvent) => { - // Close when clicking the overlay background (not the dialog itself) - if (evt.target === evt.currentTarget) { - this.props.onClose(); - } - }; - - /** @internal */ - override render() { - return ( -
-
-
-

Flow Graph Editor — Help

- -
-
- {HelpTopics.map((topic) => { - const isExpanded = this.state.expandedTopics.has(topic.id); - return ( -
-
this._toggleTopic(topic.id)}> - - {topic.title} + return ( + !data.open && onClose()}> + + + Flow Graph Editor — Help + + + {HelpTopics.map((topic) => ( + +
topicRefs.current.set(topic.id, el)} className={classes.topicContent}> + {topic.sections.map((section, idx) => ( +
+ {section.heading && {section.heading}} + {/* Content is developer-authored from helpContent.ts — safe to render as HTML. + If this ever accepts user/external content, sanitize with DOMPurify first. */} + {/* eslint-disable-next-line @typescript-eslint/naming-convention */} +
+
+ ))}
- {isExpanded && ( -
- {topic.sections.map((section, idx) => ( -
- {section.heading &&

{section.heading}

} - {/* Content is developer-authored from helpContent.ts — safe to render as HTML. - If this ever accepts user/external content, sanitize with DOMPurify first. */} - {/* eslint-disable-next-line @typescript-eslint/naming-convention */} -
-
- ))} -
- )} -
- ); - })} -
-
-
- ); - } -} + + ))} + + + + + + ); +}; diff --git a/packages/tools/flowGraphEditor/src/components/howToUse/howToUse.scss b/packages/tools/flowGraphEditor/src/components/howToUse/howToUse.scss deleted file mode 100644 index b9130f9fa74..00000000000 --- a/packages/tools/flowGraphEditor/src/components/howToUse/howToUse.scss +++ /dev/null @@ -1,119 +0,0 @@ -.fge-howto-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.5); - z-index: 9000; - display: flex; - align-items: center; - justify-content: center; -} - -.fge-howto-dialog { - background: #2a2a2a; - border: 1px solid #555; - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); - width: 600px; - max-width: 90vw; - max-height: 80vh; - display: flex; - flex-direction: column; - font: - 14px "acumin-pro", - sans-serif; - color: #ccc; - - .fge-howto-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px 12px; - border-bottom: 1px solid #444; - - h2 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: #eee; - } - - .fge-howto-close { - background: none; - border: none; - color: #888; - cursor: pointer; - font-size: 18px; - padding: 0; - line-height: 1; - &:hover { - color: #fff; - } - } - } - - .fge-howto-body { - padding: 16px 20px; - overflow-y: auto; - flex: 1; - - .fge-howto-section { - margin-bottom: 20px; - - h3 { - margin: 0 0 8px; - font-size: 14px; - font-weight: 600; - color: #ddd; - } - - p { - margin: 0 0 8px; - font-size: 13px; - line-height: 1.5; - color: #aaa; - } - } - - .fge-howto-code-block { - position: relative; - background: #1e1e1e; - border: 1px solid #444; - border-radius: 4px; - padding: 12px 40px 12px 12px; - font: - 12px "Consolas", - "Courier New", - monospace; - color: #d4d4d4; - white-space: pre-wrap; - word-break: break-all; - line-height: 1.5; - overflow-x: auto; - - .fge-howto-copy-btn { - position: absolute; - top: 6px; - right: 6px; - background: #464646; - border: 1px solid #666; - border-radius: 3px; - color: #ccc; - cursor: pointer; - font-size: 11px; - padding: 2px 8px; - &:hover { - background: #555; - color: #fff; - } - &.copied { - background: #2d7a3a; - color: #fff; - border-color: #2d7a3a; - } - } - } - } -} diff --git a/packages/tools/flowGraphEditor/src/components/howToUse/howToUseDialogComponent.tsx b/packages/tools/flowGraphEditor/src/components/howToUse/howToUseDialogComponent.tsx index 1d17527f55e..bcfadc85e7d 100644 --- a/packages/tools/flowGraphEditor/src/components/howToUse/howToUseDialogComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/howToUse/howToUseDialogComponent.tsx @@ -1,65 +1,109 @@ -import * as React from "react"; +import { type FunctionComponent, useCallback, useState } from "react"; + +import { Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, Subtitle2, Text, makeStyles, tokens } from "@fluentui/react-components"; +import { CheckmarkRegular, CopyRegular } from "@fluentui/react-icons"; + import { type GlobalState } from "../../globalState"; -import "./howToUse.scss"; interface IHowToUseDialogProps { globalState: GlobalState; onClose: () => void; } -interface IHowToUseDialogState { - copiedIndex: number | null; -} +const useStyles = makeStyles({ + surface: { + width: "700px", + maxWidth: "90%", + maxHeight: "80vh", + }, + body: { + // Constrain DialogContent to fit within the shrunk surface, accounting for + // DialogSurface's 24px top/bottom padding. This ensures content scrolls within + // the visible dialog area instead of overflowing. + maxHeight: "calc(80vh - 2 * 24px)", + minHeight: 0, + overflowY: "auto", + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalL, + }, + section: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalS, + }, + codeBlock: { + position: "relative", + background: tokens.colorNeutralBackground3, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusMedium, + padding: tokens.spacingHorizontalM, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + whiteSpace: "pre", + overflowX: "auto", + color: tokens.colorNeutralForeground1, + }, + copyButton: { + position: "absolute", + top: tokens.spacingVerticalS, + right: tokens.spacingHorizontalS, + }, + inlineCode: { + background: tokens.colorNeutralBackground3, + padding: `1px ${tokens.spacingHorizontalXS}`, + borderRadius: tokens.borderRadiusSmall, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + }, +}); -/** - * Dialog that shows code samples for integrating a saved flow graph into a user's project. - */ -export class HowToUseDialogComponent extends React.Component { - /** @internal */ - constructor(props: IHowToUseDialogProps) { - super(props); - this.state = { copiedIndex: null }; - } - - private _copyToClipboard(text: string, index: number) { - if (navigator.clipboard) { - navigator.clipboard - .writeText(text) - // eslint-disable-next-line github/no-then - .then(() => { - this.setState({ copiedIndex: index }); - setTimeout(() => this.setState({ copiedIndex: null }), 2000); - }) - // eslint-disable-next-line github/no-then - .catch(() => { - /* clipboard not available */ - }); +const CodeBlock: FunctionComponent<{ code: string }> = ({ code }) => { + const classes = useStyles(); + const [copied, setCopied] = useState(false); + const onCopy = useCallback(() => { + if (!navigator.clipboard) { + return; } - } + // eslint-disable-next-line github/no-then + navigator.clipboard + .writeText(code) + // eslint-disable-next-line github/no-then + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + // eslint-disable-next-line github/no-then + .catch(() => { + /* clipboard not available */ + }); + }, [code]); - private _renderCodeBlock(code: string, index: number) { - const isCopied = this.state.copiedIndex === index; - return ( -
- - {code} -
- ); - } + return ( +
+ + {code} +
+ ); +}; - /** @internal */ - override render() { - const snippetId = this.props.globalState.flowGraphSnippetId; +/** + * Dialog that shows code samples for integrating a saved flow graph into a user's project. + * @returns The rendered "How to use" dialog. + */ +export const HowToUseDialogComponent: FunctionComponent = ({ globalState, onClose }) => { + const classes = useStyles(); + const snippetId = globalState.flowGraphSnippetId; - const snippetCode = `import { ParseFlowGraphCoordinatorFromSnippetAsync } from "@babylonjs/core/FlowGraph/flowGraphParser"; + const snippetCode = `import { ParseFlowGraphCoordinatorFromSnippetAsync } from "@babylonjs/core/FlowGraph/flowGraphParser"; // After creating your scene: const coordinator = await ParseFlowGraphCoordinatorFromSnippetAsync("${snippetId || ""}", { scene }); coordinator.start();`; - const fileCode = `import { ParseCoordinatorAsync } from "@babylonjs/core/FlowGraph/flowGraphParser"; + const fileCode = `import { ParseCoordinatorAsync } from "@babylonjs/core/FlowGraph/flowGraphParser"; // Load the saved JSON file: const response = await fetch("./flowGraph.json"); @@ -69,43 +113,39 @@ const data = await response.json(); const coordinator = await ParseCoordinatorAsync(data, { scene }); coordinator.start();`; - return ( -
this.props.onClose()}> -
e.stopPropagation()}> -
-

How to Use This Flow Graph

- -
-
-
-

Method 1: From Snippet Server

-

+ return ( +

!data.open && onClose()}> + + + How to Use This Flow Graph + +
+ Method 1: From Snippet Server + {snippetId ? `Your graph is saved with snippet ID: ${snippetId}. Use the following code to load it.` : "Save your graph to the snippet server first, then use the snippet ID in the code below."} -

- {this._renderCodeBlock(snippetCode, 0)} +
+
-
-

Method 2: From JSON File

-

Download your graph as a JSON file (using the Save button in the property panel), then load it with this code.

- {this._renderCodeBlock(fileCode, 1)} +
+ Method 2: From JSON File + Download your graph as a JSON file (using the Save button in the property panel), then load it with this code. +
-
-

Notes

-

- Both methods create a FlowGraphCoordinator that manages the execution context. Call flowGraph.start() after parsing to - begin execution. The scene's objects (meshes, lights, cameras) will be automatically available to the flow graph through the coordinator's - context. -

+
+ Notes + + Both methods create a FlowGraphCoordinator that manages the execution context. Call{" "} + flowGraph.start() after parsing to begin execution. The scene's objects (meshes, lights, cameras) + will be automatically available to the flow graph through the coordinator's context. +
-
-
-
- ); - } -} + + + + + ); +}; diff --git a/packages/tools/flowGraphEditor/src/components/log/log.scss b/packages/tools/flowGraphEditor/src/components/log/log.scss deleted file mode 100644 index 237719d3852..00000000000 --- a/packages/tools/flowGraphEditor/src/components/log/log.scss +++ /dev/null @@ -1,32 +0,0 @@ -#fge-log-console { - background: #333333; - height: 100%; - box-sizing: border-box; - margin: 0; - padding: 10px; - width: 100%; - overflow: hidden; - overflow-y: auto; - grid-row: 2; - grid-column: 3; - - .log { - color: white; - font-size: 14px; - font-family: "Courier New", Courier, monospace; - - &.error { - color: red; - } - - &.clickable { - cursor: pointer; - text-decoration: underline; - text-decoration-style: dotted; - - &:hover { - opacity: 0.8; - } - } - } -} diff --git a/packages/tools/flowGraphEditor/src/components/log/logComponent.tsx b/packages/tools/flowGraphEditor/src/components/log/logComponent.tsx index 85307f3cf32..e241218cbea 100644 --- a/packages/tools/flowGraphEditor/src/components/log/logComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/log/logComponent.tsx @@ -1,8 +1,10 @@ -import * as React from "react"; -import { type GlobalState } from "../../globalState"; +import { type FunctionComponent, useEffect, useRef, useState } from "react"; + +import { Text, makeStyles, mergeClasses, tokens } from "@fluentui/react-components"; + import { type FlowGraphBlock } from "core/FlowGraph/flowGraphBlock"; -import "./log.scss"; +import { type GlobalState } from "../../globalState"; interface ILogComponentProps { globalState: GlobalState; @@ -19,67 +21,96 @@ export class LogEntry { ) {} } -export class LogComponent extends React.Component { - private _logConsoleRef: React.RefObject; - constructor(props: ILogComponentProps) { - super(props); +const useStyles = makeStyles({ + console: { + background: tokens.colorNeutralBackground3, + height: "100%", + boxSizing: "border-box", + margin: 0, + padding: tokens.spacingHorizontalM, + width: "100%", + overflow: "hidden", + overflowY: "auto", + }, + log: { + color: tokens.colorNeutralForeground1, + // Render as a block so each log entry occupies its own line in the console pane. + display: "block", + }, + error: { + color: tokens.colorPaletteRedForeground1, + }, + clickable: { + cursor: "pointer", + textDecoration: "underline", + textDecorationStyle: "dotted", + ":hover": { + opacity: 0.8, + }, + }, +}); - this.state = { logs: [] }; - this._logConsoleRef = React.createRef(); - } +/** + * Console-style log panel rendered at the bottom of the central column. + * Subscribes to `globalState.onLogRequiredObservable` for new entries and supports + * click-to-navigate for entries with an attached block. + * @param props The component props. + * @returns The rendered log panel. + */ +export const LogComponent: FunctionComponent = (props) => { + const { globalState } = props; + const classes = useStyles(); + const consoleRef = useRef(null); + const [logs, setLogs] = useState([]); - override componentDidMount() { - this.props.globalState.onLogRequiredObservable.add((log) => { - const currentLogs = this.state.logs; - currentLogs.push(log); - - this.setState({ logs: currentLogs }); + useEffect(() => { + const observer = globalState.onLogRequiredObservable.add((entry) => { + setLogs((prev) => [...prev, entry]); }); - } + return () => observer?.remove(); + }, [globalState]); - override componentDidUpdate() { - if (!this._logConsoleRef.current) { - return; + // Auto-scroll to the latest entry whenever logs change. + useEffect(() => { + if (consoleRef.current) { + consoleRef.current.scrollTop = consoleRef.current.scrollHeight; } + }, [logs]); - this._logConsoleRef.current.scrollTop = this._logConsoleRef.current.scrollHeight; - } - - private _onLogEntryClick(entry: LogEntry) { - if (!entry.block || !this.props.globalState.onGetNodeFromBlock) { + const onLogEntryClick = (entry: LogEntry) => { + if (!entry.block || !globalState.onGetNodeFromBlock) { return; } - const node = this.props.globalState.onGetNodeFromBlock(entry.block); + const node = globalState.onGetNodeFromBlock(entry.block); if (!node) { return; } // Select the node and zoom to it - this.props.globalState.stateManager.onSelectionChangedObservable.notifyObservers({ selection: node }); + globalState.stateManager.onSelectionChangedObservable.notifyObservers({ selection: node }); node.setIsSelected(true, false); - // Center the canvas on the node const ownerCanvas = (node as any)._ownerCanvas; if (ownerCanvas && typeof ownerCanvas.zoomToNode === "function") { ownerCanvas.zoomToNode(node); } - } + }; - override render() { - return ( -
- {this.state.logs.map((l, i) => { - const hasBlock = !!l.block; - return ( -
this._onLogEntryClick(l) : undefined} - > - {l.time.getHours() + ":" + l.time.getMinutes() + ":" + l.time.getSeconds() + ": " + l.message} -
- ); - })} -
- ); - } -} + return ( +
+ {logs.map((l, i) => { + const hasBlock = !!l.block; + return ( + onLogEntryClick(l) : undefined} + > + {l.time.getHours() + ":" + l.time.getMinutes() + ":" + l.time.getSeconds() + ": " + l.message} + + ); + })} +
+ ); +}; diff --git a/packages/tools/flowGraphEditor/src/components/nodeList/nodeList.scss b/packages/tools/flowGraphEditor/src/components/nodeList/nodeList.scss deleted file mode 100644 index ab56d3b7ef4..00000000000 --- a/packages/tools/flowGraphEditor/src/components/nodeList/nodeList.scss +++ /dev/null @@ -1,159 +0,0 @@ -#fgeNodeList { - background: #333333; - height: 100%; - margin: 0; - padding: 0; - display: grid; - grid-template-rows: 1fr; - width: 100%; - overflow: hidden; - - .panes { - height: 100%; - overflow: hidden; - - .pane { - color: white; - - overflow: hidden; - height: 100%; - - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - - .filter { - display: flex; - align-items: center; - padding-right: 10px; - - input { - flex: 1; - margin: 10px 6px 5px 10px; - display: block; - border: none; - padding: 0; - border-bottom: solid 1px rgb(51, 183, 102); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 96%, rgb(51, 183, 102) 4%); - background-position: -1000px 0; - background-size: 1000px 100%; - background-repeat: no-repeat; - color: white; - } - - input:focus { - box-shadow: none; - outline: none; - background-position: 0 0; - } - - input::placeholder { - color: gray; - } - - .filter-clear { - flex-shrink: 0; - margin-top: 5px; - background: none; - border: none; - color: #aaa; - cursor: pointer; - font-size: 14px; - line-height: 1; - padding: 0 2px; - visibility: hidden; - - &.visible { - visibility: visible; - } - - &:hover { - color: white; - } - } - } - - .list-container { - overflow-x: hidden; - overflow-y: auto; - height: calc(100% - 32px); - - .underline { - border-bottom: 0.5px solid rgba(255, 255, 255, 0.5); - } - - .draggableLine { - height: 30px; - display: grid; - align-items: center; - justify-items: stretch; - background: #222222; - cursor: grab; - text-align: center; - margin: 0; - box-sizing: border-box; - - &:hover { - background: rgb(51, 183, 102); - color: white; - } - } - - .paneContainer { - margin-top: 3px; - display: grid; - grid-template-rows: 100%; - grid-template-columns: 100%; - - .paneContainer-content { - grid-row: 1; - grid-column: 1; - - .header { - display: grid; - grid-template-columns: 1fr auto; - background: #555555; - height: 30px; - padding-right: 5px; - cursor: pointer; - - .title { - border-left: 3px solid rgb(51, 183, 102); - padding-left: 5px; - grid-column: 1; - display: flex; - align-items: center; - } - - .collapse { - grid-column: 2; - display: flex; - align-items: center; - justify-content: center; - width: 24px; - transform-origin: center; - - img { - width: 12px; - height: 12px; - transition: transform 0.15s ease; - } - - &.closed { - img { - transform: rotate(180deg); - } - } - } - } - - .paneList > div:not(:last-child) { - border-bottom: 1px solid rgba(255, 255, 255, 0.15); - } - } - } - } - } - } -} diff --git a/packages/tools/flowGraphEditor/src/components/nodeList/nodeListComponent.tsx b/packages/tools/flowGraphEditor/src/components/nodeList/nodeListComponent.tsx index f48e931fc95..b87bcf413fa 100644 --- a/packages/tools/flowGraphEditor/src/components/nodeList/nodeListComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/nodeList/nodeListComponent.tsx @@ -1,372 +1,323 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import * as React from "react"; -import { type GlobalState } from "../../globalState"; -import { LineContainerComponent } from "../../sharedComponents/lineContainerComponent"; -import { DraggableLineComponent } from "../../sharedComponents/draggableLineComponent"; -import { type Observer } from "core/Misc/observable"; -import { type Nullable } from "core/types"; -import { NodeLedger } from "shared-ui-components/nodeGraphSystem/nodeLedger"; -import { AllFlowGraphBlocks } from "../../allBlockNames"; -import { GetBlockType, BlockTypeHeaderColor } from "../../graphSystem/blockTypeColors"; -import { GetTemplatesByCategory, AllCompositeTemplates } from "../../compositeTemplates"; - -import "./nodeList.scss"; - -/** Props for the NodeListComponent. */ -interface INodeListComponentProps { - globalState: GlobalState; -} - -/** - * Left-panel block list with filter/search for the Flow Graph Editor. - */ -export class NodeListComponent extends React.Component { - /** Observer for the reset event. */ - private _onResetRequiredObserver: Nullable>; - /** Ref for the filter input element, used to restore focus after clearing. */ - private _inputRef = React.createRef(); - - /** Tooltip descriptions keyed by block class name. */ - private static _Tooltips: { [key: string]: string } = { - // Events - FlowGraphSceneReadyEventBlock: "Triggered when the scene is ready", - FlowGraphSceneTickEventBlock: "Triggered every frame", - FlowGraphMeshPickEventBlock: "Triggered when a mesh is picked", - FlowGraphPointerEventBlock: "Triggered on pointer events", - FlowGraphPointerDownEventBlock: "Triggered on pointer down", - FlowGraphPointerUpEventBlock: "Triggered on pointer up", - FlowGraphPointerMoveEventBlock: "Triggered on pointer move", - FlowGraphPointerOverEventBlock: "Triggered on pointer over", - FlowGraphPointerOutEventBlock: "Triggered on pointer out", - FlowGraphReceiveCustomEventBlock: "Triggered when a custom event is received", - FlowGraphSendCustomEventBlock: "Sends a custom event", - FlowGraphKeyDownEventBlock: "Triggered when a keyboard key is pressed down", - FlowGraphKeyUpEventBlock: "Triggered when a keyboard key is released", - FlowGraphIsKeyPressedBlock: "Checks if a keyboard key is currently held down", - - // Control Flow - FlowGraphBranchBlock: "Branches execution based on a condition", - FlowGraphForLoopBlock: "Loops over a range of values", - FlowGraphWhileLoopBlock: "Loops while a condition is true", - FlowGraphSwitchBlock: "Switches between outputs based on a value", - FlowGraphSequenceBlock: "Executes outputs in sequence", - FlowGraphMultiGateBlock: "Executes one of multiple outputs", - FlowGraphFlipFlopBlock: "Alternates between two outputs", - FlowGraphDoNBlock: "Executes N times then stops", - FlowGraphWaitAllBlock: "Waits for all inputs to fire", - FlowGraphSetDelayBlock: "Delays execution", - FlowGraphCancelDelayBlock: "Cancels a pending delay", - FlowGraphCallCounterBlock: "Counts how many times it was called", - FlowGraphDebounceBlock: "Debounces execution", - FlowGraphThrottleBlock: "Throttles execution", - - // Animation - FlowGraphPlayAnimationBlock: "Plays an animation", - FlowGraphStopAnimationBlock: "Stops an animation", - FlowGraphPauseAnimationBlock: "Pauses an animation", - FlowGraphInterpolationBlock: "Interpolates a value over time", - - // Physics Events - FlowGraphPhysicsCollisionEventBlock: "Fires when a physics collision occurs on a body", - - // Physics Actions - FlowGraphApplyForceBlock: "Applies a force to a physics body at a location", - FlowGraphApplyImpulseBlock: "Applies an instantaneous impulse to a physics body", - FlowGraphSetLinearVelocityBlock: "Sets the linear velocity of a physics body", - FlowGraphSetAngularVelocityBlock: "Sets the angular velocity of a physics body", - FlowGraphSetPhysicsMotionTypeBlock: "Sets the motion type (static/animated/dynamic)", - - // Physics Data - FlowGraphGetLinearVelocityBlock: "Gets the linear velocity of a physics body", - FlowGraphGetAngularVelocityBlock: "Gets the angular velocity of a physics body", - FlowGraphGetPhysicsMassPropertiesBlock: "Gets mass, center of mass, and inertia", - - // Audio Actions - FlowGraphPlaySoundBlock: "Plays an Audio V2 sound with volume, offset, and loop options", - FlowGraphStopSoundBlock: "Stops an Audio V2 sound", - FlowGraphPauseSoundBlock: "Pauses or resumes an Audio V2 sound", - FlowGraphSetSoundVolumeBlock: "Sets the volume of an Audio V2 sound", - - // Audio Events - FlowGraphSoundEndedEventBlock: "Fires when an Audio V2 sound stops or ends (including manual stop)", - - // Audio Data - FlowGraphGetSoundVolumeBlock: "Gets the current volume of an Audio V2 sound", - FlowGraphIsSoundPlayingBlock: "Checks whether an Audio V2 sound is currently playing", - - // Math Constants - FlowGraphEBlock: "Euler's number (e)", - FlowGraphPIBlock: "Pi constant", - FlowGraphInfBlock: "Infinity constant", - FlowGraphNaNBlock: "NaN constant", - FlowGraphRandomBlock: "Random number generator", - - // Math Arithmetic - FlowGraphAddBlock: "Adds two values", - FlowGraphSubtractBlock: "Subtracts two values", - FlowGraphMultiplyBlock: "Multiplies two values", - FlowGraphDivideBlock: "Divides two values", - FlowGraphModuloBlock: "Modulo operation", - FlowGraphNegationBlock: "Negates a value", - FlowGraphAbsBlock: "Absolute value", - FlowGraphSignBlock: "Sign of a value", - FlowGraphMinBlock: "Minimum of two values", - FlowGraphMaxBlock: "Maximum of two values", - FlowGraphClampBlock: "Clamps a value between min and max", - FlowGraphSaturateBlock: "Clamps a value between 0 and 1", - FlowGraphMathInterpolationBlock: "Linearly interpolates between two values", - FlowGraphPowerBlock: "Raises a value to a power", - FlowGraphSquareRootBlock: "Square root", - FlowGraphCubeRootBlock: "Cube root", - - // Math Rounding - FlowGraphFloorBlock: "Rounds down", - FlowGraphCeilBlock: "Rounds up", - FlowGraphRoundBlock: "Rounds to nearest", - FlowGraphTruncBlock: "Truncates to integer", - FlowGraphFractBlock: "Fractional part", - - // Math Trigonometry - FlowGraphSinBlock: "Sine", - FlowGraphCosBlock: "Cosine", - FlowGraphTanBlock: "Tangent", - FlowGraphASinBlock: "Arc sine", - FlowGraphACosBlock: "Arc cosine", - FlowGraphATanBlock: "Arc tangent", - FlowGraphATan2Block: "Arc tangent 2", - FlowGraphSinhBlock: "Hyperbolic sine", - FlowGraphCoshBlock: "Hyperbolic cosine", - FlowGraphTanhBlock: "Hyperbolic tangent", - FlowGraphASinhBlock: "Hyperbolic arc sine", - FlowGraphACoshBlock: "Hyperbolic arc cosine", - FlowGraphATanhBlock: "Hyperbolic arc tangent", - FlowGraphDegToRadBlock: "Degrees to radians", - FlowGraphRadToDegBlock: "Radians to degrees", - - // Math Logarithmic - FlowGraphExponentialBlock: "Exponential (e^x)", - FlowGraphLogBlock: "Natural logarithm", - FlowGraphLog2Block: "Base-2 logarithm", - FlowGraphLog10Block: "Base-10 logarithm", - - // Math Comparison - FlowGraphEqualityBlock: "Tests equality", - FlowGraphLessThanBlock: "Less than comparison", - FlowGraphLessThanOrEqualBlock: "Less than or equal comparison", - FlowGraphGreaterThanBlock: "Greater than comparison", - FlowGraphGreaterThanOrEqualBlock: "Greater than or equal comparison", - FlowGraphIsNaNBlock: "Tests if NaN", - FlowGraphIsInfBlock: "Tests if Infinity", - FlowGraphConditionalBlock: "Selects between two values based on a condition", - - // Vector Math - FlowGraphLengthBlock: "Vector length", - FlowGraphNormalizeBlock: "Normalizes a vector", - FlowGraphDotBlock: "Dot product", - FlowGraphCrossBlock: "Cross product", - FlowGraphRotate2DBlock: "Rotates a 2D vector", - FlowGraphRotate3DBlock: "Rotates a 3D vector", - - // Matrix Math - FlowGraphTransposeBlock: "Transposes a matrix", - FlowGraphDeterminantBlock: "Determinant of a matrix", - FlowGraphInvertMatrixBlock: "Inverts a matrix", - FlowGraphMatrixMultiplicationBlock: "Multiplies two matrices", - - // Bitwise - FlowGraphBitwiseAndBlock: "Bitwise AND", - FlowGraphBitwiseOrBlock: "Bitwise OR", - FlowGraphBitwiseXorBlock: "Bitwise XOR", - FlowGraphBitwiseNotBlock: "Bitwise NOT", - FlowGraphBitwiseLeftShiftBlock: "Bitwise left shift", - FlowGraphBitwiseRightShiftBlock: "Bitwise right shift", - FlowGraphLeadingZerosBlock: "Count leading zeros", - FlowGraphTrailingZerosBlock: "Count trailing zeros", - FlowGraphOneBitsCounterBlock: "Count set bits", - - // Data Conversion - FlowGraphCombineVector2Block: "Combines components into a Vector2", - FlowGraphCombineVector3Block: "Combines components into a Vector3", - FlowGraphCombineVector4Block: "Combines components into a Vector4", - FlowGraphCombineMatrixBlock: "Combines components into a Matrix", - FlowGraphCombineMatrix2DBlock: "Combines components into a 2D Matrix", - FlowGraphCombineMatrix3DBlock: "Combines components into a 3D Matrix", - FlowGraphExtractVector2Block: "Extracts components from a Vector2", - FlowGraphExtractVector3Block: "Extracts components from a Vector3", - FlowGraphExtractVector4Block: "Extracts components from a Vector4", - FlowGraphExtractMatrixBlock: "Extracts components from a Matrix", - FlowGraphExtractMatrix2DBlock: "Extracts components from a 2D Matrix", - FlowGraphExtractMatrix3DBlock: "Extracts components from a 3D Matrix", - FlowGraphTransformVectorBlock: "Transforms a vector by a matrix", - FlowGraphTransformCoordinatesBlock: "Transforms coordinates by a matrix", - FlowGraphTransformCoordinatesSystemBlock: "Transforms a coordinate system", - FlowGraphConjugateBlock: "Conjugate of a quaternion", - FlowGraphAngleBetweenBlock: "Angle between two vectors", - FlowGraphQuaternionFromAxisAngleBlock: "Creates quaternion from axis/angle", - FlowGraphAxisAngleFromQuaternionBlock: "Extracts axis/angle from quaternion", - FlowGraphQuaternionFromDirectionsBlock: "Creates quaternion from directions", - FlowGraphMatrixDecompose: "Decomposes a matrix into components", - FlowGraphMatrixCompose: "Composes a matrix from components", - - // Type Conversion - FlowGraphBooleanToFloat: "Converts boolean to float", - FlowGraphBooleanToInt: "Converts boolean to integer", - FlowGraphFloatToBoolean: "Converts float to boolean", - FlowGraphIntToBoolean: "Converts integer to boolean", - FlowGraphIntToFloat: "Converts integer to float", - FlowGraphFloatToInt: "Converts float to integer", - - // Data Access - FlowGraphConstantBlock: "A constant value", - FlowGraphGetPropertyBlock: "Gets a property from an object", - FlowGraphSetPropertyBlock: "Sets a property on an object", - FlowGraphGetVariableBlock: "Gets a context variable", - FlowGraphSetVariableBlock: "Sets a context variable", - FlowGraphGetAssetBlock: "Gets an asset by name", - FlowGraphJsonPointerParserBlock: "Parses a JSON pointer path", - FlowGraphArrayIndexBlock: "Gets an element from an array", - FlowGraphIndexOfBlock: "Finds the index of an element", - FlowGraphDataSwitchBlock: "Selects data based on an index", - - // Utility - FlowGraphConsoleLogBlock: "Logs a message to the console", - FlowGraphEasingBlock: "Applies an easing function", - FlowGraphBezierCurveEasing: "Applies a bezier curve easing", - FlowGraphContextBlock: "Gets the flow graph context", - FlowGraphCodeExecutionBlock: "Executes custom code", - FlowGraphFunctionReference: "Reference to a function flow graph", - FlowGraphDebugBlock: "Debug passthrough — shows the value flowing through a data connection", - }; - - /** - * Creates a new NodeListComponent. - * @param props - component props - */ - constructor(props: INodeListComponentProps) { - super(props); - - this.state = { filter: "" }; - - this._onResetRequiredObserver = this.props.globalState.onResetRequiredObservable.add(() => { - this.forceUpdate(); - }); - } - - /** Removes the reset observer when the component is unmounted. */ - override componentWillUnmount() { - this.props.globalState.onResetRequiredObservable.remove(this._onResetRequiredObserver); - } - - /** - * Updates the block list filter. - * @param filter - the new filter string - */ - filterContent(filter: string) { - this.setState({ filter: filter }); - } - - /** Clears the current filter and returns focus to the input. */ - clearFilter() { - this.setState({ filter: "" }, () => this._inputRef.current?.focus()); - } - - /** - * Renders the node list panel. - * @returns the rendered JSX - */ - override render() { - const allBlocks = AllFlowGraphBlocks; - - // Create node menu - const blockMenu = []; - for (const key in allBlocks) { - const blockList = allBlocks[key] - .filter((b: string) => !this.state.filter || b.toLowerCase().indexOf(this.state.filter.toLowerCase()) !== -1) - .sort((a: string, b: string) => a.localeCompare(b)) - .map((blockName: string) => { - const blockType = GetBlockType(blockName); - const color = BlockTypeHeaderColor[blockType]; - return ; - }); - - if (blockList.length) { - blockMenu.push( - - {blockList} - - ); - } - - // Register blocks - const ledger = NodeLedger.RegisteredNodeNames; - for (const cat in allBlocks) { - const blocks = allBlocks[cat] as string[]; - if (blocks.length) { - for (const block of blocks) { - if (!ledger.includes(block)) { - ledger.push(block); - } - } - } - } - NodeLedger.NameFormatter = (name) => { - let finalName = name; - // Remove "FlowGraph" prefix and "Block" suffix for display - if (finalName.startsWith("FlowGraph")) { - finalName = finalName.substring(9); - } - if (finalName.endsWith("Block")) { - finalName = finalName.substring(0, finalName.length - 5); - } - - return finalName; - }; - } - - // Add composite template entries to the palette - const templateCategories = GetTemplatesByCategory(); - for (const categoryName of Object.keys(templateCategories)) { - const templateNames = templateCategories[categoryName]; - const templateItems = templateNames - .filter((name: string) => !this.state.filter || name.toLowerCase().indexOf(this.state.filter.toLowerCase()) !== -1) - .map((name: string) => { - const template = AllCompositeTemplates[name]; - return ; - }); - - if (templateItems.length) { - blockMenu.push( - - {templateItems} - - ); - } - } - - return ( -
-
-
-
- (this.props.globalState.lockObject.lock = true)} - onBlur={() => { - this.props.globalState.lockObject.lock = false; - }} - onChange={(evt) => this.filterContent(evt.target.value)} - /> - -
-
{blockMenu}
-
-
-
- ); - } -} +/* eslint-disable @typescript-eslint/naming-convention */ +import { type FunctionComponent, useEffect } from "react"; + +import { makeStyles, tokens } from "@fluentui/react-components"; + +import { NodeLedger } from "shared-ui-components/nodeGraphSystem/nodeLedger"; +import { Accordion, AccordionSection, AccordionSectionItem } from "shared-ui-components/fluent/primitives/accordion"; + +import { type GlobalState } from "../../globalState"; +import { AllFlowGraphBlocks } from "../../allBlockNames"; +import { GetBlockType, BlockTypeHeaderColor } from "../../graphSystem/blockTypeColors"; +import { GetTemplatesByCategory, AllCompositeTemplates } from "../../compositeTemplates"; +import { DraggableLine } from "../common/draggableLine"; + +interface INodeListComponentProps { + globalState: GlobalState; +} + +/** + * Tooltip descriptions keyed by block class name. + * + * NOTE: This map is generated from the legacy implementation; new blocks should add their + * tooltip here so the palette surfaces a useful description. + */ +const Tooltips: Record = { + // Events + FlowGraphSceneReadyEventBlock: "Triggered when the scene is ready", + FlowGraphSceneTickEventBlock: "Triggered every frame", + FlowGraphMeshPickEventBlock: "Triggered when a mesh is picked", + FlowGraphPointerEventBlock: "Triggered on pointer events", + FlowGraphPointerDownEventBlock: "Triggered on pointer down", + FlowGraphPointerUpEventBlock: "Triggered on pointer up", + FlowGraphPointerMoveEventBlock: "Triggered on pointer move", + FlowGraphPointerOverEventBlock: "Triggered on pointer over", + FlowGraphPointerOutEventBlock: "Triggered on pointer out", + FlowGraphReceiveCustomEventBlock: "Triggered when a custom event is received", + FlowGraphSendCustomEventBlock: "Sends a custom event", + FlowGraphKeyDownEventBlock: "Triggered when a keyboard key is pressed down", + FlowGraphKeyUpEventBlock: "Triggered when a keyboard key is released", + FlowGraphIsKeyPressedBlock: "Checks if a keyboard key is currently held down", + + // Control Flow + FlowGraphBranchBlock: "Branches execution based on a condition", + FlowGraphForLoopBlock: "Loops over a range of values", + FlowGraphWhileLoopBlock: "Loops while a condition is true", + FlowGraphSwitchBlock: "Switches between outputs based on a value", + FlowGraphSequenceBlock: "Executes outputs in sequence", + FlowGraphMultiGateBlock: "Executes one of multiple outputs", + FlowGraphFlipFlopBlock: "Alternates between two outputs", + FlowGraphDoNBlock: "Executes N times then stops", + FlowGraphWaitAllBlock: "Waits for all inputs to fire", + FlowGraphSetDelayBlock: "Delays execution", + FlowGraphCancelDelayBlock: "Cancels a pending delay", + FlowGraphCallCounterBlock: "Counts how many times it was called", + FlowGraphDebounceBlock: "Debounces execution", + FlowGraphThrottleBlock: "Throttles execution", + + // Animation + FlowGraphPlayAnimationBlock: "Plays an animation", + FlowGraphStopAnimationBlock: "Stops an animation", + FlowGraphPauseAnimationBlock: "Pauses an animation", + FlowGraphInterpolationBlock: "Interpolates a value over time", + + // Physics Events + FlowGraphPhysicsCollisionEventBlock: "Fires when a physics collision occurs on a body", + + // Physics Actions + FlowGraphApplyForceBlock: "Applies a force to a physics body at a location", + FlowGraphApplyImpulseBlock: "Applies an instantaneous impulse to a physics body", + FlowGraphSetLinearVelocityBlock: "Sets the linear velocity of a physics body", + FlowGraphSetAngularVelocityBlock: "Sets the angular velocity of a physics body", + FlowGraphSetPhysicsMotionTypeBlock: "Sets the motion type (static/animated/dynamic)", + + // Physics Data + FlowGraphGetLinearVelocityBlock: "Gets the linear velocity of a physics body", + FlowGraphGetAngularVelocityBlock: "Gets the angular velocity of a physics body", + FlowGraphGetPhysicsMassPropertiesBlock: "Gets mass, center of mass, and inertia", + + // Audio Actions + FlowGraphPlaySoundBlock: "Plays an Audio V2 sound with volume, offset, and loop options", + FlowGraphStopSoundBlock: "Stops an Audio V2 sound", + FlowGraphPauseSoundBlock: "Pauses or resumes an Audio V2 sound", + FlowGraphSetSoundVolumeBlock: "Sets the volume of an Audio V2 sound", + + // Audio Events + FlowGraphSoundEndedEventBlock: "Fires when an Audio V2 sound stops or ends (including manual stop)", + + // Audio Data + FlowGraphGetSoundVolumeBlock: "Gets the current volume of an Audio V2 sound", + FlowGraphIsSoundPlayingBlock: "Checks whether an Audio V2 sound is currently playing", + + // Math Constants + FlowGraphEBlock: "Euler's number (e)", + FlowGraphPIBlock: "Pi constant", + FlowGraphInfBlock: "Infinity constant", + FlowGraphNaNBlock: "NaN constant", + FlowGraphRandomBlock: "Random number generator", + + // Math Arithmetic + FlowGraphAddBlock: "Adds two values", + FlowGraphSubtractBlock: "Subtracts two values", + FlowGraphMultiplyBlock: "Multiplies two values", + FlowGraphDivideBlock: "Divides two values", + FlowGraphModuloBlock: "Modulo operation", + FlowGraphNegationBlock: "Negates a value", + FlowGraphAbsBlock: "Absolute value", + FlowGraphSignBlock: "Sign of a value", + FlowGraphMinBlock: "Minimum of two values", + FlowGraphMaxBlock: "Maximum of two values", + FlowGraphExpBlock: "Exponential function", + FlowGraphLogBlock: "Natural logarithm", + FlowGraphLog2Block: "Base-2 logarithm", + FlowGraphLog10Block: "Base-10 logarithm", + FlowGraphSqrtBlock: "Square root", + FlowGraphCubeRootBlock: "Cube root", + FlowGraphPowerBlock: "Power", + + // Math Trigonometry + FlowGraphSinBlock: "Sine", + FlowGraphCosBlock: "Cosine", + FlowGraphTanBlock: "Tangent", + FlowGraphAsinBlock: "Inverse sine", + FlowGraphAcosBlock: "Inverse cosine", + FlowGraphAtanBlock: "Inverse tangent", + FlowGraphAtan2Block: "Inverse tangent of two values", + FlowGraphSinhBlock: "Hyperbolic sine", + FlowGraphCoshBlock: "Hyperbolic cosine", + FlowGraphTanhBlock: "Hyperbolic tangent", + FlowGraphAsinhBlock: "Inverse hyperbolic sine", + FlowGraphAcoshBlock: "Inverse hyperbolic cosine", + FlowGraphAtanhBlock: "Inverse hyperbolic tangent", + + // Math Comparison + FlowGraphEqualityBlock: "Equality comparison", + FlowGraphLessThanBlock: "Less than comparison", + FlowGraphLessThanOrEqualBlock: "Less than or equal comparison", + FlowGraphGreaterThanBlock: "Greater than comparison", + FlowGraphGreaterThanOrEqualBlock: "Greater than or equal comparison", + FlowGraphIsValidBlock: "Checks if a value is valid (not null/undefined/NaN/Infinity)", + FlowGraphIsNaNBlock: "Checks if a value is NaN", + FlowGraphIsInfBlock: "Checks if a value is Infinity", + + // Math Bitwise + FlowGraphBitwiseAndBlock: "Bitwise AND", + FlowGraphBitwiseOrBlock: "Bitwise OR", + FlowGraphBitwiseXorBlock: "Bitwise XOR", + FlowGraphBitwiseNotBlock: "Bitwise NOT", + FlowGraphBitwiseLeftShiftBlock: "Bitwise left shift", + FlowGraphBitwiseRightShiftBlock: "Bitwise right shift", + FlowGraphCountLeadingZerosBlock: "Counts leading zeros", + FlowGraphCountTrailingZerosBlock: "Counts trailing zeros", + FlowGraphLeadingOnesBlock: "Counts leading ones", + FlowGraphTrailingOnesBlock: "Counts trailing ones", + + // Math Rounding + FlowGraphRoundBlock: "Rounds to nearest integer", + FlowGraphFloorBlock: "Floor", + FlowGraphCeilBlock: "Ceiling", + FlowGraphTruncBlock: "Truncates fractional part", + FlowGraphFractBlock: "Fractional part", + FlowGraphSaturateBlock: "Clamps a value to [0, 1]", + FlowGraphClampBlock: "Clamps a value between min and max", + FlowGraphInterpolateBlock: "Linear interpolation", + + // Vector / Quaternion + FlowGraphLengthBlock: "Vector length", + FlowGraphLengthSquaredBlock: "Vector length squared", + FlowGraphNormalizeBlock: "Normalizes a vector", + FlowGraphDotBlock: "Dot product", + FlowGraphCrossBlock: "Cross product", + FlowGraphRotate2DBlock: "Rotates a 2D vector", + FlowGraphRotate3DBlock: "Rotates a 3D vector", + FlowGraphTransposeBlock: "Transposes a matrix", + FlowGraphDeterminantBlock: "Matrix determinant", + FlowGraphInverseBlock: "Inverts a matrix or quaternion", + FlowGraphMatMulBlock: "Matrix multiplication", + FlowGraphTransformBlock: "Transforms a vector by a matrix", + FlowGraphConjugateBlock: "Conjugate of a quaternion", + FlowGraphAngleBetweenBlock: "Angle between two vectors", + FlowGraphQuaternionFromAxisAngleBlock: "Creates quaternion from axis/angle", + FlowGraphAxisAngleFromQuaternionBlock: "Extracts axis/angle from quaternion", + FlowGraphQuaternionFromDirectionsBlock: "Creates quaternion from directions", + FlowGraphMatrixDecompose: "Decomposes a matrix into components", + FlowGraphMatrixCompose: "Composes a matrix from components", + + // Type Conversion + FlowGraphBooleanToFloat: "Converts boolean to float", + FlowGraphBooleanToInt: "Converts boolean to integer", + FlowGraphFloatToBoolean: "Converts float to boolean", + FlowGraphIntToBoolean: "Converts integer to boolean", + FlowGraphIntToFloat: "Converts integer to float", + FlowGraphFloatToInt: "Converts float to integer", + + // Data Access + FlowGraphConstantBlock: "A constant value", + FlowGraphGetPropertyBlock: "Gets a property from an object", + FlowGraphSetPropertyBlock: "Sets a property on an object", + FlowGraphGetVariableBlock: "Gets a context variable", + FlowGraphSetVariableBlock: "Sets a context variable", + FlowGraphGetAssetBlock: "Gets an asset by name", + FlowGraphJsonPointerParserBlock: "Parses a JSON pointer path", + FlowGraphArrayIndexBlock: "Gets an element from an array", + FlowGraphIndexOfBlock: "Finds the index of an element", + FlowGraphDataSwitchBlock: "Selects data based on an index", + + // Utility + FlowGraphConsoleLogBlock: "Logs a message to the console", + FlowGraphEasingBlock: "Applies an easing function", + FlowGraphBezierCurveEasing: "Applies a bezier curve easing", + FlowGraphContextBlock: "Gets the flow graph context", + FlowGraphCodeExecutionBlock: "Executes custom code", + FlowGraphFunctionReference: "Reference to a function flow graph", + FlowGraphDebugBlock: "Debug passthrough - shows the value flowing through a data connection", +}; + +const useStyles = makeStyles({ + root: { + display: "flex", + flexDirection: "column", + height: "100%", + overflow: "hidden", + background: tokens.colorNeutralBackground1, + }, +}); + +const FormatCategoryName = (raw: string): string => raw.replace("__", ": ").replace(/_/g, " "); + +const TemplateCategoryColor = "#8854d0"; + +/** + * Left-panel block list with built-in filtering and pinning, powered by `Accordion`. + * + * Rebuilt on top of `shared-ui-components/fluent/primitives/accordion`. Each block category + * is an `AccordionSection`; each block (or composite template) is registered as an + * `AccordionSectionItem` so the accordion's search box filters across all categories at once + * and individual blocks can be pinned to the top via the built-in pin UI. + * @returns The rendered node list panel. + */ +export const NodeListComponent: FunctionComponent = ({ globalState }) => { + const classes = useStyles(); + + // Register every block name with the NodeLedger so the canvas can format display names + // consistently. (This was previously done inline inside the legacy render method.) + useEffect(() => { + const ledger = NodeLedger.RegisteredNodeNames; + for (const cat in AllFlowGraphBlocks) { + const blocks = AllFlowGraphBlocks[cat] as string[]; + for (const block of blocks) { + if (!ledger.includes(block)) { + ledger.push(block); + } + } + } + NodeLedger.NameFormatter = (name) => { + let finalName = name; + if (finalName.startsWith("FlowGraph")) { + finalName = finalName.substring(9); + } + if (finalName.endsWith("Block")) { + finalName = finalName.substring(0, finalName.length - 5); + } + return finalName; + }; + }, []); + + // The Accordion does not currently expose a re-render trigger, but the editor fires + // `onResetRequiredObservable` to nudge the panel in case future block additions need + // a refresh. Keep the subscription so the palette re-renders if the observable fires. + useEffect(() => { + const obs = globalState.onResetRequiredObservable.add(() => { + // No-op: AllFlowGraphBlocks is module-level and stable; reset just refreshes derived state. + }); + return () => { + obs?.remove(); + }; + }, [globalState]); + + const blockSections: { title: string; items: { name: string; tooltip: string; color: string }[] }[] = []; + for (const category of Object.keys(AllFlowGraphBlocks)) { + const items = (AllFlowGraphBlocks[category] as string[]) + .slice() + .sort((a, b) => a.localeCompare(b)) + .map((blockName) => { + const blockType = GetBlockType(blockName); + return { + name: blockName, + tooltip: Tooltips[blockName] ?? "", + color: BlockTypeHeaderColor[blockType], + }; + }); + if (items.length > 0) { + blockSections.push({ title: FormatCategoryName(category), items }); + } + } + + const templateCategories = GetTemplatesByCategory(); + const templateSections: { title: string; items: { name: string; tooltip: string; color: string }[] }[] = []; + for (const categoryName of Object.keys(templateCategories)) { + const items = templateCategories[categoryName].map((name: string) => { + const template = AllCompositeTemplates[name]; + return { name, tooltip: template.description, color: TemplateCategoryColor }; + }); + if (items.length > 0) { + templateSections.push({ title: `Templates: ${categoryName}`, items }); + } + } + + return ( +
+ + {[...blockSections, ...templateSections].map((section) => ( + + {section.items.map((item) => ( + + + + ))} + + ))} + +
+ ); +}; diff --git a/packages/tools/flowGraphEditor/src/components/preview/scenePreview.scss b/packages/tools/flowGraphEditor/src/components/preview/scenePreview.scss deleted file mode 100644 index 503dec31866..00000000000 --- a/packages/tools/flowGraphEditor/src/components/preview/scenePreview.scss +++ /dev/null @@ -1,162 +0,0 @@ -.scene-preview-container { - display: grid; - grid-template-rows: auto 1fr auto; - height: 100%; - width: 100%; - overflow: hidden; - background: #333333; - color: white; - font-size: 13px; - - .snippet-loader { - background: #222222; - padding: 0; - - .snippet-header { - height: 30px; - font-size: 16px; - color: white; - background: #222222; - display: flex; - align-items: center; - justify-content: center; - user-select: none; - } - - .snippet-input-row { - display: grid; - grid-template-columns: 1fr auto; - gap: 4px; - padding: 4px 8px 6px; - - .snippet-input { - background: #3a3a3a; - border: 1px solid #555555; - border-radius: 3px; - color: white; - padding: 4px 8px; - font-size: 12px; - outline: none; - min-width: 0; - - &:focus { - border-color: rgb(51, 183, 102); - } - - &::placeholder { - color: #888888; - } - } - - .snippet-load-btn { - background: #222222; - border: 1px solid rgb(51, 183, 102); - color: white; - padding: 4px 12px; - cursor: pointer; - font-size: 12px; - border-radius: 3px; - opacity: 0.9; - white-space: nowrap; - - &:hover:not(:disabled) { - opacity: 1; - background: #2a2a2a; - } - - &:active:not(:disabled) { - background: #282828; - } - - &:disabled { - opacity: 0.4; - cursor: not-allowed; - } - } - } - - .snippet-error { - color: #ff6b6b; - padding: 2px 8px 6px; - font-size: 11px; - } - - .snippet-status { - padding: 2px 8px 6px; - font-size: 11px; - color: #aaaaaa; - - .status-count { - color: rgb(51, 183, 102); - font-weight: bold; - } - - .status-wired { - display: inline-block; - margin-left: 8px; - color: rgb(51, 183, 102); - font-size: 10px; - } - } - } - - .preview-canvas-container { - position: relative; - overflow: hidden; - border-top: 1px solid #555555; - - .preview-canvas { - width: 100%; - height: 100%; - outline: none; - padding: 0; - } - - .preview-placeholder { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - color: #777777; - font-size: 12px; - padding: 20px; - background: #2a2a2a; - user-select: none; - } - } - - .context-summary { - background: #2a2a2a; - border-top: 1px solid #555555; - padding: 4px 0; - overflow-y: auto; - max-height: 120px; - - .category-list { - .category-item { - display: grid; - grid-template-columns: 1fr auto; - padding: 2px 10px; - font-size: 11px; - - &:hover { - background: #3a3a3a; - } - - .category-label { - color: #cccccc; - } - - .category-count { - color: rgb(51, 183, 102); - font-weight: bold; - } - } - } - } -} diff --git a/packages/tools/flowGraphEditor/src/components/preview/scenePreviewComponent.tsx b/packages/tools/flowGraphEditor/src/components/preview/scenePreviewComponent.tsx index ea751b3669d..3dc55e93a21 100644 --- a/packages/tools/flowGraphEditor/src/components/preview/scenePreviewComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/preview/scenePreviewComponent.tsx @@ -10,13 +10,17 @@ import { SceneContext } from "../../sceneContext"; import { SerializationTools } from "../../serializationTools"; import { LogEntry } from "../log/logComponent"; import { LoadSnippet, type IPlaygroundSnippetResult } from "@tools/snippet-loader"; - -import "./scenePreview.scss"; +import { Body1, Button, Input, Tooltip, makeStyles, tokens } from "@fluentui/react-components"; +import { CheckmarkRegular } from "@fluentui/react-icons"; interface IScenePreviewComponentProps { globalState: GlobalState; } +interface IScenePreviewComponentInnerProps extends IScenePreviewComponentProps { + classes: ReturnType; +} + interface IScenePreviewComponentState { snippetId: string; isLoading: boolean; @@ -24,13 +28,92 @@ interface IScenePreviewComponentState { sceneObjectCount: number; } +const useStyles = makeStyles({ + container: { + display: "grid", + gridTemplateRows: "auto 1fr auto", + height: "100%", + width: "100%", + overflow: "hidden", + background: tokens.colorNeutralBackground3, + color: tokens.colorNeutralForeground1, + fontSize: tokens.fontSizeBase200, + }, + loader: { background: tokens.colorNeutralBackground2 }, + inputRow: { + display: "grid", + gridTemplateColumns: "1fr auto", + gap: tokens.spacingHorizontalXS, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS} ${tokens.spacingVerticalSNudge}`, + }, + fillInput: { width: "100%" }, + error: { + color: tokens.colorPaletteRedForeground1, + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS} ${tokens.spacingVerticalSNudge}`, + fontSize: tokens.fontSizeBase300, + }, + status: { + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS} ${tokens.spacingVerticalSNudge}`, + fontSize: tokens.fontSizeBase300, + color: tokens.colorNeutralForeground2, + }, + statusCount: { + color: tokens.colorPaletteGreenForeground1, + fontWeight: tokens.fontWeightBold, + }, + statusWired: { + display: "inline-flex", + alignItems: "center", + gap: tokens.spacingHorizontalXXS, + marginLeft: tokens.spacingHorizontalS, + color: tokens.colorPaletteGreenForeground1, + fontSize: tokens.fontSizeBase300, + }, + canvasContainer: { + position: "relative", + overflow: "hidden", + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + }, + canvas: { + display: "block", + width: "100%", + height: "100%", + outline: "none", + padding: 0, + }, + summary: { + background: tokens.colorNeutralBackground2, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + padding: `${tokens.spacingVerticalXS} 0`, + overflowY: "auto", + maxHeight: "120px", + }, + categoryItem: { + display: "grid", + gridTemplateColumns: "1fr auto", + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalM}`, + fontSize: tokens.fontSizeBase300, + ":hover": { background: tokens.colorNeutralBackground1Hover }, + }, + categoryLabel: { color: tokens.colorNeutralForeground2 }, + categoryCount: { + color: tokens.colorPaletteGreenForeground1, + fontWeight: tokens.fontWeightBold, + }, +}); + /** * Component that provides a Playground snippet loader and a live scene preview. - * Loading a snippet executes its createScene(), renders the result in a canvas, - * and populates a SceneContext that catalogues every object in the scene for use - * as references in flow graph blocks. + * Wraps the stateful inner class so we can use `makeStyles` (a hook). + * @param props - The component props. + * @returns The rendered scene preview. */ -export class ScenePreviewComponent extends React.Component { +export const ScenePreviewComponent: React.FunctionComponent = (props) => { + const classes = useStyles(); + return ; +}; + +class ScenePreviewInner extends React.Component { private _canvasRef: React.RefObject; private _onContextRefreshedObserver: Nullable> = null; private _onSceneContextChangedObserver: Nullable> = null; @@ -39,7 +122,7 @@ export class ScenePreviewComponent extends React.Component> = null; /** @internal */ - constructor(props: IScenePreviewComponentProps) { + constructor(props: IScenePreviewComponentInnerProps) { super(props); this._canvasRef = React.createRef(); @@ -75,8 +158,11 @@ export class ScenePreviewComponent extends React.Component { - if (this.state.snippetId) { + const sceneSource = this.props.globalState.sceneSource; + if ((sceneSource === "snippet" || (!sceneSource && this.state.snippetId)) && this.state.snippetId) { void this.loadSnippetAsync(); + } else if (sceneSource === "default" || (!sceneSource && !this.state.snippetId)) { + void this._createDefaultSceneAsync(); } }); @@ -85,8 +171,30 @@ export class ScenePreviewComponent extends React.Component DOM element, but the Engine on the existing sceneContext is bound to the previous + // canvas, and a WebGL context cannot be transferred between canvas elements. Detect the + // mismatch and rebuild against the new canvas; if a snippet is already loaded, re-run it, + // otherwise create the default scene. Loses in-flight preview interaction state but preserves + // the editor's snippet selection. + const ctx = this.props.globalState.sceneContext; + const canvas = this._canvasRef.current; + const canvasMismatch = !!ctx && !!canvas && ctx.engine.getRenderingCanvas() !== canvas; + const pendingSnippetId = this.props.globalState.snippetId; + if (canvasMismatch) { + this._disposeCurrentScene(); + if (pendingSnippetId) { + this.setState({ snippetId: pendingSnippetId }, () => { + void this.loadSnippetAsync(); + }); + } else { + void this._createDefaultSceneAsync(); + } + } else if (!ctx && !pendingSnippetId) { + // First mount with no prior state — create a default scene so the editor is usable + // without a snippet. void this._createDefaultSceneAsync(); } } @@ -126,6 +234,7 @@ export class ScenePreviewComponent extends React.Component engine.resize(); + // Resize the engine's internal buffer AND re-render immediately so the canvas paints + // at the new size during the same frame the ResizeObserver fires. Without the inline + // re-render, the GL buffer at the old size flashes briefly stretched into the new CSS + // size, which reads as a visible flicker while the user drags the pane edge. + const resizeHandler = () => { + engine.resize(); + if (scene.activeCamera || (scene.activeCameras && scene.activeCameras.length > 0)) { + scene.render(); + } + }; window.addEventListener("resize", resizeHandler); let resizeObserver: ResizeObserver | null = null; @@ -162,11 +280,13 @@ export class ScenePreviewComponent extends React.Component -
-
SCENE CONTEXT
-
- +
+
+ this.setState({ snippetId: e.target.value })} + onChange={(_, data) => this.setState({ snippetId: data.value })} onKeyDown={this._handleKeyDown} disabled={isLoading} /> - +
- {error &&
{error}
} + {error && {error}} {ctx && ( -
- {sceneObjectCount} objects in scene context - - ✓ wired to flow graph - +
+ {sceneObjectCount} objects in scene context + + + wired to flow graph + +
)}
-
- +
+
- {ctx &&
{this._renderCategorySummary(ctx)}
} + {ctx &&
{this._renderCategorySummary(ctx)}
}
); } private _renderCategorySummary(ctx: SceneContext) { + const { classes } = this.props; const categories = [ { label: "Meshes", count: ctx.meshes.length }, { label: "Lights", count: ctx.lights.length }, @@ -669,11 +794,11 @@ export class ScenePreviewComponent extends React.Component c.count > 0); return ( -
+
{categories.map((c) => ( -
- {c.label} - {c.count} +
+ {c.label} + {c.count}
))}
diff --git a/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTab.scss b/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTab.scss deleted file mode 100644 index 55b6bae4bd5..00000000000 --- a/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTab.scss +++ /dev/null @@ -1,866 +0,0 @@ -.fge-right-panel { - #propertyTab { - $line-padding-left: 5px; - color: white; - background: #333333; - - #header { - height: 30px; - font-size: 16px; - color: white; - background: #222222; - grid-row: 1; - text-align: center; - display: grid; - grid-template-columns: 30px 1fr; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - - #logo { - position: relative; - grid-column: 1; - width: 24px; - height: 24px; - left: 0; - display: flex; - align-self: center; - justify-self: center; - } - - #title { - grid-column: 2; - display: grid; - align-items: center; - text-align: center; - } - } - - .range { - -webkit-appearance: none; - width: 120px; - height: 6px; - background: #d3d3d3; - border-radius: 5px; - outline: none; - opacity: 0.7; - -webkit-transition: 0.2s; - transition: opacity 0.2s; - } - - .range:hover { - opacity: 1; - } - - .range::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 14px; - height: 14px; - border-radius: 50%; - background: rgb(51, 183, 102); - cursor: pointer; - } - - .range::-moz-range-thumb { - width: 14px; - height: 14px; - border-radius: 50%; - background: rgb(51, 183, 102); - cursor: pointer; - } - - input[type="color"] { - -webkit-appearance: none; - border: 1px solid rgba(255, 255, 255, 0.5); - padding: 0; - width: 30px; - height: 20px; - } - input[type="color"]::-webkit-color-swatch-wrapper { - padding: 0; - } - input[type="color"]::-webkit-color-swatch { - border: none; - } - - .sliderLine { - padding-left: $line-padding-left; - height: 30px; - display: grid; - grid-template-rows: 100%; - grid-template-columns: 1fr 50px auto; - - .label { - grid-column: 1; - display: flex; - align-items: center; - } - - .slider { - grid-column: 3; - grid-row: 1; - margin-right: 5px; - width: 90%; - display: flex; - align-items: center; - } - - .floatLine { - grid-column: 2; - padding-left: $line-padding-left; - - .label { - grid-column: 1; - display: flex; - align-items: center; - } - - .short { - grid-column: 1; - display: flex; - align-items: center; - - input { - width: 35px; - } - - input::-webkit-outer-spin-button, - input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - - input[type="number"] { - -moz-appearance: textfield; - } - } - - .copy { - display: none; - } - } - - .copy { - display: none; - } - } - - .textInputLine { - padding-left: $line-padding-left; - height: 30px; - display: grid; - grid-template-columns: 1fr 120px auto; - - .label { - grid-column: 1; - display: flex; - align-items: center; - } - - .value { - display: flex; - align-items: center; - grid-column: 2; - - input { - width: calc(100% - 5px); - margin-right: 5px; - } - } - } - - .textInputArea { - padding-left: $line-padding-left; - height: 50px; - display: grid; - grid-template-columns: 1fr 120px; - - .label { - grid-column: 1; - display: flex; - align-items: center; - } - - textarea { - margin-right: 5px; - margin-left: -50%; - height: 40px; - resize: none; - } - - .value { - display: flex; - align-items: center; - grid-column: 2; - } - } - - .paneContainer { - margin-top: 3px; - display: grid; - grid-template-rows: 100%; - grid-template-columns: 100%; - - .paneList { - border-left: 3px solid transparent; - } - - &:hover { - .paneList { - border-left: 3px solid rgba(51, 183, 102, 0.8); - } - - .paneContainer-content { - .header { - .title { - border-left: 3px solid rgb(51, 183, 102); - } - } - } - } - - .paneContainer-highlight-border { - grid-row: 1; - grid-column: 1; - opacity: 1; - border: 3px solid red; - transition: opacity 250ms; - pointer-events: none; - - &.transparent { - opacity: 0; - } - } - - .paneContainer-content { - grid-row: 1; - grid-column: 1; - - .header { - display: grid; - grid-template-columns: 1fr auto; - background: #555555; - height: 30px; - padding-right: 5px; - cursor: pointer; - - .title { - border-left: 3px solid transparent; - padding-left: 5px; - grid-column: 1; - display: flex; - align-items: center; - } - - .collapse { - grid-column: 2; - display: flex; - align-items: center; - justify-items: center; - transform-origin: center; - - &.closed { - transform: rotate(180deg); - } - } - } - - .paneList > div:not(:last-child) { - border-bottom: 0.5px solid rgba(255, 255, 255, 0.1); - } - - .fragment > div:not(:last-child) { - border-bottom: 0.5px solid rgba(255, 255, 255, 0.1); - } - } - } - - .color-picker { - height: calc(100% - 8px); - margin: 4px; - width: calc(100% - 8px); - - .color-rect { - height: calc(100% - 4px); - border: 2px white solid; - cursor: pointer; - min-height: 18px; - } - - .color-picker-cover { - position: fixed; - top: 0px; - right: 0px; - bottom: 0px; - left: 0px; - z-index: 1; - } - - .color-picker-float { - z-index: 2; - position: absolute; - } - } - - .gradient-step { - display: grid; - grid-template-rows: 100%; - grid-template-columns: 20px 30px 40px auto 20px 30px; - padding-top: 5px; - padding-left: 5px; - padding-bottom: 5px; - - .step { - grid-row: 1; - grid-column: 1; - } - - .color { - grid-row: 1; - grid-column: 2; - cursor: pointer; - } - - .step-value { - margin-left: 5px; - grid-row: 1; - grid-column: 3; - text-align: right; - margin-right: 5px; - } - - .step-slider { - grid-row: 1; - grid-column: 4; - display: grid; - justify-content: stretch; - align-content: center; - margin-right: -5px; - padding-left: 12px; - - input { - width: 90%; - } - } - - .gradient-copy { - grid-row: 1; - grid-column: 5; - display: grid; - align-content: center; - justify-content: center; - - .img { - height: 20px; - width: 20px; - } - .img:hover { - cursor: pointer; - } - } - .gradient-delete { - grid-row: 1; - grid-column: 6; - display: grid; - align-content: center; - justify-content: center; - .img { - height: 20px; - width: 20px; - } - .img:hover { - cursor: pointer; - } - } - } - - .floatLine { - padding-left: $line-padding-left; - height: 30px; - display: grid; - grid-template-columns: 1fr 120px; - - .label { - grid-column: 1; - display: flex; - align-items: center; - } - - .value { - align-self: center; - grid-column: 2; - display: flex; - align-items: center; - border: 1px solid #ced4da; - padding: 5px 2px; - border-radius: 3px; - background: white; - width: 110px; - height: 10px; - - &:focus { - color: #212529; - background-color: #fff; - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgb(13, 110, 253); - } - - input { - display: inline-block; - width: 100%; - border: none; - background: none; - - &:focus { - color: #203b35; - outline: none; - border: 0px; - box-shadow: none; - } - } - } - - .short { - grid-column: 2; - - display: flex; - align-items: center; - - input { - width: 27px; - } - - input::-webkit-outer-spin-button, - input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - - input[type="number"] { - -moz-appearance: textfield; - } - } - - .copy { - display: none; - } - } - - .vector3Line { - padding-left: $line-padding-left; - display: grid; - - .firstLine { - display: grid; - grid-template-columns: 1fr auto 20px; - height: 30px; - - .label { - grid-column: 1; - display: flex; - align-items: center; - } - - .vector { - grid-column: 2; - display: flex; - align-items: center; - text-align: right; - opacity: 0.8; - } - - .expand { - grid-column: 3; - display: grid; - align-items: center; - justify-items: center; - cursor: pointer; - } - - .copy { - display: none; - } - } - - .secondLine { - display: grid; - padding-right: 5px; - border-left: 1px solid rgb(51, 183, 102); - - .no-right-margin { - margin-right: 0; - } - - .numeric { - display: grid; - grid-template-columns: 1fr auto; - } - - .numeric-label { - text-align: right; - grid-column: 1; - display: flex; - align-items: center; - justify-self: right; - margin-right: 10px; - } - - .numeric-value { - align-self: center; - grid-column: 2; - display: flex; - align-items: center; - border: 1px solid #ced4da; - padding: 5px 2px; - border-radius: 3px; - background: white; - width: 120px; - height: 10px; - - &:focus { - color: #212529; - background-color: #fff; - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgb(13, 110, 253); - } - - input { - display: inline-block; - width: 100%; - border: none; - background: none; - - &:focus { - color: #203b35; - outline: none; - border: 0px; - box-shadow: none; - } - } - } - } - } - - .buttonLine { - height: 30px; - display: grid; - align-items: center; - justify-items: stretch; - padding-bottom: 5px; - - &.disabled { - opacity: 0.3; - } - - input[type="file"] { - display: none; - } - - .file-upload { - background: #222222; - border: 1px solid rgb(51, 183, 102); - margin: 5px 10px; - color: white; - padding: 4px 5px; - padding-top: 0px; - opacity: 0.9; - cursor: pointer; - text-align: center; - } - - .file-upload:hover { - opacity: 1; - } - - .file-upload:active { - transform: scale(0.98); - transform-origin: 0.5 0.5; - } - - button { - background: #222222; - border: 1px solid rgb(51, 183, 102); - margin: 5px 10px 5px 10px; - color: white; - padding: 4px 5px; - opacity: 0.9; - } - - button:hover { - opacity: 1; - } - - button:active { - background: #282828; - } - - button:focus { - border: 1px solid rgb(51, 183, 102); - outline: 0px; - } - } - - .checkBoxLine { - padding-left: $line-padding-left; - height: 30px; - display: grid; - grid-template-columns: 1fr auto; - - .label { - grid-column: 1; - display: flex; - align-items: center; - } - - .checkBox { - grid-column: 2; - - display: flex; - align-items: center; - - .lbl { - position: relative; - display: block; - height: 14px; - width: 34px; - margin-right: 5px; - background: #898989; - border-radius: 100px; - cursor: pointer; - transition: all 0.3s ease; - } - - .lbl:after { - position: absolute; - left: 3px; - top: 2px; - display: block; - width: 10px; - height: 10px; - border-radius: 100px; - background: #fff; - box-shadow: 0px 3px 3px rgba(0, 0, 0, 0.05); - content: ""; - transition: all 0.15s ease; - } - - .lbl:active:after { - transform: scale(1.15, 0.85); - } - - .cbx:checked ~ label { - background: rgb(51, 183, 102); - } - - .cbx:checked ~ label:after { - left: 20px; - background: rgb(24, 117, 22); - } - - .cbx:checked ~ label.disabled { - background: rgb(22, 117, 36); - cursor: pointer; - } - - .cbx:checked ~ label.disabled:after { - left: 20px; - background: rgb(85, 85, 85); - cursor: pointer; - } - - .cbx ~ label.disabled { - background: rgb(85, 85, 85); - cursor: pointer; - } - - .hidden { - display: none; - } - } - - .copy { - display: none; - } - } - - .listLine { - padding-left: $line-padding-left; - height: 30px; - display: grid; - grid-template-columns: 1fr auto; - - .label { - grid-column: 1; - display: flex; - align-items: center; - } - - .options { - grid-column: 2; - - display: flex; - align-items: center; - margin-right: 5px; - - select { - width: 115px; - } - } - - .copy { - display: none; - } - } - - .color3Line { - padding-left: $line-padding-left; - display: grid; - - .firstLine { - height: 30px; - display: grid; - grid-template-columns: 1fr auto 20px; - - .label { - grid-column: 1; - display: flex; - align-items: center; - } - - .textInputLine { - display: none; - } - - .color3 { - grid-column: 2; - width: 50px; - - display: flex; - align-items: center; - - input { - margin-right: 5px; - } - } - - .expand { - grid-column: 3; - display: grid; - align-items: center; - justify-items: center; - cursor: pointer; - - img { - height: 100%; - width: 20px; - } - } - - .copy { - grid-column: 4; - display: grid; - align-items: center; - justify-items: center; - cursor: pointer; - - img { - height: 100%; - width: 24px; - } - } - } - - .secondLine { - display: grid; - padding-right: 5px; - border-left: 1px solid rgb(51, 183, 102); - - .numeric { - display: grid; - grid-template-columns: 1fr auto; - } - - .numeric-label { - text-align: right; - grid-column: 1; - display: flex; - align-items: center; - justify-self: right; - margin-right: 10px; - } - - .numeric-value { - width: 120px; - grid-column: 2; - display: flex; - align-items: center; - border: 1px solid rgb(51, 183, 102); - } - } - } - - .textLine { - padding-left: $line-padding-left; - min-height: 30px; - display: grid; - grid-template-columns: 1fr auto; - - .label { - grid-column: 1; - display: flex; - align-items: center; - } - - &.label-center { - justify-items: center; - } - - &.bold { - font-weight: bold; - text-decoration: underline; - } - - .link-value { - grid-column: 2; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - text-align: end; - opacity: 0.8; - margin: 5px; - margin-top: 6px; - max-width: 140px; - text-decoration: underline; - cursor: pointer; - } - - .value { - grid-column: 2; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - text-align: end; - opacity: 0.8; - margin: 5px; - margin-top: 6px; - max-width: 200px; - -webkit-user-select: text; - -moz-user-select: text; - -ms-user-select: text; - user-select: text; - - &.check { - color: green; - } - - &.uncheck { - color: red; - } - } - } - } -} diff --git a/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTabComponent.tsx b/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTabComponent.tsx index ad306c13118..fa6bfff63aa 100644 --- a/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTabComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTabComponent.tsx @@ -1,406 +1,423 @@ -import * as React from "react"; -import { type GlobalState } from "../../globalState"; -import { type Nullable } from "core/types"; -import { LineContainerComponent } from "../../sharedComponents/lineContainerComponent"; -import { StringTools } from "shared-ui-components/stringTools"; -import { FileButtonLineComponent } from "../../sharedComponents/fileButtonLineComponent"; -import { Tools } from "core/Misc/tools"; -import { SerializationTools } from "../../serializationTools"; -import { CheckBoxLineComponent } from "../../sharedComponents/checkBoxLineComponent"; -import { DataStorage } from "core/Misc/dataStorage"; -import { Engine } from "core/Engines/engine"; -import { FramePropertyTabComponent } from "../../graphSystem/properties/framePropertyComponent"; -import { FrameNodePortPropertyTabComponent } from "../../graphSystem/properties/frameNodePortPropertyComponent"; -import { NodePortPropertyTabComponent } from "../../graphSystem/properties/nodePortPropertyComponent"; -import { type Observer } from "core/Misc/observable"; -import { LogEntry } from "../log/logComponent"; -import "./propertyTab.scss"; -import { GraphNode } from "shared-ui-components/nodeGraphSystem/graphNode"; -import { GraphFrame } from "shared-ui-components/nodeGraphSystem/graphFrame"; -import { NodePort } from "shared-ui-components/nodeGraphSystem/nodePort"; -import { type FrameNodePort } from "shared-ui-components/nodeGraphSystem/frameNodePort"; -import { IsFramePortData } from "shared-ui-components/nodeGraphSystem/tools"; -import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; -import { type LockObject } from "shared-ui-components/tabs/propertyGrids/lockObject"; -import { TextLineComponent } from "shared-ui-components/lines/textLineComponent"; -import { SliderLineComponent } from "shared-ui-components/lines/sliderLineComponent"; -import { Constants } from "core/Engines/constants"; -import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; -import { ShowToast } from "../toast/toastComponent"; - -interface IPropertyTabComponentProps { - globalState: GlobalState; - lockObject: LockObject; -} - -interface IPropertyTabComponentState { - currentNode: Nullable; - currentFrame: Nullable; - currentFrameNodePort: Nullable; - currentNodePort: Nullable; - uploadInProgress: boolean; -} - -export class PropertyTabComponent extends React.Component { - private _onBuiltObserver: Nullable>; - private _onHashChange = () => { - void this._loadSnippetFromHashAsync(); - }; - - constructor(props: IPropertyTabComponentProps) { - super(props); - - this.state = { currentNode: null, currentFrame: null, currentFrameNodePort: null, currentNodePort: null, uploadInProgress: false }; - } - - override componentDidMount() { - this.props.globalState.stateManager.onSelectionChangedObservable.add((options) => { - const { selection } = options || {}; - if (selection instanceof GraphNode) { - this.setState({ currentNode: selection, currentFrame: null, currentFrameNodePort: null, currentNodePort: null }); - } else if (selection instanceof GraphFrame) { - this.setState({ currentNode: null, currentFrame: selection, currentFrameNodePort: null, currentNodePort: null }); - } else if (IsFramePortData(selection)) { - this.setState({ currentNode: null, currentFrame: selection.frame, currentFrameNodePort: selection.port, currentNodePort: null }); - } else if (selection instanceof NodePort) { - this.setState({ currentNode: null, currentFrame: null, currentFrameNodePort: null, currentNodePort: selection }); - } else { - this.setState({ currentNode: null, currentFrame: null, currentFrameNodePort: null, currentNodePort: null }); - } - }); - - this._onBuiltObserver = this.props.globalState.onBuiltObservable.add(() => { - this.forceUpdate(); - }); - - this.props.globalState.hostDocument.defaultView?.addEventListener("hashchange", this._onHashChange); - void this._loadSnippetFromHashAsync(); - } - - override componentWillUnmount() { - this.props.globalState.onBuiltObservable.remove(this._onBuiltObserver); - this.props.globalState.hostDocument.defaultView?.removeEventListener("hashchange", this._onHashChange); - } - - private _getSnippetIdFromHash(): string { - const hash = this.props.globalState.hostDocument.defaultView?.location.hash.substring(1) ?? ""; - try { - return decodeURIComponent(hash); - } catch { - return hash; - } - } - - private _setSnippetIdInHash(snippetId: string): void { - const hostWindow = this.props.globalState.hostDocument.defaultView; - if (!hostWindow) { - return; - } - const { pathname, search } = hostWindow.location; - hostWindow.history.replaceState(null, "", `${pathname}${search}#${snippetId}`); - } - - private async _loadSnippetFromHashAsync(): Promise { - const snippetId = this._getSnippetIdFromHash(); - if (!snippetId || snippetId === this.props.globalState.flowGraphSnippetId) { - return; - } - await this.loadFromSnippetAsync(snippetId); - } - - load(file: File) { - Tools.ReadFile( - file, - (data) => { - const decoder = new TextDecoder("utf-8"); - const doLoadAsync = async () => { - await SerializationTools.DeserializeAsync(JSON.parse(decoder.decode(data)), this.props.globalState); - this.props.globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); - this.props.globalState.onClearUndoStack.notifyObservers(); - ShowToast(this.props.globalState, "Flow graph loaded from file", "success"); - }; - void doLoadAsync(); - }, - undefined, - true - ); - } - - save() { - const json = SerializationTools.Serialize(this.props.globalState.flowGraph, this.props.globalState); - StringTools.DownloadAsFile(this.props.globalState.hostDocument, json, "flowGraph.json"); - ShowToast(this.props.globalState, "Flow graph saved to file", "success"); - } - - /** - * Load a flow graph from a .glb/.gltf file that contains the BABYLON_flow_graph extension. - * @param file - the glb/gltf file to load - */ - loadGlb(file: File) { - const doLoadAsync = async () => { - try { - const imported = await SerializationTools.ImportFromGlbAsync(file, this.props.globalState); - if (imported) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Flow graph loaded from glTF file", false)); - } else { - this.props.globalState.onLogRequiredObservable.notifyObservers( - new LogEntry("No BABYLON_flow_graph extension found in this file. Drop the file on the preview pane to load its scene and KHR_interactivity data.", true) - ); - } - } catch (err) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Error loading glTF: " + err, true)); - } - }; - void doLoadAsync(); - } - - /** - * Export the flow graph (and optionally the preview scene) as a .glb file. - */ - async exportGlbAsync() { - try { - const scene = this.props.globalState.sceneContext?.scene ?? null; - await SerializationTools.ExportGlbAsync(this.props.globalState.flowGraph, this.props.globalState, scene); - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Flow graph exported as flowGraph.glb", false)); - } catch (err) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Error exporting glTF: " + err, true)); - } - } - - customSave() { - this.setState({ uploadInProgress: true }); - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Saving your flow graph to Babylon.js snippet server...", false)); - this.props.globalState - .customSave!.action(SerializationTools.Serialize(this.props.globalState.flowGraph, this.props.globalState)) - // eslint-disable-next-line github/no-then - .then(() => { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Flow graph saved successfully", false)); - this.setState({ uploadInProgress: false }); - }) - // eslint-disable-next-line github/no-then - .catch((err: any) => { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry(err, true)); - this.setState({ uploadInProgress: false }); - }); - } - - async saveToSnippetServerAsync() { - const json = SerializationTools.Serialize(this.props.globalState.flowGraph, this.props.globalState); - const dataToSend = { - payload: JSON.stringify({ flowGraph: json }), - name: "", - description: "", - tags: "", - }; - - const snippetId = this.props.globalState.flowGraphSnippetId; - const url = Constants.SnippetUrl + (snippetId ? "/" + snippetId : ""); - - try { - const response = await fetch(url, { - method: "POST", - // eslint-disable-next-line @typescript-eslint/naming-convention - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(dataToSend), - }); - - if (response.ok) { - const snippet = await response.json(); - let newId = snippet.id; - if (snippet.version && snippet.version !== "0") { - newId += "#" + snippet.version; - } - this.props.globalState.flowGraphSnippetId = newId; - this._setSnippetIdInHash(newId); - this.forceUpdate(); - - if (navigator.clipboard) { - try { - await navigator.clipboard.writeText(newId); - } catch { - /* clipboard may not be available in all contexts */ - } - } - - const windowAsAny = window as any; - if (windowAsAny.Playground && snippetId) { - windowAsAny.Playground.onRequestCodeChangeObservable.notifyObservers({ - regex: new RegExp(snippetId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), - replace: newId, - }); - } - - ShowToast(this.props.globalState, "Graph saved — ID: " + newId + " (copied to clipboard)", "success"); - } else { - ShowToast(this.props.globalState, `Unable to save flow graph (${(dataToSend.payload.length / 1024).toFixed(0)} KB). Please try again.`, "error"); - } - } catch { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Unable to save flow graph to snippet server", true)); - } - } - - async loadFromSnippetAsync(snippetId?: string) { - const id = snippetId || this.props.globalState.hostDocument.defaultView!.prompt("Please enter the snippet ID to load"); - if (!id) { - return; - } - - this.props.globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Loading flow graph from snippet " + id + "...", false)); - - const url = Constants.SnippetUrl + "/" + id.replace(/#/g, "/"); - - try { - const response = await fetch(url); - if (response.ok) { - const snippet = await response.json(); - const jsonPayload = JSON.parse(snippet.jsonPayload); - const serializationObject = JSON.parse(jsonPayload.flowGraph); - - try { - await SerializationTools.DeserializeAsync(serializationObject, this.props.globalState); - this.props.globalState.flowGraphSnippetId = id; - this._setSnippetIdInHash(id); - this.props.globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); - this.props.globalState.onClearUndoStack.notifyObservers(); - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Flow graph loaded from snippet " + id, false)); - ShowToast(this.props.globalState, "Flow graph loaded from snippet " + id, "success"); - this.forceUpdate(); - } catch (err) { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Error loading snippet: " + err, true)); - ShowToast(this.props.globalState, "Error loading snippet: " + err, "error"); - } - } else { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Unable to load snippet " + id, true)); - ShowToast(this.props.globalState, "Unable to load snippet " + id, "error"); - } - } catch { - this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Unable to load snippet " + id, true)); - } - } - - override render() { - if (this.state.currentNode) { - return ( -
- - {this.state.currentNode?.renderProperties() || this.state.currentNodePort?.node.renderProperties()} -
- ); - } - - if (this.state.currentFrameNodePort && this.state.currentFrame) { - return ( - - ); - } - - if (this.state.currentNodePort) { - return ; - } - - if (this.state.currentFrame) { - return ; - } - - const gridSize = DataStorage.ReadNumber("GridSize", 20); - - return ( -
- -
- - - this.props.globalState.hostDocument.defaultView!.open("https://doc.babylonjs.com/features/featuresDeepDive/flowGraph", "_blank")} - /> - - - { - this.props.globalState.onZoomToFitRequiredObservable.notifyObservers(); - }} - /> - { - this.props.globalState.onReOrganizedRequiredObservable.notifyObservers(); - }} - /> - - - { - DataStorage.WriteNumber("GridSize", value); - this.props.globalState.stateManager.onGridSizeChanged.notifyObservers(); - this.forceUpdate(); - }} - /> - DataStorage.ReadBoolean("ShowGrid", true)} - onSelect={(value: boolean) => { - DataStorage.WriteBoolean("ShowGrid", value); - this.props.globalState.stateManager.onGridSizeChanged.notifyObservers(); - }} - /> - - - this.load(file)} accept=".json" /> - this.loadGlb(file)} accept=".glb,.gltf" /> - { - this.save(); - }} - /> - {this.props.globalState.customSave && ( - { - this.customSave(); - }} - /> - )} - - - {this.props.globalState.flowGraphSnippetId && ( - - )} - await this.loadFromSnippetAsync()} /> - await this.saveToSnippetServerAsync()} /> - -
-
- ); - } -} +import * as React from "react"; +import { type GlobalState } from "../../globalState"; +import { type Nullable } from "core/types"; +import { StringTools } from "shared-ui-components/stringTools"; +import { Tools } from "core/Misc/tools"; +import { SerializationTools } from "../../serializationTools"; +import { DataStorage } from "core/Misc/dataStorage"; +import { Engine } from "core/Engines/engine"; +import { FramePropertyTabComponent } from "../../graphSystem/properties/framePropertyComponent"; +import { FrameNodePortPropertyTabComponent } from "../../graphSystem/properties/frameNodePortPropertyComponent"; +import { NodePortPropertyTabComponent } from "../../graphSystem/properties/nodePortPropertyComponent"; +import { type Observer } from "core/Misc/observable"; +import { LogEntry } from "../log/logComponent"; +import { GraphNode } from "shared-ui-components/nodeGraphSystem/graphNode"; +import { GraphFrame } from "shared-ui-components/nodeGraphSystem/graphFrame"; +import { NodePort } from "shared-ui-components/nodeGraphSystem/nodePort"; +import { type FrameNodePort } from "shared-ui-components/nodeGraphSystem/frameNodePort"; +import { IsFramePortData } from "shared-ui-components/nodeGraphSystem/tools"; +import { type LockObject } from "shared-ui-components/tabs/propertyGrids/lockObject"; +import { Constants } from "core/Engines/constants"; +import { ShowToast } from "../toast/toastComponent"; +import { Accordion, AccordionSection } from "shared-ui-components/fluent/primitives/accordion"; +import { Button } from "shared-ui-components/fluent/primitives/button"; +import { SwitchPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/switchPropertyLine"; +import { SyncedSliderPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/syncedSliderPropertyLine"; +import { TextInputPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/inputPropertyLine"; +import { LinkPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/linkPropertyLine"; +import { TextPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/textPropertyLine"; +import { FileUploadLine } from "shared-ui-components/fluent/hoc/fileUploadLine"; +import { makeStyles, tokens } from "@fluentui/react-components"; + +interface IPropertyTabComponentProps { + globalState: GlobalState; + lockObject: LockObject; +} + +interface IPropertyTabInnerProps extends IPropertyTabComponentProps { + classes: ReturnType; +} + +interface IPropertyTabComponentState { + currentNode: Nullable; + currentFrame: Nullable; + currentFrameNodePort: Nullable; + currentNodePort: Nullable; + uploadInProgress: boolean; +} + +const useStyles = makeStyles({ + root: { + display: "flex", + flexDirection: "column", + height: "100%", + overflowY: "auto", + background: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + }, + buttonStack: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + alignItems: "stretch", + padding: `${tokens.spacingVerticalXS} 0`, + }, +}); + +/** + * Property tab - right-pane content. + * + * Displays either the property panel of the currently selected node/frame/port, or a + * default view of editor-wide controls (UI, options, file, snippet) organised into a + * Fluent `Accordion`. Wraps the stateful inner class so we can use `makeStyles`. + * @param props - The component props. + * @returns The rendered property tab. + */ +export const PropertyTabComponent: React.FunctionComponent = (props) => { + const classes = useStyles(); + return ; +}; + +class PropertyTabInner extends React.Component { + private _onBuiltObserver: Nullable>; + private _onHashChange = () => { + void this._loadSnippetFromHashAsync(); + }; + + constructor(props: IPropertyTabInnerProps) { + super(props); + + this.state = { currentNode: null, currentFrame: null, currentFrameNodePort: null, currentNodePort: null, uploadInProgress: false }; + } + + override componentDidMount() { + this.props.globalState.stateManager.onSelectionChangedObservable.add((options) => { + const { selection } = options || {}; + if (selection instanceof GraphNode) { + this.setState({ currentNode: selection, currentFrame: null, currentFrameNodePort: null, currentNodePort: null }); + } else if (selection instanceof GraphFrame) { + this.setState({ currentNode: null, currentFrame: selection, currentFrameNodePort: null, currentNodePort: null }); + } else if (IsFramePortData(selection)) { + this.setState({ currentNode: null, currentFrame: selection.frame, currentFrameNodePort: selection.port, currentNodePort: null }); + } else if (selection instanceof NodePort) { + this.setState({ currentNode: null, currentFrame: null, currentFrameNodePort: null, currentNodePort: selection }); + } else { + this.setState({ currentNode: null, currentFrame: null, currentFrameNodePort: null, currentNodePort: null }); + } + }); + + this._onBuiltObserver = this.props.globalState.onBuiltObservable.add(() => { + this.forceUpdate(); + }); + + this.props.globalState.hostDocument.defaultView?.addEventListener("hashchange", this._onHashChange); + void this._loadSnippetFromHashAsync(); + } + + override componentWillUnmount() { + this.props.globalState.onBuiltObservable.remove(this._onBuiltObserver); + this.props.globalState.hostDocument.defaultView?.removeEventListener("hashchange", this._onHashChange); + } + + private _getSnippetIdFromHash(): string { + const hash = this.props.globalState.hostDocument.defaultView?.location.hash.substring(1) ?? ""; + try { + return decodeURIComponent(hash); + } catch { + return hash; + } + } + + private _setSnippetIdInHash(snippetId: string): void { + const hostWindow = this.props.globalState.hostDocument.defaultView; + if (!hostWindow) { + return; + } + const { pathname, search } = hostWindow.location; + hostWindow.history.replaceState(null, "", `${pathname}${search}#${snippetId}`); + } + + private async _loadSnippetFromHashAsync(): Promise { + const snippetId = this._getSnippetIdFromHash(); + if (!snippetId || snippetId === this.props.globalState.flowGraphSnippetId) { + return; + } + await this.loadFromSnippetAsync(snippetId); + } + + load(file: File) { + Tools.ReadFile( + file, + (data) => { + const decoder = new TextDecoder("utf-8"); + const doLoadAsync = async () => { + await SerializationTools.DeserializeAsync(JSON.parse(decoder.decode(data)), this.props.globalState); + this.props.globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + this.props.globalState.onClearUndoStack.notifyObservers(); + ShowToast(this.props.globalState, "Flow graph loaded from file", "success"); + }; + void doLoadAsync(); + }, + undefined, + true + ); + } + + save() { + const json = SerializationTools.Serialize(this.props.globalState.flowGraph, this.props.globalState); + StringTools.DownloadAsFile(this.props.globalState.hostDocument, json, "flowGraph.json"); + ShowToast(this.props.globalState, "Flow graph saved to file", "success"); + } + + /** + * Load a flow graph from a .glb/.gltf file that contains the BABYLON_flow_graph extension. + * @param file - the glb/gltf file to load + */ + loadGlb(file: File) { + const doLoadAsync = async () => { + try { + const imported = await SerializationTools.ImportFromGlbAsync(file, this.props.globalState); + if (imported) { + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Flow graph loaded from glTF file", false)); + } else { + this.props.globalState.onLogRequiredObservable.notifyObservers( + new LogEntry("No BABYLON_flow_graph extension found in this file. Drop the file on the preview pane to load its scene and KHR_interactivity data.", true) + ); + } + } catch (err) { + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Error loading glTF: " + err, true)); + } + }; + void doLoadAsync(); + } + + /** + * Export the flow graph (and optionally the preview scene) as a .glb file. + */ + async exportGlbAsync() { + try { + const scene = this.props.globalState.sceneContext?.scene ?? null; + await SerializationTools.ExportGlbAsync(this.props.globalState.flowGraph, this.props.globalState, scene); + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Flow graph exported as flowGraph.glb", false)); + } catch (err) { + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Error exporting glTF: " + err, true)); + } + } + + customSave() { + this.setState({ uploadInProgress: true }); + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Saving your flow graph to Babylon.js snippet server...", false)); + this.props.globalState + .customSave!.action(SerializationTools.Serialize(this.props.globalState.flowGraph, this.props.globalState)) + // eslint-disable-next-line github/no-then + .then(() => { + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Flow graph saved successfully", false)); + this.setState({ uploadInProgress: false }); + }) + // eslint-disable-next-line github/no-then + .catch((err: any) => { + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry(err, true)); + this.setState({ uploadInProgress: false }); + }); + } + + async saveToSnippetServerAsync() { + const json = SerializationTools.Serialize(this.props.globalState.flowGraph, this.props.globalState); + const dataToSend = { + payload: JSON.stringify({ flowGraph: json }), + name: "", + description: "", + tags: "", + }; + + const snippetId = this.props.globalState.flowGraphSnippetId; + const url = Constants.SnippetUrl + (snippetId ? "/" + snippetId : ""); + + try { + const response = await fetch(url, { + method: "POST", + // eslint-disable-next-line @typescript-eslint/naming-convention + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(dataToSend), + }); + + if (response.ok) { + const snippet = await response.json(); + let newId = snippet.id; + if (snippet.version && snippet.version !== "0") { + newId += "#" + snippet.version; + } + this.props.globalState.flowGraphSnippetId = newId; + this._setSnippetIdInHash(newId); + this.forceUpdate(); + + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(newId); + } catch { + /* clipboard may not be available in all contexts */ + } + } + + const windowAsAny = window as any; + if (windowAsAny.Playground && snippetId) { + windowAsAny.Playground.onRequestCodeChangeObservable.notifyObservers({ + regex: new RegExp(snippetId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), + replace: newId, + }); + } + + ShowToast(this.props.globalState, "Graph saved - ID: " + newId + " (copied to clipboard)", "success"); + } else { + ShowToast(this.props.globalState, `Unable to save flow graph (${(dataToSend.payload.length / 1024).toFixed(0)} KB). Please try again.`, "error"); + } + } catch { + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Unable to save flow graph to snippet server", true)); + } + } + + async loadFromSnippetAsync(snippetId?: string) { + const id = snippetId || this.props.globalState.hostDocument.defaultView!.prompt("Please enter the snippet ID to load"); + if (!id) { + return; + } + + this.props.globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Loading flow graph from snippet " + id + "...", false)); + + const url = Constants.SnippetUrl + "/" + id.replace(/#/g, "/"); + + try { + const response = await fetch(url); + if (response.ok) { + const snippet = await response.json(); + const jsonPayload = JSON.parse(snippet.jsonPayload); + const serializationObject = JSON.parse(jsonPayload.flowGraph); + + try { + await SerializationTools.DeserializeAsync(serializationObject, this.props.globalState); + this.props.globalState.flowGraphSnippetId = id; + this._setSnippetIdInHash(id); + this.props.globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + this.props.globalState.onClearUndoStack.notifyObservers(); + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Flow graph loaded from snippet " + id, false)); + ShowToast(this.props.globalState, "Flow graph loaded from snippet " + id, "success"); + this.forceUpdate(); + } catch (err) { + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Error loading snippet: " + err, true)); + ShowToast(this.props.globalState, "Error loading snippet: " + err, "error"); + } + } else { + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Unable to load snippet " + id, true)); + ShowToast(this.props.globalState, "Unable to load snippet " + id, "error"); + } + } catch { + this.props.globalState.onLogRequiredObservable.notifyObservers(new LogEntry("Unable to load snippet " + id, true)); + } + } + + override render() { + const { classes } = this.props; + if (this.state.currentNode) { + return
{this.state.currentNode?.renderProperties() || this.state.currentNodePort?.node.renderProperties()}
; + } + + if (this.state.currentFrameNodePort && this.state.currentFrame) { + return ( + + ); + } + + if (this.state.currentNodePort) { + return ; + } + + if (this.state.currentFrame) { + return ; + } + + const gridSize = DataStorage.ReadNumber("GridSize", 20); + const showGrid = DataStorage.ReadBoolean("ShowGrid", true); + const docUrl = "https://doc.babylonjs.com/features/featuresDeepDive/flowGraph"; + + return ( +
+ + + + + + + +
+
+
+ + + { + DataStorage.WriteNumber("GridSize", value); + this.props.globalState.stateManager.onGridSizeChanged.notifyObservers(); + this.forceUpdate(); + }} + /> + { + DataStorage.WriteBoolean("ShowGrid", value); + this.props.globalState.stateManager.onGridSizeChanged.notifyObservers(); + this.forceUpdate(); + }} + /> + + + +
+ this.load(files[0])} /> + this.loadGlb(files[0])} /> +
+
+ + + {this.props.globalState.flowGraphSnippetId && ( + { + this.props.globalState.flowGraphSnippetId = value; + this.forceUpdate(); + }} + /> + )} +
+
+
+
+
+ ); + } +} diff --git a/packages/tools/flowGraphEditor/src/components/toast/toast.scss b/packages/tools/flowGraphEditor/src/components/toast/toast.scss deleted file mode 100644 index 6cbd81f239a..00000000000 --- a/packages/tools/flowGraphEditor/src/components/toast/toast.scss +++ /dev/null @@ -1,79 +0,0 @@ -.fge-toast-container { - position: fixed; - bottom: 16px; - right: 16px; - z-index: 9999; - display: flex; - flex-direction: column-reverse; - gap: 8px; - pointer-events: none; -} - -.fge-toast { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 16px; - border-radius: 6px; - font: - 13px "acumin-pro", - sans-serif; - color: #fff; - pointer-events: auto; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); - animation: fge-toast-slide-in 0.25s ease-out; - max-width: 380px; - word-wrap: break-word; - - &.fge-toast-info { - background: #2b579a; - } - - &.fge-toast-success { - background: #2d7a3a; - } - - &.fge-toast-error { - background: #a33; - } - - &.fge-toast-warning { - background: #8a6d1b; - } - - .fge-toast-icon { - flex-shrink: 0; - font-size: 16px; - } - - .fge-toast-message { - flex: 1; - white-space: pre-line; - } - - .fge-toast-close { - flex-shrink: 0; - background: none; - border: none; - color: rgba(255, 255, 255, 0.7); - cursor: pointer; - font-size: 14px; - padding: 0; - line-height: 1; - - &:hover { - color: #fff; - } - } -} - -@keyframes fge-toast-slide-in { - from { - opacity: 0; - transform: translateY(16px); - } - to { - opacity: 1; - transform: translateY(0); - } -} diff --git a/packages/tools/flowGraphEditor/src/components/toast/toastComponent.tsx b/packages/tools/flowGraphEditor/src/components/toast/toastComponent.tsx index 9fefa50945d..0c817627402 100644 --- a/packages/tools/flowGraphEditor/src/components/toast/toastComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/toast/toastComponent.tsx @@ -1,161 +1,17 @@ -import * as React from "react"; -import { type GlobalState } from "../../globalState"; -import { type Nullable } from "core/types"; -import { type Observer } from "core/Misc/observable"; -import { LogEntry } from "../log/logComponent"; -import "./toast.scss"; - -export type ToastSeverity = "info" | "success" | "error" | "warning"; - -interface IToastEntry { - id: number; - message: string; - severity: ToastSeverity; - timerId: Nullable>; - startedAt: number; - remainingDuration: number; -} - -interface IToastContainerProps { - globalState: GlobalState; -} - -interface IToastContainerState { - toasts: IToastEntry[]; -} - -const SEVERITY_ICONS: Record = { - info: "ℹ", - success: "✓", - error: "✕", - warning: "⚠", -}; - -/** Auto-dismiss duration in ms */ -const TOAST_DURATION_MS = 4000; - -/** - * Container component that renders brief auto-dismissing toast notifications. - * Listens to `globalState.onToastNotification` for incoming messages. - */ -export class ToastContainerComponent extends React.Component { - private _observer: Nullable> = null; - private _nextId = 0; - - /** @internal */ - constructor(props: IToastContainerProps) { - super(props); - this.state = { toasts: [] }; - } - - /** @internal */ - override componentDidMount() { - this._observer = this.props.globalState.onToastNotification.add((data) => { - const id = this._nextId++; - const timerId = this._createDismissTimer(id, TOAST_DURATION_MS); - this.setState((prev) => ({ - toasts: [...prev.toasts, { id, message: data.message, severity: data.severity, timerId, startedAt: Date.now(), remainingDuration: TOAST_DURATION_MS }], - })); - }); - } - - /** @internal */ - override componentWillUnmount() { - this._observer?.remove(); - this._observer = null; - // Clear all pending timers - for (const t of this.state.toasts) { - if (t.timerId) { - clearTimeout(t.timerId); - } - } - } - - private _createDismissTimer(id: number, duration: number) { - return setTimeout(() => this._dismiss(id), duration); - } - - private _pauseDismiss(id: number) { - this.setState((prev) => ({ - toasts: prev.toasts.map((toast) => { - if (toast.id !== id || !toast.timerId) { - return toast; - } - clearTimeout(toast.timerId); - const elapsed = Date.now() - toast.startedAt; - return { - ...toast, - timerId: null, - remainingDuration: Math.max(0, toast.remainingDuration - elapsed), - }; - }), - })); - } - - private _resumeDismiss(id: number) { - this.setState((prev) => ({ - toasts: prev.toasts.map((toast) => { - if (toast.id !== id || toast.timerId) { - return toast; - } - const remainingDuration = Math.max(1, toast.remainingDuration); - return { - ...toast, - timerId: this._createDismissTimer(id, remainingDuration), - startedAt: Date.now(), - remainingDuration, - }; - }), - })); - } - - private _dismiss(id: number) { - this.setState((prev) => ({ - toasts: prev.toasts.filter((t) => { - if (t.id === id) { - if (t.timerId) { - clearTimeout(t.timerId); - } - return false; - } - return true; - }), - })); - } - - /** @internal */ - override render() { - return ( -
- {this.state.toasts.map((toast) => ( -
this._pauseDismiss(toast.id)} - onMouseLeave={() => this._resumeDismiss(toast.id)} - > - {SEVERITY_ICONS[toast.severity]} - {toast.message} - -
- ))} -
- ); - } -} - -/** - * Helper to show a toast notification through globalState. - * @param globalState - the global state to notify - * @param message - the text to display - * @param severity - the toast severity (defaults to "info") - */ -export function ShowToast(globalState: GlobalState, message: string, severity: ToastSeverity = "info"): void { - globalState.onToastNotification.notifyObservers({ message, severity }); - // Also emit to the log panel for persistence - globalState.onLogRequiredObservable.notifyObservers(new LogEntry(message, severity === "error")); -} +import { type GlobalState } from "../../globalState"; +import { LogEntry } from "../log/logComponent"; + +export type ToastSeverity = "info" | "success" | "error" | "warning"; + +/** + * Helper to show a toast notification through globalState. The actual toast UI + * is rendered by the modular tool framework via `toastBridgeService`. + * @param globalState - the global state to notify + * @param message - the text to display + * @param severity - the toast severity (defaults to "info") + */ +export function ShowToast(globalState: GlobalState, message: string, severity: ToastSeverity = "info"): void { + globalState.onToastNotification.notifyObservers({ message, severity }); + // Also emit to the log panel for persistence + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(message, severity === "error")); +} diff --git a/packages/tools/flowGraphEditor/src/components/variables/variables.scss b/packages/tools/flowGraphEditor/src/components/variables/variables.scss deleted file mode 100644 index 2ea797932b4..00000000000 --- a/packages/tools/flowGraphEditor/src/components/variables/variables.scss +++ /dev/null @@ -1,337 +0,0 @@ -// Variables strip — compact horizontal panel between toolbar and canvas -.fge-variables-strip { - background: #252525; - border-bottom: 1px solid #555; - flex-shrink: 0; - font: - 12px "acumin-pro", - sans-serif; - color: #ccc; - - .fge-variables-strip-header { - display: flex; - align-items: center; - height: 26px; - padding: 0 8px; - gap: 6px; - background: #2a2a2a; - } - - .fge-variables-toggle { - background: none; - border: none; - color: #888; - cursor: pointer; - font-size: 9px; - padding: 0 2px; - line-height: 1; - - &:hover { - color: #ccc; - } - } - - .fge-variables-strip-title { - font-size: 11px; - font-weight: 600; - color: #aaa; - text-transform: uppercase; - letter-spacing: 0.04em; - user-select: none; - } - - .fge-variables-live-badge { - color: #4caf50; - font-size: 10px; - font-weight: 600; - } - - .fge-variables-strip-add { - margin-left: auto; - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - background: #464646; - color: rgb(51, 183, 102); - border: 1px solid rgb(51, 183, 102); - border-radius: 3px; - cursor: pointer; - font-size: 14px; - font-weight: 700; - line-height: 1; - - &:hover { - background: rgba(51, 183, 102, 0.2); - color: #fff; - } - } - - .fge-variables-strip-body { - max-height: 120px; - overflow-y: auto; - overflow-x: hidden; - } - - .fge-variables-strip-empty { - color: #666; - font-style: italic; - font-size: 11px; - padding: 4px 12px; - } - - .fge-variables-strip-table { - display: flex; - flex-wrap: wrap; - gap: 1px; - padding: 4px 8px; - } - - .fge-var-cell { - display: flex; - flex-direction: column; - background: #2e2e2e; - border: 1px solid #3a3a3a; - border-radius: 3px; - padding: 3px 6px; - min-width: 120px; - max-width: 220px; - - &:hover { - border-color: #555; - } - } - - .fge-var-cell-name-row { - display: flex; - align-items: center; - gap: 4px; - } - - .fge-var-cell-name { - flex: 1; - font: - 11px "Consolas", - "Courier New", - monospace; - color: #d4d4d4; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - cursor: default; - min-width: 0; - } - - .fge-var-cell-name-input { - flex: 1; - background: #1e1e1e; - border: 1px solid #4a90d9; - border-radius: 2px; - color: #d4d4d4; - font: - 11px "Consolas", - "Courier New", - monospace; - padding: 1px 4px; - outline: none; - min-width: 0; - } - - .fge-var-cell-delete { - background: none; - border: none; - color: #666; - cursor: pointer; - font-size: 11px; - padding: 0 2px; - line-height: 1; - flex-shrink: 0; - - &:hover { - color: #e55; - } - } - - // --- Type selector row --- - .fge-var-cell-type-row { - margin-top: 2px; - } - - .fge-var-cell-type-select { - width: 100%; - height: 20px; - padding: 0 2px; - font-size: 10px; - font-weight: 500; - color: #bbb; - background: #333; - border: 1px solid #555; - border-radius: 2px; - cursor: pointer; - outline: none; - box-sizing: border-box; - - &:hover { - border-color: #777; - } - - &:focus { - border-color: rgb(100, 160, 220); - } - - option { - background: #2a2a2a; - color: #ccc; - } - - optgroup { - color: #888; - font-style: normal; - } - } - - // --- Value editor row --- - .fge-var-cell-value-row { - margin-top: 2px; - } - - .fge-var-cell-value { - display: block; - font: - 10px "Consolas", - "Courier New", - monospace; - color: #4caf50; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding: 1px 0; - border-bottom: 1px dashed transparent; - - &:hover { - color: #6bd06b; - border-bottom-color: #4caf50; - } - } - - .fge-var-cell-value-input { - width: 100%; - background: #1e1e1e; - border: 1px solid #4caf50; - border-radius: 2px; - color: #4caf50; - font: - 10px "Consolas", - "Courier New", - monospace; - padding: 1px 4px; - outline: none; - box-sizing: border-box; - } - - // --- Number input --- - .fge-var-cell-number-input { - width: 100%; - height: 20px; - background: #1e1e1e; - border: 1px solid #555; - border-radius: 2px; - color: #4caf50; - font: - 10px "Consolas", - "Courier New", - monospace; - padding: 1px 4px; - outline: none; - box-sizing: border-box; - - &:focus { - border-color: #4caf50; - } - } - - // --- Boolean toggle --- - .fge-var-cell-bool-toggle { - display: flex; - align-items: center; - gap: 4px; - cursor: pointer; - font: - 10px "Consolas", - "Courier New", - monospace; - color: #4caf50; - - input[type="checkbox"] { - accent-color: #4caf50; - cursor: pointer; - } - } - - // --- Vector / Color component inputs --- - .fge-var-cell-components { - display: flex; - flex-wrap: wrap; - gap: 2px; - } - - .fge-var-cell-component { - display: flex; - align-items: center; - gap: 2px; - } - - .fge-var-cell-component-label { - font-size: 9px; - font-weight: 600; - color: #888; - text-transform: uppercase; - min-width: 8px; - } - - .fge-var-cell-component-input { - width: 42px; - height: 18px; - background: #1e1e1e; - border: 1px solid #555; - border-radius: 2px; - color: #4caf50; - font: - 9px "Consolas", - "Courier New", - monospace; - padding: 0 2px; - outline: none; - box-sizing: border-box; - - &:focus { - border-color: #4caf50; - } - } - - // --- Scene object picker --- - .fge-var-cell-object-select { - width: 100%; - height: 20px; - padding: 0 2px; - font-size: 10px; - color: #4caf50; - background: #1e1e1e; - border: 1px solid #555; - border-radius: 2px; - cursor: pointer; - outline: none; - box-sizing: border-box; - - &:focus { - border-color: #4caf50; - } - - option { - background: #2a2a2a; - color: #ccc; - } - } -} diff --git a/packages/tools/flowGraphEditor/src/components/variables/variablesPanelComponent.tsx b/packages/tools/flowGraphEditor/src/components/variables/variablesPanelComponent.tsx index aca2396215d..24233fecf34 100644 --- a/packages/tools/flowGraphEditor/src/components/variables/variablesPanelComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/variables/variablesPanelComponent.tsx @@ -1,722 +1,1023 @@ -import * as React from "react"; -import { type GlobalState } from "../../globalState"; -import { type Nullable } from "core/types"; -import { type Observer } from "core/Misc/observable"; -import { FlowGraphState } from "core/FlowGraph/flowGraph"; -import { FlowGraphInteger } from "core/FlowGraph/CustomTypes/flowGraphInteger"; -import { - GatherVariables, - RenameVariable, - DeleteVariable, - FormatVariableValue, - ParseVariableValue, - VariableTypeGroups, - IsSceneObjectType, - IsVectorOrColorType, - GetComponentLabels, - GetComponents, - BuildFromComponents, - GetDefaultValueForType, - InferVariableType, - type IVariableEntry, - type VariableTypeName, -} from "../../variableUtils"; -import "./variables.scss"; - -interface IVariablesPanelProps { - globalState: GlobalState; -} - -interface IVariablesPanelState { - variables: IVariableEntry[]; - /** Index of the variable whose *name* is being edited (null = none). */ - editingNameIndex: number | null; - editingName: string; - /** Index of the variable whose *value* is being edited (null = none). */ - editingValueIndex: number | null; - editingValue: string; - isRunning: boolean; - runtimeValues: Map; - /** Per-variable declared type, keyed by variable name. */ - variableTypes: Map; - collapsed: boolean; -} - -/** - * Compact variables strip that sits between the toolbar and the canvas. - * Shows variable names (shared across contexts) and per-context values - * with inline editing for both. - */ -export class VariablesPanelComponent extends React.Component { - private _builtObserver: Nullable> = null; - private _stateObserver: Nullable> = null; - private _contextChangedObserver: Nullable> = null; - private _pollTimer: ReturnType | null = null; - - /** @internal */ - constructor(props: IVariablesPanelProps) { - super(props); - this.state = { - variables: [], - editingNameIndex: null, - editingName: "", - editingValueIndex: null, - editingValue: "", - isRunning: false, - runtimeValues: new Map(), - variableTypes: new Map(), - collapsed: false, - }; - } - - /** @internal */ - override componentDidMount() { - this._builtObserver = this.props.globalState.onBuiltObservable.add(() => { - this._subscribeToFlowGraph(); - this._refreshVariables(); - }); - this._contextChangedObserver = this.props.globalState.onSelectedContextChanged.add(() => { - this._refreshVariables(); - this._pollRuntimeValues(); - }); - this._subscribeToFlowGraph(); - this._refreshVariables(); - } - - /** @internal */ - override componentWillUnmount() { - this._builtObserver?.remove(); - this._builtObserver = null; - this._stateObserver?.remove(); - this._stateObserver = null; - this._contextChangedObserver?.remove(); - this._contextChangedObserver = null; - this._stopPolling(); - } - - private _subscribeToFlowGraph() { - this._stateObserver?.remove(); - this._stateObserver = null; - this._stopPolling(); - - const fg = this.props.globalState.flowGraph; - if (!fg) { - return; - } - - const running = fg.state === FlowGraphState.Started; - this.setState({ isRunning: running }); - if (running) { - this._startPolling(); - } - - this._stateObserver = fg.onStateChangedObservable.add((newState) => { - const isRunning = newState === FlowGraphState.Started; - this.setState({ isRunning }); - if (isRunning) { - this._startPolling(); - } else { - this._pollRuntimeValues(); - this._stopPolling(); - } - }); - } - - private _startPolling() { - this._stopPolling(); - this._pollRuntimeValues(); - this._pollTimer = setInterval(() => this._pollRuntimeValues(), 200); - } - - private _stopPolling() { - if (this._pollTimer !== null) { - clearInterval(this._pollTimer); - this._pollTimer = null; - } - } - - private _pollRuntimeValues() { - const fg = this.props.globalState.flowGraph; - if (!fg) { - return; - } - - const values = new Map(); - const ctx = fg.getContext(this.props.globalState.selectedContextIndex); - if (ctx) { - for (const [key, val] of Object.entries(ctx.userVariables)) { - values.set(key, FormatVariableValue(val)); - } - } - this.setState({ runtimeValues: values }); - } - - private _refreshVariables() { - const fg = this.props.globalState.flowGraph; - if (!fg) { - this.setState({ variables: [], variableTypes: new Map() }); - return; - } - - const variables = GatherVariables(fg); - - // Read type annotations from the selected context (or first available) - const ctx = fg.getContext(this.props.globalState.selectedContextIndex) ?? fg.getContext(0); - const variableTypes = new Map(); - if (ctx) { - for (const v of variables) { - const declared = ctx.getVariableType(v.name) as VariableTypeName | undefined; - if (declared) { - variableTypes.set(v.name, declared); - } else { - // Infer from current value - const val = ctx.userVariables[v.name]; - variableTypes.set(v.name, InferVariableType(val)); - } - } - } - this.setState({ variables, variableTypes }); - } - - private _renameVariable(oldName: string, newName: string) { - if (!newName || newName === oldName) { - return; - } - - const fg = this.props.globalState.flowGraph; - if (!fg) { - return; - } - - RenameVariable(fg, oldName, newName); - - // Migrate variable type annotations across all contexts - for (let i = 0; i < fg.contextCount; i++) { - const ctx = fg.getContext(i); - if (ctx) { - const oldType = ctx.getVariableType(oldName); - if (oldType) { - ctx.setVariableType(newName, oldType); - delete ctx.variableTypes[oldName]; - } - } - } - - this.props.globalState.stateManager.onRebuildRequiredObservable.notifyObservers(); - this._refreshVariables(); - } - - private _deleteVariable(name: string) { - const fg = this.props.globalState.flowGraph; - if (!fg) { - return; - } - - DeleteVariable(fg, name); - - // Remove stored type annotations so deleted variables don't reappear after reload - for (let i = 0; i < fg.contextCount; i++) { - const ctx = fg.getContext(i); - if (ctx) { - delete ctx.variableTypes[name]; - } - } - - this.props.globalState.stateManager.onRebuildRequiredObservable.notifyObservers(); - this._refreshVariables(); - } - - private _addVariable() { - const existing = new Set(this.state.variables.map((v) => v.name)); - let idx = 1; - let name = "newVariable"; - while (existing.has(name)) { - name = `newVariable${idx++}`; - } - - const fg = this.props.globalState.flowGraph; - if (!fg) { - return; - } - - let ctx = fg.getContext(this.props.globalState.selectedContextIndex); - if (!ctx) { - ctx = fg.createContext(); - } - ctx.setVariable(name, 0); - ctx.setVariableType(name, "number"); - // Also set on all other contexts - for (let i = 0; i < fg.contextCount; i++) { - const other = fg.getContext(i); - if (other && other !== ctx) { - if (!other.hasVariable(name)) { - other.setVariable(name, 0); - } - other.setVariableType(name, "number"); - } - } - - const variables = GatherVariables(fg); - const newIdx = variables.findIndex((v) => v.name === name); - const variableTypes = new Map(this.state.variableTypes); - variableTypes.set(name, "number"); - this.setState({ variables, variableTypes, editingNameIndex: newIdx, editingName: name, collapsed: false }); - } - - // --- Name editing --- - - private _startNameEditing(index: number) { - this.setState({ editingNameIndex: index, editingName: this.state.variables[index].name }); - } - - private _commitNameEditing() { - const { editingNameIndex, editingName, variables } = this.state; - if (editingNameIndex === null || editingNameIndex >= variables.length) { - this.setState({ editingNameIndex: null }); - return; - } - const oldName = variables[editingNameIndex].name; - const newName = editingName.trim(); - this.setState({ editingNameIndex: null }); - if (newName && newName !== oldName) { - this._renameVariable(oldName, newName); - } - } - - // --- Value editing --- - - private _startValueEditing(index: number) { - const name = this.state.variables[index].name; - const display = this.state.runtimeValues.get(name) ?? "undefined"; - this.setState({ editingValueIndex: index, editingValue: display }); - } - - private _commitValueEditing() { - const { editingValueIndex, editingValue, variables } = this.state; - if (editingValueIndex === null || editingValueIndex >= variables.length) { - this.setState({ editingValueIndex: null }); - return; - } - const name = variables[editingValueIndex].name; - this.setState({ editingValueIndex: null }); - - const fg = this.props.globalState.flowGraph; - if (!fg) { - return; - } - - const ctx = fg.getContext(this.props.globalState.selectedContextIndex); - if (!ctx) { - return; - } - - const currentValue = ctx.userVariables[name]; - const parsed = ParseVariableValue(editingValue, currentValue); - ctx.setVariable(name, parsed); - this._pollRuntimeValues(); - } - - // --- Type changing --- - - private _changeVariableType(varName: string, newType: VariableTypeName) { - const fg = this.props.globalState.flowGraph; - if (!fg) { - return; - } - - const defaultValue = GetDefaultValueForType(newType); - - // Update type annotation and set default value on all contexts - for (let i = 0; i < fg.contextCount; i++) { - const ctx = fg.getContext(i); - if (ctx) { - ctx.setVariableType(varName, newType); - ctx.setVariable(varName, defaultValue); - } - } - - const variableTypes = new Map(this.state.variableTypes); - variableTypes.set(varName, newType); - this.setState({ variableTypes }); - this._pollRuntimeValues(); - } - - // --- Scene object helpers --- - - /** Cache: sceneUid → typeName → { lengths, options } */ - private _sceneObjectCache = new Map>(); - - private _getSceneObjectsForType(typeName: VariableTypeName): { name: string; uniqueId: number }[] { - const fg = this.props.globalState.flowGraph; - if (!fg) { - return []; - } - const ctx = fg.getContext(this.props.globalState.selectedContextIndex); - if (!ctx) { - return []; - } - const scene = ctx.getScene(); - const sceneUid = scene.uid ?? "0"; - - // Get or create per-scene cache - let typeCache = this._sceneObjectCache.get(sceneUid); - if (!typeCache) { - typeCache = new Map(); - this._sceneObjectCache.set(sceneUid, typeCache); - } - - // Determine source collections and current lengths for cache invalidation - const sources = this._getSceneCollections(scene, typeName); - const currentLengths = sources.map((s) => s.length); - const cached = typeCache.get(typeName); - if (cached && cached.lengths.length === currentLengths.length && cached.lengths.every((len, i) => len === currentLengths[i])) { - return cached.options; - } - - // Rebuild - let options: { name: string; uniqueId: number }[]; - if (typeName === "TransformNode") { - options = [...scene.transformNodes, ...scene.meshes].map((n) => ({ name: n.name, uniqueId: n.uniqueId })); - } else if (sources.length > 0) { - options = sources[0].map((item) => ({ name: item.name, uniqueId: item.uniqueId })); - } else { - options = []; - } - - typeCache.set(typeName, { lengths: currentLengths, options }); - return options; - } - - private _getSceneCollections( - scene: { meshes: any[]; transformNodes: any[]; cameras: any[]; lights: any[]; materials: any[]; animationGroups: any[] }, - typeName: VariableTypeName - ): any[][] { - switch (typeName) { - case "Mesh": - return [scene.meshes]; - case "TransformNode": - return [scene.transformNodes, scene.meshes]; - case "Camera": - return [scene.cameras]; - case "Light": - return [scene.lights]; - case "Material": - return [scene.materials]; - case "AnimationGroup": - return [scene.animationGroups]; - default: - return []; - } - } - - private _setSceneObjectVariable(varName: string, typeName: VariableTypeName, uniqueId: number) { - const fg = this.props.globalState.flowGraph; - if (!fg) { - return; - } - const ctx = fg.getContext(this.props.globalState.selectedContextIndex); - if (!ctx) { - return; - } - const scene = ctx.getScene(); - let obj: unknown = undefined; - switch (typeName) { - case "Mesh": - obj = scene.meshes.find((m) => m.uniqueId === uniqueId); - break; - case "TransformNode": - obj = scene.transformNodes.find((n) => n.uniqueId === uniqueId) ?? scene.meshes.find((m) => m.uniqueId === uniqueId); - break; - case "Camera": - obj = scene.cameras.find((c) => c.uniqueId === uniqueId); - break; - case "Light": - obj = scene.lights.find((l) => l.uniqueId === uniqueId); - break; - case "Material": - obj = scene.materials.find((m) => m.uniqueId === uniqueId); - break; - case "AnimationGroup": - obj = scene.animationGroups.find((ag) => ag.uniqueId === uniqueId); - break; - } - ctx.setVariable(varName, obj); - this._pollRuntimeValues(); - } - - // --- Component editing for Vector/Color --- - - private _setVectorComponent(varName: string, typeName: VariableTypeName, componentIndex: number, value: number) { - const fg = this.props.globalState.flowGraph; - if (!fg) { - return; - } - const ctx = fg.getContext(this.props.globalState.selectedContextIndex); - if (!ctx) { - return; - } - const current = ctx.userVariables[varName]; - const components = GetComponents(current, typeName); - components[componentIndex] = value; - const newValue = BuildFromComponents(components, typeName); - ctx.setVariable(varName, newValue); - this._pollRuntimeValues(); - } - - private _renderTypeSelector(varName: string, currentType: VariableTypeName) { - return ( - - ); - } - - private _renderValueEditor(varName: string, typeName: VariableTypeName, idx: number) { - const { editingValueIndex, editingValue, runtimeValues } = this.state; - - // --- Boolean: toggle --- - if (typeName === "boolean") { - const fg = this.props.globalState.flowGraph; - const ctx = fg?.getContext(this.props.globalState.selectedContextIndex); - const currentVal = ctx?.userVariables[varName]; - return ( - - ); - } - - // --- Number / Integer: number input --- - if (typeName === "number" || typeName === "FlowGraphInteger") { - const fg = this.props.globalState.flowGraph; - const ctx = fg?.getContext(this.props.globalState.selectedContextIndex); - const raw = ctx?.userVariables[varName]; - const numVal = typeName === "FlowGraphInteger" ? (raw?.value ?? 0) : typeof raw === "number" ? raw : 0; - return ( - { - this.props.globalState.lockObject.lock = true; - }} - onBlur={() => { - this.props.globalState.lockObject.lock = false; - }} - onChange={(e) => { - const n = typeName === "FlowGraphInteger" ? Math.round(Number(e.target.value)) : Number(e.target.value); - if (!isNaN(n)) { - if (typeName === "FlowGraphInteger") { - ctx?.setVariable(varName, new FlowGraphInteger(n)); - } else { - ctx?.setVariable(varName, n); - } - this._pollRuntimeValues(); - } - }} - onKeyDown={(e) => e.stopPropagation()} - /> - ); - } - - // --- Vector / Color: component inputs --- - if (IsVectorOrColorType(typeName)) { - const fg = this.props.globalState.flowGraph; - const ctx = fg?.getContext(this.props.globalState.selectedContextIndex); - const current = ctx?.userVariables[varName]; - const components = GetComponents(current, typeName); - const labels = GetComponentLabels(typeName); - return ( -
- {labels.map((label, ci) => ( -
- {label} - { - this.props.globalState.lockObject.lock = true; - }} - onBlur={() => { - this.props.globalState.lockObject.lock = false; - }} - onChange={(e) => { - const n = Number(e.target.value); - if (!isNaN(n)) { - this._setVectorComponent(varName, typeName, ci, n); - } - }} - onKeyDown={(e) => e.stopPropagation()} - /> -
- ))} -
- ); - } - - // --- Scene objects: dropdown picker --- - if (IsSceneObjectType(typeName)) { - const objects = this._getSceneObjectsForType(typeName); - const fg = this.props.globalState.flowGraph; - const ctx = fg?.getContext(this.props.globalState.selectedContextIndex); - const current = ctx?.userVariables[varName]; - const currentUid = (current as { uniqueId?: number })?.uniqueId ?? -1; - return ( - - ); - } - - // --- String / Any: text input --- - if (editingValueIndex === idx) { - return ( - this.setState({ editingValue: e.target.value })} - onFocus={() => { - this.props.globalState.lockObject.lock = true; - }} - onBlur={() => { - this.props.globalState.lockObject.lock = false; - this._commitValueEditing(); - }} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - this._commitValueEditing(); - } else if (e.key === "Escape") { - this.setState({ editingValueIndex: null }); - } - }} - autoFocus - /> - ); - } - return ( - this._startValueEditing(idx)} title="Click to edit value"> - {runtimeValues.get(varName) ?? "undefined"} - - ); - } - - /** @internal */ - override render() { - const { variables, editingNameIndex, editingName, variableTypes, collapsed } = this.state; - const varCount = variables.length; - - return ( -
-
- - Variables{varCount > 0 ? ` (${varCount})` : ""} - {this.state.isRunning && ● Live} - -
- {!collapsed && ( -
- {variables.length === 0 ? ( -
No variables. Click + to add one, or use GetVariable/SetVariable blocks.
- ) : ( -
- {variables.map((v, idx) => { - const typeName = variableTypes.get(v.name) ?? "any"; - return ( -
-
- {editingNameIndex === idx ? ( - this.setState({ editingName: e.target.value })} - onFocus={() => { - this.props.globalState.lockObject.lock = true; - }} - onBlur={() => { - this.props.globalState.lockObject.lock = false; - this._commitNameEditing(); - }} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - this._commitNameEditing(); - } else if (e.key === "Escape") { - this.setState({ editingNameIndex: null }); - } - }} - autoFocus - /> - ) : ( - this._startNameEditing(idx)} - title={`${v.name} (${v.getCount}G/${v.setCount}S) — double-click to rename`} - > - {v.name} - - )} - -
-
{this._renderTypeSelector(v.name, typeName)}
-
{this._renderValueEditor(v.name, typeName, idx)}
-
- ); - })} -
- )} -
- )} -
- ); - } -} +import * as React from "react"; +import { type GlobalState } from "../../globalState"; +import { type Nullable } from "core/types"; +import { type Observer } from "core/Misc/observable"; +import { FlowGraphState } from "core/FlowGraph/flowGraph"; +import { FlowGraphInteger } from "core/FlowGraph/CustomTypes/flowGraphInteger"; +import { + GatherVariables, + RenameVariable, + DeleteVariable, + FormatVariableValue, + ParseVariableValue, + VariableTypeGroups, + IsSceneObjectType, + IsVectorOrColorType, + GetComponentLabels, + GetComponents, + BuildFromComponents, + GetDefaultValueForType, + InferVariableType, + type IVariableEntry, + type VariableTypeName, +} from "../../variableUtils"; +import { + Body1, + Caption1, + Button, + Card, + Divider, + Dropdown, + Field, + Input, + Option, + OptionGroup, + Switch, + Toolbar, + ToolbarButton, + Tooltip, + makeStyles, + mergeClasses, + tokens, +} from "@fluentui/react-components"; +import { AddRegular, ChevronDownRegular, ChevronRightRegular, DismissRegular } from "@fluentui/react-icons"; +import { Collapse } from "shared-ui-components/fluent/primitives/collapse"; + +interface IVariablesPanelProps { + globalState: GlobalState; + /** + * "horizontal" (default) lays the variable cards out as a wrapping flex row — used by + * the legacy in-canvas strip. "vertical" stacks them top-to-bottom and stretches each + * card to the container width — used by the side-pane host so cards fill the pane width. + */ + layout?: "horizontal" | "vertical"; + /** + * When true (default) the panel renders its own header (collapse arrow, "Variables" title, + * live badge, add button). When false the header is omitted and only the body is rendered — + * use this when the host already provides a title/header (e.g. a side pane's PaneHeader). + * The "+" add button moves to a footer in this mode so it remains accessible. + */ + showHeader?: boolean; +} + +interface IVariablesPanelInnerProps extends IVariablesPanelProps { + classes: ReturnType; +} + +interface IVariablesPanelState { + variables: IVariableEntry[]; + /** Index of the variable whose *name* is being edited (null = none). */ + editingNameIndex: number | null; + editingName: string; + /** Index of the variable whose *value* is being edited (null = none). */ + editingValueIndex: number | null; + editingValue: string; + isRunning: boolean; + runtimeValues: Map; + /** Per-variable declared type, keyed by variable name. */ + variableTypes: Map; + collapsed: boolean; +} + +const useStyles = makeStyles({ + strip: { + background: tokens.colorNeutralBackground3, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + flexShrink: 0, + fontSize: tokens.fontSizeBase200, + color: tokens.colorNeutralForeground1, + }, + stripVertical: { + // In side-pane (vertical) layout the panel fills the full pane height; no bottom border + // since the pane already has its own boundaries, and no flexShrink so the body region + // can expand. + borderBottom: "none", + flex: 1, + display: "flex", + flexDirection: "column", + minHeight: 0, + background: "transparent", + }, + header: { + display: "flex", + alignItems: "center", + height: "26px", + padding: `0 ${tokens.spacingHorizontalXS}`, + gap: tokens.spacingHorizontalXS, + background: tokens.colorNeutralBackground2, + }, + title: { + fontSize: tokens.fontSizeBase100, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground2, + textTransform: "uppercase", + letterSpacing: "0.04em", + userSelect: "none", + }, + liveBadge: { + color: tokens.colorPaletteGreenForeground1, + fontSize: tokens.fontSizeBase100, + fontWeight: tokens.fontWeightSemibold, + }, + addButton: { marginLeft: "auto" }, + body: { + maxHeight: "120px", + overflowY: "auto", + overflowX: "hidden", + }, + bodyVertical: { + // In side-pane (vertical) layout the body fills the available pane height instead of + // being capped to ~120px. The host pane is already a flex column with overflow hidden, + // so flex-grow lets us claim the leftover space and scroll within it. + maxHeight: "none", + flex: 1, + minHeight: 0, + }, + empty: { + // `display: block` is necessary because Body1 renders as a span by default, and padding + // on inline elements isn't applied uniformly to wrapped lines (the second line would lose + // its leading indent). Block makes padding work uniformly across wrap points. + display: "block", + color: tokens.colorNeutralForeground3, + fontStyle: "italic", + fontSize: tokens.fontSizeBase300, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalM}`, + }, + table: { + display: "flex", + flexWrap: "wrap", + // Spacing between adjacent variable Cards on the same row and between rows. + columnGap: tokens.spacingHorizontalS, + rowGap: tokens.spacingVerticalS, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`, + }, + tableVertical: { + // Stack cards vertically (one per row) and let each card stretch to fill the side pane width. + flexWrap: "nowrap", + flexDirection: "column", + alignItems: "stretch", + }, + cell: { + // Trim the Fluent Card to fit our compact variables strip. Background, border, radius, + // and hover treatment all come from Card itself. + minWidth: "120px", + maxWidth: "220px", + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`, + gap: tokens.spacingVerticalXS, + }, + cellVertical: { + // In vertical layout we don't want a maxWidth that creates whitespace next to the card — + // let it span the full pane width. + maxWidth: "none", + width: "100%", + }, + sideToolbar: { + // Fluent at the top of the side-pane variant. The Toolbar's own padding + // already aligns its first item with the left edge of the cards' container padding, + // so no extra wrapping styles are needed. + flexShrink: 0, + }, + sideToolbarLiveBadge: { + color: tokens.colorPaletteGreenForeground1, + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold, + marginLeft: "auto", + marginRight: tokens.spacingHorizontalM, + }, + sideToolbarDivider: { + // Fluent defaults to flex-grow: 1 (vertical fill), which is fine in a column + // flex container, but pin its height so it stays the visible 1px line. `inset` adds + // horizontal margin on each side so the rule sits flush with the card content padding. + flexGrow: 0, + flexShrink: 0, + }, + nameRow: { display: "flex", alignItems: "center", gap: tokens.spacingHorizontalXS }, + name: { + flex: 1, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + color: tokens.colorNeutralForeground1, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + cursor: "default", + minWidth: 0, + }, + nameInput: { flex: 1, minWidth: 0 }, + typeRow: { marginTop: "2px" }, + typeSelect: { + // Compact dropdown to fit in the variables strip's small per-row layout. + width: "100%", + minWidth: "auto", + }, + typeOptionGroupLabel: { + // The Fluent dropdown popover renders inside a portal which our `Theme` + // intentionally configures with `applyStylesToPortals: false`. As a result, + // CSS custom properties like `var(--fontFamilyBase)` aren't resolved inside the + // popover, and `` labels fall back to the browser default (Times New + // Roman). Hard-code Fluent's web font stack here, applied via OptionGroup's `label` + // slot, so just *our* group labels look right. + fontFamily: "'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + }, + valueRow: { marginTop: "2px" }, + value: { + display: "block", + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase100, + color: tokens.colorPaletteGreenForeground1, + cursor: "pointer", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + padding: "1px 0", + ":hover": { color: tokens.colorPaletteGreenForeground3 }, + }, + components: { display: "flex", flexWrap: "wrap", gap: "2px" }, + component: { display: "flex", alignItems: "center", gap: "2px" }, + componentLabel: { + fontSize: tokens.fontSizeBase100, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground3, + textTransform: "uppercase", + minWidth: "8px", + }, + componentInput: { width: "60px" }, + objectSelect: { + width: "100%", + height: "20px", + padding: `0 ${tokens.spacingHorizontalXXS}`, + fontSize: tokens.fontSizeBase100, + color: tokens.colorPaletteGreenForeground1, + background: tokens.colorNeutralBackground3, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusSmall, + cursor: "pointer", + outline: "none", + boxSizing: "border-box", + }, + boolToggle: { display: "flex", alignItems: "center", gap: tokens.spacingHorizontalXS }, +}); + +/** + * Compact variables strip that sits between the toolbar and the canvas. + * Shows variable names (shared across contexts) and per-context values + * with inline editing for both. + * + * Wraps `VariablesPanelInner` (the class component containing the logic) so we can use + * `makeStyles` (a hook) without converting the entire stateful component to a function. + * @param props - The component props. + * @returns The rendered variables panel. + */ +export const VariablesPanelComponent: React.FunctionComponent = (props) => { + const classes = useStyles(); + return ; +}; + +class VariablesPanelInner extends React.Component { + private _builtObserver: Nullable> = null; + private _stateObserver: Nullable> = null; + private _contextChangedObserver: Nullable> = null; + private _pollTimer: ReturnType | null = null; + + /** @internal */ + constructor(props: IVariablesPanelInnerProps) { + super(props); + this.state = { + variables: [], + editingNameIndex: null, + editingName: "", + editingValueIndex: null, + editingValue: "", + isRunning: false, + runtimeValues: new Map(), + variableTypes: new Map(), + collapsed: false, + }; + } + + /** @internal */ + override componentDidMount() { + this._builtObserver = this.props.globalState.onBuiltObservable.add(() => { + this._subscribeToFlowGraph(); + this._refreshVariables(); + }); + this._contextChangedObserver = this.props.globalState.onSelectedContextChanged.add(() => { + this._refreshVariables(); + this._pollRuntimeValues(); + }); + this._subscribeToFlowGraph(); + this._refreshVariables(); + } + + /** @internal */ + override componentWillUnmount() { + this._builtObserver?.remove(); + this._builtObserver = null; + this._stateObserver?.remove(); + this._stateObserver = null; + this._contextChangedObserver?.remove(); + this._contextChangedObserver = null; + this._stopPolling(); + } + + private _subscribeToFlowGraph() { + this._stateObserver?.remove(); + this._stateObserver = null; + this._stopPolling(); + + const fg = this.props.globalState.flowGraph; + if (!fg) { + return; + } + + const running = fg.state === FlowGraphState.Started; + this.setState({ isRunning: running }); + if (running) { + this._startPolling(); + } + + this._stateObserver = fg.onStateChangedObservable.add((newState) => { + const isRunning = newState === FlowGraphState.Started; + this.setState({ isRunning }); + if (isRunning) { + this._startPolling(); + } else { + this._pollRuntimeValues(); + this._stopPolling(); + } + }); + } + + private _startPolling() { + this._stopPolling(); + this._pollRuntimeValues(); + this._pollTimer = setInterval(() => this._pollRuntimeValues(), 200); + } + + private _stopPolling() { + if (this._pollTimer !== null) { + clearInterval(this._pollTimer); + this._pollTimer = null; + } + } + + private _pollRuntimeValues() { + const fg = this.props.globalState.flowGraph; + if (!fg) { + return; + } + + const values = new Map(); + const ctx = fg.getContext(this.props.globalState.selectedContextIndex); + if (ctx) { + for (const [key, val] of Object.entries(ctx.userVariables)) { + values.set(key, FormatVariableValue(val)); + } + } + this.setState({ runtimeValues: values }); + } + + private _refreshVariables() { + const fg = this.props.globalState.flowGraph; + if (!fg) { + this.setState({ variables: [], variableTypes: new Map() }); + return; + } + + const variables = GatherVariables(fg); + + // Read type annotations from the selected context (or first available) + const ctx = fg.getContext(this.props.globalState.selectedContextIndex) ?? fg.getContext(0); + const variableTypes = new Map(); + if (ctx) { + for (const v of variables) { + const declared = ctx.getVariableType(v.name) as VariableTypeName | undefined; + if (declared) { + variableTypes.set(v.name, declared); + } else { + // Infer from current value + const val = ctx.userVariables[v.name]; + variableTypes.set(v.name, InferVariableType(val)); + } + } + } + this.setState({ variables, variableTypes }); + } + + private _renameVariable(oldName: string, newName: string) { + if (!newName || newName === oldName) { + return; + } + + const fg = this.props.globalState.flowGraph; + if (!fg) { + return; + } + + RenameVariable(fg, oldName, newName); + + // Migrate variable type annotations across all contexts + for (let i = 0; i < fg.contextCount; i++) { + const ctx = fg.getContext(i); + if (ctx) { + const oldType = ctx.getVariableType(oldName); + if (oldType) { + ctx.setVariableType(newName, oldType); + delete ctx.variableTypes[oldName]; + } + } + } + + this.props.globalState.stateManager.onRebuildRequiredObservable.notifyObservers(); + this.props.globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + this.props.globalState.onResetRequiredObservable.notifyObservers(false); + this._refreshVariables(); + } + + private _deleteVariable(name: string) { + const fg = this.props.globalState.flowGraph; + if (!fg) { + return; + } + + DeleteVariable(fg, name); + + // Remove stored type annotations so deleted variables don't reappear after reload + for (let i = 0; i < fg.contextCount; i++) { + const ctx = fg.getContext(i); + if (ctx) { + delete ctx.variableTypes[name]; + } + } + + this.props.globalState.stateManager.onRebuildRequiredObservable.notifyObservers(); + this.props.globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + this.props.globalState.onResetRequiredObservable.notifyObservers(false); + this._refreshVariables(); + } + + private _addVariable() { + const existing = new Set(this.state.variables.map((v) => v.name)); + let idx = 1; + let name = "newVariable"; + while (existing.has(name)) { + name = `newVariable${idx++}`; + } + + const fg = this.props.globalState.flowGraph; + if (!fg) { + return; + } + + let ctx = fg.getContext(this.props.globalState.selectedContextIndex); + if (!ctx) { + ctx = fg.createContext(); + } + ctx.setVariable(name, 0); + ctx.setVariableType(name, "number"); + // Also set on all other contexts + for (let i = 0; i < fg.contextCount; i++) { + const other = fg.getContext(i); + if (other && other !== ctx) { + if (!other.hasVariable(name)) { + other.setVariable(name, 0); + } + other.setVariableType(name, "number"); + } + } + + const variables = GatherVariables(fg); + const newIdx = variables.findIndex((v) => v.name === name); + const variableTypes = new Map(this.state.variableTypes); + variableTypes.set(name, "number"); + this.setState({ variables, variableTypes, editingNameIndex: newIdx, editingName: name, collapsed: false }); + } + + // --- Name editing --- + + private _startNameEditing(index: number) { + this.setState({ editingNameIndex: index, editingName: this.state.variables[index].name }); + } + + private _commitNameEditing() { + const { editingNameIndex, editingName, variables } = this.state; + if (editingNameIndex === null || editingNameIndex >= variables.length) { + this.setState({ editingNameIndex: null }); + return; + } + const oldName = variables[editingNameIndex].name; + const newName = editingName.trim(); + this.setState({ editingNameIndex: null }); + if (newName && newName !== oldName) { + this._renameVariable(oldName, newName); + } + } + + // --- Value editing --- + + private _startValueEditing(index: number) { + const name = this.state.variables[index].name; + const display = this.state.runtimeValues.get(name) ?? "undefined"; + this.setState({ editingValueIndex: index, editingValue: display }); + } + + private _commitValueEditing() { + const { editingValueIndex, editingValue, variables } = this.state; + if (editingValueIndex === null || editingValueIndex >= variables.length) { + this.setState({ editingValueIndex: null }); + return; + } + const name = variables[editingValueIndex].name; + this.setState({ editingValueIndex: null }); + + const fg = this.props.globalState.flowGraph; + if (!fg) { + return; + } + + const ctx = fg.getContext(this.props.globalState.selectedContextIndex); + if (!ctx) { + return; + } + + const currentValue = ctx.userVariables[name]; + const parsed = ParseVariableValue(editingValue, currentValue); + ctx.setVariable(name, parsed); + this._pollRuntimeValues(); + } + + // --- Type changing --- + + private _changeVariableType(varName: string, newType: VariableTypeName) { + const fg = this.props.globalState.flowGraph; + if (!fg) { + return; + } + + const defaultValue = GetDefaultValueForType(newType); + + // Update type annotation and set default value on all contexts + for (let i = 0; i < fg.contextCount; i++) { + const ctx = fg.getContext(i); + if (ctx) { + ctx.setVariableType(varName, newType); + ctx.setVariable(varName, defaultValue); + } + } + + const variableTypes = new Map(this.state.variableTypes); + variableTypes.set(varName, newType); + this.setState({ variableTypes }); + this._pollRuntimeValues(); + } + + // --- Scene object helpers --- + + /** Cache of scene object picker options by scene and type. */ + private _sceneObjectCache = new Map>(); + + private _getSceneObjectsForType(typeName: VariableTypeName): { name: string; uniqueId: number }[] { + const fg = this.props.globalState.flowGraph; + if (!fg) { + return []; + } + const ctx = fg.getContext(this.props.globalState.selectedContextIndex); + if (!ctx) { + return []; + } + const scene = ctx.getScene(); + const sceneUid = scene.uid ?? "0"; + + // Get or create per-scene cache + let typeCache = this._sceneObjectCache.get(sceneUid); + if (!typeCache) { + typeCache = new Map(); + this._sceneObjectCache.set(sceneUid, typeCache); + } + + // Determine source collections and current lengths for cache invalidation + const sources = this._getSceneCollections(scene, typeName); + const currentLengths = sources.map((s) => s.length); + const cached = typeCache.get(typeName); + if (cached && cached.lengths.length === currentLengths.length && cached.lengths.every((len, i) => len === currentLengths[i])) { + return cached.options; + } + + // Rebuild + let options: { name: string; uniqueId: number }[]; + if (typeName === "TransformNode") { + options = [...scene.transformNodes, ...scene.meshes].map((n) => ({ name: n.name, uniqueId: n.uniqueId })); + } else if (sources.length > 0) { + options = sources[0].map((item) => ({ name: item.name, uniqueId: item.uniqueId })); + } else { + options = []; + } + + typeCache.set(typeName, { lengths: currentLengths, options }); + return options; + } + + private _getSceneCollections( + scene: { meshes: any[]; transformNodes: any[]; cameras: any[]; lights: any[]; materials: any[]; animationGroups: any[] }, + typeName: VariableTypeName + ): any[][] { + switch (typeName) { + case "Mesh": + return [scene.meshes]; + case "TransformNode": + return [scene.transformNodes, scene.meshes]; + case "Camera": + return [scene.cameras]; + case "Light": + return [scene.lights]; + case "Material": + return [scene.materials]; + case "AnimationGroup": + return [scene.animationGroups]; + default: + return []; + } + } + + private _setSceneObjectVariable(varName: string, typeName: VariableTypeName, uniqueId: number) { + const fg = this.props.globalState.flowGraph; + if (!fg) { + return; + } + const ctx = fg.getContext(this.props.globalState.selectedContextIndex); + if (!ctx) { + return; + } + const scene = ctx.getScene(); + let obj: unknown = undefined; + switch (typeName) { + case "Mesh": + obj = scene.meshes.find((m) => m.uniqueId === uniqueId); + break; + case "TransformNode": + obj = scene.transformNodes.find((n) => n.uniqueId === uniqueId) ?? scene.meshes.find((m) => m.uniqueId === uniqueId); + break; + case "Camera": + obj = scene.cameras.find((c) => c.uniqueId === uniqueId); + break; + case "Light": + obj = scene.lights.find((l) => l.uniqueId === uniqueId); + break; + case "Material": + obj = scene.materials.find((m) => m.uniqueId === uniqueId); + break; + case "AnimationGroup": + obj = scene.animationGroups.find((ag) => ag.uniqueId === uniqueId); + break; + } + ctx.setVariable(varName, obj); + this._pollRuntimeValues(); + } + + // --- Component editing for Vector/Color --- + + private _setVectorComponent(varName: string, typeName: VariableTypeName, componentIndex: number, value: number) { + const fg = this.props.globalState.flowGraph; + if (!fg) { + return; + } + const ctx = fg.getContext(this.props.globalState.selectedContextIndex); + if (!ctx) { + return; + } + const current = ctx.userVariables[varName]; + const components = GetComponents(current, typeName); + components[componentIndex] = value; + const newValue = BuildFromComponents(components, typeName); + ctx.setVariable(varName, newValue); + this._pollRuntimeValues(); + } + + private _renderTypeSelector(varName: string, currentType: VariableTypeName) { + // Use raw Fluent Dropdown + OptionGroup here because the shared wrapper at + // `shared-ui-components/fluent/primitives/dropdown` takes a flat options array and + // doesn't expose grouped options. The shared wrapper's other ergonomics (ToolContext + // sizing, etc.) aren't critical at this density. + const { classes } = this.props; + const currentLabel = VariableTypeGroups.flatMap((g) => g.types).find((t) => t.name === currentType)?.label ?? currentType; + return ( + + { + if (data.optionValue) { + this._changeVariableType(varName, data.optionValue as VariableTypeName); + } + }} + > + {VariableTypeGroups.map((group) => ( + + {group.types.map((t) => ( + + ))} + + ))} + + + ); + } + + private _renderValueEditor(varName: string, typeName: VariableTypeName, idx: number) { + const { classes } = this.props; + const { editingValueIndex, editingValue, runtimeValues } = this.state; + + // --- Boolean: toggle --- + if (typeName === "boolean") { + const fg = this.props.globalState.flowGraph; + const ctx = fg?.getContext(this.props.globalState.selectedContextIndex); + const currentVal = ctx?.userVariables[varName]; + return ( + + { + ctx?.setVariable(varName, data.checked); + this._pollRuntimeValues(); + }} + /> + + ); + } + + // --- Number / Integer: number input --- + if (typeName === "number" || typeName === "FlowGraphInteger") { + const fg = this.props.globalState.flowGraph; + const ctx = fg?.getContext(this.props.globalState.selectedContextIndex); + const raw = ctx?.userVariables[varName]; + const numVal = typeName === "FlowGraphInteger" ? (raw?.value ?? 0) : typeof raw === "number" ? raw : 0; + return ( + { + this.props.globalState.lockObject.lock = true; + }} + onBlur={() => { + this.props.globalState.lockObject.lock = false; + }} + onChange={(_, data) => { + const n = typeName === "FlowGraphInteger" ? Math.round(Number(data.value)) : Number(data.value); + if (!isNaN(n)) { + if (typeName === "FlowGraphInteger") { + ctx?.setVariable(varName, new FlowGraphInteger(n)); + } else { + ctx?.setVariable(varName, n); + } + this._pollRuntimeValues(); + } + }} + onKeyDown={(e) => e.stopPropagation()} + /> + ); + } + + // --- String: text input --- + if (typeName === "string") { + const fg = this.props.globalState.flowGraph; + const ctx = fg?.getContext(this.props.globalState.selectedContextIndex); + const currentVal = ctx?.userVariables[varName]; + return ( + { + this.props.globalState.lockObject.lock = true; + }} + onBlur={() => { + this.props.globalState.lockObject.lock = false; + }} + onChange={(_, data) => { + ctx?.setVariable(varName, data.value); + this._pollRuntimeValues(); + }} + onKeyDown={(e) => e.stopPropagation()} + /> + ); + } + + // --- Vector / Color: component inputs --- + if (IsVectorOrColorType(typeName)) { + const fg = this.props.globalState.flowGraph; + const ctx = fg?.getContext(this.props.globalState.selectedContextIndex); + const current = ctx?.userVariables[varName]; + const components = GetComponents(current, typeName); + const labels = GetComponentLabels(typeName); + return ( +
+ {labels.map((label, ci) => ( +
+ {label} + { + this.props.globalState.lockObject.lock = true; + }} + onBlur={() => { + this.props.globalState.lockObject.lock = false; + }} + onChange={(_, data) => { + const n = Number(data.value); + if (!isNaN(n)) { + this._setVectorComponent(varName, typeName, ci, n); + } + }} + onKeyDown={(e) => e.stopPropagation()} + /> +
+ ))} +
+ ); + } + + // --- Scene objects: dropdown picker --- + if (IsSceneObjectType(typeName)) { + const objects = this._getSceneObjectsForType(typeName); + const fg = this.props.globalState.flowGraph; + const ctx = fg?.getContext(this.props.globalState.selectedContextIndex); + const current = ctx?.userVariables[varName]; + const currentUid = (current as { uniqueId?: number })?.uniqueId ?? -1; + return ( + + ); + } + + // --- Any: text input --- + if (editingValueIndex === idx) { + return ( + this.setState({ editingValue: data.value })} + onFocus={() => { + this.props.globalState.lockObject.lock = true; + }} + onBlur={() => { + this.props.globalState.lockObject.lock = false; + this._commitValueEditing(); + }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + this._commitValueEditing(); + } else if (e.key === "Escape") { + this.setState({ editingValueIndex: null }); + } + }} + autoFocus + /> + ); + } + return ( + + this._startValueEditing(idx)}> + {runtimeValues.get(varName) ?? "undefined"} + + + ); + } + + /** @internal */ + override render() { + const { classes } = this.props; + const layout = this.props.layout ?? "horizontal"; + const showHeader = this.props.showHeader ?? true; + const isVertical = layout === "vertical"; + const { variables, editingNameIndex, editingName, variableTypes, collapsed } = this.state; + const varCount = variables.length; + // The collapse arrow only does anything when the header is rendered. In headerless mode + // (side-pane usage) the host pane is the unit of show/hide, so the body is always visible. + const bodyVisible = !showHeader || !collapsed; + + const bodyContent = ( +
+ {variables.length === 0 ? ( + No variables. Click + to add one, or use GetVariable/SetVariable blocks. + ) : ( +
+ {variables.map((v, idx) => { + const typeName = variableTypes.get(v.name) ?? "any"; + return ( + +
+ {editingNameIndex === idx ? ( + this.setState({ editingName: data.value })} + onFocus={() => { + this.props.globalState.lockObject.lock = true; + }} + onBlur={() => { + this.props.globalState.lockObject.lock = false; + this._commitNameEditing(); + }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + this._commitNameEditing(); + } else if (e.key === "Escape") { + this.setState({ editingNameIndex: null }); + } + }} + autoFocus + /> + ) : ( + + this._startNameEditing(idx)}> + {v.name} + + + )} + +
+
{this._renderTypeSelector(v.name, typeName)}
+
{this._renderValueEditor(v.name, typeName, idx)}
+
+ ); + })} +
+ )} +
+ ); + + return ( +
+ {showHeader && ( +
+ +
+ )} + {!showHeader && ( + <> + + + } onClick={() => this._addVariable()}> + Add variable + + + {this.state.isRunning && ● Live} + + + + )} + {showHeader ? ( + + {bodyContent} + + ) : ( + bodyContent + )} +
+ ); + } +} diff --git a/packages/tools/flowGraphEditor/src/custom.d.ts b/packages/tools/flowGraphEditor/src/custom.d.ts deleted file mode 100644 index e8092070e89..00000000000 --- a/packages/tools/flowGraphEditor/src/custom.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare module "*.svg" { - const content: string; - export default content; -} - -declare module "*.module.scss" { - const content: Record; - export = content; -} diff --git a/packages/tools/flowGraphEditor/src/flowGraphEditor.ts b/packages/tools/flowGraphEditor/src/flowGraphEditor.ts index 8c64fd14ba4..f4a67aeb402 100644 --- a/packages/tools/flowGraphEditor/src/flowGraphEditor.ts +++ b/packages/tools/flowGraphEditor/src/flowGraphEditor.ts @@ -1,15 +1,22 @@ -import * as React from "react"; -import { createRoot } from "react-dom/client"; -import { GlobalState } from "./globalState"; -import { GraphEditor } from "./graphEditor"; import { type FlowGraph } from "core/FlowGraph/flowGraph"; -import { SerializationTools } from "./serializationTools"; import { type Observable } from "core/Misc/observable"; +import { type Scene } from "core/scene"; +import { CreatePopup } from "shared-ui-components/popupHelper"; +import { MakeModularTool } from "shared-ui-components/modularTool/modularTool"; + import { RegisterToDisplayManagers } from "./graphSystem/registerToDisplayLedger"; import { RegisterToPropertyTabManagers } from "./graphSystem/registerToPropertyLedger"; import { RegisterTypeLedger } from "./graphSystem/registerToTypeLedger"; -import { type Scene } from "core/scene"; -import { CreatePopup } from "shared-ui-components/popupHelper"; + +import { CentralGraphServiceDefinition } from "./services/centralGraphService"; +import { DialogBridgeServiceDefinition } from "./services/dialogBridgeService"; +import { MakeGlobalStateService } from "./services/globalStateService"; +import { NodeListServiceDefinition } from "./services/nodeListService"; +import { PropertyTabServiceDefinition } from "./services/propertyTabService"; +import { ScenePreviewServiceDefinition } from "./services/scenePreviewService"; +import { ToastBridgeServiceDefinition } from "./services/toastBridgeService"; +import { ToolbarServiceDefinition } from "./services/toolbarService"; +import { VariablesServiceDefinition } from "./services/variablesService"; /** * Interface used to specify creation options for the flow graph editor @@ -31,8 +38,8 @@ export interface IFlowGraphEditorOptions { * Class used to create a flow graph editor */ export class FlowGraphEditor { - private static _CurrentState: GlobalState; - private static _PopupWindow: Window | null; + private static _CurrentDisposer: { dispose: () => Promise } | undefined; + private static _PopupWindow: Window | null = null; /** * Show the flow graph editor @@ -44,82 +51,104 @@ export class FlowGraphEditor { RegisterToPropertyTabManagers(); RegisterTypeLedger(); - if (this._CurrentState) { - if (this._PopupWindow) { - this._PopupWindow.close(); - } + // Tear down any previously shown editor (and its popup window). + if (this._CurrentDisposer) { + void this._CurrentDisposer.dispose(); + this._CurrentDisposer = undefined; + } + const previousPopup = this._PopupWindow; + this._PopupWindow = null; + if (previousPopup && !previousPopup.closed) { + previousPopup.close(); } let hostElement = options.hostElement; + let popupWindow: Window | null = null; if (!hostElement) { + // Use the legacy CreatePopup which copies stylesheets from the main window into the + // popup. The graph canvas (shared `nodeGraphSystem/`) still ships traditional CSS, + // so without CopyStyles its visuals would be unstyled in the popup. Fluent / Griffel / + // makeStaticStyles work alongside it because MakeModularTool derives `targetDocument` + // from `containerElement.ownerDocument` (see Theme.tsx / modularTool.tsx). + // + // TODO: when the graph canvas is migrated off SCSS, switch this to OpenPopupWindow + // (in `shared-ui-components/fluent/hoc/popupWindow.ts`) for a fully Fluent-native flow. hostElement = CreatePopup("BABYLON.JS FLOW GRAPH EDITOR", { - onWindowCreateCallback: (w) => (this._PopupWindow = w), + onWindowCreateCallback: (w) => { + popupWindow = w; + this._PopupWindow = w; + }, width: 1000, height: 800, })!; } - const scene = options.hostScene || options.flowGraph.scene; - const globalState = new GlobalState(scene); - // If the flow graph belongs to a coordinator, use it for multi-graph support. - // Otherwise the flowGraph setter will handle single-graph mode. - const existingCoordinator = options.flowGraph.coordinator; - if (existingCoordinator) { - globalState.coordinator = existingCoordinator; - const activeIndex = existingCoordinator.flowGraphs.indexOf(options.flowGraph); - if (activeIndex >= 0) { - globalState.activeGraphIndex = activeIndex; - } - } else { - globalState.flowGraph = options.flowGraph; - } - globalState.hostElement = hostElement; - globalState.hostDocument = hostElement.ownerDocument!; - globalState.hostScene = options.hostScene; - globalState.customSave = options.customSave; - globalState.hostWindow = hostElement.ownerDocument.defaultView!; - globalState.stateManager.hostDocument = globalState.hostDocument; - - const graphEditor = React.createElement(GraphEditor, { - globalState: globalState, + // Bootstrap the modular tool. The framework derives `targetDocument` from + // `hostElement.ownerDocument`, so popup-window hosting and main-window hosting + // both work without any additional plumbing here. + const tool = MakeModularTool({ + namespace: "FlowGraphEditor", + containerElement: hostElement, + serviceDefinitions: [ + MakeGlobalStateService(options, hostElement), + CentralGraphServiceDefinition, + DialogBridgeServiceDefinition, + NodeListServiceDefinition, + PropertyTabServiceDefinition, + ScenePreviewServiceDefinition, + ToastBridgeServiceDefinition, + ToolbarServiceDefinition, + VariablesServiceDefinition, + ], + toolbarMode: "full", + showThemeSelector: true, + leftPaneMinWidth: 250, + leftPaneDefaultWidth: 250, + rightPaneMinWidth: 250, + rightPaneDefaultWidth: 300, }); - const root = createRoot(hostElement); - root.render(graphEditor); + this._CurrentDisposer = tool; - if (options.customLoadObservable) { - options.customLoadObservable.add((data) => { - const doLoadAsync = async () => { - await SerializationTools.DeserializeAsync(data, globalState); - }; - void doLoadAsync(); - }); - } - - this._CurrentState = globalState; - - globalState.hostWindow.addEventListener("beforeunload", () => { - globalState.onPopupClosedObservable.notifyObservers(); - }); + // Whenever the editor is hosted in a popup window, wire teardown so the modular + // tool (React root + observers) is disposed when the user closes the popup or + // the parent page is refreshed — even if no hostScene was supplied. + if (popupWindow) { + const capturedPopup: Window = popupWindow; + const capturedTool = tool; - // Close the popup window when the page is refreshed or scene is disposed - if (options.hostScene && this._PopupWindow) { - options.hostScene.onDisposeObservable.addOnce(() => { - if (this._PopupWindow) { - this._PopupWindow.close(); - } - }); + // Close the popup if the parent page is being unloaded. const onBeforeUnload = () => { - if (this._PopupWindow) { - this._PopupWindow.close(); + if (!capturedPopup.closed) { + capturedPopup.close(); } }; window.addEventListener("beforeunload", onBeforeUnload); - // Clean up when popup closes - globalState.onPopupClosedObservable.addOnce(() => { + + // When the popup itself unloads (user closed it, navigated away, etc.), + // dispose the modular tool and clear the static references so we don't + // leak observers / React root. + const onPopupUnload = () => { window.removeEventListener("beforeunload", onBeforeUnload); - }); + if (FlowGraphEditor._PopupWindow === capturedPopup) { + FlowGraphEditor._PopupWindow = null; + } + if (FlowGraphEditor._CurrentDisposer === capturedTool) { + void capturedTool.dispose(); + FlowGraphEditor._CurrentDisposer = undefined; + } + }; + capturedPopup.addEventListener("unload", onPopupUnload, { once: true }); + + // Close the popup window when the host scene is disposed (if one was provided). + if (options.hostScene) { + options.hostScene.onDisposeObservable.addOnce(() => { + if (!capturedPopup.closed) { + capturedPopup.close(); + } + }); + } } } } diff --git a/packages/tools/flowGraphEditor/src/globalState.ts b/packages/tools/flowGraphEditor/src/globalState.ts index ecc55741028..7e0e28f703d 100644 --- a/packages/tools/flowGraphEditor/src/globalState.ts +++ b/packages/tools/flowGraphEditor/src/globalState.ts @@ -771,12 +771,16 @@ export class GlobalState { public continueExecution(): void { const ctx = this._flowGraph?.getContext(this.selectedContextIndex); ctx?.continueExecution(); + this.onBreakpointsChanged.notifyObservers(); } /** Step one block and pause again */ public stepExecution(): void { const ctx = this._flowGraph?.getContext(this.selectedContextIndex); ctx?.stepExecution(); + if (!ctx?.pendingActivation) { + this.onBreakpointsChanged.notifyObservers(); + } } // ── Execution Context Management ─────────────────────────────────── @@ -864,6 +868,8 @@ export class GlobalState { /** The scene context populated when a Playground snippet is loaded */ sceneContext: Nullable = null; + /** The source used to create the current preview scene, if known. */ + sceneSource: "default" | "snippet" | "file" | null = null; /** Observable triggered when the scene context changes (snippet loaded/disposed) */ onSceneContextChanged = new Observable>(); @@ -1468,6 +1474,30 @@ export class GlobalState { this._snapshotUserVariablesFrom(); } + /** + * Recreate saved execution contexts after an operation that clears them. + * Keeps stopped graphs editable in the editor without starting execution. + */ + public restoreSavedContexts(): void { + if (!this._flowGraph) { + return; + } + + const previousContextCount = this._flowGraph.contextCount; + this._restoreContextsFromSnapshots(this._flowGraph); + const currentContextCount = this._flowGraph.contextCount; + + if (currentContextCount === previousContextCount) { + return; + } + + if (currentContextCount > 0 && this._selectedContextIndex >= currentContextCount) { + this._selectedContextIndex = currentContextCount - 1; + } + this.onContextListChanged.notifyObservers(); + this.onSelectedContextChanged.notifyObservers(this._selectedContextIndex); + } + /** * Returns the last serialized context snapshots (taken before stop()/setScene()). * Used by SerializationTools to inject context data into the serialized output diff --git a/packages/tools/flowGraphEditor/src/graphEditor.tsx b/packages/tools/flowGraphEditor/src/graphEditor.tsx index 3044b8b4988..b227d9456cb 100644 --- a/packages/tools/flowGraphEditor/src/graphEditor.tsx +++ b/packages/tools/flowGraphEditor/src/graphEditor.tsx @@ -1,16 +1,11 @@ import * as React from "react"; import { type GlobalState } from "./globalState"; -import { NodeListComponent } from "./components/nodeList/nodeListComponent"; -import { PropertyTabComponent } from "./components/propertyTab/propertyTabComponent"; -import { Portal } from "./portal"; import { LogComponent, LogEntry } from "./components/log/logComponent"; import { type Nullable } from "core/types"; import { type Observer } from "core/Misc/observable"; -import { MessageDialog } from "shared-ui-components/components/MessageDialog"; import { SerializationTools } from "./serializationTools"; import { blockFactory } from "core/FlowGraph/Blocks/flowGraphBlockFactory"; -import "./main.scss"; import { GraphCanvasComponent } from "shared-ui-components/nodeGraphSystem/graphCanvas"; import { type GraphNode } from "shared-ui-components/nodeGraphSystem/graphNode"; import { GraphFrame } from "shared-ui-components/nodeGraphSystem/graphFrame"; @@ -21,28 +16,153 @@ import { type FlowGraphBlock } from "core/FlowGraph/flowGraphBlock"; import { FlowGraphExecutionBlock } from "core/FlowGraph/flowGraphExecutionBlock"; import { ParseFlowGraph } from "core/FlowGraph/flowGraphParser"; import { FlowGraphCoordinator } from "core/FlowGraph/flowGraphCoordinator"; -import { SplitContainer } from "shared-ui-components/split/splitContainer"; -import { Splitter } from "shared-ui-components/split/splitter"; -import { ControlledSize, SplitDirection } from "shared-ui-components/split/splitContext"; -import { ScenePreviewComponent } from "./components/preview/scenePreviewComponent"; -import { GraphControlsComponent } from "./components/graphControls/graphControlsComponent"; -import { VariablesPanelComponent } from "./components/variables/variablesPanelComponent"; import { HistoryStack } from "shared-ui-components/historyStack"; import { FlowGraphEventBlock } from "core/FlowGraph/flowGraphEventBlock"; import { type IFlowGraphValidationResult, FlowGraphValidationSeverity } from "core/FlowGraph/flowGraphValidator"; import { AnalyzeSmartGroup, ApplySmartGroupExposure } from "./graphSystem/smartGroup"; import { HelpDialogComponent } from "./components/help/helpDialogComponent"; import { type HelpTopicId } from "./components/help/helpContent"; -import { ContextMenuComponent, type ContextMenuEntry } from "./components/contextMenu/contextMenuComponent"; -import { ToastContainerComponent, ShowToast } from "./components/toast/toastComponent"; +import { GraphTabBarComponent } from "./components/graphTabBar/graphTabBarComponent"; +import { ShowToast } from "./components/toast/toastComponent"; import { HowToUseDialogComponent } from "./components/howToUse/howToUseDialogComponent"; import { AllCompositeTemplates, type ICompositeTemplate } from "./compositeTemplates"; -import { GraphTabBarComponent } from "./components/graphTabBar/graphTabBarComponent"; +import { Divider, makeStyles, Menu, MenuDivider, MenuItem, MenuList, MenuPopover, MenuTrigger, Title3, mergeClasses, tokens } from "@fluentui/react-components"; +import { useResizeHandle } from "@fluentui-contrib/react-resize-handle"; +import { createVirtualElementFromClick, type PositioningVirtualElement } from "@fluentui/react-positioning"; + +/** + * Local context-menu item shape used by the right-click menu on the graph canvas. + * Built dynamically by `_onContextMenu` and rendered inline as Fluent ``s. + */ +type ContextMenuItem = { + label: string; + action: () => void; + shortcut?: string; + disabled?: boolean; + ariaLabel?: string; +}; +type ContextMenuSeparator = { isSeparator: true }; +type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator; +function IsContextMenuSeparator(entry: ContextMenuEntry): entry is ContextMenuSeparator { + return "isSeparator" in entry && entry.isSeparator === true; +} + +const MobileBlockerMediaQuery = "@media screen and (max-width: 899px)"; + +const useGraphEditorLayoutStyles = makeStyles({ + diagramContainer: { + display: "flex", + flexDirection: "column", + width: "100%", + height: "100%", + overflow: "hidden", + background: "#3a4a4f", + fontFamily: "acumin-pro", + fontSize: "14px", + }, + diagramCanvasPane: { + display: "flex", + flexDirection: "column", + width: "100%", + flex: 1, + minHeight: 0, + overflow: "hidden", + "& > :last-child": { + flex: 1, + minHeight: 0, + overflow: "hidden", + }, + }, + blocker: { + position: "absolute", + width: "calc(100% - 40px)", + height: "100%", + top: 0, + left: 0, + background: "rgba(20, 20, 20, 0.95)", + fontFamily: "acumin-pro", + color: "white", + fontSize: "24px", + display: "none", + alignContent: "center", + justifyContent: "center", + userSelect: "none", + padding: "20px", + textAlign: "center", + [MobileBlockerMediaQuery]: { + display: "grid", + }, + }, + waitScreen: { + display: "grid", + justifyContent: "center", + alignContent: "center", + height: "100%", + width: "100%", + background: "#464646", + opacity: 0.95, + color: "white", + fontFamily: "acumin-pro", + fontSize: "24px", + position: "absolute", + top: 0, + left: 0, + }, + hidden: { + visibility: "hidden", + }, +}); + +const LogHeightAdjustCSSVar = "--flow-graph-log-height-adjust"; + +const useLogResizeStyles = makeStyles({ + divider: { + flex: "0 0 auto", + margin: "0", + minHeight: tokens.spacingVerticalM, + cursor: "ns-resize", + alignItems: "end", + }, + logPane: { + flex: "0 0 auto", + overflow: "hidden", + minHeight: 0, + }, +}); + +/** + * Renders the resize handle (Fluent ``) and the resizable log container as siblings. + * + * This only exists as a separate function component because {@link useResizeHandle} is a React + * hook and cannot be called from a class component's render method. Once {@link GraphEditor} + * is converted to a function component, this can be deleted: the divider and the resizable + * div can become inline siblings of the canvas pane in `GraphEditor`'s render, with + * `useResizeHandle` called directly in `GraphEditor` itself. + * @param props The component props. + * @returns A fragment containing the divider and the resizable log container. + */ +const LogResizeRegion: React.FC = ({ children }) => { + const classes = useLogResizeStyles(); + const { elementRef, handleRef } = useResizeHandle({ + growDirection: "up", + relative: true, + variableName: LogHeightAdjustCSSVar, + variableTarget: "element", + }); + return ( + <> + +
+ {children} +
+ + ); +}; /** * Pre-populate string (and other primitive) config fields for blocks whose constructors * receive those fields via the config object. Without this, `_defaultValue` on the - * DataConnection starts as `undefined` and the property panel can't show a text field. + * DataConnection starts as `undefined` a nd the property panel can't show a text field. * Key = FlowGraphBlockNames string value (i.e. `getClassName()` output). */ interface IGraphEditorProps { @@ -50,13 +170,130 @@ interface IGraphEditorProps { } interface IGraphEditorState { - message: string; - isError: boolean; helpTopicId: HelpTopicId | undefined | null; - contextMenu: { x: number; y: number; items: ContextMenuEntry[] } | null; + contextMenu: { target: PositioningVirtualElement; items: ContextMenuEntry[] } | null; + showHowToUse: boolean; + isWaitScreenVisible: boolean; +} + +interface IGraphEditorLayoutProps { + globalState: GlobalState; + diagramContainerRef: React.RefObject; + graphCanvasRef: React.RefObject; + contextMenu: IGraphEditorState["contextMenu"]; + helpTopicId: IGraphEditorState["helpTopicId"]; showHowToUse: boolean; + isWaitScreenVisible: boolean; + onPointerMove: (evt: React.PointerEvent) => void; + onPointerDown: (evt: React.PointerEvent) => void; + onDragOver: (evt: React.DragEvent) => void; + onDrop: (evt: React.DragEvent) => void; + onContextMenu: (evt: React.MouseEvent) => void; + onEmitNewNode: (nodeData: INodeData) => GraphNode; + onCloseHelp: () => void; + onCloseHowToUse: () => void; + onCloseContextMenu: () => void; + onMenuAction: (action: () => void) => void; } +const GraphEditorLayout: React.FC = (props) => { + const { + globalState, + diagramContainerRef, + graphCanvasRef, + contextMenu, + helpTopicId, + showHowToUse, + isWaitScreenVisible, + onPointerMove, + onPointerDown, + onDragOver, + onDrop, + onContextMenu, + onEmitNewNode, + onCloseHelp, + onCloseHowToUse, + onCloseContextMenu, + onMenuAction, + } = props; + const classes = useGraphEditorLayoutStyles(); + + return ( + <> +
+
+ + +
+ + + +
+ {helpTopicId !== null && } + {showHowToUse && } +
+ Flow Graph Editor needs a horizontal resolution of at least 900px +
+
+ Processing...please wait +
+ {contextMenu && ( + { + if (!data.open) { + onCloseContextMenu(); + } + }} + positioning={{ target: contextMenu.target, position: "below", align: "start" }} + > + {/* Empty trigger — Fluent requires one but we control open state imperatively. */} + + + + + {contextMenu.items.map((entry, idx) => { + if (IsContextMenuSeparator(entry)) { + return ; + } + return ( + onMenuAction(entry.action)} + > + {entry.label} + + ); + })} + + + + )} + + ); +}; + /** * Main React component for the Flow Graph Editor */ @@ -74,10 +311,23 @@ export class GraphEditor extends React.Component> = null; private _activeGraphObserver: Nullable> = null; private _blockClassRegistry = new Map(); + private _buildVersion = 0; /** Cache for O(1) block→GraphNode lookups (rebuilt on graph load) */ private _blockToNodeMap = new Map(); private _onDocumentKeyDown = (evt: KeyboardEvent) => { + // Don't process editor shortcuts (Delete, Backspace, Ctrl+Z, Ctrl+A, Ctrl+F, etc.) while + // the user is typing in a text input, textarea, or contentEditable element. Without this + // guard, pressing Delete inside a property-panel input would delete the selected node, + // and Ctrl+A would select all canvas nodes instead of all the input's text. + const target = evt.target as HTMLElement | null; + if (target) { + const tag = target.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable) { + return; + } + } + if (this._historyStack && this._historyStack.processKeyEvent(evt)) { return; } @@ -325,11 +575,10 @@ export class GraphEditor extends React.Component { - this.setState({ message: message, isError: true }); - }); - // ── Validation wiring ────────────────────────────────────────── // Provide the editor's block list so "unreachable" detection works. this.props.globalState.registerEditorBlocksProvider(() => { @@ -567,12 +812,14 @@ export class GraphEditor extends React.Component { + if (buildVersion !== this._buildVersion || flowGraph !== this.props.globalState.flowGraph) { + return; + } this.reOrganize(null); }, 0); } @@ -637,12 +887,12 @@ export class GraphEditor extends React.Component 0) { - this.setState({ contextMenu: { x: evt.clientX, y: evt.clientY, items } }); + this.setState({ contextMenu: { target: createVirtualElementFromClick(evt.nativeEvent), items } }); } }; @@ -1125,9 +1375,16 @@ export class GraphEditor extends React.Component) { const data = event.dataTransfer.getData("babylonjs-flow-graph-node"); - const container = this._diagramContainerRef.current!; - const dropX = event.clientX - container.offsetLeft; - const dropY = event.clientY - container.offsetTop; + // Use the graph canvas's own container rect so the drop point is computed in the + // canvas's local coordinate space. The diagram pane includes toolbars (tab bar, + // graph controls, variables panel) above the canvas, so its bounding rect is offset. + const canvasContainer = this._graphCanvas?.canvasContainer; + if (!canvasContainer) { + return; + } + const rect = canvasContainer.getBoundingClientRect(); + const dropX = event.clientX - rect.left; + const dropY = event.clientY - rect.top; // Check if this is a composite template drop const dropTemplate = AllCompositeTemplates[data]; @@ -1221,115 +1478,69 @@ export class GraphEditor extends React.Component - { - this._mouseLocationX = evt.pageX; - this._mouseLocationY = evt.pageY; - }} - onPointerDown={(evt) => { - if ((evt.target as HTMLElement).nodeName === "INPUT") { - return; - } - this.props.globalState.lockObject.lock = false; - }} - onDragOver={(evt) => { - // Allow dropping 3D scene files anywhere on the editor. - // Check both DataTransferItem.kind (modern) and dataTransfer.types (legacy/Firefox) - // to ensure preventDefault is called even when items list is unavailable. - const dt = evt.dataTransfer; - const hasFile = - (dt?.items && Array.from(dt.items).some((item) => item.kind === "file")) || - (dt?.types && (dt.types.includes("Files") || dt.types.includes("application/x-moz-file"))); - if (hasFile) { - evt.preventDefault(); - evt.stopPropagation(); - } - }} - onDrop={(evt) => { - const files = evt.dataTransfer?.files; - if (!files || files.length === 0) { - return; - } - // Always prevent default when files are dropped to avoid browser navigation. + { + this._mouseLocationX = evt.pageX; + this._mouseLocationY = evt.pageY; + }} + onPointerDown={(evt) => { + if ((evt.target as HTMLElement).nodeName === "INPUT") { + return; + } + this.props.globalState.lockObject.lock = false; + }} + onDragOver={(evt) => { + // Allow dropping 3D scene files anywhere on the editor, and node-list drag items + // onto the central content. Check both DataTransferItem.kind (modern) and + // dataTransfer.types (legacy/Firefox) to ensure preventDefault is called even + // when items list is unavailable. + const dt = evt.dataTransfer; + const hasFile = + (dt?.items && Array.from(dt.items).some((item) => item.kind === "file")) || + (dt?.types && (dt.types.includes("Files") || dt.types.includes("application/x-moz-file"))); + const hasNodeData = dt?.types && dt.types.includes("babylonjs-flow-graph-node"); + if (hasFile || hasNodeData) { evt.preventDefault(); evt.stopPropagation(); - const supportedExtensions = [".glb", ".gltf", ".babylon"]; - for (let i = 0; i < files.length; i++) { - const name = files[i].name.toLowerCase(); - if (supportedExtensions.some((ext) => name.endsWith(ext))) { - this.props.globalState.onDropEventReceivedObservable.notifyObservers(evt.nativeEvent); - return; - } + } + }} + onDrop={(evt) => { + // Files dropped on the canvas itself (block palette items) are handled by the + // inner pane below. This top-level handler only intercepts 3D scene files + // dropped anywhere in the central content. + const files = evt.dataTransfer?.files; + if (!files || files.length === 0) { + this.dropNewBlock(evt); + return; + } + evt.preventDefault(); + evt.stopPropagation(); + const supportedExtensions = [".glb", ".gltf", ".babylon"]; + for (let i = 0; i < files.length; i++) { + const name = files[i].name.toLowerCase(); + if (supportedExtensions.some((ext) => name.endsWith(ext))) { + this.props.globalState.onDropEventReceivedObservable.notifyObservers(evt.nativeEvent); + return; } - }} - > - {/* Node creation menu */} - - - - - {/* The node graph diagram */} - { - this.dropNewBlock(event); - }} - onDragOver={(event) => { - event.preventDefault(); - }} - > -
- - - - { - return this.appendBlock(nodeData.data as FlowGraphBlock); - }} - /> -
- - -
- - - - {/* Property tab + Scene preview */} - - - - - -
- this.setState({ message: "" })} /> - {this.state.helpTopicId !== null && ( - this.setState({ helpTopicId: null })} /> - )} - {this.state.showHowToUse && this.setState({ showHowToUse: false })} />} -
Flow Graph Editor needs a horizontal resolution of at least 900px
-
Processing...please wait
- {this.state.contextMenu && ( - this.setState({ contextMenu: null })} - /> - )} - - + } + }} + onContextMenu={this._onContextMenu} + onEmitNewNode={(nodeData) => this.appendBlock(nodeData.data as FlowGraphBlock)} + onCloseHelp={() => this.setState({ helpTopicId: null })} + onCloseHowToUse={() => this.setState({ showHowToUse: false })} + onCloseContextMenu={() => this.setState({ contextMenu: null })} + onMenuAction={(action) => { + action(); + this.setState({ contextMenu: null }); + }} + /> ); } } diff --git a/packages/tools/flowGraphEditor/src/graphSystem/blockNodeData.module.scss b/packages/tools/flowGraphEditor/src/graphSystem/blockNodeData.module.scss deleted file mode 100644 index 635d6ed1ec4..00000000000 --- a/packages/tools/flowGraphEditor/src/graphSystem/blockNodeData.module.scss +++ /dev/null @@ -1,3 +0,0 @@ -.hidden { - display: none !important; -} diff --git a/packages/tools/flowGraphEditor/src/graphSystem/blockNodeData.ts b/packages/tools/flowGraphEditor/src/graphSystem/blockNodeData.ts index 7db29df9ef4..ea9acd4c414 100644 --- a/packages/tools/flowGraphEditor/src/graphSystem/blockNodeData.ts +++ b/packages/tools/flowGraphEditor/src/graphSystem/blockNodeData.ts @@ -2,7 +2,6 @@ import { type INodeContainer } from "shared-ui-components/nodeGraphSystem/interf import { type INodeData } from "shared-ui-components/nodeGraphSystem/interfaces/nodeData"; import { type IPortData } from "shared-ui-components/nodeGraphSystem/interfaces/portData"; import { ConnectionPointPortData } from "./connectionPointPortData"; -import * as styles from "./blockNodeData.module.scss"; import { type FlowGraphBlock } from "core/FlowGraph/flowGraphBlock"; import { type FlowGraphExecutionBlock } from "core/FlowGraph/flowGraphExecutionBlock"; import { FlowGraphBlockDisplayName } from "./blockDisplayUtils"; @@ -126,7 +125,7 @@ export class BlockNodeData implements INodeData { * @param _img - the image element (unused) */ public prepareHeaderIcon(iconDiv: HTMLDivElement, _img: HTMLImageElement) { - iconDiv.classList.add(styles.hidden); + iconDiv.style.display = "none"; } /** Gets the invisible endpoints (not applicable) */ diff --git a/packages/tools/flowGraphEditor/src/graphSystem/display/debugDisplayManager.module.scss b/packages/tools/flowGraphEditor/src/graphSystem/display/debugDisplayManager.module.scss deleted file mode 100644 index fdf71e5c9c2..00000000000 --- a/packages/tools/flowGraphEditor/src/graphSystem/display/debugDisplayManager.module.scss +++ /dev/null @@ -1,28 +0,0 @@ -.debugBlock { - width: 40px; - grid-template-rows: 0px 40px 0px; - border-radius: 5px; - transform: translateY(-7px); -} - -.hidden { - display: none; -} - -.translatedConnections { - transform: translateY(7px); -} - -.roundSelectionBorder { - border-radius: 0px; -} - -.debugContent { - width: 20px; - transform: translate(16px, 10px); - font-size: 16px; - font-weight: bold; - color: white; - pointer-events: none; - user-select: none; -} diff --git a/packages/tools/flowGraphEditor/src/graphSystem/display/debugDisplayManager.ts b/packages/tools/flowGraphEditor/src/graphSystem/display/debugDisplayManager.ts index 7d0527789c6..b5a71699cd4 100644 --- a/packages/tools/flowGraphEditor/src/graphSystem/display/debugDisplayManager.ts +++ b/packages/tools/flowGraphEditor/src/graphSystem/display/debugDisplayManager.ts @@ -1,6 +1,5 @@ import { type IDisplayManager, type VisualContentDescription } from "shared-ui-components/nodeGraphSystem/interfaces/displayManager"; import { type INodeData } from "shared-ui-components/nodeGraphSystem/interfaces/nodeData"; -import * as styles from "./debugDisplayManager.module.scss"; import { type FlowGraphDebugBlock } from "core/FlowGraph/Blocks/Data/flowGraphDebugBlock"; export class DebugDisplayManager implements IDisplayManager { @@ -36,11 +35,24 @@ export class DebugDisplayManager implements IDisplayManager { const connections = visualContent.connections; const selectionBorder = visualContent.selectionBorder; - visual.classList.add(styles.debugBlock); - headerContainer.classList.add(styles.hidden); - content.classList.add(styles.debugContent); + visual.style.width = "40px"; + visual.style.gridTemplateRows = "0px 40px 0px"; + visual.style.borderRadius = "5px"; + visual.style.transform = "translateY(-7px)"; + + headerContainer.style.display = "none"; + + content.style.width = "20px"; + content.style.transform = "translate(16px, 10px)"; + content.style.fontSize = "16px"; + content.style.fontWeight = "bold"; + content.style.color = "white"; + content.style.pointerEvents = "none"; + content.style.userSelect = "none"; content.textContent = "?"; - connections.classList.add(styles.translatedConnections); - selectionBorder.classList.add(styles.roundSelectionBorder); + + connections.style.transform = "translateY(7px)"; + + selectionBorder.style.borderRadius = "0px"; } } diff --git a/packages/tools/flowGraphEditor/src/graphSystem/properties/constantBlockPropertyComponent.tsx b/packages/tools/flowGraphEditor/src/graphSystem/properties/constantBlockPropertyComponent.tsx index cb60d237f65..59009fe147e 100644 --- a/packages/tools/flowGraphEditor/src/graphSystem/properties/constantBlockPropertyComponent.tsx +++ b/packages/tools/flowGraphEditor/src/graphSystem/properties/constantBlockPropertyComponent.tsx @@ -1,365 +1,342 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import * as React from "react"; -import { LineContainerComponent } from "../../sharedComponents/lineContainerComponent"; -import { type IPropertyComponentProps } from "shared-ui-components/nodeGraphSystem/interfaces/propertyComponentProps"; -import { OptionsLine } from "shared-ui-components/lines/optionsLineComponent"; -import { FloatLineComponent } from "shared-ui-components/lines/floatLineComponent"; -import { CheckBoxLineComponent } from "../../sharedComponents/checkBoxLineComponent"; -import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; -import { Vector2LineComponent } from "shared-ui-components/lines/vector2LineComponent"; -import { Vector3LineComponent } from "shared-ui-components/lines/vector3LineComponent"; -import { Color3LineComponent } from "shared-ui-components/lines/color3LineComponent"; -import { Color4LineComponent } from "shared-ui-components/lines/color4LineComponent"; -import { MatrixLineComponent } from "shared-ui-components/lines/matrixLineComponent"; -import { GeneralPropertyTabComponent } from "./genericNodePropertyComponent"; -import { type FlowGraphConstantBlock } from "core/FlowGraph/Blocks/Data/flowGraphConstantBlock"; -import { getRichTypeFromValue, RichTypeAny } from "core/FlowGraph/flowGraphRichTypes"; -import { FlowGraphInteger } from "core/FlowGraph/CustomTypes/flowGraphInteger"; -import { Vector2, Vector3, Vector4, Quaternion, Matrix } from "core/Maths/math.vector"; -import { Color3, Color4 } from "core/Maths/math.color"; -import { type GlobalState } from "../../globalState"; -import { type SceneContext, SceneContextCategory } from "../../sceneContext"; -import { type Observer } from "core/Misc/observable"; - -const ValueTypeOptions = [ - { label: "Number", value: "number" }, - { label: "Integer", value: "FlowGraphInteger" }, - { label: "Boolean", value: "boolean" }, - { label: "String", value: "string" }, - { label: "Vector2", value: "Vector2" }, - { label: "Vector3", value: "Vector3" }, - { label: "Vector4", value: "Vector4" }, - { label: "Quaternion", value: "Quaternion" }, - { label: "Color3", value: "Color3" }, - { label: "Color4", value: "Color4" }, - { label: "Matrix", value: "Matrix" }, - { label: "Mesh", value: "Mesh" }, - { label: "Light", value: "Light" }, - { label: "Camera", value: "Camera" }, - { label: "Material", value: "Material" }, - { label: "Animation Group", value: "AnimationGroup" }, - { label: "Animation", value: "Animation" }, -]; - -/** Scene object type names that require a scene picker instead of an inline editor. */ -const SceneObjectTypes = new Set(["Mesh", "Light", "Camera", "Material", "AnimationGroup", "Animation"]); - -/** Maps a scene object type name to the SceneContextCategory used to query the SceneContext. */ -const SceneObjectCategoryMap: Record = { - Mesh: SceneContextCategory.Mesh, - Light: SceneContextCategory.Light, - Camera: SceneContextCategory.Camera, - Material: SceneContextCategory.Material, - AnimationGroup: SceneContextCategory.AnimationGroup, - Animation: SceneContextCategory.Animation, -}; - -function DetectValueType(value: any, config?: any): string { - // If the config has a stored type hint (for scene objects or null values), use it. - if (config?._valueTypeName && SceneObjectTypes.has(config._valueTypeName)) { - return config._valueTypeName; - } - if (value instanceof FlowGraphInteger) { - return "FlowGraphInteger"; - } - if (typeof value === "number") { - return "number"; - } - if (typeof value === "boolean") { - return "boolean"; - } - if (typeof value === "string") { - return "string"; - } - if (value instanceof Color4) { - return "Color4"; - } // check before Color3 since Color4 extends Color3 - if (value instanceof Color3) { - return "Color3"; - } - if (value instanceof Quaternion) { - return "Quaternion"; - } // check before Vector4 - if (value instanceof Vector4) { - return "Vector4"; - } - if (value instanceof Vector3) { - return "Vector3"; - } - if (value instanceof Vector2) { - return "Vector2"; - } - if (value instanceof Matrix) { - return "Matrix"; - } - // Scene objects: check for uniqueId + name (common to all Babylon.js Node/Asset types) - if (value != null && typeof value === "object" && typeof value.getClassName === "function" && "uniqueId" in value) { - const className: string = value.getClassName(); - if (className === "AnimationGroup") { - return "AnimationGroup"; - } - if (className === "Animation") { - return "Animation"; - } - // Mesh subtypes: Mesh, InstancedMesh, GroundMesh, etc. - if ("geometry" in value || className.includes("Mesh")) { - return "Mesh"; - } - if (className.includes("Light")) { - return "Light"; - } - if (className.includes("Camera")) { - return "Camera"; - } - if (className.includes("Material")) { - return "Material"; - } - } - // Fall back to stored type hint if available - if (config?._valueTypeName) { - return config._valueTypeName; - } - return "number"; -} - -function CreateDefaultValue(typeName: string): any { - switch (typeName) { - case "number": - return 0; - case "FlowGraphInteger": - return new FlowGraphInteger(0); - case "boolean": - return false; - case "string": - return ""; - case "Vector2": - return Vector2.Zero(); - case "Vector3": - return Vector3.Zero(); - case "Vector4": - return Vector4.Zero(); - case "Quaternion": - return Quaternion.Identity(); - case "Color3": - return Color3.Black(); - case "Color4": - return new Color4(0, 0, 0, 1); - case "Matrix": - return Matrix.Identity(); - case "Mesh": - case "Light": - case "Camera": - case "Material": - case "AnimationGroup": - case "Animation": - return null; - default: - return 0; - } -} - -/** - * Property panel for FlowGraphConstantBlock. - * Shows a type selector and a value editor that adapts to the current type. - */ -export class ConstantBlockPropertyComponent extends React.Component { - private _sceneContextObserver: Observer | null = null; - private _contextRefreshObserver: Observer | null = null; - - constructor(props: IPropertyComponentProps) { - super(props); - const globalState = props.stateManager.data as GlobalState; - this.state = { sceneContext: globalState.sceneContext }; - } - - override componentDidMount() { - const globalState = this.props.stateManager.data as GlobalState; - this._sceneContextObserver = globalState.onSceneContextChanged.add((ctx) => { - this._contextRefreshObserver?.remove(); - this._contextRefreshObserver = ctx?.onContextRefreshed.add(() => this.forceUpdate()) ?? null; - this.setState({ sceneContext: ctx }); - }); - if (globalState.sceneContext) { - this._contextRefreshObserver = globalState.sceneContext.onContextRefreshed.add(() => this.forceUpdate()); - } - } - - override componentWillUnmount() { - const globalState = this.props.stateManager.data as GlobalState; - if (this._sceneContextObserver) { - globalState.onSceneContextChanged.remove(this._sceneContextObserver); - this._sceneContextObserver = null; - } - this._contextRefreshObserver?.remove(); - this._contextRefreshObserver = null; - } - - private _getBlock(): FlowGraphConstantBlock { - return this.props.nodeData.data as FlowGraphConstantBlock; - } - - private _updateValue(newValue: any) { - const block = this._getBlock(); - block.config.value = newValue; - this.props.stateManager.onUpdateRequiredObservable.notifyObservers(block); - this.forceUpdate(); - } - - private _changeType(newTypeName: string) { - const block = this._getBlock(); - const newValue = CreateDefaultValue(newTypeName); - block.config.value = newValue; - - // Store the type hint so we can detect the type even when value is null. - (block.config as any)._valueTypeName = newTypeName; - - // Update the output port's rich type so downstream connections see the new type. - const output = block.getDataOutput("output"); - if (output) { - const richType = newValue != null ? getRichTypeFromValue(newValue) : RichTypeAny; - (output as any).richType = richType; - } - - this.props.stateManager.onRebuildRequiredObservable.notifyObservers(); - this.props.stateManager.onUpdateRequiredObservable.notifyObservers(block); - this.forceUpdate(); - } - - private _renderValueEditor(value: any): JSX.Element | null { - const lock = this.props.stateManager.lockObject; - const block = this._getBlock(); - const notify = () => this.props.stateManager.onUpdateRequiredObservable.notifyObservers(block); - - if (typeof value === "boolean") { - return block.config.value === true} onSelect={(v) => this._updateValue(v)} />; - } - - if (value instanceof FlowGraphInteger) { - const proxy = { v: value.value }; - return ( - this._updateValue(new FlowGraphInteger(v))} - /> - ); - } - - if (typeof value === "number") { - const proxy = { v: value }; - return this._updateValue(v)} />; - } - - if (typeof value === "string") { - const proxy = { v: value }; - return ( - this._updateValue(v)} - /> - ); - } - - if (value instanceof Vector2) { - return ; - } - - if (value instanceof Vector3 && !(value instanceof Vector4) && !(value instanceof Quaternion)) { - return ; - } - - // Color3 before Vector3 subclasses since Color3/4 have dedicated editors - if (value instanceof Color4) { - return ; - } - - if (value instanceof Color3) { - return ; - } - - if (value instanceof Matrix) { - return ; - } - - // Fallback: show the type name for uneditable types (Vector4, Quaternion) - return
Cannot edit {DetectValueType(value, block.config)} inline.
; - } - - private _renderSceneObjectPicker(typeName: string): JSX.Element { - const block = this._getBlock(); - const { sceneContext } = this.state; - const category = SceneObjectCategoryMap[typeName]; - - if (!sceneContext || !category) { - return
Load a scene in the Preview panel to pick {typeName.toLowerCase()}s.
; - } - - const entries = sceneContext.getByCategory(category); - const currentValue = block.config.value; - const currentId = currentValue != null && typeof currentValue === "object" && "uniqueId" in currentValue ? (currentValue as any).uniqueId : -1; - - return ( - <> - ({ label: e.name || `(id ${e.uniqueId})`, value: e.uniqueId }))]} - target={{}} - propertyName="_unused" - noDirectUpdate={true} - extractValue={() => currentId} - onSelect={(value) => { - const uid = value as number; - if (uid === -1) { - this._updateValue(null); - } else { - const entry = entries.find((e) => e.uniqueId === uid); - if (entry) { - this._updateValue(entry.object); - } - } - }} - /> - {entries.length === 0 &&
No {typeName.toLowerCase()}s found in the scene.
} - - ); - } - - override render() { - const { stateManager, nodeData } = this.props; - const block = this._getBlock(); - const value = block.config.value; - const currentType = DetectValueType(value, block.config); - const isSceneObject = SceneObjectTypes.has(currentType); - - return ( - <> - - - - currentType} - onSelect={(v) => this._changeType(v as string)} - /> - {isSceneObject ? this._renderSceneObjectPicker(currentType) : this._renderValueEditor(value)} - - - ); - } -} +/* eslint-disable @typescript-eslint/naming-convention */ +import * as React from "react"; +import { type IPropertyComponentProps } from "shared-ui-components/nodeGraphSystem/interfaces/propertyComponentProps"; +import { Accordion, AccordionSection } from "shared-ui-components/fluent/primitives/accordion"; +import { PropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/propertyLine"; +import { TextInputPropertyLine, NumberInputPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/inputPropertyLine"; +import { SwitchPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/switchPropertyLine"; +import { StringDropdownPropertyLine, NumberDropdownPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/dropdownPropertyLine"; +import { Color3PropertyLine, Color4PropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/colorPropertyLine"; +import { Vector2PropertyLine, Vector3PropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/vectorPropertyLine"; +import { type DropdownOption } from "shared-ui-components/fluent/primitives/dropdown"; +import { Body1, makeStyles, tokens } from "@fluentui/react-components"; + +import { RenderGeneralSection, MatrixEditor } from "./genericNodePropertyComponent"; +import { type FlowGraphConstantBlock } from "core/FlowGraph/Blocks/Data/flowGraphConstantBlock"; +import { getRichTypeFromValue, RichTypeAny } from "core/FlowGraph/flowGraphRichTypes"; +import { FlowGraphInteger } from "core/FlowGraph/CustomTypes/flowGraphInteger"; +import { Vector2, Vector3, Vector4, Quaternion, Matrix } from "core/Maths/math.vector"; +import { Color3, Color4 } from "core/Maths/math.color"; +import { type GlobalState } from "../../globalState"; +import { type SceneContext, SceneContextCategory } from "../../sceneContext"; +import { type Observer } from "core/Misc/observable"; + +const ValueTypeOptions: DropdownOption[] = [ + { label: "Number", value: "number" }, + { label: "Integer", value: "FlowGraphInteger" }, + { label: "Boolean", value: "boolean" }, + { label: "String", value: "string" }, + { label: "Vector2", value: "Vector2" }, + { label: "Vector3", value: "Vector3" }, + { label: "Vector4", value: "Vector4" }, + { label: "Quaternion", value: "Quaternion" }, + { label: "Color3", value: "Color3" }, + { label: "Color4", value: "Color4" }, + { label: "Matrix", value: "Matrix" }, + { label: "Mesh", value: "Mesh" }, + { label: "Light", value: "Light" }, + { label: "Camera", value: "Camera" }, + { label: "Material", value: "Material" }, + { label: "Animation Group", value: "AnimationGroup" }, + { label: "Animation", value: "Animation" }, +]; + +const useStyles = makeStyles({ + helpText: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForeground3, + fontStyle: "italic", + }, +}); + +/** Scene object type names that require a scene picker instead of an inline editor. */ +const SceneObjectTypes = new Set(["Mesh", "Light", "Camera", "Material", "AnimationGroup", "Animation"]); + +/** Maps a scene object type name to the SceneContextCategory used to query the SceneContext. */ +const SceneObjectCategoryMap: Record = { + Mesh: SceneContextCategory.Mesh, + Light: SceneContextCategory.Light, + Camera: SceneContextCategory.Camera, + Material: SceneContextCategory.Material, + AnimationGroup: SceneContextCategory.AnimationGroup, + Animation: SceneContextCategory.Animation, +}; + +function DetectValueType(value: any, config?: any): string { + // If the config has a stored type hint (for scene objects or null values), use it. + if (config?._valueTypeName && SceneObjectTypes.has(config._valueTypeName)) { + return config._valueTypeName; + } + if (value instanceof FlowGraphInteger) { + return "FlowGraphInteger"; + } + if (typeof value === "number") { + return "number"; + } + if (typeof value === "boolean") { + return "boolean"; + } + if (typeof value === "string") { + return "string"; + } + if (value instanceof Color4) { + return "Color4"; + } // check before Color3 since Color4 extends Color3 + if (value instanceof Color3) { + return "Color3"; + } + if (value instanceof Quaternion) { + return "Quaternion"; + } // check before Vector4 + if (value instanceof Vector4) { + return "Vector4"; + } + if (value instanceof Vector3) { + return "Vector3"; + } + if (value instanceof Vector2) { + return "Vector2"; + } + if (value instanceof Matrix) { + return "Matrix"; + } + // Scene objects: check for uniqueId + name (common to all Babylon.js Node/Asset types) + if (value != null && typeof value === "object" && typeof value.getClassName === "function" && "uniqueId" in value) { + const className: string = value.getClassName(); + if (className === "AnimationGroup") { + return "AnimationGroup"; + } + if (className === "Animation") { + return "Animation"; + } + // Mesh subtypes: Mesh, InstancedMesh, GroundMesh, etc. + if ("geometry" in value || className.includes("Mesh")) { + return "Mesh"; + } + if (className.includes("Light")) { + return "Light"; + } + if (className.includes("Camera")) { + return "Camera"; + } + if (className.includes("Material")) { + return "Material"; + } + } + // Fall back to stored type hint if available + if (config?._valueTypeName) { + return config._valueTypeName; + } + return "number"; +} + +function CreateDefaultValue(typeName: string): any { + switch (typeName) { + case "number": + return 0; + case "FlowGraphInteger": + return new FlowGraphInteger(0); + case "boolean": + return false; + case "string": + return ""; + case "Vector2": + return Vector2.Zero(); + case "Vector3": + return Vector3.Zero(); + case "Vector4": + return Vector4.Zero(); + case "Quaternion": + return Quaternion.Identity(); + case "Color3": + return Color3.Black(); + case "Color4": + return new Color4(0, 0, 0, 1); + case "Matrix": + return Matrix.Identity(); + case "Mesh": + case "Light": + case "Camera": + case "Material": + case "AnimationGroup": + case "Animation": + return null; + default: + return 0; + } +} + +/** + * Property panel for FlowGraphConstantBlock. + * Shows a type selector and a value editor that adapts to the current type. + */ +export class ConstantBlockPropertyComponent extends React.Component { + private _sceneContextObserver: Observer | null = null; + private _contextRefreshObserver: Observer | null = null; + + constructor(props: IPropertyComponentProps) { + super(props); + const globalState = props.stateManager.data as GlobalState; + this.state = { sceneContext: globalState.sceneContext }; + } + + override componentDidMount() { + const globalState = this.props.stateManager.data as GlobalState; + this._sceneContextObserver = globalState.onSceneContextChanged.add((ctx) => { + this._contextRefreshObserver?.remove(); + this._contextRefreshObserver = ctx?.onContextRefreshed.add(() => this.forceUpdate()) ?? null; + this.setState({ sceneContext: ctx }); + }); + if (globalState.sceneContext) { + this._contextRefreshObserver = globalState.sceneContext.onContextRefreshed.add(() => this.forceUpdate()); + } + } + + override componentWillUnmount() { + const globalState = this.props.stateManager.data as GlobalState; + if (this._sceneContextObserver) { + globalState.onSceneContextChanged.remove(this._sceneContextObserver); + this._sceneContextObserver = null; + } + this._contextRefreshObserver?.remove(); + this._contextRefreshObserver = null; + } + + private _getBlock(): FlowGraphConstantBlock { + return this.props.nodeData.data as FlowGraphConstantBlock; + } + + private _updateValue(newValue: any) { + const block = this._getBlock(); + block.config.value = newValue; + this.props.stateManager.onUpdateRequiredObservable.notifyObservers(block); + this.forceUpdate(); + } + + private _changeType(newTypeName: string) { + const block = this._getBlock(); + const newValue = CreateDefaultValue(newTypeName); + block.config.value = newValue; + + // Store the type hint so we can detect the type even when value is null. + (block.config as any)._valueTypeName = newTypeName; + + // Update the output port's rich type so downstream connections see the new type. + const output = block.getDataOutput("output"); + if (output) { + const richType = newValue != null ? getRichTypeFromValue(newValue) : RichTypeAny; + (output as any).richType = richType; + } + + this.props.stateManager.onRebuildRequiredObservable.notifyObservers(); + this.props.stateManager.onUpdateRequiredObservable.notifyObservers(block); + this.forceUpdate(); + } + + private _renderValueEditor(value: any): JSX.Element | null { + const block = this._getBlock(); + + if (typeof value === "boolean") { + return this._updateValue(v)} />; + } + + if (value instanceof FlowGraphInteger) { + return this._updateValue(new FlowGraphInteger(Math.trunc(v)))} />; + } + + if (typeof value === "number") { + return this._updateValue(v)} />; + } + + if (typeof value === "string") { + return this._updateValue(v)} />; + } + + if (value instanceof Vector2) { + return this._updateValue(v.clone())} />; + } + + // Color3 before Vector3 subclasses since Color3/4 have dedicated editors + if (value instanceof Color4) { + return this._updateValue(v.clone())} />; + } + + if (value instanceof Color3) { + return this._updateValue(v.clone())} />; + } + + if (value instanceof Vector3 && !(value instanceof Vector4) && !(value instanceof Quaternion)) { + return this._updateValue(v.clone())} />; + } + + if (value instanceof Matrix) { + return ( + + this._updateValue(v)} /> + + ); + } + + // Fallback: show the type name for uneditable types (Vector4, Quaternion) + return Cannot edit {DetectValueType(value, block.config)} inline.; + } + + private _renderSceneObjectPicker(typeName: string): JSX.Element { + const block = this._getBlock(); + const { sceneContext } = this.state; + const category = SceneObjectCategoryMap[typeName]; + + if (!sceneContext || !category) { + return Load a scene in the Preview panel to pick {typeName.toLowerCase()}s.; + } + + const entries = sceneContext.getByCategory(category); + const currentValue = block.config.value; + const currentId = currentValue != null && typeof currentValue === "object" && "uniqueId" in currentValue ? (currentValue as any).uniqueId : -1; + + return ( + <> + ({ label: e.name || `(id ${e.uniqueId})`, value: e.uniqueId }))] as DropdownOption[]} + value={currentId} + onChange={(uid) => { + if (uid === -1) { + this._updateValue(null); + } else { + const entry = entries.find((e) => e.uniqueId === uid); + if (entry) { + this._updateValue(entry.object); + } + } + }} + /> + {entries.length === 0 && No {typeName.toLowerCase()}s found in the scene.} + + ); + } + + override render() { + const block = this._getBlock(); + const value = block.config.value; + const currentType = DetectValueType(value, block.config); + const isSceneObject = SceneObjectTypes.has(currentType); + + return ( + + {RenderGeneralSection(this.props)} + + + this._changeType(v)} /> + {isSceneObject ? this._renderSceneObjectPicker(currentType) : this._renderValueEditor(value)} + + + ); + } +} + +const HelpText: React.FunctionComponent<{ children: React.ReactNode }> = ({ children }) => { + const classes = useStyles(); + return {children}; +}; diff --git a/packages/tools/flowGraphEditor/src/graphSystem/properties/customEventPropertyComponent.tsx b/packages/tools/flowGraphEditor/src/graphSystem/properties/customEventPropertyComponent.tsx index 5c87bf60fa6..5d577e8dbe8 100644 --- a/packages/tools/flowGraphEditor/src/graphSystem/properties/customEventPropertyComponent.tsx +++ b/packages/tools/flowGraphEditor/src/graphSystem/properties/customEventPropertyComponent.tsx @@ -1,228 +1,260 @@ -import * as React from "react"; -import { LineContainerComponent } from "../../sharedComponents/lineContainerComponent"; -import { type IPropertyComponentProps } from "shared-ui-components/nodeGraphSystem/interfaces/propertyComponentProps"; -import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; -import { OptionsLine } from "shared-ui-components/lines/optionsLineComponent"; -import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; -import { - GeneralPropertyTabComponent, - ConstructorVariablesPropertyTabComponent, - DataConnectionsPropertyTabComponent, - GenericPropertyTabComponent, -} from "./genericNodePropertyComponent"; -import { FLOW_GRAPH_TYPE_OPTIONS } from "./constructorConfigRegistry"; -import { type FlowGraphBlock } from "core/FlowGraph/flowGraphBlock"; -import { getRichTypeByFlowGraphType } from "core/FlowGraph/flowGraphRichTypes"; -import { RemoveDataInput, RemoveDataOutput } from "./blockMutationHelper"; -import { FlowGraphBlockNames } from "core/FlowGraph/Blocks/flowGraphBlockNames"; -import { ConnectionPointPortData } from "../connectionPointPortData"; -import { type BlockNodeData } from "../blockNodeData"; - -interface ICustomEventPropertyState { - newKeyName: string; - newKeyType: string; -} - -/** - * Property panel for FlowGraphReceiveCustomEventBlock and FlowGraphSendCustomEventBlock. - * Shows the eventId (via ConstructorVariables) and a dynamic editor for eventData entries. - * - * ReceiveCustomEvent creates data OUTPUTS per eventData key. - * SendCustomEvent creates data INPUTS per eventData key. - */ -export class CustomEventPropertyComponent extends React.Component { - constructor(props: IPropertyComponentProps) { - super(props); - this.state = { newKeyName: "", newKeyType: "number" }; - } - - private _getBlock(): FlowGraphBlock { - return this.props.nodeData.data as FlowGraphBlock; - } - - private _isReceiveBlock(): boolean { - return this._getBlock().getClassName() === FlowGraphBlockNames.ReceiveCustomEvent; - } - - private _getEventData(): { [key: string]: { type: any; value?: any } } { - const config = this._getBlock().config as any; - return config?.eventData || {}; - } - - private _addEntry() { - const name = this.state.newKeyName.trim(); - if (!name) { - return; - } - - const block = this._getBlock(); - const config = block.config as any; - const eventData = config.eventData || {}; - - // Prevent duplicate keys - if (name in eventData) { - return; - } - - const richType = getRichTypeByFlowGraphType(this.state.newKeyType); - eventData[name] = { type: richType }; - config.eventData = eventData; - - // Register the port on the block and update nodeData so the visual node picks it up - const blockNodeData = this.props.nodeData as BlockNodeData; - const nodeData = this.props.nodeData; - if (this._isReceiveBlock()) { - const newPort = block.registerDataOutput(name, richType); - nodeData.outputs.push(new ConnectionPointPortData(newPort, blockNodeData.nodeContainer, "data")); - } else { - const newPort = block.registerDataInput(name, richType); - nodeData.inputs.push(new ConnectionPointPortData(newPort, blockNodeData.nodeContainer, "data")); - } - - this.setState({ newKeyName: "" }); - this.props.stateManager.onRebuildRequiredObservable.notifyObservers(); - if (this._isReceiveBlock()) { - nodeData.onOutputCountChanged?.(); - } else { - nodeData.onInputCountChanged?.(); - } - } - - private _removeEntry(key: string) { - const block = this._getBlock(); - const config = block.config as any; - const eventData = config.eventData; - if (!eventData || !(key in eventData)) { - return; - } - - delete eventData[key]; - - const nodeData = this.props.nodeData; - if (this._isReceiveBlock()) { - const portIndex = nodeData.outputs.findIndex((p) => p.name === key); - RemoveDataOutput(block, key); - if (portIndex !== -1) { - nodeData.outputs.splice(portIndex, 1); - nodeData.onOutputRemoved?.(portIndex); - } - } else { - const portIndex = nodeData.inputs.findIndex((p) => p.name === key); - RemoveDataInput(block, key); - if (portIndex !== -1) { - nodeData.inputs.splice(portIndex, 1); - nodeData.onInputRemoved?.(portIndex); - } - } - - this.props.stateManager.onRebuildRequiredObservable.notifyObservers(); - this.forceUpdate(); - } - - private _getTypeName(entry: { type: any }): string { - return entry.type?.typeName || "any"; - } - - private _changeEntryType(key: string, newTypeName: string) { - const block = this._getBlock(); - const config = block.config as any; - const eventData = config.eventData; - if (!eventData || !(key in eventData)) { - return; - } - - const richType = getRichTypeByFlowGraphType(newTypeName); - eventData[key].type = richType; - - // Update the richType on the matching data connection so port colours refresh - if (this._isReceiveBlock()) { - const output = block.getDataOutput(key); - if (output) { - (output as any).richType = richType; - } - } else { - const input = block.getDataInput(key); - if (input) { - (input as any).richType = richType; - } - } - - this.props.stateManager.onRebuildRequiredObservable.notifyObservers(); - this.props.stateManager.onUpdateRequiredObservable.notifyObservers(block); - this.forceUpdate(); - } - - override render() { - const { stateManager, nodeData } = this.props; - const eventData = this._getEventData(); - const keys = Object.keys(eventData); - const isReceive = this._isReceiveBlock(); - - return ( - <> - - - - - {keys.map((key) => { - const typeName = this._getTypeName(eventData[key]); - return ( -
- {key} - typeName} - onSelect={(v) => this._changeEntryType(key, v as string)} - /> - -
- ); - })} - {keys.length === 0 &&
No event data defined.
} - - {/* New entry form */} - this.setState({ newKeyName: v })} - /> - this.state.newKeyType} - onSelect={(v) => this.setState({ newKeyType: v as string })} - /> - this._addEntry()} isDisabled={!this.state.newKeyName.trim()} /> -
- - - - - ); - } -} +import * as React from "react"; +import { type IPropertyComponentProps } from "shared-ui-components/nodeGraphSystem/interfaces/propertyComponentProps"; +import { Accordion, AccordionSection } from "shared-ui-components/fluent/primitives/accordion"; +import { Button } from "shared-ui-components/fluent/primitives/button"; +import { LineContainer } from "shared-ui-components/fluent/hoc/propertyLines/propertyLine"; +import { TextInputPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/inputPropertyLine"; +import { StringDropdownPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/dropdownPropertyLine"; +import { StringDropdown, type DropdownOption } from "shared-ui-components/fluent/primitives/dropdown"; +import { Body1, makeStyles, tokens } from "@fluentui/react-components"; +import { DismissRegular } from "@fluentui/react-icons"; + +import { RenderGeneralSection, RenderConstructorVariablesSection, RenderDataConnectionsSection, RenderGenericPropStoreSections } from "./genericNodePropertyComponent"; +import { FLOW_GRAPH_TYPE_OPTIONS } from "./constructorConfigRegistry"; +import { type FlowGraphBlock } from "core/FlowGraph/flowGraphBlock"; +import { getRichTypeByFlowGraphType } from "core/FlowGraph/flowGraphRichTypes"; +import { RemoveDataInput, RemoveDataOutput } from "./blockMutationHelper"; +import { FlowGraphBlockNames } from "core/FlowGraph/Blocks/flowGraphBlockNames"; +import { ConnectionPointPortData } from "../connectionPointPortData"; +import { type BlockNodeData } from "../blockNodeData"; + +interface ICustomEventPropertyState { + newKeyName: string; + newKeyType: string; +} + +const useStyles = makeStyles({ + entryRow: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalS, + padding: `0 ${tokens.spacingHorizontalXS}`, + width: "100%", + }, + entryName: { + flex: 1, + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + entryTypePicker: { + width: "120px", + }, + empty: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForeground3, + fontStyle: "italic", + }, +}); + +const TypeOptions: DropdownOption[] = FLOW_GRAPH_TYPE_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value })); + +const EntryRow: React.FunctionComponent<{ name: string; typeName: string; onChangeType: (typeName: string) => void; onRemove: () => void }> = ({ + name, + typeName, + onChangeType, + onRemove, +}) => { + const classes = useStyles(); + return ( + +
+ {name} +
+ +
+
+
+ ); +}; + +const EventDataContent: React.FunctionComponent<{ + keys: string[]; + eventData: Record; + newKeyName: string; + newKeyType: string; + canAdd: boolean; + onChangeNewKeyName: (value: string) => void; + onChangeNewKeyType: (value: string) => void; + onChangeEntryType: (key: string, typeName: string) => void; + onAddEntry: () => void; + onRemoveEntry: (key: string) => void; +}> = ({ keys, eventData, newKeyName, newKeyType, canAdd, onChangeNewKeyName, onChangeNewKeyType, onChangeEntryType, onAddEntry, onRemoveEntry }) => { + const classes = useStyles(); + return ( + <> + {keys.map((key) => ( + onChangeEntryType(key, t)} + onRemove={() => onRemoveEntry(key)} + /> + ))} + {keys.length === 0 && No event data defined.} + + + -
- ); - })} - {cases.length === 0 &&
No cases defined.
} - this.setState({ newCaseValue: v })} - /> - this._addCase()} /> - - - - - - ); - } -} +import * as React from "react"; +import { type IPropertyComponentProps } from "shared-ui-components/nodeGraphSystem/interfaces/propertyComponentProps"; +import { Accordion, AccordionSection } from "shared-ui-components/fluent/primitives/accordion"; +import { Button } from "shared-ui-components/fluent/primitives/button"; +import { LineContainer } from "shared-ui-components/fluent/hoc/propertyLines/propertyLine"; +import { NumberInputPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/inputPropertyLine"; +import { Body1, makeStyles, tokens } from "@fluentui/react-components"; +import { DismissRegular } from "@fluentui/react-icons"; + +import { RenderGeneralSection, RenderConstructorVariablesSection, RenderDataConnectionsSection, RenderGenericPropStoreSections } from "./genericNodePropertyComponent"; +import { type FlowGraphDataSwitchBlock } from "core/FlowGraph/Blocks/Data/flowGraphDataSwitchBlock"; +import { type FlowGraphBlock } from "core/FlowGraph/flowGraphBlock"; +import { RichTypeAny } from "core/FlowGraph/flowGraphRichTypes"; +import { RemoveDataInput } from "./blockMutationHelper"; +import { getNumericValue } from "core/FlowGraph/utils"; + +interface IDataSwitchPropertyState { + newCaseValue: number; +} + +const useStyles = makeStyles({ + caseRow: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalS, + padding: `0 ${tokens.spacingHorizontalXS}`, + width: "100%", + }, + caseLabel: { + flex: 1, + }, + empty: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForeground3, + fontStyle: "italic", + }, +}); + +const CaseRow: React.FunctionComponent<{ value: number; onRemove: () => void }> = ({ value, onRemove }) => { + const classes = useStyles(); + return ( + +
+ Case: {value} +
+
+ ); +}; + +const DataSwitchCasesContent: React.FunctionComponent<{ + cases: { display: number; raw: any }[]; + newCaseValue: number; + onNewCaseValueChange: (value: number) => void; + onAddCase: () => void; + onRemoveCase: (caseValue: any) => void; +}> = ({ cases, newCaseValue, onNewCaseValueChange, onAddCase, onRemoveCase }) => { + const classes = useStyles(); + return ( + <> + {cases.map(({ display, raw }) => ( + onRemoveCase(raw)} /> + ))} + {cases.length === 0 && No cases defined.} + +
+ {debugBlock.log.length === 0 && } + + ); } } diff --git a/packages/tools/flowGraphEditor/src/graphSystem/properties/frameNodePortPropertyComponent.tsx b/packages/tools/flowGraphEditor/src/graphSystem/properties/frameNodePortPropertyComponent.tsx index 29dd89527d8..43ba35a001c 100644 --- a/packages/tools/flowGraphEditor/src/graphSystem/properties/frameNodePortPropertyComponent.tsx +++ b/packages/tools/flowGraphEditor/src/graphSystem/properties/frameNodePortPropertyComponent.tsx @@ -1,15 +1,15 @@ import * as React from "react"; -import { LineContainerComponent } from "../../sharedComponents/lineContainerComponent"; import { type GlobalState } from "../../globalState"; import { type Nullable } from "core/types"; import { type Observer } from "core/Misc/observable"; import { type StateManager } from "shared-ui-components/nodeGraphSystem/stateManager"; import { type ISelectionChangedOptions } from "shared-ui-components/nodeGraphSystem/interfaces/selectionChangedOptions"; -import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; import { type GraphFrame, FramePortPosition } from "shared-ui-components/nodeGraphSystem/graphFrame"; import { IsFramePortData } from "shared-ui-components/nodeGraphSystem/tools"; import { type FrameNodePort } from "shared-ui-components/nodeGraphSystem/frameNodePort"; -import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; +import { Accordion, AccordionSection } from "shared-ui-components/fluent/primitives/accordion"; +import { Button } from "shared-ui-components/fluent/primitives/button"; +import { TextInputPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/inputPropertyLine"; export interface IFrameNodePortPropertyTabComponentProps { stateManager: StateManager; @@ -52,34 +52,37 @@ export class FrameNodePortPropertyTabComponent extends React.Component - -
- - - {this.props.frameNodePort.framePortPosition !== FramePortPosition.Top && ( - { - this.props.frame.moveFramePortUp(this.props.frameNodePort); - }} - /> - )} + + + { + this.props.frameNodePort.portName = value; + this.forceUpdate(); + }} + /> + {this.props.frameNodePort.framePortPosition !== FramePortPosition.Top && ( +
-
+ {this.props.frameNodePort.framePortPosition !== FramePortPosition.Bottom && ( +
+ + + { + this.props.frame.name = value; + this.forceUpdate(); + }} + /> + { + this.props.frame.color = value.clone(); + this.forceUpdate(); + }} + /> + { + this.props.frame.comments = value; + this.forceUpdate(); + }} + /> +
+ + ); +}; + +const VariablesSectionContent: React.FunctionComponent<{ + variables: string[]; + pickableVars: string[]; + onAddVariable: (name: string) => void; + onRemoveVariable: (name: string) => void; +}> = ({ variables, pickableVars, onAddVariable, onRemoveVariable }) => { + const classes = useStyles(); + const [draftName, setDraftName] = React.useState(""); + return ( + <> + {variables.map((name) => ( + onRemoveVariable(name)} /> + ))} + {variables.length === 0 && No variables defined.} + ({ label: n, value: n }))} + onChange={(value) => { + if (value) { + onAddVariable(value); + setDraftName(""); + } else { + setDraftName(value); + } + }} + /> + + ); +}; + /** * Property panel for FlowGraphSetVariableBlock. * Handles both single-variable mode (via ConstructorVariablesPropertyTabComponent) and @@ -101,58 +160,32 @@ export class SetVariablePropertyComponent extends React.Component - + + {RenderGeneralSection(this.props)} {/* Single-variable mode: handled by the standard constructor config UI */} - {!isMulti && } + {!isMulti && RenderConstructorVariablesSection(this.props)} {/* Multi-variable mode: dynamic list */} {isMulti && ( - - {(config.variables || []).map((varName: string) => ( -
- {varName} - -
- ))} - {(config.variables || []).length === 0 &&
No variables defined.
} - { - if (v) { - this._addExistingVariable(v); - } - }} + + this._addExistingVariable(name)} + onRemoveVariable={(name) => this._removeVariable(name)} /> -
+ )} - - - + {RenderDataConnectionsSection(this.props)} + {RenderGenericPropStoreSections(this.props)} +
); } } diff --git a/packages/tools/flowGraphEditor/src/graphSystem/properties/switchBlockPropertyComponent.tsx b/packages/tools/flowGraphEditor/src/graphSystem/properties/switchBlockPropertyComponent.tsx index b4a686afebb..10f0b7a10c3 100644 --- a/packages/tools/flowGraphEditor/src/graphSystem/properties/switchBlockPropertyComponent.tsx +++ b/packages/tools/flowGraphEditor/src/graphSystem/properties/switchBlockPropertyComponent.tsx @@ -1,9 +1,13 @@ import * as React from "react"; -import { LineContainerComponent } from "../../sharedComponents/lineContainerComponent"; import { type IPropertyComponentProps } from "shared-ui-components/nodeGraphSystem/interfaces/propertyComponentProps"; -import { FloatLineComponent } from "shared-ui-components/lines/floatLineComponent"; -import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; -import { GeneralPropertyTabComponent, DataConnectionsPropertyTabComponent, GenericPropertyTabComponent } from "./genericNodePropertyComponent"; +import { Accordion, AccordionSection } from "shared-ui-components/fluent/primitives/accordion"; +import { Button } from "shared-ui-components/fluent/primitives/button"; +import { LineContainer } from "shared-ui-components/fluent/hoc/propertyLines/propertyLine"; +import { NumberInputPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/inputPropertyLine"; +import { Body1, makeStyles, tokens } from "@fluentui/react-components"; +import { DismissRegular } from "@fluentui/react-icons"; + +import { RenderGeneralSection, RenderDataConnectionsSection, RenderGenericPropStoreSections } from "./genericNodePropertyComponent"; import { type FlowGraphSwitchBlock } from "core/FlowGraph/Blocks/Execution/ControlFlow/flowGraphSwitchBlock"; import { type FlowGraphBlock } from "core/FlowGraph/flowGraphBlock"; import { RemoveSignalOutput } from "./blockMutationHelper"; @@ -13,6 +17,36 @@ interface ISwitchBlockPropertyState { newCaseValue: number; } +const useStyles = makeStyles({ + caseRow: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalS, + padding: `0 ${tokens.spacingHorizontalXS}`, + width: "100%", + }, + caseLabel: { + flex: 1, + }, + empty: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForeground3, + fontStyle: "italic", + }, +}); + +const CaseRow: React.FunctionComponent<{ value: number; onRemove: () => void }> = ({ value, onRemove }) => { + const classes = useStyles(); + return ( + +
+ Case: {value} +
+
+ ); +}; + /** * Property panel for FlowGraphSwitchBlock. * Shows the list of case values with add/remove controls. @@ -59,54 +93,47 @@ export class SwitchBlockPropertyComponent extends React.Component - - - - {cases.map((caseVal: any, idx: number) => { - const numVal = getNumericValue(caseVal); - return ( -
- Case: {numVal} - -
- ); - })} - {cases.length === 0 &&
No cases defined.
} - this.setState({ newCaseValue: v })} + + {RenderGeneralSection(this.props)} + + + this.setState({ newCaseValue: Math.trunc(v) })} + onAddCase={() => this._addCase()} + onRemoveCase={(caseVal) => this._removeCase(caseVal)} /> - this._addCase()} /> -
+ - - - + {RenderDataConnectionsSection(this.props)} + {RenderGenericPropStoreSections(this.props)} + ); } } + +const SwitchCasesContent: React.FunctionComponent<{ + cases: any[]; + newCaseValue: number; + onNewCaseValueChange: (value: number) => void; + onAddCase: () => void; + onRemoveCase: (caseValue: any) => void; +}> = ({ cases, newCaseValue, onNewCaseValueChange, onAddCase, onRemoveCase }) => { + const classes = useStyles(); + return ( + <> + {cases.map((caseVal: any) => { + const numVal = getNumericValue(caseVal); + return onRemoveCase(caseVal)} />; + })} + {cases.length === 0 && No cases defined.} + + -
- ); - } - return this.props.children; - } -} - -export class Portal extends React.Component> { - override render() { - return ReactDOM.createPortal({this.props.children}, this.props.globalState.hostElement); - } -} diff --git a/packages/tools/flowGraphEditor/src/services/centralGraphService.tsx b/packages/tools/flowGraphEditor/src/services/centralGraphService.tsx new file mode 100644 index 00000000000..752245c9983 --- /dev/null +++ b/packages/tools/flowGraphEditor/src/services/centralGraphService.tsx @@ -0,0 +1,29 @@ +import { type ServiceDefinition } from "shared-ui-components/modularTool/modularity/serviceDefinition"; +import { type IShellService, ShellServiceIdentity } from "shared-ui-components/modularTool/services/shellService"; + +import { GraphEditor } from "../graphEditor"; +import { type IGlobalStateService, GlobalStateServiceIdentity } from "./globalStateService"; + +/** + * Phase 1 (passthrough) central content service. + * + * Registers the existing legacy `` class component as the shell's central + * content. Subsequent phases will decompose this into a leaner central component plus + * dedicated side-pane services for the node list, property tab, scene preview, etc. + */ +export const CentralGraphServiceDefinition: ServiceDefinition<[], [IShellService, IGlobalStateService]> = { + friendlyName: "Central Graph Service", + consumes: [ShellServiceIdentity, GlobalStateServiceIdentity], + factory: (shellService, globalStateService) => { + const registration = shellService.addCentralContent({ + key: "FlowGraphEditor", + component: () => , + }); + + return { + dispose: () => { + registration.dispose(); + }, + }; + }, +}; diff --git a/packages/tools/flowGraphEditor/src/services/dialogBridgeService.tsx b/packages/tools/flowGraphEditor/src/services/dialogBridgeService.tsx new file mode 100644 index 00000000000..3620b5a313c --- /dev/null +++ b/packages/tools/flowGraphEditor/src/services/dialogBridgeService.tsx @@ -0,0 +1,32 @@ +import { type ServiceDefinition } from "shared-ui-components/modularTool/modularity/serviceDefinition"; +import { type IDialogService, DialogServiceIdentity } from "shared-ui-components/modularTool/services/dialogService"; + +import { type IGlobalStateService, GlobalStateServiceIdentity } from "./globalStateService"; + +/** + * Bridges the legacy `globalState.stateManager.onErrorMessageDialogRequiredObservable` observable + * to the modular tool's built-in {@link IDialogService}. + * + * Existing call sites notify the observable to show error dialogs (legacy `MessageDialog`). + * This service replaces the legacy renderer with the framework's `Dialog`, keeping the + * observable-based call-site API stable. + */ +export const DialogBridgeServiceDefinition: ServiceDefinition<[], [IGlobalStateService, IDialogService]> = { + friendlyName: "Dialog Bridge Service", + consumes: [GlobalStateServiceIdentity, DialogServiceIdentity], + factory: (globalStateService, dialogService) => { + const observer = globalStateService.globalState.stateManager.onErrorMessageDialogRequiredObservable.add((message: string) => { + dialogService.showDialog({ + type: "alert", + intent: "error", + title: message, + }); + }); + + return { + dispose: () => { + observer?.remove(); + }, + }; + }, +}; diff --git a/packages/tools/flowGraphEditor/src/services/globalStateService.ts b/packages/tools/flowGraphEditor/src/services/globalStateService.ts new file mode 100644 index 00000000000..125456b67d4 --- /dev/null +++ b/packages/tools/flowGraphEditor/src/services/globalStateService.ts @@ -0,0 +1,89 @@ +import { type IService, type ServiceDefinition } from "shared-ui-components/modularTool/modularity/serviceDefinition"; + +import { GlobalState } from "../globalState"; +import { type IFlowGraphEditorOptions } from "../flowGraphEditor"; +import { SerializationTools } from "../serializationTools"; + +/** Service identity for the flow graph editor's GlobalState. */ +export const GlobalStateServiceIdentity = Symbol("GlobalStateService"); + +/** + * Service contract that exposes the editor's {@link GlobalState} to other services. + * + * The {@link GlobalState} class itself is large (lots of observables, helpers, mutators) + * and intentionally not refactored as part of the Fluent port — the service simply makes + * the existing instance available via the modular tool's service container. + */ +export interface IGlobalStateService extends IService { + /** The active Flow Graph Editor global state. */ + readonly globalState: GlobalState; +} + +/** + * Builds a {@link ServiceDefinition} for the flow graph editor's {@link GlobalState}. + * + * Uses a factory rather than {@link IFlowGraphEditorOptions} on a parent container because the + * options describe instance-specific inputs to this particular editor invocation + * (`flowGraph`, `hostScene`, `customSave`, ...), not shared parent-scoped services. + * + * @param options The editor options provided to `FlowGraphEditor.Show()`. + * @param hostElement The host DOM element (popup body or caller-supplied container). + * `GlobalState` needs the host element/document/window for popup-aware + * behaviour and for the state manager. + * @returns A service definition that produces an {@link IGlobalStateService}. + */ +export function MakeGlobalStateService(options: IFlowGraphEditorOptions, hostElement: HTMLElement): ServiceDefinition<[IGlobalStateService], []> { + return { + friendlyName: "Global State Service", + produces: [GlobalStateServiceIdentity], + factory: () => { + const scene = options.hostScene ?? options.flowGraph.scene; + const globalState = new GlobalState(scene); + + // If the flow graph belongs to a coordinator, use it for multi-graph support. + // Otherwise the flowGraph setter will handle single-graph mode. + const existingCoordinator = options.flowGraph.coordinator; + if (existingCoordinator) { + globalState.coordinator = existingCoordinator; + const activeIndex = existingCoordinator.flowGraphs.indexOf(options.flowGraph); + if (activeIndex >= 0) { + globalState.activeGraphIndex = activeIndex; + } + } else { + globalState.flowGraph = options.flowGraph; + } + + globalState.hostElement = hostElement; + globalState.hostDocument = hostElement.ownerDocument!; + globalState.hostScene = options.hostScene; + globalState.customSave = options.customSave; + globalState.hostWindow = hostElement.ownerDocument.defaultView!; + globalState.stateManager.hostDocument = globalState.hostDocument; + + const babylonGlobal = (globalThis as { BABYLON?: Record }).BABYLON; + const flowGraphEditorGlobal = babylonGlobal?.["FlowGraphEditor"] as Record | undefined; + if (flowGraphEditorGlobal) { + flowGraphEditorGlobal["_CurrentState"] = globalState; + } + + // Wire the optional load observable that callers can use to push + // serialized graph state back into the editor at any time. + const loadObserver = options.customLoadObservable?.add((data) => { + const doLoadAsync = async () => { + await SerializationTools.DeserializeAsync(data, globalState); + }; + void doLoadAsync(); + }); + + return { + globalState, + dispose: () => { + options.customLoadObservable?.remove(loadObserver ?? null); + if (flowGraphEditorGlobal?.["_CurrentState"] === globalState) { + flowGraphEditorGlobal["_CurrentState"] = undefined; + } + }, + }; + }, + }; +} diff --git a/packages/tools/flowGraphEditor/src/services/nodeListService.tsx b/packages/tools/flowGraphEditor/src/services/nodeListService.tsx new file mode 100644 index 00000000000..f4ea623ddd0 --- /dev/null +++ b/packages/tools/flowGraphEditor/src/services/nodeListService.tsx @@ -0,0 +1,34 @@ +import { type ServiceDefinition } from "shared-ui-components/modularTool/modularity/serviceDefinition"; +import { type IShellService, ShellServiceIdentity } from "shared-ui-components/modularTool/services/shellService"; + +import { AppsListRegular } from "@fluentui/react-icons"; + +import { NodeListComponent } from "../components/nodeList/nodeListComponent"; +import { type IGlobalStateService, GlobalStateServiceIdentity } from "./globalStateService"; + +/** + * Phase 2 (passthrough) side-pane service that hosts the legacy `NodeListComponent` in the + * shell's left side pane. The component is rewritten in a later phase; for now it is wrapped + * verbatim so the layout stays parity with the legacy editor. + */ +export const NodeListServiceDefinition: ServiceDefinition<[], [IShellService, IGlobalStateService]> = { + friendlyName: "Node List Service", + consumes: [ShellServiceIdentity, GlobalStateServiceIdentity], + factory: (shellService, globalStateService) => { + const registration = shellService.addSidePane({ + key: "FlowGraphNodeList", + title: "Nodes", + icon: AppsListRegular, + horizontalLocation: "left", + verticalLocation: "top", + teachingMoment: false, + content: () => , + }); + + return { + dispose: () => { + registration.dispose(); + }, + }; + }, +}; diff --git a/packages/tools/flowGraphEditor/src/services/propertyTabService.tsx b/packages/tools/flowGraphEditor/src/services/propertyTabService.tsx new file mode 100644 index 00000000000..6cb92175b38 --- /dev/null +++ b/packages/tools/flowGraphEditor/src/services/propertyTabService.tsx @@ -0,0 +1,56 @@ +import { useContext, useMemo } from "react"; + +import { type ServiceDefinition } from "shared-ui-components/modularTool/modularity/serviceDefinition"; +import { type IShellService, ShellServiceIdentity } from "shared-ui-components/modularTool/services/shellService"; +import { ToolContext } from "shared-ui-components/fluent/hoc/fluentToolWrapper"; + +import { PropertyTabComponent } from "../components/propertyTab/propertyTabComponent"; +import { type IGlobalStateService, GlobalStateServiceIdentity } from "./globalStateService"; + +import { DocumentTextRegular } from "@fluentui/react-icons"; + +/** + * Phase 2 (passthrough) side-pane service that hosts the legacy `PropertyTabComponent` in + * the shell's right (top) side pane. The pane header carries the tool's name and the + * Babylon.js logo, mirroring `packages/tools/viewer-configurator/src/configuratorService.tsx`. + * + * The pane content overrides the surrounding `ToolContext` with `size: "small"` so all shared + * UI components inside (property lines, buttons, dropdowns, etc.) automatically use their + * compact size, keeping the dense property layout regardless of the user's tool-wide compact + * mode preference. Other ToolContext fields (toolName, disableCopy, useFluent) are inherited + * from the surrounding `UXContextProvider`. + * + * The component is rewritten on top of `ExtensibleAccordion` in a later phase; for now it is + * wrapped verbatim so the layout stays parity with the legacy editor. + */ +export const PropertyTabServiceDefinition: ServiceDefinition<[], [IShellService, IGlobalStateService]> = { + friendlyName: "Property Tab Service", + consumes: [ShellServiceIdentity, GlobalStateServiceIdentity], + factory: (shellService, globalStateService) => { + const registration = shellService.addSidePane({ + key: "FlowGraphProperties", + title: "Properties", + icon: DocumentTextRegular, + horizontalLocation: "right", + verticalLocation: "top", + teachingMoment: false, + content: () => { + // Override the surrounding ToolContext so descendants pick up size: "small" without + // affecting any siblings outside this pane. + const parentToolContext = useContext(ToolContext); + const toolContext = useMemo(() => ({ ...parentToolContext, size: "small" as const }), [parentToolContext]); + return ( + + + + ); + }, + }); + + return { + dispose: () => { + registration.dispose(); + }, + }; + }, +}; diff --git a/packages/tools/flowGraphEditor/src/services/scenePreviewService.tsx b/packages/tools/flowGraphEditor/src/services/scenePreviewService.tsx new file mode 100644 index 00000000000..f29b9169ad4 --- /dev/null +++ b/packages/tools/flowGraphEditor/src/services/scenePreviewService.tsx @@ -0,0 +1,34 @@ +import { type ServiceDefinition } from "shared-ui-components/modularTool/modularity/serviceDefinition"; +import { type IShellService, ShellServiceIdentity } from "shared-ui-components/modularTool/services/shellService"; + +import { VideoRegular } from "@fluentui/react-icons"; + +import { ScenePreviewComponent } from "../components/preview/scenePreviewComponent"; +import { type IGlobalStateService, GlobalStateServiceIdentity } from "./globalStateService"; + +/** + * Phase 2 (passthrough) side-pane service that hosts the legacy `ScenePreviewComponent` in + * the shell's right (bottom) side pane. The component is rewritten in a later phase; for now + * it is wrapped verbatim so the layout stays parity with the legacy editor. + */ +export const ScenePreviewServiceDefinition: ServiceDefinition<[], [IShellService, IGlobalStateService]> = { + friendlyName: "Scene Preview Service", + consumes: [ShellServiceIdentity, GlobalStateServiceIdentity], + factory: (shellService, globalStateService) => { + const registration = shellService.addSidePane({ + key: "FlowGraphScenePreview", + title: "Scene Preview", + icon: VideoRegular, + horizontalLocation: "right", + verticalLocation: "bottom", + teachingMoment: false, + content: () => , + }); + + return { + dispose: () => { + registration.dispose(); + }, + }; + }, +}; diff --git a/packages/tools/flowGraphEditor/src/services/toastBridgeService.ts b/packages/tools/flowGraphEditor/src/services/toastBridgeService.ts new file mode 100644 index 00000000000..4c1411f7ef5 --- /dev/null +++ b/packages/tools/flowGraphEditor/src/services/toastBridgeService.ts @@ -0,0 +1,29 @@ +import { type ServiceDefinition } from "shared-ui-components/modularTool/modularity/serviceDefinition"; +import { type IToastService, ToastServiceIdentity } from "shared-ui-components/modularTool/services/toastService"; + +import { type IGlobalStateService, GlobalStateServiceIdentity } from "./globalStateService"; + +/** + * Bridges the legacy `globalState.onToastNotification` observable to the modular tool's + * built-in {@link IToastService}. + * + * Existing call sites use `ShowToast(globalState, message, severity)` (see + * `components/toast/toastComponent.tsx`) which notifies the observable. This service + * replaces the legacy `` renderer with the framework's + * `ToastProvider`, keeping the call-site API stable while removing the local container. + */ +export const ToastBridgeServiceDefinition: ServiceDefinition<[], [IGlobalStateService, IToastService]> = { + friendlyName: "Toast Bridge Service", + consumes: [GlobalStateServiceIdentity, ToastServiceIdentity], + factory: (globalStateService, toastService) => { + const observer = globalStateService.globalState.onToastNotification.add((data) => { + toastService.showToast(data.message, { intent: data.severity }); + }); + + return { + dispose: () => { + observer?.remove(); + }, + }; + }, +}; diff --git a/packages/tools/flowGraphEditor/src/services/toolbarService.tsx b/packages/tools/flowGraphEditor/src/services/toolbarService.tsx new file mode 100644 index 00000000000..6fe3dd415de --- /dev/null +++ b/packages/tools/flowGraphEditor/src/services/toolbarService.tsx @@ -0,0 +1,68 @@ +import { useCallback } from "react"; + +import { type ServiceDefinition } from "shared-ui-components/modularTool/modularity/serviceDefinition"; +import { type IShellService, ShellServiceIdentity } from "shared-ui-components/modularTool/services/shellService"; +import { Button } from "shared-ui-components/fluent/primitives/button"; + +import { CodeRegular, QuestionCircleRegular } from "@fluentui/react-icons"; + +import { GraphControlsComponent } from "../components/graphControls/graphControlsComponent"; +import { type IGlobalStateService, GlobalStateServiceIdentity } from "./globalStateService"; + +/** + * Adds the Help and How-to-Use buttons to the bottom-right toolbar slot of the shell, and + * mounts the {@link GraphControlsComponent} (undo/redo/play/pause/etc.) into the top-left + * toolbar slot so the controls travel with the shell's full-mode toolbar instead of being + * stacked above the canvas as a second bar. + * + * The button click handlers fire `globalState.onHelpRequested` / `onHowToUseRequested`, + * which the central content's `` listens to in order to mount the + * existing `HelpDialogComponent` and `HowToUseDialogComponent` overlays. + */ +export const ToolbarServiceDefinition: ServiceDefinition<[], [IShellService, IGlobalStateService]> = { + friendlyName: "Toolbar Service", + consumes: [ShellServiceIdentity, GlobalStateServiceIdentity], + factory: (shellService, globalStateService) => { + const graphControlsRegistration = shellService.addToolbarItem({ + key: "FlowGraphGraphControls", + horizontalLocation: "left", + verticalLocation: "top", + teachingMoment: false, + component: () => , + }); + + const helpRegistration = shellService.addToolbarItem({ + key: "FlowGraphHelp", + horizontalLocation: "right", + verticalLocation: "bottom", + teachingMoment: false, + component: () => { + const onClick = useCallback(() => { + globalStateService.globalState.onHelpRequested.notifyObservers(undefined); + }, []); + return