diff --git a/.changeset/petrinaut-presentation-profiles.md b/.changeset/petrinaut-presentation-profiles.md new file mode 100644 index 00000000000..3e4bfee1b94 --- /dev/null +++ b/.changeset/petrinaut-presentation-profiles.md @@ -0,0 +1,7 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add a `presentationProfile` prop to `Petrinaut` (`editor` or `review`) that +gates authoring-only controls, and extract the scenario and playback controls +into shared components reusable outside the full editor. diff --git a/apps/petrinaut-website/src/examples/embedded-example-page.tsx b/apps/petrinaut-website/src/examples/embedded-example-page.tsx index 26937e40a20..9eda19e9bd6 100644 --- a/apps/petrinaut-website/src/examples/embedded-example-page.tsx +++ b/apps/petrinaut-website/src/examples/embedded-example-page.tsx @@ -78,6 +78,7 @@ export const EmbeddedExamplePage: FunctionComponent< handle={handle} hideNetManagementControls="all" navigation={navigation} + presentationProfile="review" readonly slots={{ topBarStart: ( diff --git a/apps/petrinaut-website/src/examples/full-example-page.tsx b/apps/petrinaut-website/src/examples/full-example-page.tsx index 3b3deb5465d..810983766a3 100644 --- a/apps/petrinaut-website/src/examples/full-example-page.tsx +++ b/apps/petrinaut-website/src/examples/full-example-page.tsx @@ -72,6 +72,7 @@ export const FullExamplePage = ({ handle={handle} hideNetManagementControls="all" navigation={navigation} + presentationProfile="review" readonly slots={{ topBarStart: ( diff --git a/libs/@hashintel/petrinaut/README.md b/libs/@hashintel/petrinaut/README.md index 1cf6bb46f26..ef08cd39744 100644 --- a/libs/@hashintel/petrinaut/README.md +++ b/libs/@hashintel/petrinaut/README.md @@ -27,6 +27,28 @@ For host applications that own their Petri net data, implement a guide lives in the architecture docs: [Embedding in a host application](https://github.com/hashintel/hash/blob/main/libs/%40local/petrinaut-arch-docs/content/handle/host-integration.mdx). +### Presentation profiles + +`presentationProfile` chooses how much editing chrome the component draws: + +- `editor`, the default, draws the full authoring surface. +- `review` drops the controls whose only purpose is to change the net: the + add and delete actions in the sidebar lists and property panels. Source + code, custom visualizers, the minimap and the viewport settings stay, so a + reader can still inspect and simulate the model. + +The profile is chrome, not enforcement. A host that must not accept edits +passes `readonly` as well, which is what disables the fields themselves: + +```tsx + +``` + ## Commands and the palette Petrinaut registers its user-invocable actions (undo, tools, search, panel diff --git a/libs/@hashintel/petrinaut/src/ui/components/sub-view/deferred-sub-view.tsx b/libs/@hashintel/petrinaut/src/ui/components/sub-view/deferred-sub-view.tsx new file mode 100644 index 00000000000..1538fe1af2a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/sub-view/deferred-sub-view.tsx @@ -0,0 +1,51 @@ +import { lazy, Suspense } from "react"; + +import type { SubView } from "./types"; + +type DeferredSubViewOptions = Omit< + SubView, + "component" | "renderHeaderAction" +> & { + load: () => Promise; + hasHeaderAction?: boolean; +}; + +/** + * Keeps an optional subview behind a bundle boundary while preserving the + * synchronous descriptor required by the panel layout. + */ +export const createDeferredSubView = ({ + load, + hasHeaderAction = false, + ...descriptor +}: DeferredSubViewOptions): SubView => { + const DeferredContent = lazy(async () => { + const subView = await load(); + return { default: subView.component }; + }); + const Content = () => ( + + + + ); + + if (!hasHeaderAction) { + return { ...descriptor, component: Content }; + } + + const DeferredHeaderAction = lazy(async () => { + const subView = await load(); + const HeaderAction = () => subView.renderHeaderAction?.() ?? null; + return { default: HeaderAction }; + }); + + return { + ...descriptor, + component: Content, + renderHeaderAction: () => ( + + + + ), + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/components/sub-view/types.ts b/libs/@hashintel/petrinaut/src/ui/components/sub-view/types.ts index a94ebf7619b..8de203cc3dd 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/sub-view/types.ts +++ b/libs/@hashintel/petrinaut/src/ui/components/sub-view/types.ts @@ -35,6 +35,16 @@ export interface SubView { * Only used in vertical (collapsible) layout. */ renderHeaderAction?: () => ReactNode; + /** + * Whether the header action creates, deletes or clears something. A + * presentation that hides mutation actions hides only these. + * + * Defaults to false, because the slot also carries close buttons, view + * toggles and status labels, and losing those costs a reader more than a + * disabled Add button does. An action that is a mutation only some of the + * time reads the presentation itself instead of setting this. + */ + headerActionMutates?: boolean; /** * Whether this subview should grow to fill available space. * Only affects vertical layout. Defaults to false. diff --git a/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx index 594be9013ae..6defa673f3a 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -10,6 +10,7 @@ import { css, cva, cx } from "@hashintel/ds-helpers/css"; import { UserSettingsContext } from "../../../../react/state/user-settings-context"; import { useScrollOverflow } from "../../../hooks/use-scroll-overflow"; +import { usePetrinautPresentation } from "../../../views/shared/presentation-context"; import type { SubView } from "../types"; @@ -422,6 +423,7 @@ interface VerticalSubViewsContainerProps { export const VerticalSubViewsContainer: React.FC< VerticalSubViewsContainerProps > = ({ name, subViews, defaultExpanded = true }) => { + const presentation = usePetrinautPresentation(); const { showAnimations, subViewPanels, updateSubViewSection } = use(UserSettingsContext); @@ -500,7 +502,12 @@ export const VerticalSubViewsContainer: React.FC< renderTitle={subView.renderTitle} isExpanded={isExpanded} onToggle={() => toggleSection(subView)} - renderHeaderAction={subView.renderHeaderAction} + renderHeaderAction={ + subView.headerActionMutates && + !presentation.showMutationActions + ? undefined + : subView.renderHeaderAction + } alwaysShowHeaderAction={subView.alwaysShowHeaderAction} /> diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index c628607d697..3234406ed38 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -20,6 +20,10 @@ import { PetrinautProvider } from "../react/petrinaut-provider"; import { Stack } from "./components/stack"; import { MonacoProvider } from "./monaco/provider"; import { EditorView } from "./views/Editor/editor-view"; +import { + PetrinautPresentationProvider, + type PetrinautPresentationProfile, +} from "./views/shared/presentation-context"; // `clip`, not `hidden`: a hidden-overflow box is still programmatically // scrollable, and focusing an element the canvas transform pushed past the @@ -112,6 +116,12 @@ export type PetrinautProps = { lspWorkerFactory?: LspWorkerFactory; /** Optional host-controlled, router-neutral app location. */ navigation?: PetrinautNavigationController; + /** + * Presentation policy for the full editor. `review` keeps the full editor + * surface while suppressing authoring actions for route-scoped read-only + * examples. The default remains `editor`. + */ + presentationProfile?: PetrinautPresentationProfile; }; const noop = () => {}; @@ -140,6 +150,7 @@ export const Petrinaut: FunctionComponent = ({ monteCarloWorkerFactory, lspWorkerFactory, navigation, + presentationProfile = "editor", }) => { const portalContainerRef = useRef(null); const instance = useMemo( @@ -167,19 +178,21 @@ export const Petrinaut: FunctionComponent = ({ lspWorkerFactory={lspWorkerFactory} navigation={navigation} > - - - - - + + + + + + + ); 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 ff34e613d4c..f5b062b915c 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 @@ -10,6 +10,7 @@ import { type PlaybackSpeed, } from "../../../../../react/playback/context"; import { SimulationContext } from "../../../../../react/simulation/context"; +import { usePetrinautPresentation } from "../../../shared/presentation-context"; import { ToolbarButton } from "./toolbar-button"; const contentWidthStyle = css({ @@ -139,15 +140,27 @@ const maxTimeInputStyle = css({ fontVariantNumeric: "tabular-nums", }); -// Split speeds into two rows of 4 -const speedRows: PlaybackSpeed[][] = [ - PLAYBACK_SPEEDS.slice(0, 4), - PLAYBACK_SPEEDS.slice(4), -]; +export type PlaybackSettingsMenuProps = { + allowedSpeeds?: readonly PlaybackSpeed[]; +}; + +const toSpeedRows = (speeds: readonly PlaybackSpeed[]): PlaybackSpeed[][] => { + const rows: PlaybackSpeed[][] = []; + for (let index = 0; index < speeds.length; index += 4) { + rows.push(speeds.slice(index, index + 4)); + } + return rows; +}; -export const PlaybackSettingsMenu = () => { +export const PlaybackSettingsMenu = ({ + allowedSpeeds = PLAYBACK_SPEEDS, +}: PlaybackSettingsMenuProps) => { + const presentation = usePetrinautPresentation(); const triggerRef = useRef(null); const [open, setOpen] = useState(false); + const playModesVisible = !presentation.compactControls; + const stoppingConditionsVisible = !presentation.compactControls; + const speedRows = toSpeedRows(allowedSpeeds); const { state: simulationState, @@ -200,200 +213,264 @@ export const PlaybackSettingsMenu = () => { onClose={() => setOpen(false)} > - + {[ + , + ...(playModesVisible + ? [ + +
+ When pressing play +
+ + + +
+ , + ] + : []), - {/* When pressing play section */} - -
When pressing play
- - + ))} +
+ ))} + {stoppingConditionsVisible && ( +
)} - - -
- + , - {/* Playback speed section */} - -
Playback speed
- {speedRows.map((row) => ( -
- {row.map((speed) => ( - - ))} -
- ))} -
- - - {/* Stopping conditions section */} - -
Stopping conditions
- - -
- + aria-disabled={hasSimulation} + tooltip={ + hasSimulation + ? "Reset simulation to change stopping conditions" + : undefined + } + > + + + Run indefinitely + + {stoppingCondition === "indefinitely" && ( + + )} + + +
+ , + ] + : []), + ]} )} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx index 6f7abf7556d..7c280ae0805 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx @@ -1,28 +1,38 @@ import { use } from "react"; import { Icon } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; +import { css, cva } from "@hashintel/ds-helpers/css"; import { PlaybackContext } from "../../../../../react/playback/context"; import { SimulationContext } from "../../../../../react/simulation/context"; import { EditorContext } from "../../../../../react/state/editor-context"; +import { usePetrinautPresentation } from "../../../shared/presentation-context"; import { CollapsibleGroup } from "./collapsible-group"; import { PlaybackSettingsMenu } from "./playback-settings-menu"; import { ToolbarButton } from "./toolbar-button"; import { ToolbarDivider } from "./toolbar-divider"; -const frameInfoStyle = css({ - display: "flex", - flexDirection: "column", - alignItems: "center", - fontSize: "[10px]", - color: "neutral.s105", - fontWeight: "medium", - lineHeight: "[1]", - width: "[90px]", - fontVariantNumeric: "tabular-nums", - overflow: "hidden", - whiteSpace: "nowrap", +import type { PlaybackSpeed } from "../../../../../react/playback/context"; + +const frameInfoStyle = cva({ + base: { + display: "flex", + flexDirection: "column", + alignItems: "center", + fontSize: "[10px]", + color: "neutral.s105", + fontWeight: "medium", + lineHeight: "[1]", + width: "[90px]", + fontVariantNumeric: "tabular-nums", + overflow: "hidden", + whiteSpace: "nowrap", + }, + variants: { + compact: { + true: { width: "[64px]" }, + }, + }, }); const elapsedTimeStyle = css({ @@ -38,45 +48,59 @@ const frameIndexStyle = css({ marginTop: "[1px]", }); -const sliderStyle = css({ - width: "[300px]", - height: "[4px]", - appearance: "none", - background: "neutral.s30", - borderRadius: "[2px]", - outline: "none", - cursor: "pointer", - "&:disabled": { - opacity: "[0.5]", - cursor: "not-allowed", - }, - "&::-webkit-slider-thumb": { +const sliderStyle = cva({ + base: { + width: "[300px]", + height: "[4px]", appearance: "none", - width: "[12px]", - height: "[12px]", - borderRadius: "[50%]", - background: "blue.s90", + background: "neutral.s30", + borderRadius: "[2px]", + outline: "none", cursor: "pointer", + "&:disabled": { + opacity: "[0.5]", + cursor: "not-allowed", + }, + "&::-webkit-slider-thumb": { + appearance: "none", + width: "[12px]", + height: "[12px]", + borderRadius: "[50%]", + background: "blue.s90", + cursor: "pointer", + }, + "&::-moz-range-thumb": { + width: "[12px]", + height: "[12px]", + borderRadius: "[50%]", + background: "blue.s90", + cursor: "pointer", + border: "none", + }, }, - "&::-moz-range-thumb": { - width: "[12px]", - height: "[12px]", - borderRadius: "[50%]", - background: "blue.s90", - cursor: "pointer", - border: "none", + variants: { + compact: { + true: { + width: "[clamp(96px, 30vw, 220px)]", + flex: "[1 1 160px]", + minWidth: "[96px]", + }, + }, }, }); -interface SimulationControlsProps { +export interface SimulationControlsProps { disabled?: boolean; inSubnet?: boolean; + allowedPlaybackSpeeds?: readonly PlaybackSpeed[]; } export const SimulationControls: React.FC = ({ disabled = false, inSubnet = false, + allowedPlaybackSpeeds, }) => { + const presentation = usePetrinautPresentation(); const { dt, state: simulationState, reset } = use(SimulationContext); const { @@ -207,8 +231,12 @@ export const SimulationControls: React.FC = ({ {hasSimulation && ( <> -
-
Frame
+
+ {!presentation.compactControls &&
Frame
}
{frameIndex + 1} / {totalFrames}
@@ -224,14 +252,14 @@ export const SimulationControls: React.FC = ({ onChange={(event) => setCurrentViewedFrame(Number(event.target.value)) } - className={sliderStyle} + className={sliderStyle({ compact: presentation.compactControls })} /> )} - + ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 7526548548e..4328f705489 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -6,6 +6,7 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { EditorContext } from "../../../../../../react/state/editor-context"; import { focusLands } from "../../../../../worksheet/focus-flow"; import { useFocusStops } from "../../../../../worksheet/use-focus-stops"; +import { usePetrinautPresentation } from "../../../../shared/presentation-context"; import { RowActionCell } from "./row-action-cell"; import type { @@ -185,6 +186,7 @@ interface FilterableListSubViewConfig { const FilterHeaderAction: React.FC<{ renderExtraAction?: () => ReactNode; }> = ({ renderExtraAction }) => { + const presentation = usePetrinautPresentation(); const { setSearchOpen } = use(EditorContext); return ( @@ -197,7 +199,9 @@ const FilterHeaderAction: React.FC<{ iconName="search" onClick={() => setSearchOpen(true)} /> - {renderExtraAction?.()} + {/* Searching a list is not a mutation, so only the extra action, which + is the list's Add button, answers to the presentation. */} + {presentation.showMutationActions && renderExtraAction?.()} ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx index de212622d96..8b21445b993 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx @@ -11,6 +11,7 @@ import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { focusLands } from "../../../../../worksheet/focus-flow"; import { useFocusStops } from "../../../../../worksheet/use-focus-stops"; +import { usePetrinautPresentation } from "../../../../shared/presentation-context"; import { RowActionCell } from "./row-action-cell"; import type { SubView } from "../../../../../components/sub-view/types"; @@ -139,12 +140,14 @@ const targetKey = (target: FocusStopTarget): string => `${target.stopId}:${target.column}`; const NetsListContent: React.FC = () => { + const presentation = usePetrinautPresentation(); const { petriNetDefinition: { subnets }, } = use(SDCPNContext); const { activeSubnetId, setActiveSubnetId } = use(ActiveNetContext); const { updateSubnet, removeSubnet } = usePetrinautMutations(); const isReadOnly = useIsReadOnly(); + const mutationActionsVisible = presentation.showMutationActions; const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(""); @@ -233,7 +236,10 @@ const NetsListContent: React.FC = () => { if ( (event.key === "Delete" || event.key === "Backspace") && stopId !== ROOT_STOP_ID && - !isReadOnly + !isReadOnly && + // A keyboard delete is a mutation, so a presentation that hides the + // delete button has to refuse the shortcut too. + mutationActionsVisible ) { event.preventDefault(); event.stopPropagation(); @@ -296,7 +302,11 @@ const NetsListContent: React.FC = () => { setActiveSubnetId(subnet.id); } }} - onDoubleClick={() => startEditing(subnet.id, subnet.name)} + onDoubleClick={() => { + if (mutationActionsVisible) { + startEditing(subnet.id, subnet.name); + } + }} onKeyDown={onRowKeyDown(subnet.id, () => setActiveSubnetId(subnet.id), )} @@ -324,7 +334,7 @@ const NetsListContent: React.FC = () => { ) : ( {subnet.name} )} - {editingId !== subnet.id && ( + {mutationActionsVisible && editingId !== subnet.id && ( , + headerActionMutates: true, defaultCollapsed: false, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx index 98d2090514e..30140ea252f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx @@ -172,6 +172,7 @@ const arcMainContentSubView: SubView = { main: true, component: ArcMainContent, renderHeaderAction: () => , + headerActionMutates: true, alwaysShowHeaderAction: true, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/component-instance-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/component-instance-properties/subviews/main.tsx index ab7ae047959..f98343a628c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/component-instance-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/component-instance-properties/subviews/main.tsx @@ -201,5 +201,6 @@ export const componentInstanceMainContentSubView: SubView = { renderTitle: () => , component: ComponentInstanceMainContent, renderHeaderAction: () => , + headerActionMutates: true, alwaysShowHeaderAction: true, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/code-field.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/code-field.tsx new file mode 100644 index 00000000000..6db3c4d6544 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/code-field.tsx @@ -0,0 +1,49 @@ +import { Form } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { useIsReadOnly } from "../../../../../../../react/state/use-is-read-only"; +import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; +import { CodeEditor } from "../../../../../../monaco/code-editor"; +import { getDocumentUri } from "../../../../../../monaco/editor-paths"; +import { useDiffEqPropertiesContext } from "../context"; + +const codeFieldStyle = css({ + display: "flex", + flexDirection: "column", + minHeight: "0", +}); + +const codeEditorBoxStyle = css({ + flex: "1", + minHeight: "0", +}); + +export const DifferentialEquationCodeField: React.FC = () => { + const { differentialEquation, updateDifferentialEquation } = + useDiffEqPropertiesContext(); + const isReadOnly = useIsReadOnly(); + + return ( + +
+ { + updateDifferentialEquation({ + equationId: differentialEquation.id, + update: { code: newCode ?? "" }, + }); + }} + options={{ readOnly: isReadOnly }} + tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} + /> +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx index d5a99c730dd..6b29e832460 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { Button, @@ -20,8 +20,7 @@ import { useIsReadOnly } from "../../../../../../../react/state/use-is-read-only import { DraftFieldInput } from "../../../../../../components/draft-field-input"; import { DifferentialEquationIcon } from "../../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; -import { CodeEditor } from "../../../../../../monaco/code-editor"; -import { getDocumentUri } from "../../../../../../monaco/editor-paths"; +import { usePetrinautPresentation } from "../../../../../shared/presentation-context"; import { useDiffEqPropertiesContext } from "../context"; import type { SubView } from "../../../../../../components/sub-view/types"; @@ -34,18 +33,6 @@ const fieldsSectionStyle = css({ gridTemplateRows: "[auto auto minmax(0, 1fr)]", }); -// the Code fieldset stretches to its grid row; hand the height to the editor -const codeFieldStyle = css({ - display: "flex", - flexDirection: "column", - minHeight: "[0]", -}); - -const codeEditorBoxStyle = css({ - flex: "[1]", - minHeight: "[0]", -}); - const colorDotStyle = css({ width: "[12px]", height: "[12px]", @@ -83,12 +70,18 @@ const arcStyle = css({ alignItems: "center", }); +const DeferredDifferentialEquationCodeField = lazy(async () => { + const { DifferentialEquationCodeField } = await import("./code-field"); + return { default: DifferentialEquationCodeField }; +}); + const DiffEqMainContent: React.FC = () => { const { differentialEquation, types, places, updateDifferentialEquation } = useDiffEqPropertiesContext(); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingTypeId, setPendingTypeId] = useState(null); const isReadOnly = useIsReadOnly(); + const presentation = usePetrinautPresentation(); const placesUsingEquation = places.filter((place) => { if (!place.differentialEquationId) { @@ -182,32 +175,11 @@ const DiffEqMainContent: React.FC = () => { - -
- { - updateDifferentialEquation({ - equationId: differentialEquation.id, - update: { code: newCode ?? "" }, - }); - }} - options={{ readOnly: isReadOnly }} - tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} - /> -
-
+ {presentation.showSourceCode && ( + + + + )} {showConfirmDialog && ( @@ -334,5 +306,6 @@ export const diffEqMainContentSubView: SubView = { main: true, component: DiffEqMainContent, renderHeaderAction: () => , + headerActionMutates: true, alwaysShowHeaderAction: true, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx index 27a4a7cba50..2fbcf02794f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx @@ -102,6 +102,7 @@ const multiSelectionMainSubView: SubView = { main: true, component: MultiSelectionContent, renderHeaderAction: () => , + headerActionMutates: true, alwaysShowHeaderAction: true, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx index 8af3fc60b0e..6ffd27abd57 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx @@ -2,10 +2,7 @@ import { use } from "react"; import { css, cva, cx } from "@hashintel/ds-helpers/css"; -import { usePetrinautMutations } from "../../../../../react"; -import { ActiveNetContext } from "../../../../../react/state/active-net-context"; import { EditorContext } from "../../../../../react/state/editor-context"; -import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { usePanelTarget } from "../../../../../react/state/use-selection"; import { UserSettingsContext } from "../../../../../react/state/user-settings-context"; import { GlassPanel } from "../../../../components/glass-panel"; @@ -14,14 +11,7 @@ import { MIN_PROPERTIES_PANEL_WIDTH, PANEL_MARGIN, } from "../../../../constants/ui"; -import { ArcProperties } from "./arc-properties/main"; -import { ComponentInstanceProperties } from "./component-instance-properties/main"; -import { DifferentialEquationProperties } from "./differential-equation-properties/main"; -import { MultiSelectionPanel } from "./multi-selection-panel"; -import { ParameterProperties } from "./parameter-properties/main"; -import { PlaceProperties } from "./place-properties/main"; -import { TransitionProperties } from "./transition-properties/main"; -import { TypeProperties } from "./type-properties/main"; +import { SelectedItemProperties } from "./selected-item-properties"; const glassPanelStyle = css({ position: "absolute", @@ -68,176 +58,10 @@ export const PropertiesPanel: React.FC = () => { isPanelAnimating, } = use(EditorContext); - const { activeNet: petriNetDefinition } = use(ActiveNetContext); - const { extensions, petriNetDefinition: fullSdcpn } = use(SDCPNContext); - const { - updatePlace, - updateTransition, - updateArcWeight, - updateArcType, - updateArcPlace, - removeArc, - updateType, - addTypeElement, - updateTypeElement, - removeTypeElement, - moveTypeElement, - updateDifferentialEquation, - updateParameter, - updateComponentInstance, - deleteItemsByIds, - } = usePetrinautMutations(); - const panelTarget = usePanelTarget(); const isOpen = panelTarget.kind !== "none"; - let content: React.ReactNode = null; - - if (panelTarget.kind === "single") { - const { item } = panelTarget; - - switch (item.type) { - case "place": { - const placeData = petriNetDefinition.places.find( - (place) => place.id === item.id, - ); - if (placeData) { - content = ( - - ); - } - break; - } - - case "transition": { - const transitionData = petriNetDefinition.transitions.find( - (transition) => transition.id === item.id, - ); - if (transitionData) { - content = ( - - ); - } - break; - } - - case "arc": { - content = ( - - ); - break; - } - - case "type": { - if (!extensions.colors) { - break; - } - const typeData = petriNetDefinition.types.find( - (type) => type.id === item.id, - ); - if (typeData) { - content = ( - - ); - } - break; - } - - case "differentialEquation": { - if (!extensions.colors || !extensions.dynamics) { - break; - } - const equationData = petriNetDefinition.differentialEquations.find( - (equation) => equation.id === item.id, - ); - if (equationData) { - content = ( - - ); - } - break; - } - - case "parameter": { - if (!extensions.parameters) { - break; - } - const parameterData = petriNetDefinition.parameters.find( - (parameter) => parameter.id === item.id, - ); - if (parameterData) { - content = ( - - ); - } - break; - } - - case "componentInstance": { - const instanceData = petriNetDefinition.componentInstances.find( - (instance) => instance.id === item.id, - ); - if (instanceData) { - const subnet = - (fullSdcpn.subnets ?? []).find( - (subnetCandidate) => subnetCandidate.id === instanceData.subnetId, - ) ?? null; - content = ( - - ); - } - break; - } - } - } else if (panelTarget.kind === "multi") { - content = ( - - ); - } - // Calculate bottom offset based on bottom panel visibility // Gap between PropertiesPanel and BottomPanel matches gap between LeftSideBar and BottomPanel const bottomOffset = isBottomPanelOpen ? bottomPanelHeight + PANEL_MARGIN : 0; @@ -268,7 +92,7 @@ export const PropertiesPanel: React.FC = () => { maxSize: MAX_PROPERTIES_PANEL_WIDTH, }} > - {content} + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx index 0f4e8fcca1b..50da8d739a0 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx @@ -4,11 +4,12 @@ import { css } from "@hashintel/ds-helpers/css"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; +import { createDeferredSubView } from "../../../../../components/sub-view/deferred-sub-view"; import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; +import { usePetrinautPresentation } from "../../../../shared/presentation-context"; import { PlacePropertiesProvider } from "./context"; import { placeMainContentSubView } from "./subviews/main"; import { placeInitialStateSubView } from "./subviews/place-initial-state/subview"; -import { placeVisualizerSubView } from "./subviews/place-visualizer/subview"; import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; @@ -26,6 +27,25 @@ const baseSubViews: SubView[] = [ placeInitialStateSubView, ]; +const placeVisualizerSubView = createDeferredSubView({ + id: "place-visualizer", + headerActionMutates: true, + title: "Visualizer", + tooltip: + "Custom visualization of tokens in this place, defined by visualizer code.", + defaultCollapsed: true, + alwaysShowHeaderAction: true, + hasHeaderAction: true, + resizable: { + minHeight: 200, + maxHeight: 1200, + defaultHeight: 300, + }, + load: async () => + (await import("./subviews/place-visualizer/subview")) + .placeVisualizerSubView, +}); + interface PlacePropertiesProps { place: Place; types: Color[]; @@ -38,15 +58,17 @@ export const PlaceProperties: React.FC = ({ updatePlace, }) => { const isReadOnly = useIsReadOnly(); + const presentation = usePetrinautPresentation(); const { extensions } = use(SDCPNContext); const placeType = extensions.colors && place.colorId ? (types.find((tp) => tp.id === place.colorId) ?? null) : null; - const subViews = extensions.colors - ? [...baseSubViews, placeVisualizerSubView] - : baseSubViews; + const subViews = + extensions.colors && presentation.showSourceCode + ? [...baseSubViews, placeVisualizerSubView] + : baseSubViews; return (
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx index 0aba8a19bc2..bfd741128cf 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx @@ -494,5 +494,6 @@ export const placeMainContentSubView: SubView = { main: true, component: PlaceMainContent, renderHeaderAction: () => , + headerActionMutates: true, alwaysShowHeaderAction: true, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx index e32baf7dd12..0cf379ffa15 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx @@ -12,6 +12,7 @@ import { css } from "@hashintel/ds-helpers/css"; import { PlaybackContext } from "../../../../../../../../react/playback/context"; import { SimulationContext } from "../../../../../../../../react/simulation/context"; import { UI_MESSAGES } from "../../../../../../../constants/ui-messages"; +import { usePetrinautPresentation } from "../../../../../../shared/presentation-context"; import { usePlacePropertiesContext } from "../../context"; import { InitialStateEditor } from "./initial-state-editor"; @@ -38,6 +39,7 @@ const scenarioInfoStyle = css({ * Only shown when not in simulation mode and there's data to clear. */ const ClearStateHeaderAction: React.FC = () => { + const presentation = usePetrinautPresentation(); const { place, placeType } = usePlacePropertiesContext(); const { state, initialMarking, setInitialMarking, selectedScenarioId } = use(SimulationContext); @@ -65,6 +67,13 @@ const ClearStateHeaderAction: React.FC = () => { return null; } + // This slot carries a status above and a mutation here, so the sub-view + // cannot declare itself one or the other: only the button goes when a + // presentation hides mutation actions. + if (!presentation.showMutationActions) { + return null; + } + const handleClear = () => { setInitialMarking( place.id, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/selected-item-properties.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/selected-item-properties.tsx new file mode 100644 index 00000000000..c59e41473c1 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/selected-item-properties.tsx @@ -0,0 +1,174 @@ +import { use } from "react"; + +import { usePetrinautMutations } from "../../../../../react"; +import { ActiveNetContext } from "../../../../../react/state/active-net-context"; +import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; +import { usePanelTarget } from "../../../../../react/state/use-selection"; +import { ArcProperties } from "./arc-properties/main"; +import { ComponentInstanceProperties } from "./component-instance-properties/main"; +import { DifferentialEquationProperties } from "./differential-equation-properties/main"; +import { MultiSelectionPanel } from "./multi-selection-panel"; +import { ParameterProperties } from "./parameter-properties/main"; +import { PlaceProperties } from "./place-properties/main"; +import { TransitionProperties } from "./transition-properties/main"; +import { TypeProperties } from "./type-properties/main"; + +/** + * Resolves the editor selection to the existing entity-specific property + * content. Layout shells (such as the resizable editor panel) + * intentionally share this component so their entity rendering cannot drift. + */ +export const SelectedItemProperties: React.FC = () => { + const { activeNet: petriNetDefinition } = use(ActiveNetContext); + const { extensions, petriNetDefinition: fullSdcpn } = use(SDCPNContext); + const { + updatePlace, + updateTransition, + updateArcWeight, + updateArcType, + updateArcPlace, + removeArc, + updateType, + addTypeElement, + updateTypeElement, + removeTypeElement, + moveTypeElement, + updateDifferentialEquation, + updateParameter, + updateComponentInstance, + deleteItemsByIds, + } = usePetrinautMutations(); + const panelTarget = usePanelTarget(); + + if (panelTarget.kind === "single") { + const { item } = panelTarget; + + switch (item.type) { + case "place": { + const place = petriNetDefinition.places.find( + (candidate) => candidate.id === item.id, + ); + return place ? ( + + ) : null; + } + + case "transition": { + const transition = petriNetDefinition.transitions.find( + (candidate) => candidate.id === item.id, + ); + return transition ? ( + + ) : null; + } + + case "arc": + return ( + + ); + + case "type": { + if (!extensions.colors) { + return null; + } + const type = petriNetDefinition.types.find( + (candidate) => candidate.id === item.id, + ); + return type ? ( + + ) : null; + } + + case "differentialEquation": { + if (!extensions.colors || !extensions.dynamics) { + return null; + } + const differentialEquation = + petriNetDefinition.differentialEquations.find( + (candidate) => candidate.id === item.id, + ); + return differentialEquation ? ( + + ) : null; + } + + case "parameter": { + if (!extensions.parameters) { + return null; + } + const parameter = petriNetDefinition.parameters.find( + (candidate) => candidate.id === item.id, + ); + return parameter ? ( + + ) : null; + } + + case "componentInstance": { + const instance = petriNetDefinition.componentInstances.find( + (candidate) => candidate.id === item.id, + ); + if (!instance) { + return null; + } + const subnet = + (fullSdcpn.subnets ?? []).find( + (candidate) => candidate.id === instance.subnetId, + ) ?? null; + return ( + + ); + } + } + } + + if (panelTarget.kind === "multi") { + return ( + + ); + } + + return null; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/main.tsx index 0eddd890522..7414f13d82b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/main.tsx @@ -5,14 +5,14 @@ import { getTransitionLogicAvailability } from "@hashintel/petrinaut-core"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; +import { createDeferredSubView } from "../../../../../components/sub-view/deferred-sub-view"; import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; +import { usePetrinautPresentation } from "../../../../shared/presentation-context"; import { TransitionPropertiesProvider, type TransitionLogicNet, } from "./context"; import { transitionMainContentSubView } from "./subviews/main"; -import { transitionFiringTimeSubView } from "./subviews/transition-firing-time/subview"; -import { transitionResultsSubView } from "./subviews/transition-results/subview"; import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; @@ -25,6 +25,42 @@ const containerStyle = css({ minHeight: "[0]", }); +const transitionFiringTimeSubView = createDeferredSubView({ + id: "transition-firing-time", + headerActionMutates: true, + title: "Firing Time", + defaultCollapsed: true, + tooltip: + "Define the rate at or conditions under which this transition will fire, optionally based on each set of input tokens' data (where input tokens have types).", + hasHeaderAction: true, + resizable: { + minHeight: 250, + maxHeight: 1200, + defaultHeight: 300, + }, + load: async () => + (await import("./subviews/transition-firing-time/subview")) + .transitionFiringTimeSubView, +}); + +const transitionResultsSubView = createDeferredSubView({ + id: "transition-results", + headerActionMutates: true, + title: "Transition Results", + defaultCollapsed: true, + tooltip: + "This function determines the data for output tokens, optionally based on the input token data and any global parameters defined.", + hasHeaderAction: true, + resizable: { + minHeight: 300, + maxHeight: 1200, + defaultHeight: 500, + }, + load: async () => + (await import("./subviews/transition-results/subview")) + .transitionResultsSubView, +}); + interface TransitionPropertiesProps { transition: Transition; net: TransitionLogicNet; @@ -47,6 +83,7 @@ export const TransitionProperties: React.FC = ({ removeArc, }) => { const isReadOnly = useIsReadOnly(); + const presentation = usePetrinautPresentation(); const { extensions, petriNetDefinition } = use(SDCPNContext); const logicAvailability = getTransitionLogicAvailability( transition, @@ -57,8 +94,12 @@ export const TransitionProperties: React.FC = ({ const subViews: SubView[] = [ transitionMainContentSubView, - ...(logicAvailability.lambda ? [transitionFiringTimeSubView] : []), - ...(logicAvailability.transitionKernel ? [transitionResultsSubView] : []), + ...(presentation.showSourceCode && logicAvailability.lambda + ? [transitionFiringTimeSubView] + : []), + ...(presentation.showSourceCode && logicAvailability.transitionKernel + ? [transitionResultsSubView] + : []), ]; return ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx index 2f8f91ed198..59022cb2f49 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx @@ -302,6 +302,7 @@ export const transitionMainContentSubView: SubView = { main: true, component: TransitionMainContent, renderHeaderAction: () => , + headerActionMutates: true, alwaysShowHeaderAction: true, resizable: { minHeight: 100, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx index 7d361614b3b..2aa97bfb145 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx @@ -21,6 +21,7 @@ import { DraftFieldInput } from "../../../../../../components/draft-field-input" import { SectionList } from "../../../../../../components/section"; import { TokenTypeIcon } from "../../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; +import { usePetrinautPresentation } from "../../../../../shared/presentation-context"; import { ColorSelect } from "../color-select"; import { useTypePropertiesContext } from "../context"; @@ -185,6 +186,7 @@ const TypeMainContent: React.FC = () => { moveTypeElement, } = useTypePropertiesContext(); const isDisabled = useIsReadOnly(); + const presentation = usePetrinautPresentation(); const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); const [elementNameInputs, setElementNameInputs] = @@ -381,29 +383,33 @@ const TypeMainContent: React.FC = () => { disabled={isDisabled} labelTooltip="A type is an ordered tuple of token attributes. Real attributes can be updated by dynamics; integer and boolean attributes are discrete." labelActions={ -
))}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts index 40905368c1a..1ef0ef14fdb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts @@ -25,6 +25,8 @@ export type CanvasController = { ) => void; zoomIn: () => void; zoomOut: () => void; + /** Frames the whole scene, easing the move when the renderer supports it. */ + fitView: () => void; /** Client (viewport-relative screen) coordinates to scene coordinates. */ screenToScene: (point: CanvasPoint) => CanvasPoint; sceneToScreen: (point: CanvasPoint) => CanvasPoint; 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 d51aac5a2c3..228ca2b09b1 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 @@ -7,6 +7,7 @@ import { usePetrinautNavigation } from "../../../../react/navigation"; import { EditorContext } from "../../../../react/state/editor-context"; import { VIEWPORT_CONTROLS_OFFSET } from "../../../constants/ui"; import { useCanvasInsets } from "../../../hooks/use-canvas-insets"; +import { usePetrinautPresentation } from "../../shared/presentation-context"; import { useCanvasController } from "../canvas-renderer"; import { ViewportSettingsDialog } from "./viewport-settings-dialog"; @@ -36,6 +37,7 @@ const blurredBackground = css({ backdropFilter: "[blur(10px)]" }); export const ViewportControls: React.FC<{ viewportActions?: ViewportAction[]; }> = ({ viewportActions }) => { + const presentation = usePetrinautPresentation(); const navigation = usePetrinautNavigation(); const isSettingsOpen = navigation.state.overlay?.type === "viewport-settings"; const setIsSettingsOpen = (open: boolean) => { @@ -44,7 +46,10 @@ export const ViewportControls: React.FC<{ { cause: "user", action: "overlay" }, ); }; - const { zoomIn, zoomOut } = useCanvasController(); + // Fit view goes through the canvas controller like the zooms: these + // controls sit above the renderer contract and must not reach for a + // renderer's own API. + const { fitView, zoomIn, zoomOut } = useCanvasController(); const { collapseAllPanels, isPanelAnimating } = use(EditorContext); // Shared with the bottom toolbar, so the two keep clear of the same panels @@ -84,39 +89,53 @@ export const ViewportControls: React.FC<{