diff --git a/.changeset/heavy-terms-refuse.md b/.changeset/heavy-terms-refuse.md new file mode 100644 index 00000000000..a92d4a4ebb4 --- /dev/null +++ b/.changeset/heavy-terms-refuse.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": minor +--- + +Let minZoom be dynamically based on the size of the net diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 5760ef18e4e..a78f77208a9 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -47,6 +47,7 @@ "d3-scale": "4.0.2", "elkjs": "0.11.0", "fuzzysort": "3.1.0", + "lodash-es": "4.18.1", "monaco-editor": "0.55.1", "react-icons": "5.5.0", "react-resizable-panels": "4.6.5", @@ -66,6 +67,7 @@ "@testing-library/react": "16.3.2", "@types/babel__standalone": "7.1.9", "@types/d3-scale": "4.0.9", + "@types/lodash-es": "4.17.12", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260315.1", diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/util/use-debounce-callback.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/util/use-debounce-callback.tsx new file mode 100644 index 00000000000..17a15ef2920 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/util/use-debounce-callback.tsx @@ -0,0 +1,20 @@ +import { debounce, type DebouncedFunc } from "lodash-es"; +import { useEffect, useMemo } from "react"; + +// debounces a function, holding the debounced function between re-renders +// when unmounting, we flush any unresolved debounced calls when updating the debounced +// function or unmounting to avoid resolving on stale data or refs +export function useDebounceCallback< + // eslint-disable-next-line typescript-eslint/no-explicit-any + T extends (...args: any) => ReturnType, +>(func: T, delay = 500): DebouncedFunc { + const debounced = useMemo(() => debounce(func, delay), [func, delay]); + + useEffect(() => { + return () => { + debounced.flush(); + }; + }, [func, delay]); + + return debounced; +} diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/util/use-resize-observer.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/util/use-resize-observer.tsx new file mode 100644 index 00000000000..45c821929ab --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/util/use-resize-observer.tsx @@ -0,0 +1,19 @@ +import { useEffect, type RefObject } from "react"; + +// sets up a resize observer on an element +export function useResizeObserver( + elementRef: RefObject, + func: (entries?: ResizeObserverEntry[]) => void, +): void { + useEffect(() => { + if (elementRef.current) { + const resizeObserver = new ResizeObserver(func); + + resizeObserver.observe(elementRef.current); + + return () => { + resizeObserver.disconnect(); + }; + } + }, [elementRef, func]); +} diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx index 669726bbaa7..f2e206e65c7 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx @@ -1,6 +1,8 @@ import "@xyflow/react/dist/style.css"; import { css } from "@hashintel/ds-helpers/css"; +import { useResizeObserver } from "./hooks/util/use-resize-observer"; +import { useDebounceCallback } from "./hooks/util/use-debounce-callback"; import type { Connection } from "@xyflow/react"; import { Background, ReactFlow, SelectionMode } from "@xyflow/react"; import { use, useEffect, useMemo, useRef, useState } from "react"; @@ -43,6 +45,8 @@ const REACTFLOW_EDGE_TYPES = { default: Arc, }; +const ZOOM_PADDING = 0.5; + const canvasContainerStyle = css({ width: "[100%]", height: "[100%]", @@ -69,6 +73,9 @@ export const SDCPNView: React.FC<{ () => (compactNodes ? COMPACT_NODE_TYPES : CLASSIC_NODE_TYPES), [compactNodes], ); + // min-zoom 0 allows a user to zoom out infinitely. We later constrain this to be slightly larger than the nodes present + // in the net, but default to 0 until we can measure the viewport height + const [minZoom, setMinZoom] = useState(0); // SDCPN store const { petriNetId } = use(SDCPNContext); @@ -91,12 +98,47 @@ export const SDCPNView: React.FC<{ // Center viewport on SDCPN load useEffect(() => { void reactFlowInstance?.fitView({ - padding: 0.4, - minZoom: 0.4, + padding: ZOOM_PADDING, + minZoom: minZoom, maxZoom: 1.1, }); }, [reactFlowInstance, petriNetId]); + // This sets the min zoom (ie the max you can zoom out to) to be slightly larger than the total size of the current net. + // We also avoid shrinking the zoom to be lower than the current zoom level to avoid changing the zoom without user input + const fitZoomToNodes = useDebounceCallback( + ( + instance: PetrinautReactFlowInstance | null, + canvasEl: React.RefObject, + ) => { + const nodesSize = instance?.getNodesBounds(instance.getNodes()); + const viewportSize = canvasEl.current?.getBoundingClientRect(); + + if (viewportSize && nodesSize) { + // Specifically check that the height and width are not 0. If the net is empty/size 0, use a default minZoom of 0.5 + // otherwise, set the minZoom to the size of the net with some extra padding + const newZoom = + nodesSize.height && nodesSize.width + ? Math.min( + viewportSize.height / nodesSize.height, + viewportSize.width / nodesSize.width, + ) * ZOOM_PADDING + : 0.5; + + // Don't reduce the zoom level below the users current zoom + const currentZoom = instance?.getViewport().zoom; + const safeZoom = currentZoom ? Math.min(currentZoom, newZoom) : newZoom; + + setMinZoom(safeZoom); + } + }, + 100, + ); + + useResizeObserver(canvasContainer, () => { + fitZoomToNodes(reactFlowInstance, canvasContainer); + }); + const isReadonly = useIsReadOnly(); function isValidConnection(connection: Connection) { @@ -141,6 +183,7 @@ export const SDCPNView: React.FC<{ function onInit(instance: PetrinautReactFlowInstance) { setReactFlowInstance(instance); + fitZoomToNodes(instance, canvasContainer); } // Shared function to create a node at a given position @@ -314,7 +357,10 @@ export const SDCPNView: React.FC<{ edges={arcs} nodeTypes={nodeTypes} edgeTypes={REACTFLOW_EDGE_TYPES} - onNodesChange={applyNodeChanges} + onNodesChange={(n) => { + applyNodeChanges(n); + fitZoomToNodes(reactFlowInstance, canvasContainer); + }} onEdgesChange={applyNodeChanges} onConnect={isReadonly ? undefined : onConnect} onInit={onInit} @@ -322,6 +368,9 @@ export const SDCPNView: React.FC<{ onPaneClick={onPaneClick} onDrop={isReadonly ? undefined : onDrop} onDragOver={isReadonly ? undefined : onDragOver} + onViewportChange={() => { + fitZoomToNodes(reactFlowInstance, canvasContainer); + }} defaultViewport={{ x: 0, y: 0, zoom: 1 }} proOptions={{ hideAttribution: true }} panOnDrag={isPanMode ? true : isAddMode ? false : [1, 2]} @@ -337,6 +386,7 @@ export const SDCPNView: React.FC<{ deleteKeyCode={null} panOnScroll={false} zoomOnScroll + minZoom={minZoom} > {showMinimap && } diff --git a/yarn.lock b/yarn.lock index 2e209585c3e..fec629fe40e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7807,6 +7807,7 @@ __metadata: "@testing-library/react": "npm:16.3.2" "@types/babel__standalone": "npm:7.1.9" "@types/d3-scale": "npm:4.0.9" + "@types/lodash-es": "npm:4.17.12" "@types/react": "npm:19.2.7" "@types/react-dom": "npm:19.2.3" "@typescript/native-preview": "npm:7.0.0-dev.20260315.1" @@ -7817,6 +7818,7 @@ __metadata: elkjs: "npm:0.11.0" fuzzysort: "npm:3.1.0" jsdom: "npm:24.1.3" + lodash-es: "npm:4.18.1" monaco-editor: "npm:0.55.1" oxlint: "npm:1.55.0" oxlint-tsgolint: "npm:0.17.0"