diff --git a/.changeset/bottom-bar-panel-clearance.md b/.changeset/bottom-bar-panel-clearance.md new file mode 100644 index 00000000000..a4004b84ea5 --- /dev/null +++ b/.changeset/bottom-bar-panel-clearance.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Keep the bottom toolbar clear of the side panels and the viewport controls: it stays centered on the canvas until a panel would cover it, then shifts aside, and collapses to its essential controls — expanding again on hover or focus — when the space between the panels is too narrow for the full set. diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index fedd5733889..0d1aa4315e2 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -11,6 +11,13 @@ The editor is organized around a central canvas where you build your net: - **Bottom panel** -- tabs for Diagnostics (code errors), Simulation Settings, and Timeline (during simulation). - **Bottom toolbar** -- editing mode buttons, simulation controls, the AI assistant toggle, and a show/hide button for the bottom panel. +The bottom toolbar sits centered on the canvas and steps aside rather than +slide under anything: open a panel wide enough to reach it and it shifts to the +free side, keeping clear of the left sidebar, the properties panel and the +viewport controls. Where even that leaves too little room it shrinks to the +cursor, the panel toggle, the diagnostics status and Play; point at it, or tab +into it, and the rest comes back for as long as you stay on it. + full-editor ## Top bar diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md index 9ee2336477b..864865b6b6a 100644 --- a/libs/@hashintel/petrinaut/docs/simulation.md +++ b/libs/@hashintel/petrinaut/docs/simulation.md @@ -98,6 +98,10 @@ The bottom toolbar provides playback controls: The frame counter shows the current frame number, total frames, and elapsed simulation time. +Playback widens the toolbar, so in a narrow window it keeps Play and folds the +scrubber, the frame counter and the playback settings away until you point at +it. + simulation-toolbar ### Speed diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx index 6ab6c248657..4693fb8e05d 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx @@ -46,6 +46,7 @@ const editorContextValue = ( setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, setPropertiesPanelWidth: () => {}, + setAiAssistantWidth: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx index 9672cec8e14..b7fa73a7a46 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx @@ -49,6 +49,7 @@ const editorContextValue = ( setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, setPropertiesPanelWidth: () => {}, + setAiAssistantWidth: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts index 54122e7d9f3..1936ff1a8c1 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts @@ -1,6 +1,7 @@ import { createContext, createRef } from "react"; import { + DEFAULT_AI_ASSISTANT_WIDTH, DEFAULT_BOTTOM_PANEL_HEIGHT, DEFAULT_LEFT_SIDEBAR_WIDTH, DEFAULT_PROPERTIES_PANEL_WIDTH, @@ -81,6 +82,11 @@ export type EditorState = { propertiesPanelWidth: number; isBottomPanelOpen: boolean; bottomPanelHeight: number; + /** + * Width of the AI assistant panel. Held here rather than inside the panel so + * the surfaces that have to keep clear of it can read it. + */ + aiAssistantWidth: number; activeBottomPanelTab: BottomPanelTab; componentSubnetId: string | null; selection: SelectionMap; @@ -127,6 +133,7 @@ export type EditorActions = { setLeftSidebarOpen: (isOpen: boolean) => void; setLeftSidebarWidth: (width: number) => void; setPropertiesPanelWidth: (width: number) => void; + setAiAssistantWidth: (width: number) => void; setBottomPanelOpen: (isOpen: boolean) => void; toggleBottomPanel: () => void; setBottomPanelHeight: (height: number) => void; @@ -189,6 +196,7 @@ export const initialEditorState: EditorState = { propertiesPanelWidth: DEFAULT_PROPERTIES_PANEL_WIDTH, isBottomPanelOpen: false, bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, + aiAssistantWidth: DEFAULT_AI_ASSISTANT_WIDTH, activeBottomPanelTab: "diagnostics", componentSubnetId: null, selection: new Map(), @@ -215,6 +223,7 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, setPropertiesPanelWidth: () => {}, + setAiAssistantWidth: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx index a9b5e170c60..6b43a1f678b 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx @@ -410,6 +410,8 @@ export const EditorProvider: React.FC = ({ children }) => { setState((prev) => ({ ...prev, leftSidebarWidth: width })), setPropertiesPanelWidth: (width) => setState((prev) => ({ ...prev, propertiesPanelWidth: width })), + setAiAssistantWidth: (width) => + setState((prev) => ({ ...prev, aiAssistantWidth: width })), setBottomPanelOpen: (isOpen) => { scheduleAnimationEnd(); setState((prev) => ({ diff --git a/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts b/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts index 472d222462d..ad68b8472cf 100644 --- a/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts +++ b/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts @@ -11,3 +11,5 @@ export const DEFAULT_LEFT_SIDEBAR_WIDTH = 320; export const DEFAULT_PROPERTIES_PANEL_WIDTH = 450; export const DEFAULT_BOTTOM_PANEL_HEIGHT = 180; + +export const DEFAULT_AI_ASSISTANT_WIDTH = 500; diff --git a/libs/@hashintel/petrinaut/src/ui/constants/ui.ts b/libs/@hashintel/petrinaut/src/ui/constants/ui.ts index fe0a3c89429..ba4ca9d0e8a 100644 --- a/libs/@hashintel/petrinaut/src/ui/constants/ui.ts +++ b/libs/@hashintel/petrinaut/src/ui/constants/ui.ts @@ -29,6 +29,15 @@ export const MAX_LEFT_SIDEBAR_WIDTH = 500; export const MIN_PROPERTIES_PANEL_WIDTH = 250; export const MAX_PROPERTIES_PANEL_WIDTH = 800; +// Viewport controls — the button column at the bottom right of the canvas. +// The width is one `xs` icon button, rounded up from its rendered 21px. +export const VIEWPORT_CONTROLS_OFFSET = 12; +export const VIEWPORT_CONTROLS_WIDTH = 24; + +/** What a control sharing the column's row has to leave free of it. */ +export const VIEWPORT_CONTROLS_CLEARANCE = + VIEWPORT_CONTROLS_OFFSET + VIEWPORT_CONTROLS_WIDTH; + // Bottom Panel (DEFAULT_BOTTOM_PANEL_HEIGHT in react/state/panel-defaults) export const MIN_BOTTOM_PANEL_HEIGHT = 100; export const MAX_BOTTOM_PANEL_HEIGHT = 600; diff --git a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts new file mode 100644 index 00000000000..73d82896401 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { getCanvasInsets, type PanelLayoutState } from "./use-canvas-insets"; + +const closed: PanelLayoutState = { + isLeftSidebarOpen: false, + isSearchOpen: false, + leftSidebarWidth: 320, + hasSelection: false, + propertiesPanelWidth: 450, + isAiAssistantOpen: false, + aiAssistantWidth: 500, + isBottomPanelOpen: false, + bottomPanelHeight: 180, +}; + +describe("getCanvasInsets", () => { + it("counts nothing while every panel is closed", () => { + expect(getCanvasInsets(closed)).toEqual({ left: 0, right: 0, bottom: 0 }); + }); + + it("counts the left sidebar whether the toggle or search opened it", () => { + expect(getCanvasInsets({ ...closed, isLeftSidebarOpen: true }).left).toBe( + 320, + ); + expect(getCanvasInsets({ ...closed, isSearchOpen: true }).left).toBe(320); + }); + + it("counts the properties panel only against a selection", () => { + expect(getCanvasInsets(closed).right).toBe(0); + expect(getCanvasInsets({ ...closed, hasSelection: true }).right).toBe(450); + }); + + it("stacks the assistant on the properties panel, which it docks beside", () => { + expect(getCanvasInsets({ ...closed, isAiAssistantOpen: true }).right).toBe( + 500, + ); + expect( + getCanvasInsets({ + ...closed, + hasSelection: true, + isAiAssistantOpen: true, + }).right, + ).toBe(950); + }); + + it("counts the bottom panel's height, not its open state alone", () => { + expect( + getCanvasInsets({ + ...closed, + isBottomPanelOpen: true, + bottomPanelHeight: 240, + }).bottom, + ).toBe(240); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts new file mode 100644 index 00000000000..503b4305152 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts @@ -0,0 +1,51 @@ +import { use } from "react"; + +import { EditorContext } from "../../react/state/editor-context"; +import { PANEL_MARGIN } from "../constants/ui"; + +/** How much of the canvas each edge's panels cover, in CSS pixels. */ +export interface CanvasInsets { + readonly left: number; + readonly right: number; + readonly bottom: number; +} + +/** The editor state the insets are derived from. */ +export interface PanelLayoutState { + readonly isLeftSidebarOpen: boolean; + readonly isSearchOpen: boolean; + readonly leftSidebarWidth: number; + readonly hasSelection: boolean; + readonly propertiesPanelWidth: number; + readonly isAiAssistantOpen: boolean; + readonly aiAssistantWidth: number; + readonly isBottomPanelOpen: boolean; + readonly bottomPanelHeight: number; +} + +/** + * Each edge's rule is the one the panel on it renders by: search opens the + * left sidebar without the toggle, a selection opens the properties panel, and + * the assistant docks beside the properties panel rather than over it, so an + * open pair covers the sum of the two. + */ +export const getCanvasInsets = (state: PanelLayoutState): CanvasInsets => ({ + left: + state.isLeftSidebarOpen || state.isSearchOpen + ? state.leftSidebarWidth + PANEL_MARGIN + : 0, + right: + (state.hasSelection ? state.propertiesPanelWidth + PANEL_MARGIN : 0) + + (state.isAiAssistantOpen ? state.aiAssistantWidth : 0), + bottom: state.isBottomPanelOpen ? state.bottomPanelHeight + PANEL_MARGIN : 0, +}); + +/** + * What the docked panels take out of the canvas, for the controls that float + * over it and have to keep clear of them. + * + * The panels overlay the canvas rather than shrinking it, so a floating + * control cannot read this off its own layout. + */ +export const useCanvasInsets = (): CanvasInsets => + getCanvasInsets(use(EditorContext)); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts new file mode 100644 index 00000000000..0e0addd3290 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { + type BottomBarBounds, + fitsWithinBounds, + getBottomBarOffset, +} from "./bottom-bar-placement"; + +const bounds = (overrides: Partial = {}): BottomBarBounds => ({ + containerWidth: 1000, + leftInset: 0, + rightInset: 0, + margin: 12, + ...overrides, +}); + +describe("getBottomBarOffset", () => { + it("leaves a bar that clears both panels centred", () => { + expect( + getBottomBarOffset(bounds({ leftInset: 200, rightInset: 200 }), 400), + ).toBe(0); + }); + + it("pushes a bar clear of the left panel", () => { + // Centred, the 600px bar starts at 200px, 62px inside the panel and its margin. + expect(getBottomBarOffset(bounds({ leftInset: 250 }), 600)).toBe(62); + }); + + it("pushes a bar clear of the right panel", () => { + expect(getBottomBarOffset(bounds({ rightInset: 250 }), 600)).toBe(-62); + }); + + it("pushes clear of the wider side when both panels are open", () => { + const space = bounds({ + containerWidth: 1200, + leftInset: 100, + rightInset: 400, + }); + + expect(getBottomBarOffset(space, 500)).toBe(-62); + }); + + it("keeps the left edge when the bar is wider than the space between the panels", () => { + const space = bounds({ leftInset: 400, rightInset: 400 }); + const offset = getBottomBarOffset(space, 400); + + // 1000 - 400 = 600, and the left edge lands on the panel edge plus margin. + expect(offset).toBe(112); + expect((1000 - 400) / 2 + offset).toBe(412); + }); + + it("keeps the left edge when the panels are lopsided too", () => { + // The right inset alone leaves less than the bar needs, so neither edge + // can be cleared and the left one wins. A symmetric pair would pass this + // by arithmetic accident. + const space = bounds({ leftInset: 120, rightInset: 700 }); + const offset = getBottomBarOffset(space, 400); + + expect((1000 - 400) / 2 + offset).toBe(132); + }); + + it("clears both panels for every width it says fits", () => { + const space = bounds({ leftInset: 250, rightInset: 350 }); + /** Where the bar ends up, once the offset is applied. */ + const placed = (width: number) => { + const left = + (space.containerWidth - width) / 2 + getBottomBarOffset(space, width); + return { left, right: left + width }; + }; + + for (const width of [100, 300, 376]) { + expect(fitsWithinBounds(space, width)).toBe(true); + expect(placed(width).left).toBeGreaterThanOrEqual(262); + expect(placed(width).right).toBeLessThanOrEqual(638); + } + + // A pixel over, and no position clears both: the left edge is what the + // offset holds on to. + expect(fitsWithinBounds(space, 377)).toBe(false); + expect(placed(377).left).toBe(262); + }); + + it("stays put until the container and the bar have been measured", () => { + expect(getBottomBarOffset(bounds({ containerWidth: 0 }), 400)).toBe(0); + expect(getBottomBarOffset(bounds(), 0)).toBe(0); + }); +}); + +describe("fitsWithinBounds", () => { + it("counts both insets and both margins against the container", () => { + // 1000 - 250 - 350 - 2 x 12 + const space = bounds({ leftInset: 250, rightInset: 350 }); + + expect(fitsWithinBounds(space, 376)).toBe(true); + expect(fitsWithinBounds(space, 377)).toBe(false); + }); + + it("imposes no limit before the container has been measured", () => { + expect(fitsWithinBounds(bounds({ containerWidth: 0 }), 800)).toBe(true); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts new file mode 100644 index 00000000000..d819c54a7c0 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts @@ -0,0 +1,62 @@ +/** + * Where the bottom bar sits between the panels docked around the canvas, and + * whether everything it can show fits there. + * + * The bar is centered on the canvas rather than on the space left between the + * panels: opening a panel must not move a bar that still has room. A bar that + * would run under a panel is pushed aside, and only as far as it takes to + * clear it. + */ + +/** The space the bar has to sit in, in CSS pixels. */ +export interface BottomBarBounds { + /** Width of the canvas area the bar is centered on. */ + readonly containerWidth: number; + /** Width taken by whatever is docked on the left. */ + readonly leftInset: number; + /** Width taken by whatever is docked on the right, viewport controls included. */ + readonly rightInset: number; + /** Gap kept clear inside each inset, so the bar never touches a panel. */ + readonly margin: number; +} + +/** Width left for the bar once the insets and both margins are taken out. */ +const getAvailableWidth = (bounds: BottomBarBounds): number => + bounds.containerWidth - + bounds.leftInset - + bounds.rightInset - + bounds.margin * 2; + +/** + * Whether a bar of `width` clears both insets while it stays centered, or can + * be pushed aside far enough to. An unmeasured container imposes no limit, so + * the bar shows everything until it has been measured. + */ +export const fitsWithinBounds = ( + bounds: BottomBarBounds, + width: number, +): boolean => bounds.containerWidth <= 0 || width <= getAvailableWidth(bounds); + +/** + * How far to shift a bar of `barWidth` from the centered position for it to + * clear both insets: positive to the right, negative to the left, zero while + * the centered bar already clears them. A bar wider than the space between the + * insets cannot clear both, and keeps its left edge. + */ +export const getBottomBarOffset = ( + bounds: BottomBarBounds, + barWidth: number, +): number => { + if (bounds.containerWidth <= 0 || barWidth <= 0) { + return 0; + } + + const centeredLeft = (bounds.containerWidth - barWidth) / 2; + const leftLimit = bounds.leftInset + bounds.margin; + const rightLimit = + bounds.containerWidth - bounds.rightInset - bounds.margin - barWidth; + + // `Math.max` last so the left limit wins when the two cross, which is what + // happens once the bar is wider than the space between the insets. + return Math.max(leftLimit, Math.min(centeredLeft, rightLimit)) - centeredLeft; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index aa264351c90..c594ef13d90 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -1,4 +1,4 @@ -import { use, useCallback, useEffect } from "react"; +import { use, useEffect, useRef } from "react"; import { Icon } from "@hashintel/ds-components"; import { css, cva } from "@hashintel/ds-helpers/css"; @@ -11,21 +11,31 @@ import { EditorContext, type EditorState, } from "../../../../../react/state/editor-context"; +import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; +import { BottomBarCollapseContext } from "./collapse-context"; +import { CollapsibleGroup } from "./collapsible-group"; +import { CursorModeDropdown } from "./cursor-mode-dropdown"; import { DiagnosticsIndicator } from "./diagnostics-indicator"; +import { EditionTools } from "./edition-tools"; import { SimulationControls } from "./simulation-controls"; import { ToolbarButton } from "./toolbar-button"; import { ToolbarDivider } from "./toolbar-divider"; -import { ToolbarModes } from "./toolbar-modes"; +import { useBottomBarLayout } from "./use-bottom-bar-layout"; import { useKeyboardShortcuts } from "./use-keyboard-shortcuts"; +/** Gap between the bar and whatever is below it, canvas or bottom panel. */ +const BOTTOM_BAR_GAP = 24; + const glassPanelStyle = css({ padding: "1", backgroundColor: "white.a95", borderWidth: "thin", borderColor: "neutral.a50", boxShadow: "[0 3px 11px rgba(0, 0, 0, 0.1)]", - transition: "[all 0.3s ease]", + // Named rather than `all`, which would animate the width a folding group + // changes and take twice as long doing it as the group itself. + transition: "[background-color 0.3s ease, box-shadow 0.3s ease]", _hover: { backgroundColor: "white.a110", boxShadow: "[0 4px 13px rgba(0, 0, 0, 0.15)]", @@ -38,21 +48,47 @@ const toolbarContainerStyle = css({ gap: "1", }); -const bottomBarPositionStyle = css({ +// Spans the canvas so the bar centres on the canvas rather than on the space +// between the panels, and lets clicks through everywhere the bar itself is not. +const bottomBarLaneStyle = css({ position: "absolute", - left: "[50%]", - transform: "translateX(-50%)", + left: "[0]", + right: "[0]", + display: "flex", + justifyContent: "center", + pointerEvents: "none", zIndex: "[calc(var(--z-index-sticky) + 1)]", +}); + +const bottomBarStyle = css({ display: "flex", gap: "[20px]", + pointerEvents: "auto", + // A bar wider than the lane overflows rather than squashing its segments: + // revealing the hidden controls in a cramped window does exactly that. + flexShrink: 0, }); -const animatingStyle = cva({ +/** + * Only a panel opening or closing animates the bar into place. Folding moves + * it too, but there the offset follows the width the bar is measured at, frame + * by frame, and a transition would race that with a curve of its own; a resize + * drag wants none either, so the bar tracks the edge under the pointer. + * + * Both axes ride one transform, which the compositor animates like the panel's + * own slide. A main-thread property could not stay with it: the frames dropped + * while a panel's content mounts leave a layout-driven animation behind. + * + * Reduced motion is deliberately not honoured here. This transition is not + * decoration, it is what keeps the bar attached to a panel that animates + * regardless of the setting, and stopping only the bar detaches it. + */ +const barAnimatingStyle = cva({ base: {}, variants: { animating: { true: { - transition: "[bottom 150ms ease-in-out]", + transition: "[transform 150ms ease-in-out]", }, }, }, @@ -83,7 +119,6 @@ export const BottomBar: React.FC = ({ isBottomPanelOpen, setBottomPanelOpen, setActiveBottomPanelTab, - bottomPanelHeight, isAiAssistantOpen, isPanelAnimating, toggleAiAssistant, @@ -95,15 +130,12 @@ export const BottomBar: React.FC = ({ const hasDiagnostics = errorDiagnosticsCount > 0; const { activeSubnetId } = use(ActiveNetContext); const isInSubnet = activeSubnetId !== null; + const isReadOnly = useIsReadOnly(); - const showDiagnostics = useCallback(() => { + const showDiagnostics = () => { setBottomPanelOpen(true); setActiveBottomPanelTab("diagnostics"); - }, [setBottomPanelOpen, setActiveBottomPanelTab]); - - const toggleBottomPanel = useCallback(() => { - setBottomPanelOpen(!isBottomPanelOpen); - }, [setBottomPanelOpen, isBottomPanelOpen]); + }; // Fallback to cursor mode when switching away from edit while in a mutative mode. useEffect(() => { @@ -115,93 +147,127 @@ export const BottomBar: React.FC = ({ // Setup keyboard shortcuts useKeyboardShortcuts(mode, onEditionModeChange, onCursorModeChange); - // Calculate bottom offset based on bottom panel visibility - const bottomOffset = isBottomPanelOpen - ? bottomPanelHeight + 24 // panel height + margin + spacing - : 24; + const laneRef = useRef(null); + const barRef = useRef(null); + const layout = useBottomBarLayout(laneRef, barRef, { + hasViewportControls: !isActualMode, + isAnimating: isPanelAnimating, + }); + + // Edit tools are absent on a read-only net and outside edit mode, so the + // group would otherwise fold an empty box and leave its gap behind. + const hasEditionGroup = !isActualMode && (!isReadOnly || hasAiAssistant); return (
- {/* Edition tools segment */} - -
- - {hasAiAssistant && !isActualMode && ( - <> - + + {/* Edition tools segment */} + +
+ + {hasEditionGroup && ( + + + {hasAiAssistant && ( + <> + + + + + + )} + + )} +
+
+ + {/* Playback segment */} + +
setBottomPanelOpen(!isBottomPanelOpen)} + ariaLabel={isBottomPanelOpen ? "Hide panel" : "Show panel"} + ariaExpanded={isBottomPanelOpen} > - + {isBottomPanelOpen ? ( + + ) : ( + + )} - - )} -
-
- - {/* Playback segment */} - -
- - {isBottomPanelOpen ? ( - - ) : ( - - )} - - {!isActualMode && ( - <> - - - - - )} -
-
+ {!isActualMode && ( + <> + + + + + )} +
+
+ +
); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts new file mode 100644 index 00000000000..9239cf424af --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts @@ -0,0 +1,34 @@ +import { createContext } from "react"; + +/** What one collapsible group of toolbar controls takes up, in CSS pixels. */ +export interface CollapsibleGroupWidth { + /** What this group takes when its controls are shown. */ + readonly natural: number; + /** How much of that is clipped away right now — 0 while it is shown. */ + readonly hidden: number; +} + +export interface BottomBarCollapseValue { + /** True while the bar shows only its essential controls. */ + readonly isCollapsed: boolean; + /** + * Report what a group takes up, so the bar knows how wide it would be with + * everything shown. Pass `null` when the group unmounts. + */ + readonly reportGroupWidth: ( + id: string, + width: CollapsibleGroupWidth | null, + ) => void; +} + +/** + * Lets controls anywhere under the bottom bar mark themselves collapsible + * without the bar having to know what its segments are made of. + * + * The default shows everything and measures nothing: away from the bar there + * is no space to run out of. + */ +export const BottomBarCollapseContext = createContext({ + isCollapsed: false, + reportGroupWidth: () => {}, +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx new file mode 100644 index 00000000000..522cf855518 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx @@ -0,0 +1,123 @@ +import { use, useId, useLayoutEffect, useRef } from "react"; + +import { css, cva } from "@hashintel/ds-helpers/css"; + +import { BottomBarCollapseContext } from "./collapse-context"; + +/** + * The group folds by animating its grid column to nothing while the content + * inside keeps its natural width. That keeps the content measurable whether it + * is shown or hidden, so the bar can tell how wide it would be with everything + * shown without laying the controls out twice. + * + * The reveal is a selector on the bar rather than state passed back down: the + * browser maintains `:hover` and `:focus-within` itself, where mirroring them + * into React can strand the bar open — a control that unmounts while focused + * fires no blur. + * + * A menu opened from the bar renders in a portal outside it, taking the + * pointer and the focus with it, so the third selector holds the bar open + * while a control inside it reports one. The same pattern keeps the sidebar's + * row actions up; see `filterable-list-sub-view`. + */ +const groupStyle = cva({ + base: { + display: "grid", + gridTemplateColumns: "[1fr]", + transition: "[grid-template-columns 160ms ease-in, opacity 160ms ease-in]", + "@media (prefers-reduced-motion: reduce)": { + transition: "[none]", + }, + }, + variants: { + collapsed: { + true: { + gridTemplateColumns: "[0fr]", + opacity: "[0]", + pointerEvents: "none", + // Revealing answers the pointer, so it runs shorter and decelerates; + // folding is not a response to anything and eases in. The selector + // stays on one line: Panda writes the key into the class name, and a + // wrapped one stops matching the rule it generated. + '[data-bottom-bar]:hover &, [data-bottom-bar]:focus-within &, [data-bottom-bar]:has([data-state="open"]) &': + { + gridTemplateColumns: "[1fr]", + opacity: "[1]", + pointerEvents: "auto", + transition: + "[grid-template-columns 120ms ease-out, opacity 120ms ease-out]", + }, + }, + }, + }, +}); + +const clipStyle = css({ + overflow: "hidden", + minWidth: "[0]", +}); + +const contentStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", + width: "[max-content]", +}); + +/** + * Toolbar controls the bottom bar hides when it runs out of room, and shows + * again while the pointer or the keyboard is on the bar. + * + * A folded group keeps its controls focusable on purpose: focus is what + * reveals them, so making the subtree inert would leave a keyboard with no way + * in. + */ +export const CollapsibleGroup: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { isCollapsed, reportGroupWidth } = use(BottomBarCollapseContext); + const groupId = useId(); + const clipRef = useRef(null); + const contentRef = useRef(null); + + useLayoutEffect(() => { + const clip = clipRef.current; + const content = contentRef.current; + if (!clip || !content) { + return; + } + + const measure = () => { + const natural = content.getBoundingClientRect().width; + const rendered = clip.getBoundingClientRect().width; + reportGroupWidth(groupId, { + natural, + hidden: Math.max(natural - rendered, 0), + }); + }; + measure(); + + // Both boxes are watched: the content changes when a control appears, and + // the clip changes on every frame of the fold. Reporting the pair from one + // observer keeps the two in step, so the width the bar derives from them + // is right mid-animation too. + const observer = new ResizeObserver(measure); + observer.observe(clip); + observer.observe(content); + + return () => { + observer.disconnect(); + reportGroupWidth(groupId, null); + }; + }, [groupId, reportGroupWidth]); + + return ( +
+
+
+ {children} +
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx new file mode 100644 index 00000000000..1b2d374cae5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx @@ -0,0 +1,57 @@ +import { Menu, type MenuItem } from "@hashintel/ds-components"; + +import { + type CursorMode, + type EditorState, +} from "../../../../../react/state/editor-context"; +import { ToolbarMenuTrigger } from "./toolbar-menu-trigger"; + +type EditorEditionMode = EditorState["editionMode"]; + +/** Picks between the select and pan cursors, and returns to cursor mode. */ +export const CursorModeDropdown: React.FC<{ + editionMode: EditorEditionMode; + onEditionModeChange: (mode: EditorEditionMode) => void; + cursorMode: CursorMode; + onCursorModeChange: (mode: CursorMode) => void; +}> = ({ editionMode, onEditionModeChange, cursorMode, onCursorModeChange }) => { + const handleCursorChange = (mode: CursorMode) => { + onCursorModeChange(mode); + onEditionModeChange("cursor"); + }; + + const items: MenuItem[] = [ + { + id: "select", + icon: "cursor", + text: "Select", + suffix: "V", + tone: cursorMode === "select" ? "brand" : "neutral", + selected: cursorMode === "select", + onClick: () => handleCursorChange("select"), + }, + { + id: "pan", + icon: "hand", + text: "Pan", + suffix: "H", + tone: cursorMode === "pan" ? "brand" : "neutral", + selected: cursorMode === "pan", + onClick: () => handleCursorChange("pan"), + }, + ]; + + return ( + + } + items={items} + position="top" + /> + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx new file mode 100644 index 00000000000..a478d1007ce --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx @@ -0,0 +1,118 @@ +import { use } from "react"; + +import { Icon, type MenuItem, Menu } from "@hashintel/ds-components"; + +import { ActiveNetContext } from "../../../../../react/state/active-net-context"; +import { + EditorContext, + type EditorState, +} from "../../../../../react/state/editor-context"; +import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; +import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; +import { UserSettingsContext } from "../../../../../react/state/user-settings-context"; +import { writeDraggedNodeKind } from "../../../shared/canvas-node-drag"; +import { ToolbarButton } from "./toolbar-button"; +import { ToolbarDivider } from "./toolbar-divider"; +import { ToolbarMenuTrigger } from "./toolbar-menu-trigger"; + +type EditorEditionMode = EditorState["editionMode"]; + +/** Picks which subnet the next component instance comes from. */ +const ComponentDropdown: React.FC<{ + editionMode: EditorEditionMode; +}> = ({ editionMode }) => { + const { + petriNetDefinition: { subnets }, + } = use(SDCPNContext); + const { componentSubnetId, setAddComponentMode } = use(EditorContext); + + const items: MenuItem[] = (subnets ?? []).map((subnet) => ({ + id: subnet.id, + icon: "cube", + text: subnet.name, + selected: + editionMode === "add-component" && componentSubnetId === subnet.id, + onClick: () => setAddComponentMode(subnet.id), + })); + + if (items.length === 0) { + items.push({ + id: "empty", + text: "No subnets defined", + disabled: true, + onClick: () => {}, + }); + } + + return ( + + } + items={items} + position="top" + /> + ); +}; + +/** + * The tools that add nodes to the net. Nothing to offer on a read-only net, + * where every one of them is refused. + */ +export const EditionTools: React.FC<{ + editionMode: EditorEditionMode; + onEditionModeChange: (mode: EditorEditionMode) => void; +}> = ({ editionMode, onEditionModeChange }) => { + const isReadOnly = useIsReadOnly(); + const { activeSubnetId } = use(ActiveNetContext); + const isRootNet = activeSubnetId === null; + const { extensions } = use(SDCPNContext); + const { enableNetComponents } = use(UserSettingsContext); + + if (isReadOnly) { + return null; + } + + return ( + <> + + onEditionModeChange("add-place")} + isSelected={editionMode === "add-place"} + ariaLabel="Add place mode" + draggable + onDragStart={(event) => { + // eslint-disable-next-line no-param-reassign + event.dataTransfer.effectAllowed = "move"; + writeDraggedNodeKind(event.dataTransfer, "place"); + }} + > + + + onEditionModeChange("add-transition")} + isSelected={editionMode === "add-transition"} + ariaLabel="Add transition mode" + draggable + onDragStart={(event) => { + // eslint-disable-next-line no-param-reassign + event.dataTransfer.effectAllowed = "move"; + writeDraggedNodeKind(event.dataTransfer, "transition"); + }} + > + + + {isRootNet && extensions.subnets && enableNetComponents && ( + + )} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx index 14f875d1e8a..ff34e613d4c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx @@ -185,6 +185,7 @@ export const PlaybackSettingsMenu = () => { <> = ({ <> {/* Stop button - only visible when simulation exists */} {hasSimulation && ( - <> + = ({ - + )} {/* Play/Pause button - always visible */} @@ -200,35 +201,38 @@ export const SimulationControls: React.FC = ({ )} - {/* Frame controls - only visible when simulation exists */} - {hasSimulation && ( - <> -
-
Frame
-
- {frameIndex + 1} / {totalFrames} + {/* Frame controls - only visible when simulation exists - and the + playback settings, which the bar hides first when it runs short of + room: the scrubber is the widest thing on it. */} + + {hasSimulation && ( + <> +
+
Frame
+
+ {frameIndex + 1} / {totalFrames} +
+
{elapsedTime.toFixed(3)}s
-
{elapsedTime.toFixed(3)}s
-
- - - setCurrentViewedFrame(Number(event.target.value)) - } - className={sliderStyle} - /> - - - )} + + setCurrentViewedFrame(Number(event.target.value)) + } + className={sliderStyle} + /> + + + + )} - {/* Playback settings menu */} - + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx index 4bc32f975b0..6d4b36384ad 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx @@ -64,6 +64,12 @@ interface ToolbarButtonProps { onDragStart?: (event: React.DragEvent) => void; /** Forwarded to the underlying ` diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx new file mode 100644 index 00000000000..dd526f2e195 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx @@ -0,0 +1,71 @@ +import { Icon, type IconName } from "@hashintel/ds-components"; +import { css, cva } from "@hashintel/ds-helpers/css"; + +const triggerStyle = cva({ + base: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "[2px]", + border: "none", + borderRadius: "lg", + cursor: "pointer", + transition: "[all 0.2s ease]", + backgroundColor: "[transparent]", + color: "neutral.s110", + height: "8", + paddingX: "[6px]", + fontSize: "xl", + "& > *": { + transition: "[transform 0.2s ease]", + }, + _hover: { + color: "neutral.s120", + "& > *": { + transform: "[scale(1.05)]", + }, + }, + _active: { + "& > *": { + transform: "[scale(0.95)]", + }, + }, + }, + variants: { + isActive: { + true: { + color: "[#3b82f6]", + _hover: { + color: "[#2563eb]", + }, + }, + }, + }, +}); + +const chevronStyle = css({ + opacity: "[0.5]", +}); + +/** + * The button a toolbar dropdown opens from: the mode it would apply, and a + * chevron saying there is a choice behind it. + * + * Every other prop reaches the `button`, ref included. `Menu` mounts its + * trigger through Ark's `asChild`, which clones this element to attach the + * click handling, the trigger ref and its ARIA — anything this component keeps + * to itself never reaches the DOM, and the menu then does not open. + */ +export const ToolbarMenuTrigger = ({ + icon, + isActive, + ...buttonProps +}: { + icon: IconName; + isActive: boolean; +} & React.ComponentPropsWithRef<"button">) => ( + +); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx deleted file mode 100644 index c6a2d2e7bf7..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { use } from "react"; - -import { Icon, Menu, type MenuItem } from "@hashintel/ds-components"; -import { css, cva } from "@hashintel/ds-helpers/css"; - -import { ActiveNetContext } from "../../../../../react/state/active-net-context"; -import { EditorContext } from "../../../../../react/state/editor-context"; -import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; -import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; -import { UserSettingsContext } from "../../../../../react/state/user-settings-context"; -import { writeDraggedNodeKind } from "../../../shared/canvas-node-drag"; -import { ToolbarButton } from "./toolbar-button"; -import { ToolbarDivider } from "./toolbar-divider"; - -import type { - CursorMode, - EditorState, -} from "../../../../../react/state/editor-context"; - -type EditorEditionMode = EditorState["editionMode"]; - -const cursorTriggerStyle = cva({ - base: { - display: "flex", - alignItems: "center", - justifyContent: "center", - gap: "[2px]", - border: "none", - borderRadius: "lg", - cursor: "pointer", - transition: "[all 0.2s ease]", - backgroundColor: "[transparent]", - color: "neutral.s110", - height: "8", - paddingX: "[6px]", - fontSize: "xl", - "& > *": { - transition: "[transform 0.2s ease]", - }, - _hover: { - color: "neutral.s120", - "& > *": { - transform: "[scale(1.05)]", - }, - }, - _active: { - "& > *": { - transform: "[scale(0.95)]", - }, - }, - }, - variants: { - isActive: { - true: { - color: "[#3b82f6]", - _hover: { - color: "[#2563eb]", - }, - }, - }, - }, -}); - -const dropdownArrowStyle = css({ - opacity: "[0.5]", -}); - -const CursorModeDropdown: React.FC<{ - editionMode: EditorEditionMode; - onEditionModeChange: (mode: EditorEditionMode) => void; - cursorMode: CursorMode; - onCursorModeChange: (mode: CursorMode) => void; -}> = ({ editionMode, onEditionModeChange, cursorMode, onCursorModeChange }) => { - const handleCursorChange = (mode: CursorMode) => { - onCursorModeChange(mode); - onEditionModeChange("cursor"); - }; - - const items: MenuItem[] = [ - { - id: "select", - icon: "cursor", - text: "Select", - suffix: "V", - tone: cursorMode === "select" ? "brand" : "neutral", - selected: cursorMode === "select", - onClick: () => handleCursorChange("select"), - }, - { - id: "pan", - icon: "hand", - text: "Pan", - suffix: "H", - tone: cursorMode === "pan" ? "brand" : "neutral", - selected: cursorMode === "pan", - onClick: () => handleCursorChange("pan"), - }, - ]; - - return ( - - {cursorMode === "pan" ? : } - - - } - items={items} - position="top" - /> - ); -}; - -const ComponentDropdown: React.FC<{ - editionMode: EditorEditionMode; -}> = ({ editionMode }) => { - const { - petriNetDefinition: { subnets }, - } = use(SDCPNContext); - const { componentSubnetId, setAddComponentMode } = use(EditorContext); - - const items: MenuItem[] = (subnets ?? []).map((subnet) => ({ - id: subnet.id, - icon: "cube", - text: subnet.name, - selected: - editionMode === "add-component" && componentSubnetId === subnet.id, - onClick: () => setAddComponentMode(subnet.id), - })); - - if (items.length === 0) { - items.push({ - id: "empty", - text: "No subnets defined", - disabled: true, - onClick: () => {}, - }); - } - - const isActive = - editionMode === "add-component" && componentSubnetId !== null; - - return ( - - - - - } - items={items} - position="top" - /> - ); -}; - -interface ToolbarModesProps { - editionMode: EditorEditionMode; - onEditionModeChange: (mode: EditorEditionMode) => void; - cursorMode: CursorMode; - onCursorModeChange: (mode: CursorMode) => void; - showEditTools?: boolean; -} - -export const ToolbarModes: React.FC = ({ - editionMode, - onEditionModeChange, - cursorMode, - onCursorModeChange, - showEditTools = true, -}) => { - const isReadOnly = useIsReadOnly(); - const { activeSubnetId } = use(ActiveNetContext); - const isRootNet = activeSubnetId === null; - const { extensions } = use(SDCPNContext); - const { enableNetComponents } = use(UserSettingsContext); - - return ( - <> - - {showEditTools && !isReadOnly && ( - <> - - onEditionModeChange("add-place")} - isSelected={editionMode === "add-place"} - ariaLabel="Add place mode" - draggable - onDragStart={(event) => { - // eslint-disable-next-line no-param-reassign - event.dataTransfer.effectAllowed = "move"; - writeDraggedNodeKind(event.dataTransfer, "place"); - }} - > - - - onEditionModeChange("add-transition")} - isSelected={editionMode === "add-transition"} - ariaLabel="Add transition mode" - draggable - onDragStart={(event) => { - // eslint-disable-next-line no-param-reassign - event.dataTransfer.effectAllowed = "move"; - writeDraggedNodeKind(event.dataTransfer, "transition"); - }} - > - - - {isRootNet && extensions.subnets && enableNetComponents && ( - - )} - - )} - - ); -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts new file mode 100644 index 00000000000..7444756dd96 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts @@ -0,0 +1,125 @@ +import { useCallback, useState } from "react"; + +import { useElementSize } from "../../../../../react/hooks/use-element-size"; +import { VIEWPORT_CONTROLS_CLEARANCE } from "../../../../constants/ui"; +import { useCanvasInsets } from "../../../../hooks/use-canvas-insets"; +import { fitsWithinBounds, getBottomBarOffset } from "./bottom-bar-placement"; +import { type CollapsibleGroupWidth } from "./collapse-context"; + +/** Gap kept between the bar and a panel it has been pushed away from. */ +const BOTTOM_BAR_MARGIN = 12; + +export interface BottomBarLayout { + /** Shift from the centred position that clears the panels, in px. */ + readonly offsetX: number; + /** How far the bottom panel lifts the bar, in px. */ + readonly liftY: number; + /** True while the bar has room only for its essential controls. */ + readonly isCollapsed: boolean; + /** Reports what one collapsible group takes up. See the collapse context. */ + readonly reportGroupWidth: ( + id: string, + width: CollapsibleGroupWidth | null, + ) => void; +} + +/** + * Places the bottom bar between the docked panels and decides whether it has + * room for every control. + * + * The bar is measured rather than modelled: its own width is what the offset + * clamps, and each collapsible group reports both what it takes when shown and + * what it is currently hiding, so the decision never chases itself. + * + * Whether the hidden controls are on screen is not decided here at all: hover, + * keyboard focus and an open menu reveal them in CSS, which cannot go stale + * the way mirrored state does. + */ +export const useBottomBarLayout = ( + /** Spans the canvas; the bar is centred in it and measured against it. */ + laneRef: React.RefObject, + barRef: React.RefObject, + { + hasViewportControls, + isAnimating, + }: { + hasViewportControls: boolean; + /** True while a panel opens or closes, and the bar transitions with it. */ + isAnimating: boolean; + }, +): BottomBarLayout => { + const containerWidth = useElementSize(laneRef, { box: "border" })?.width ?? 0; + const barWidth = useElementSize(barRef, { box: "border" })?.width ?? 0; + + const [groupWidths, setGroupWidths] = useState< + ReadonlyMap + >(() => new Map()); + + // Identity is load-bearing rather than a performance nicety: every group + // measures from an effect keyed on this callback, and a new one each render + // would tear the observers down and report a width in a loop. + const reportGroupWidth = useCallback( + (id: string, width: CollapsibleGroupWidth | null) => { + setGroupWidths((previous) => { + const current = previous.get(id); + if (width === null) { + if (!current) { + return previous; + } + const next = new Map(previous); + next.delete(id); + return next; + } + if ( + current && + current.natural === width.natural && + current.hidden === width.hidden + ) { + return previous; + } + return new Map(previous).set(id, width); + }); + }, + [], + ); + + const insets = useCanvasInsets(); + const bounds = { + containerWidth, + leftInset: insets.left, + // The viewport controls sit in the bar's row on the right of the canvas, + // so they bound it the same way a panel does. They are absent in actual + // mode, where `SDCPNCanvas` does not render them. + rightInset: + insets.right + (hasViewportControls ? VIEWPORT_CONTROLS_CLEARANCE : 0), + margin: BOTTOM_BAR_MARGIN, + }; + + let hiddenWidth = 0; + let collapsibleWidth = 0; + for (const width of groupWidths.values()) { + hiddenWidth += width.hidden; + collapsibleWidth += width.natural; + } + + // What the bar would take with every control shown. Its two terms move + // together while a group folds, so the sum holds still throughout. + const expandedWidth = barWidth + hiddenWidth; + const isCollapsed = !fitsWithinBounds(bounds, expandedWidth); + + // While a panel animates, the bar's own transition carries it there, and an + // offset taken from a width that is itself animating would restart that + // transition every frame. The width the bar is heading for steps once + // instead. With no transition running, the measured width is what keeps the + // bar glued to its own collapse and to a panel edge being dragged. + const settledWidth = isCollapsed + ? expandedWidth - collapsibleWidth + : expandedWidth; + + return { + offsetX: getBottomBarOffset(bounds, isAnimating ? settledWidth : barWidth), + liftY: insets.bottom, + isCollapsed, + reportGroupWidth, + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx index 6d33f3ec00f..fbca7653be2 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx @@ -655,6 +655,7 @@ export function FakeEditorProvider({ setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, setPropertiesPanelWidth: () => {}, + setAiAssistantWidth: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx index b197c585cfd..4c39bb19062 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx @@ -84,6 +84,7 @@ const editorContextValue: EditorContextValue = { setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, setPropertiesPanelWidth: () => {}, + setAiAssistantWidth: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx index 42cd71bc79e..22c62fa18a5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx @@ -15,6 +15,7 @@ import { Button, Icon } from "@hashintel/ds-components"; import { css, cva } from "@hashintel/ds-helpers/css"; import { NotificationsContext } from "../../../../../react/notifications/context"; +import { EditorContext } from "../../../../../react/state/editor-context"; import { VoiceSessionContext } from "../../../../../react/voice-session/context"; import { useVoiceSessionErrorMessage, @@ -76,8 +77,6 @@ export type AiAssistantContentsProps = { voiceModeAvailable?: boolean; }; -const defaultAssistantWidth = 500; - const shellStyle = cva({ base: { position: "absolute", @@ -94,7 +93,6 @@ const shellStyle = cva({ true: { top: "0", bottom: "0", - width: `[${defaultAssistantWidth}px]`, maxWidth: "[calc(100vw - 32px)]", padding: "2", _before: { @@ -672,7 +670,12 @@ export const AiAssistantContents = ({ (message) => revealedIds.has(message.id) && message.role === "user", ).length; - const [assistantWidth, setAssistantWidth] = useState(defaultAssistantWidth); + // Held in editor state, not here: the bottom toolbar and the viewport + // controls have to keep clear of this panel, and cannot read a local value. + const { + aiAssistantWidth: assistantWidth, + setAiAssistantWidth: setAssistantWidth, + } = use(EditorContext); const [chipsDismissed, setChipsDismissed] = useState(false); diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx index 4f32dac3d42..d51aac5a2c3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx @@ -5,14 +5,13 @@ import { cx, css, cva } from "@hashintel/ds-helpers/css"; import { usePetrinautNavigation } from "../../../../react/navigation"; import { EditorContext } from "../../../../react/state/editor-context"; -import { PANEL_MARGIN } from "../../../constants/ui"; +import { VIEWPORT_CONTROLS_OFFSET } from "../../../constants/ui"; +import { useCanvasInsets } from "../../../hooks/use-canvas-insets"; import { useCanvasController } from "../canvas-renderer"; import { ViewportSettingsDialog } from "./viewport-settings-dialog"; import type { ViewportAction } from "../../../types/viewport-action"; -const BASE_OFFSET = 12; - const containerStyle = css({ position: "absolute", display: "flex", @@ -46,21 +45,14 @@ export const ViewportControls: React.FC<{ ); }; const { zoomIn, zoomOut } = useCanvasController(); - const { - collapseAllPanels, - hasSelection, - propertiesPanelWidth, - isBottomPanelOpen, - bottomPanelHeight, - isPanelAnimating, - } = use(EditorContext); + const { collapseAllPanels, isPanelAnimating } = use(EditorContext); - const isPropertiesPanelVisible = hasSelection; - const rightOffset = - BASE_OFFSET + - (isPropertiesPanelVisible ? propertiesPanelWidth + PANEL_MARGIN : 0); - const bottomOffset = - BASE_OFFSET + (isBottomPanelOpen ? bottomPanelHeight + PANEL_MARGIN : 0); + // Shared with the bottom toolbar, so the two keep clear of the same panels + // by the same rules — the assistant panel included, which used to cover the + // column when it opened. + const insets = useCanvasInsets(); + const rightOffset = VIEWPORT_CONTROLS_OFFSET + insets.right; + const bottomOffset = VIEWPORT_CONTROLS_OFFSET + insets.bottom; return (