Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bottom-bar-panel-clearance.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions libs/@hashintel/petrinaut/docs/drawing-a-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<img width="1793" height="1175" alt="full-editor" src="https://github.com/user-attachments/assets/ea41efe8-9056-479b-a936-e0d5e4196b11" />

## Top bar
Expand Down
4 changes: 4 additions & 0 deletions libs/@hashintel/petrinaut/docs/simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<img width="717" height="62" alt="simulation-toolbar" src="https://github.com/user-attachments/assets/fc39afbe-8603-4be5-88b1-83d5b09d5367" />

### Speed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const editorContextValue = (
setLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
setPropertiesPanelWidth: () => {},
setAiAssistantWidth: () => {},
setBottomPanelOpen: () => {},
toggleBottomPanel: () => {},
setBottomPanelHeight: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const editorContextValue = (
setLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
setPropertiesPanelWidth: () => {},
setAiAssistantWidth: () => {},
setBottomPanelOpen: () => {},
toggleBottomPanel: () => {},
setBottomPanelHeight: () => {},
Expand Down
9 changes: 9 additions & 0 deletions libs/@hashintel/petrinaut/src/react/state/editor-context.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -215,6 +223,7 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = {
setLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
setPropertiesPanelWidth: () => {},
setAiAssistantWidth: () => {},
setBottomPanelOpen: () => {},
toggleBottomPanel: () => {},
setBottomPanelHeight: () => {},
Expand Down
2 changes: 2 additions & 0 deletions libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,8 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ 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) => ({
Expand Down
2 changes: 2 additions & 0 deletions libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
9 changes: 9 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/constants/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
56 changes: 56 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
51 changes: 51 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts
Original file line number Diff line number Diff line change
@@ -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));
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";

import {
type BottomBarBounds,
fitsWithinBounds,
getBottomBarOffset,
} from "./bottom-bar-placement";

const bounds = (overrides: Partial<BottomBarBounds> = {}): 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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading