diff --git a/.changeset/canvas-single-render.md b/.changeset/canvas-single-render.md new file mode 100644 index 00000000000..0d17561c359 --- /dev/null +++ b/.changeset/canvas-single-render.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +The canvas renders centered on the net from its first frame, instead of jumping there after a first paint at the origin. Component instances grow with their port count so their ports have room, and auto-layout on import no longer depends on the compact/classic setting. diff --git a/.changeset/core-owns-canvas-geometry.md b/.changeset/core-owns-canvas-geometry.md new file mode 100644 index 00000000000..98e6b8c0abf --- /dev/null +++ b/.changeset/core-owns-canvas-geometry.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +The layout module exports the canvas geometry: render node dimensions (`compactNodeDimensions`, `classicNodeDimensions`, `getComponentInstanceHeight`), net bounds (`getBoundsOfCenteredBoxes`) and zoom limits (`getMinZoomForBounds`, `ZOOM_PADDING`). `layoutNodeDimensions` is now derived from the render dimensions instead of maintained by hand. diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 4c43f6501e0..ac0830fd3a4 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -156,9 +156,19 @@ export type { export { mutationActionInputSchemas } from "./action-schemas"; export { calculateGraphLayout, + classicNodeDimensions, + compactNodeDimensions, + getBoundsOfCenteredBoxes, + getComponentInstanceHeight, + getMinZoomForBounds, layoutNodeDimensions, type LayoutDimensions, + type NodeDimensions, type NodePosition, + type Rect, + type RenderNodeDimensions, + type Size, + ZOOM_PADDING, } from "./layout"; // --- AI --- diff --git a/libs/@hashintel/petrinaut-core/src/layout/dimensions.test.ts b/libs/@hashintel/petrinaut-core/src/layout/dimensions.test.ts new file mode 100644 index 00000000000..150ab23619e --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/layout/dimensions.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { + classicNodeDimensions, + compactNodeDimensions, + getComponentInstanceHeight, + layoutNodeDimensions, +} from "./dimensions"; + +describe("getComponentInstanceHeight", () => { + it("uses the base height for few ports", () => { + expect(getComponentInstanceHeight(compactNodeDimensions, 0)).toBe(96); + expect(getComponentInstanceHeight(compactNodeDimensions, 2)).toBe(96); + }); + + it("grows with the port count", () => { + expect(getComponentInstanceHeight(compactNodeDimensions, 3)).toBe(112); + expect(getComponentInstanceHeight(compactNodeDimensions, 10)).toBe(308); + }); +}); + +describe("layoutNodeDimensions", () => { + it("is the per-axis maximum of the compact and classic dimensions", () => { + for (const kind of ["place", "transition", "componentInstance"] as const) { + expect(layoutNodeDimensions[kind]).toEqual({ + width: Math.max( + compactNodeDimensions[kind].width, + classicNodeDimensions[kind].width, + ), + height: Math.max( + compactNodeDimensions[kind].height, + classicNodeDimensions[kind].height, + ), + }); + } + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts index 8c741c8c3c1..cfa208583ba 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts @@ -1,16 +1,76 @@ import type { LayoutDimensions } from "./calculate-graph-layout"; +export type NodeDimensions = { width: number; height: number }; + +/** + * Dimensions for every node kind the canvas renders. + */ +export type RenderNodeDimensions = { + place: NodeDimensions; + transition: NodeDimensions; + componentInstance: NodeDimensions; +}; + +/** + * How nodes are drawn on the canvas, per visualization mode + * (`userSettings.compactNodes`). The canvas renders nodes at exactly these + * sizes and derives bounds and viewport math from them, so React Flow never + * has to measure the DOM. + */ +export const compactNodeDimensions: RenderNodeDimensions = { + place: { width: 180, height: 50 }, + transition: { width: 180, height: 50 }, + componentInstance: { width: 180, height: 96 }, +}; + +export const classicNodeDimensions: RenderNodeDimensions = { + place: { width: 130, height: 130 }, + transition: { width: 160, height: 80 }, + componentInstance: { width: 180, height: 96 }, +}; + +const PORT_ROW_HEIGHT = 28; + +/** + * Component instances grow vertically with their port count, so the ports + * have room to spread along the node's edge. + */ +export const getComponentInstanceHeight = ( + dimensions: RenderNodeDimensions, + portCount: number, +): number => + Math.max( + dimensions.componentInstance.height, + (portCount + 1) * PORT_ROW_HEIGHT, + ); + +const maxDimensions = ( + first: NodeDimensions, + second: NodeDimensions, +): NodeDimensions => ({ + width: Math.max(first.width, second.width), + height: Math.max(first.height, second.height), +}); + /** * Layout-stable node dimensions used by {@link calculateGraphLayout}. * - * Per-axis maximum of the compact and classic rendering dimensions (see - * `ui/views/SDCPN/node-dimensions.ts`) so auto-layout output is invariant to - * the user's compact/classic visualization choice. Without this, toggling - * `userSettings.compactNodes` after running layout would visually shift every - * node. + * Per-axis maximum of the compact and classic rendering dimensions, so + * auto-layout output is invariant to the user's compact/classic visualization + * choice. Without this, toggling `userSettings.compactNodes` after running + * layout would visually shift every node. */ export const layoutNodeDimensions: LayoutDimensions = { - place: { width: 180, height: 130 }, - transition: { width: 180, height: 80 }, - componentInstance: { width: 180, height: 120 }, + place: maxDimensions( + compactNodeDimensions.place, + classicNodeDimensions.place, + ), + transition: maxDimensions( + compactNodeDimensions.transition, + classicNodeDimensions.transition, + ), + componentInstance: maxDimensions( + compactNodeDimensions.componentInstance, + classicNodeDimensions.componentInstance, + ), }; diff --git a/libs/@hashintel/petrinaut-core/src/layout/geometry.test.ts b/libs/@hashintel/petrinaut-core/src/layout/geometry.test.ts new file mode 100644 index 00000000000..173d9afc504 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/layout/geometry.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { + getBoundsOfCenteredBoxes, + getMinZoomForBounds, + ZOOM_PADDING, +} from "./geometry"; + +describe("getBoundsOfCenteredBoxes", () => { + it("returns null for no boxes", () => { + expect(getBoundsOfCenteredBoxes([])).toBeNull(); + }); + + it("spans a single box around its center", () => { + expect( + getBoundsOfCenteredBoxes([ + { position: { x: 100, y: 40 }, width: 180, height: 50 }, + ]), + ).toEqual({ x: 10, y: 15, width: 180, height: 50 }); + }); + + it("spans multiple boxes", () => { + expect( + getBoundsOfCenteredBoxes([ + { position: { x: 0, y: 0 }, width: 100, height: 20 }, + { position: { x: 200, y: 100 }, width: 40, height: 40 }, + ]), + ).toEqual({ x: -50, y: -10, width: 270, height: 130 }); + }); + + it("treats boxes with unknown size as points", () => { + expect( + getBoundsOfCenteredBoxes([ + { position: { x: -5, y: 5 } }, + { position: { x: 5, y: -5 } }, + ]), + ).toEqual({ x: -5, y: -5, width: 10, height: 10 }); + }); +}); + +describe("getMinZoomForBounds", () => { + const viewport = { width: 1000, height: 500 }; + + it("scales the fit zoom by the padding factor on the limiting axis", () => { + const bounds = { x: 0, y: 0, width: 4000, height: 1000 }; + // Width is limiting: 1000 / 4000 = 0.25, then * ZOOM_PADDING. + expect(getMinZoomForBounds(bounds, viewport)).toBeCloseTo( + 0.25 * ZOOM_PADDING, + ); + }); + + it("defaults to 0.5 when there are no bounds", () => { + expect(getMinZoomForBounds(null, viewport)).toBe(0.5); + }); + + it("defaults to 0.5 when the bounds have no area", () => { + expect( + getMinZoomForBounds({ x: 0, y: 0, width: 0, height: 0 }, viewport), + ).toBe(0.5); + }); + + it("caps the result so small nets still allow zooming out", () => { + const bounds = { x: 0, y: 0, width: 180, height: 50 }; + expect(getMinZoomForBounds(bounds, viewport)).toBe(0.75); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/layout/geometry.ts b/libs/@hashintel/petrinaut-core/src/layout/geometry.ts new file mode 100644 index 00000000000..5d50a8d45be --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/layout/geometry.ts @@ -0,0 +1,71 @@ +export type Size = { width: number; height: number }; + +export type Rect = { x: number; y: number; width: number; height: number }; + +type CenteredBox = { + position: { x: number; y: number }; + width?: number; + height?: number; +}; + +/** + * Bounding box of boxes positioned by their center point (the SDCPN + * convention for node positions). Boxes with unknown size count as points. + * Returns null when there are no boxes. + */ +export const getBoundsOfCenteredBoxes = ( + boxes: readonly CenteredBox[], +): Rect | null => { + if (boxes.length === 0) { + return null; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const box of boxes) { + const halfWidth = (box.width ?? 0) / 2; + const halfHeight = (box.height ?? 0) / 2; + minX = Math.min(minX, box.position.x - halfWidth); + minY = Math.min(minY, box.position.y - halfHeight); + maxX = Math.max(maxX, box.position.x + halfWidth); + maxY = Math.max(maxY, box.position.y + halfHeight); + } + + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +}; + +/** + * Padding factor shared by the canvas fit and zoom limits: an initial fit + * leaves this much viewport around the net, and at minimum zoom the net + * occupies this fraction of the viewport's limiting axis. + */ +export const ZOOM_PADDING = 0.4; + +/** Zoom floor while the net is empty or has no area to fit. */ +const EMPTY_BOUNDS_MIN_ZOOM = 0.5; + +/** Even a tiny net must allow zooming out a reasonable amount. */ +const MIN_ZOOM_CEILING = 0.75; + +/** + * The lowest zoom the user may reach for the given net bounds: the zoom at + * which the net occupies {@link ZOOM_PADDING} of the viewport's limiting + * axis, capped at {@link MIN_ZOOM_CEILING}. + */ +export const getMinZoomForBounds = ( + bounds: Rect | null, + viewport: Size, +): number => { + const zoomShowingWholeNet = + bounds && bounds.width > 0 && bounds.height > 0 + ? Math.min( + viewport.width / bounds.width, + viewport.height / bounds.height, + ) * ZOOM_PADDING + : EMPTY_BOUNDS_MIN_ZOOM; + + return Math.min(zoomShowingWholeNet, MIN_ZOOM_CEILING); +}; diff --git a/libs/@hashintel/petrinaut-core/src/layout/index.ts b/libs/@hashintel/petrinaut-core/src/layout/index.ts index ce337ba2f01..85065bfb6a6 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/index.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/index.ts @@ -1,6 +1,6 @@ /** * @layerRoot core.layout - * @role Computes node positions for a net, so auto-layout does not require the canvas + * @role Computes node positions, dimensions and canvas geometry for a net, so neither auto-layout nor viewport math requires the canvas */ export { @@ -8,4 +8,18 @@ export { type LayoutDimensions, type NodePosition, } from "./calculate-graph-layout"; -export { layoutNodeDimensions } from "./dimensions"; +export { + classicNodeDimensions, + compactNodeDimensions, + getComponentInstanceHeight, + layoutNodeDimensions, + type NodeDimensions, + type RenderNodeDimensions, +} from "./dimensions"; +export { + getBoundsOfCenteredBoxes, + getMinZoomForBounds, + type Rect, + type Size, + ZOOM_PADDING, +} from "./geometry"; diff --git a/libs/@hashintel/petrinaut/src/ui/lib/viewport.test.ts b/libs/@hashintel/petrinaut/src/ui/lib/viewport.test.ts index ffab178b6a7..5b76dbda623 100644 --- a/libs/@hashintel/petrinaut/src/ui/lib/viewport.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/lib/viewport.test.ts @@ -1,20 +1,26 @@ -import { getNodesBounds } from "@xyflow/react"; import { describe, expect, it } from "vitest"; -import { recenterToFitViewport } from "./viewport"; +import { + getInitialViewport, + MAX_FIT_ZOOM, + recenterToFitViewport, +} from "./viewport"; -import type { - PetrinautReactFlowInstance, - NodeType, -} from "../views/SDCPN/reactflow-types"; +import type { NodeType } from "../views/SDCPN/reactflow-types"; -const reactFlow = { getNodesBounds } as PetrinautReactFlowInstance; - -const makeNode = (x: number, y: number, width: number, height: number) => +/** Nodes are positioned by their center point (`nodeOrigin` [0.5, 0.5]). */ +const makeNode = ( + centerX: number, + centerY: number, + width: number, + height: number, +) => ({ - id: `node-${x}-${y}`, - position: { x, y }, + id: `node-${centerX}-${centerY}`, + position: { x: centerX, y: centerY }, data: {}, + width, + height, measured: { width, height }, }) as NodeType; @@ -22,80 +28,129 @@ const viewport = { x: 0, y: 0, width: 500, height: 400 }; describe("recenterToFitViewport", () => { it("returns undefined when nodes are fully inside viewport", () => { - const nodes = [makeNode(50, 50, 100, 80)]; - expect(recenterToFitViewport(reactFlow, viewport, nodes)).toBeUndefined(); + const nodes = [makeNode(100, 90, 100, 80)]; + expect(recenterToFitViewport(viewport, nodes)).toBeUndefined(); + }); + + it("returns undefined when there are no nodes", () => { + expect(recenterToFitViewport(viewport, [])).toBeUndefined(); }); it("returns adjustment when nodes overflow to the right", () => { - const nodes = [makeNode(450, 50, 100, 80)]; + const nodes = [makeNode(500, 90, 100, 80)]; // Node right edge is 550, viewport right is 500 → overflow right by 50 - const result = recenterToFitViewport(reactFlow, viewport, nodes); + const result = recenterToFitViewport(viewport, nodes); expect(result).toBeDefined(); expect(result!.x).toBe(50); expect(result!.y).toBe(0); }); it("returns adjustment when nodes overflow to the left", () => { - const nodes = [makeNode(-30, 50, 20, 80)]; + const nodes = [makeNode(-20, 90, 20, 80)]; // Node left edge is -30, viewport left is 0 → overflow left by 30 - const result = recenterToFitViewport(reactFlow, viewport, nodes); + const result = recenterToFitViewport(viewport, nodes); expect(result).toBeDefined(); expect(result!.x).toBe(-30); expect(result!.y).toBe(0); }); it("returns adjustment when nodes overflow the bottom", () => { - const nodes = [makeNode(50, 350, 80, 100)]; + const nodes = [makeNode(90, 400, 80, 100)]; // Node bottom edge is 450, viewport bottom is 400 → overflow bottom by 50 - const result = recenterToFitViewport(reactFlow, viewport, nodes); + const result = recenterToFitViewport(viewport, nodes); expect(result).toBeDefined(); expect(result!.x).toBe(0); expect(result!.y).toBe(50); }); it("returns adjustment when nodes overflow the top", () => { - const nodes = [makeNode(50, -40, 80, 20)]; + const nodes = [makeNode(90, -30, 80, 20)]; // Node top edge is -40, viewport top is 0 → overflow top by 40 - const result = recenterToFitViewport(reactFlow, viewport, nodes); + const result = recenterToFitViewport(viewport, nodes); expect(result).toBeDefined(); expect(result!.x).toBe(0); expect(result!.y).toBe(-40); }); it("returns adjustment for diagonal overflow (right + bottom)", () => { - const nodes = [makeNode(420, 330, 100, 100)]; + const nodes = [makeNode(470, 380, 100, 100)]; // Right overflow: 520-500=20, Bottom overflow: 430-400=30 - const result = recenterToFitViewport(reactFlow, viewport, nodes); + const result = recenterToFitViewport(viewport, nodes); expect(result).toBeDefined(); expect(result!.x).toBe(20); expect(result!.y).toBe(30); }); it("returns undefined when nodes are too large to fit", () => { - const nodes = [makeNode(0, 0, 600, 500)]; + const nodes = [makeNode(300, 250, 600, 500)]; // 600 > 500 width, 500 > 400 height — can't fit - expect(recenterToFitViewport(reactFlow, viewport, nodes)).toBeUndefined(); + expect(recenterToFitViewport(viewport, nodes)).toBeUndefined(); }); it("returns undefined when nodes exactly match viewport size", () => { // canFitInViewport uses strict <, so equal size means it can't fit - const nodes = [makeNode(-10, -10, 500, 400)]; - expect(recenterToFitViewport(reactFlow, viewport, nodes)).toBeUndefined(); + const nodes = [makeNode(240, 190, 500, 400)]; + expect(recenterToFitViewport(viewport, nodes)).toBeUndefined(); }); it("handles multiple nodes whose combined bounds overflow", () => { - const nodes = [makeNode(-20, 50, 40, 40), makeNode(480, 50, 30, 40)]; + const nodes = [makeNode(0, 70, 40, 40), makeNode(495, 70, 30, 40)]; // Combined bounds: x=-20..510, y=50..90 → width=530 > 500, won't fit - expect(recenterToFitViewport(reactFlow, viewport, nodes)).toBeUndefined(); + expect(recenterToFitViewport(viewport, nodes)).toBeUndefined(); }); it("handles multiple nodes that fit but are partially offscreen", () => { - const nodes = [makeNode(-20, 50, 40, 40), makeNode(200, 50, 30, 40)]; - // Combined bounds: x=-20..240, y=50..90 → width=260, height=40 — fits + const nodes = [makeNode(0, 70, 40, 40), makeNode(215, 70, 30, 40)]; + // Combined bounds: x=-20..230, y=50..90 → width=250, height=40 — fits // Left overflow: -20 - const result = recenterToFitViewport(reactFlow, viewport, nodes); + const result = recenterToFitViewport(viewport, nodes); expect(result).toBeDefined(); expect(result!.x).toBe(-20); expect(result!.y).toBe(0); }); }); + +describe("getInitialViewport", () => { + const container = { width: 1000, height: 500 }; + + it("falls back to the origin at zoom 1 when there is nothing to fit", () => { + expect(getInitialViewport(null, container)).toEqual({ + x: 0, + y: 0, + zoom: 1, + }); + expect( + getInitialViewport({ x: 10, y: 10, width: 0, height: 0 }, container), + ).toEqual({ x: 0, y: 0, zoom: 1 }); + }); + + it("centers the bounds in the container", () => { + const bounds = { x: 100, y: 200, width: 4000, height: 1000 }; + const { x, y, zoom } = getInitialViewport(bounds, container); + + const boundsCenterX = bounds.x + bounds.width / 2; + const boundsCenterY = bounds.y + bounds.height / 2; + expect(boundsCenterX * zoom + x).toBeCloseTo(container.width / 2); + expect(boundsCenterY * zoom + y).toBeCloseTo(container.height / 2); + }); + + it("caps the zoom for small nets", () => { + const bounds = { x: 0, y: 0, width: 180, height: 50 }; + expect(getInitialViewport(bounds, container).zoom).toBe(MAX_FIT_ZOOM); + }); + + it("zooms out far enough to show a large net in full", () => { + const bounds = { x: 0, y: 0, width: 10_000, height: 1000 }; + const { x, y, zoom } = getInitialViewport(bounds, container); + + // Every corner of the bounds lands inside the container. + expect(bounds.x * zoom + x).toBeGreaterThanOrEqual(0); + expect(bounds.y * zoom + y).toBeGreaterThanOrEqual(0); + expect((bounds.x + bounds.width) * zoom + x).toBeLessThanOrEqual( + container.width, + ); + expect((bounds.y + bounds.height) * zoom + y).toBeLessThanOrEqual( + container.height, + ); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/lib/viewport.ts b/libs/@hashintel/petrinaut/src/ui/lib/viewport.ts index 0f862880479..8d5c0b3f3a2 100644 --- a/libs/@hashintel/petrinaut/src/ui/lib/viewport.ts +++ b/libs/@hashintel/petrinaut/src/ui/lib/viewport.ts @@ -1,7 +1,13 @@ -import type { - PetrinautReactFlowInstance, - NodeType, -} from "../views/SDCPN/reactflow-types"; +import { getViewportForBounds } from "@xyflow/react"; + +import { + getBoundsOfCenteredBoxes, + getMinZoomForBounds, + ZOOM_PADDING, +} from "@hashintel/petrinaut-core"; + +import type { NodeType } from "../views/SDCPN/reactflow-types"; +import type { Rect, Size } from "@hashintel/petrinaut-core"; type Viewport = { x: number; @@ -10,65 +16,69 @@ type Viewport = { height: number; }; -// returns the amount offscreen as a postive integer for each direction -const getOffscreenAmount = ( - reactFlowInstance: PetrinautReactFlowInstance, - viewport: Viewport, - nodes: NodeType[], -) => { - const { x, y, width, height } = reactFlowInstance.getNodesBounds(nodes); - return { - left: Math.max(viewport.x - x, 0), - right: Math.max(x + width - (viewport.x + viewport.width), 0), - top: Math.max(viewport.y - y, 0), - bottom: Math.max(y + height - (viewport.y + viewport.height), 0), - }; -}; +/** The canvas never zooms in past this when fitting the net into view. */ +export const MAX_FIT_ZOOM = 1.1; -const isOffscreen = ( - reactFlowInstance: PetrinautReactFlowInstance, - viewport: Viewport, - nodes: NodeType[], -) => { - const { left, right, top, bottom } = getOffscreenAmount( - reactFlowInstance, - viewport, - nodes, +/** + * The viewport centered on the given net bounds, respecting the same zoom + * limits as the rest of the canvas. Top-left origin at zoom 1 when there is + * nothing to fit. + */ +export const getInitialViewport = ( + bounds: Rect | null, + container: Size, +): { x: number; y: number; zoom: number } => { + if (!bounds || bounds.width === 0 || bounds.height === 0) { + return { x: 0, y: 0, zoom: 1 }; + } + + return getViewportForBounds( + bounds, + container.width, + container.height, + getMinZoomForBounds(bounds, container), + MAX_FIT_ZOOM, + ZOOM_PADDING, ); - return left > 0 || right > 0 || top > 0 || bottom > 0; }; -const canFitInViewport = ( - reactFlowInstance: PetrinautReactFlowInstance, - viewport: Viewport, - nodes: NodeType[], -) => { - const { width, height } = reactFlowInstance.getNodesBounds(nodes); - return width < viewport.width && height < viewport.height; +// returns the amount offscreen as a positive integer for each direction +const getOffscreenAmount = (bounds: Rect, viewport: Viewport) => ({ + left: Math.max(viewport.x - bounds.x, 0), + right: Math.max(bounds.x + bounds.width - (viewport.x + viewport.width), 0), + top: Math.max(viewport.y - bounds.y, 0), + bottom: Math.max( + bounds.y + bounds.height - (viewport.y + viewport.height), + 0, + ), +}); + +const isOffscreen = (bounds: Rect, viewport: Viewport) => { + const { left, right, top, bottom } = getOffscreenAmount(bounds, viewport); + return left > 0 || right > 0 || top > 0 || bottom > 0; }; +const canFitInViewport = (bounds: Rect, viewport: Viewport) => + bounds.width < viewport.width && bounds.height < viewport.height; + // If looking to recenter an edge you should pass the nodes it connects instead // Since we don't actually hold the xy coordinates of the edge, this is the best we can do for now without // either measuring the bounding box in the dom or doing math to plot out the bezier curve export const recenterToFitViewport = ( - reactFlowInstance: PetrinautReactFlowInstance | null, viewport: Viewport, nodes: NodeType[], ) => { - if (!reactFlowInstance) return; - if (!isOffscreen(reactFlowInstance, viewport, nodes)) return; - if (!canFitInViewport(reactFlowInstance, viewport, nodes)) return; + const bounds = getBoundsOfCenteredBoxes(nodes); + if (!bounds) return; + if (!isOffscreen(bounds, viewport)) return; + if (!canFitInViewport(bounds, viewport)) return; - const { left, right, top, bottom } = getOffscreenAmount( - reactFlowInstance, - viewport, - nodes, - ); + const { left, right, top, bottom } = getOffscreenAmount(bounds, viewport); return { x: left > 0 ? left * -1 : right, y: top > 0 ? top * -1 : bottom }; }; export const getViewportRect = ( - canvas: HTMLElement, + canvasSize: Size, viewport: { x: number; y: number; zoom: number }, overlays: { left?: number; right?: number; top?: number; bottom?: number } = { left: 0, @@ -77,12 +87,13 @@ export const getViewportRect = ( bottom: 0, }, ) => { - const { width, height } = canvas.getBoundingClientRect(); return { width: - (width - (overlays.left ?? 0) - (overlays.right ?? 0)) / viewport.zoom, + (canvasSize.width - (overlays.left ?? 0) - (overlays.right ?? 0)) / + viewport.zoom, height: - (height - (overlays.top ?? 0) - (overlays.bottom ?? 0)) / viewport.zoom, + (canvasSize.height - (overlays.top ?? 0) - (overlays.bottom ?? 0)) / + viewport.zoom, x: (-viewport.x + (overlays.left ?? 0)) / viewport.zoom, y: (-viewport.y + (overlays.top ?? 0)) / viewport.zoom, zoom: viewport.zoom, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 1414f4da523..66c4739d820 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -7,7 +7,11 @@ import { use, useState } from "react"; import { type MenuItem } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; -import { calculateGraphLayout, type SDCPN } from "@hashintel/petrinaut-core"; +import { + calculateGraphLayout, + layoutNodeDimensions, + type SDCPN, +} from "@hashintel/petrinaut-core"; import { deploymentPipelineSDCPN, probabilisticSatellitesSDCPN, @@ -34,10 +38,6 @@ import { WalkthroughDialog } from "../../components/walkthrough/walkthrough-dial import { exportSDCPN } from "../../file-io/export-sdcpn"; import { exportTikZ } from "../../file-io/export-tikz"; import { importSDCPN } from "../../file-io/import-sdcpn"; -import { - classicNodeDimensions, - compactNodeDimensions, -} from "../SDCPN/node-dimensions"; import { SDCPNView } from "../SDCPN/sdcpn-view"; import { AiCtaModal } from "./components/ai-cta-modal"; import { BottomBar } from "./components/BottomBar/bottom-bar"; @@ -151,10 +151,9 @@ export const EditorView = ({ >(null); const [isAiCtaDismissed, setIsAiCtaDismissed] = useState(false); - const { compactNodes, showWalkthroughOnInit, setShowWalkthroughOnInit } = + const { showWalkthroughOnInit, setShowWalkthroughOnInit } = use(UserSettingsContext); const walkthrough = use(WalkthroughContext); - const dims = compactNodes ? compactNodeDimensions : classicNodeDimensions; // Live open state for the walkthrough. Seeded once from the persisted // "show on init" preference, so toggling that preference only takes effect @@ -232,7 +231,10 @@ export const EditorView = ({ // We must do this before createNewNet because after createNewNet triggers a // re-render, the mutatePetriNetDefinition closure would be stale. if (hadMissingPositions) { - const positions = await calculateGraphLayout(sdcpnToLoad, dims); + const positions = await calculateGraphLayout( + sdcpnToLoad, + layoutNodeDimensions, + ); if (Object.keys(positions).length > 0) { sdcpnToLoad = { diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/classic-place-node.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/classic-place-node.tsx index 16ce661063b..4ed4b6d1b50 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/classic-place-node.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/classic-place-node.tsx @@ -16,6 +16,7 @@ import type { PlaceNodeType } from "../reactflow-types"; const containerStyle = css({ position: "relative", + height: "full", }); const placeCircleStyle = cva({ @@ -23,8 +24,8 @@ const placeCircleStyle = cva({ paddingY: "4", paddingX: "2", borderRadius: "[50%]", - width: "[130px]", - height: "[130px]", + width: "full", + height: "full", display: "flex", flexDirection: "column", justifyContent: "center", diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/classic-transition-node.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/classic-transition-node.tsx index 7f9a3245be8..8cc23c65139 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/classic-transition-node.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/classic-transition-node.tsx @@ -15,14 +15,15 @@ const FIRING_ANIMATION_DURATION_MS = 300; const containerStyle = css({ position: "relative", background: "[transparent]", + height: "full", }); const transitionBoxStyle = cva({ base: { padding: "2", borderRadius: "xl", - width: "[160px]", - height: "[80px]", + width: "full", + height: "full", display: "flex", flexDirection: "column", justifyContent: "center", diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/component-instance-node.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/component-instance-node.tsx index 511ea52162f..3fbcde11c57 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/component-instance-node.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/component-instance-node.tsx @@ -13,12 +13,13 @@ const PORT_OFFSET = PORT_SIZE / 2; const containerStyle = css({ position: "relative", + height: "full", }); const cardStyle = cva({ base: { - width: "[180px]", - minHeight: "[96px]", + width: "full", + height: "full", display: "flex", flexDirection: "column", alignItems: "center", diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/node-card.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/node-card.tsx index e09d747f622..958a9b579a0 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/node-card.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/node-card.tsx @@ -14,15 +14,19 @@ export type SelectionVariant = const containerStyle = css({ position: "relative", + height: "full", }); /** * Shared card style with selection variants. * Consumers pass `borderRadius` and color overrides per node type. + * The card fills the node wrapper, which React Flow sizes to the node's + * declared dimensions (see `RenderNodeDimensions` in petrinaut-core). */ export const nodeCardStyle = cva({ base: { - width: "[180px]", + width: "full", + height: "full", display: "flex", alignItems: "center", gap: "[8px]", diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-recenter-on-panel-open.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-recenter-on-panel-open.ts index 89548d840a5..10543e14534 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-recenter-on-panel-open.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-recenter-on-panel-open.ts @@ -1,3 +1,4 @@ +import { useReactFlow } from "@xyflow/react"; import { use, useEffect, useRef } from "react"; import { parseArcId } from "@hashintel/petrinaut-core"; @@ -5,7 +6,8 @@ import { parseArcId } from "@hashintel/petrinaut-core"; import { EditorContext } from "../../../../react/state/editor-context"; import { recenterToFitViewport, getViewportRect } from "../../../lib/viewport"; -import type { PetrinautReactFlowInstance, NodeType } from "../reactflow-types"; +import type { ArcEdgeType, NodeType } from "../reactflow-types"; +import type { Size } from "@hashintel/petrinaut-core"; const RE_CENTER_PADDING = 20; @@ -14,11 +16,8 @@ const RE_CENTER_PADDING = 20; * check whether those nodes are still visible in the reduced viewport * and pan to bring them into view if needed. */ -export function useRecenterOnPanelOpen( - canvasRef: React.RefObject, - reactFlowInstance: PetrinautReactFlowInstance | null, - nodes: NodeType[], -) { +export function useRecenterOnPanelOpen(containerSize: Size, nodes: NodeType[]) { + const reactFlow = useReactFlow(); const { isBottomPanelOpen, isLeftSidebarOpen, @@ -42,8 +41,6 @@ export function useRecenterOnPanelOpen( prevBottomPanelOpen.current = isBottomPanelOpen; prevHasSelection.current = hasSelection; - if (!reactFlowInstance) return; - if (!canvasRef.current) return; if (!bottomJustOpened && !propertiesJustOpened && !leftJustOpened) return; if (selection.size === 0) return; @@ -63,18 +60,14 @@ export function useRecenterOnPanelOpen( const selectedNodes = nodes.filter((node) => selectedNodeIds.has(node.id)); if (selectedNodes.length === 0) return; - const originalViewport = reactFlowInstance.getViewport(); - const viewport = getViewportRect(canvasRef.current, originalViewport, { + const originalViewport = reactFlow.getViewport(); + const viewport = getViewportRect(containerSize, originalViewport, { left: isLeftSidebarOpen ? leftSidebarWidth : 0, bottom: isBottomPanelOpen ? bottomPanelHeight : 0, right: hasSelection ? propertiesPanelWidth : 0, }); - const adjustment = recenterToFitViewport( - reactFlowInstance, - viewport, - selectedNodes, - ); + const adjustment = recenterToFitViewport(viewport, selectedNodes); if (adjustment && (adjustment.x !== 0 || adjustment.y !== 0)) { const paddingX = @@ -90,7 +83,7 @@ export function useRecenterOnPanelOpen( ? RE_CENTER_PADDING * -1 : RE_CENTER_PADDING; // adjustment is in flow coordinates; convert to screen pixels for the viewport transform - reactFlowInstance + reactFlow .setViewport({ x: originalViewport.x - paddingX - adjustment.x * viewport.zoom, y: originalViewport.y - paddingY - adjustment.y * viewport.zoom, @@ -99,7 +92,7 @@ export function useRecenterOnPanelOpen( .catch(() => {}); } }, [ - canvasRef, + containerSize, isBottomPanelOpen, bottomPanelHeight, leftSidebarWidth, @@ -108,6 +101,6 @@ export function useRecenterOnPanelOpen( selection, propertiesPanelWidth, nodes, - reactFlowInstance, + reactFlow, ]); } diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts index 95e4ac34283..7fe49f0510a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts @@ -2,10 +2,13 @@ import { MarkerType } from "@xyflow/react"; import { use } from "react"; import { + classicNodeDimensions, + compactNodeDimensions, generateArcId, getArcEndpoint, getArcEndpointKey, getArcEndpointNodeId, + getComponentInstanceHeight, getEffectiveTransitionLambdaType, getTransitionLogicAvailability, } from "@hashintel/petrinaut-core"; @@ -16,10 +19,6 @@ import { EditorContext } from "../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../react/state/sdcpn-context"; import { UserSettingsContext } from "../../../../react/state/user-settings-context"; import { hexToHsl } from "../../../lib/hsl-color"; -import { - classicNodeDimensions, - compactNodeDimensions, -} from "../node-dimensions"; import { NOT_SELECTED_CONNECTION_OVERLAY_OPACITY } from "../styles/styling"; import type { @@ -125,8 +124,10 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { const ports = (subnet?.places ?? []) .filter((place) => place.isPort) .map((place) => ({ id: place.id, name: place.name })); - const minHeight = dimensions.componentInstance.height; - const portBasedHeight = Math.max(minHeight, ports.length * 28 + 28); + const portBasedHeight = getComponentInstanceHeight( + dimensions, + ports.length, + ); nodes.push({ id: instance.id, diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-container-size.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-container-size.ts new file mode 100644 index 00000000000..0eb2cc1f033 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-container-size.ts @@ -0,0 +1,63 @@ +import { useLayoutEffect, useState } from "react"; + +import type { Size } from "@hashintel/petrinaut-core"; + +/** + * Tracks the rendered size of `ref`'s element. Null until the element exists + * and has a non-zero size — an element the surrounding layout has not sized + * yet has nothing to show. The first real size is reported before the browser + * paints, so consumers gated on it never flash unmeasured content. Later + * changes are reported only once the size has held still for `settleMs`, so a + * window resize or panel animation re-renders consumers once instead of once + * per frame. Once measured, the last non-zero size is kept even if the + * element collapses. + */ +export function useContainerSize( + ref: React.RefObject, + settleMs: number, +): Size | null { + const [size, setSize] = useState(null); + + useLayoutEffect(() => { + const element = ref.current; + if (!element) { + return; + } + + let measured = false; + let timer: ReturnType | undefined; + + const measure = () => { + const { width, height } = element.getBoundingClientRect(); + if (width === 0 || height === 0) { + return; + } + measured = true; + setSize((previous) => + previous && previous.width === width && previous.height === height + ? previous + : { width, height }, + ); + }; + + measure(); + + const observer = new ResizeObserver(() => { + // The first real size mounts the canvas, so it must not wait. + if (!measured) { + measure(); + return; + } + clearTimeout(timer); + timer = setTimeout(measure, settleMs); + }); + observer.observe(element); + + return () => { + clearTimeout(timer); + observer.disconnect(); + }; + }, [ref, settleMs]); + + return size; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-debounce-callback.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-debounce-callback.tsx deleted file mode 100644 index 6ddd01a7961..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-debounce-callback.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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(); - }; - }, [debounced]); - - return debounced; -} diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-debounced-value.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-debounced-value.ts new file mode 100644 index 00000000000..408dbe1acb3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-debounced-value.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +/** + * Returns `value`, trailing changes by `delayMs`: the returned value only + * updates once `value` has held still for that long. + */ +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-resize-observer.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-resize-observer.tsx deleted file mode 100644 index 45c821929ab..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/util/use-resize-observer.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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/ui/views/SDCPN/node-dimensions.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/node-dimensions.ts deleted file mode 100644 index 6c59886d248..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/node-dimensions.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Visual node dimensions for SDCPN rendering. The compact / classic split is - * a visualization choice driven by `userSettings.compactNodes`. - * - * ## Design note: rendering vs. layout - * - * These constants describe **how nodes are drawn**, not how they are - * positioned. Graph layout (`lib/calculate-graph-layout.ts`) must be stable - * across the user's visualization choice — switching compact ↔ classic must - * not shift node positions, otherwise toggling the setting would visually - * scramble the user's graph. - * - * When auto-layout runs (`run-auto-layout.ts`), it should feed - * `calculateGraphLayout` a single `layoutNodeDimensions` value — per-axis - * max of compact and classic — independent of the active rendering choice: - * - * ```ts - * export const layoutNodeDimensions = { - * place: { width: 180, height: 130 }, // max(compact.place, classic.place) - * transition: { width: 180, height: 80 }, // max(compact.tx, classic.tx) - * }; - * ``` - * - * Not implemented yet — today's auto-layout still passes the active - * rendering dimensions, so running layout after the user has toggled - * `compactNodes` can shift positions. - */ - -export const compactNodeDimensions = { - place: { width: 180, height: 48 }, - transition: { width: 180, height: 48 }, - componentInstance: { width: 180, height: 96 }, -}; - -export const classicNodeDimensions = { - place: { width: 130, height: 130 }, - transition: { width: 160, height: 80 }, - componentInstance: { width: 180, height: 120 }, -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/reactflow-types.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/reactflow-types.ts index 521fcb8f234..ab9e404d0e9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/reactflow-types.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/reactflow-types.ts @@ -1,6 +1,6 @@ import type { SimulationFrameReader } from "../../../react/simulation/context"; import type { InputArc } from "@hashintel/petrinaut-core"; -import type { Edge, Node, ReactFlowInstance } from "@xyflow/react"; +import type { Edge, Node } from "@xyflow/react"; type TransitionFrameState = NonNullable< ReturnType @@ -90,11 +90,3 @@ export type PetrinautReactFlowDefinitionObject = { edges: EdgeType[]; nodes: NodeType[]; }; - -/** - * ReactFlow instance type for Petrinaut. - */ -export type PetrinautReactFlowInstance = ReactFlowInstance< - NodeType, - ArcEdgeType ->; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-canvas.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-canvas.tsx new file mode 100644 index 00000000000..5d54091b72f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-canvas.tsx @@ -0,0 +1,507 @@ +import { + Background, + ReactFlow, + SelectionMode, + useReactFlow, + useStore, +} from "@xyflow/react"; +import { use, useEffect, useState } from "react"; +import { v4 as generateUuid } from "uuid"; + +import { css } from "@hashintel/ds-helpers/css"; +import { + DEFAULT_TRANSITION_KERNEL_CODE, + generateDefaultLambdaCode, + getBoundsOfCenteredBoxes, + getMinZoomForBounds, +} from "@hashintel/petrinaut-core"; + +import { usePetrinautMutations } from "../../../react"; +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 { SNAP_GRID_SIZE } from "../../constants/ui"; +import { snapPositionToGrid } from "../../lib/snap-position-to-grid"; +import { getInitialViewport } from "../../lib/viewport"; +import { Arc } from "./components/arc"; +import { ClassicPlaceNode } from "./components/classic-place-node"; +import { ClassicTransitionNode } from "./components/classic-transition-node"; +import { ComponentInstanceNode } from "./components/component-instance-node"; +import { MiniMap } from "./components/mini-map"; +import { PlaceNode } from "./components/place-node"; +import { TransitionNode } from "./components/transition-node"; +import { ViewportControls } from "./components/viewport-controls"; +import { useApplyNodeChanges } from "./hooks/use-apply-node-changes"; +import { useRecenterOnPanelOpen } from "./hooks/use-recenter-on-panel-open"; +import { useSdcpnToReactFlow } from "./hooks/use-sdcpn-to-react-flow"; +import { useDebouncedValue } from "./hooks/util/use-debounced-value"; + +import type { ViewportAction } from "../../types/viewport-action"; +import type { ArcEdgeType, NodeType } from "./reactflow-types"; +import type { Size } from "@hashintel/petrinaut-core"; +import type { Connection } from "@xyflow/react"; + +/** + * Converts a free-form subnet display name to a valid PascalCase instance name. + * Splits on non-alphanumeric boundaries, capitalises each letter-starting word, + * and appends a trailing numeric suffix if present. + * "Hospital Ward" → "HospitalWard", "Subnet 1" → "Subnet1", "Coal Plant" → "CoalPlant" + * Falls back to "Instance" when the result would not satisfy PascalCase. + */ +const toInstanceName = (subnetName: string): string => { + const words = subnetName + .trim() + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean); + + const letterParts: string[] = []; + let trailingNumber = ""; + + for (const word of words) { + if (/^\d+$/.test(word)) { + trailingNumber = word; + } else { + const letters = word.replace(/[^a-zA-Z]/g, ""); + if (letters) { + trailingNumber = ""; + letterParts.push(letters[0]!.toUpperCase() + letters.slice(1)); + } + } + } + + const result = letterParts.join("") + trailingNumber; + return /^[A-Z][a-zA-Z]*\d*$/.test(result) ? result : "Instance"; +}; + +const COMPACT_NODE_TYPES = { + place: PlaceNode, + transition: TransitionNode, + componentInstance: ComponentInstanceNode, +}; + +const CLASSIC_NODE_TYPES = { + place: ClassicPlaceNode, + transition: ClassicTransitionNode, + componentInstance: ComponentInstanceNode, +}; + +const REACTFLOW_EDGE_TYPES = { + default: Arc, +}; + +const MIN_ZOOM_DEBOUNCE_MS = 100; + +const fadeBgStyle = css({ + position: "absolute", + inset: "[0]", + background: "[rgba(255, 255, 255, 0.3)]", + pointerEvents: "none", +}); + +/** + * SDCPNCanvas renders the net with ReactFlow and handles all ReactFlow + * interactions. It only mounts once the canvas container has been measured, + * so its very first render already shows the net centered in the viewport. + * Remounting it (via a `key`) re-centers on the current net. + */ +export const SDCPNCanvas: React.FC<{ + /** Settled size of the canvas container (see `useContainerSize`). */ + containerSize: Size; + viewportActions?: ViewportAction[]; +}> = ({ containerSize, viewportActions }) => { + const reactFlow = useReactFlow(); + + const { compactNodes, showMinimap, snapToGrid, partialSelection } = + use(UserSettingsContext); + const nodeTypes = compactNodes ? COMPACT_NODE_TYPES : CLASSIC_NODE_TYPES; + + const { petriNetDefinition } = use(SDCPNContext); + const { addPlace, addTransition, addArc, addComponentInstance } = + usePetrinautMutations(); + + const { + editionMode, + setEditionMode, + componentSubnetId, + cursorMode, + selectItem, + clearSelection, + hasCanvasSelection, + setHoveredItem, + clearHoveredItem, + globalMode, + } = use(EditorContext); + const isActualMode = globalMode === "actual"; + + // Hook for applying node changes + const applyNodeChanges = useApplyNodeChanges(); + + // Convert SDCPN to ReactFlow format with dragging state + const { nodes, edges } = useSdcpnToReactFlow(); + + // When a panel opens, recenter the viewport to keep selected nodes visible + useRecenterOnPanelOpen(containerSize, nodes); + + const bounds = getBoundsOfCenteredBoxes(nodes); + + // The viewport at mount, centered on the net. ReactFlow owns the viewport + // from then on, so later bounds or container changes must not recompute it. + const [initialViewport] = useState(() => + getInitialViewport(bounds, containerSize), + ); + + // The min zoom (ie the max you can zoom out to) keeps the net at a readable + // fraction of the viewport. + const boundsMinZoom = getMinZoomForBounds(bounds, containerSize); + + // Never raise the zoom floor above the user's current zoom — deleting nodes + // shrinks the bounds and could otherwise push the floor past the viewport. + // Subscribing to the zoom only while it is below the floor keeps re-renders + // rare: in the common case the subscription yields a constant null. + const zoomBelowBoundsMinZoom = useStore((state) => + state.transform[2] < boundsMinZoom ? state.transform[2] : null, + ); + + // Debounced so the floor holds still during a continuous zoom gesture or + // node drag, and only commits once the viewport settles — without this, a + // zoom-in while below the floor would pin the floor on every tick and make + // it impossible to reverse mid-gesture. + const minZoom = useDebouncedValue( + Math.min(boundsMinZoom, zoomBelowBoundsMinZoom ?? boundsMinZoom), + MIN_ZOOM_DEBOUNCE_MS, + ); + + const isReadonly = useIsReadOnly(); + + function isValidConnection(connection: Connection) { + const sourceNode = nodes.find((node) => node.id === connection.source); + const targetNode = nodes.find((node) => node.id === connection.target); + + if (!sourceNode || !targetNode) { + return false; + } + + if (sourceNode.type === "place" && targetNode.type === "transition") { + return true; + } + if (sourceNode.type === "transition" && targetNode.type === "place") { + return true; + } + if ( + sourceNode.type === "transition" && + targetNode.type === "componentInstance" + ) { + return connection.targetHandle?.startsWith("port-in-") ?? false; + } + if ( + sourceNode.type === "componentInstance" && + targetNode.type === "transition" + ) { + return connection.sourceHandle?.startsWith("port-out-") ?? false; + } + + return false; + } + + function onConnect(connection: Connection) { + if (!isValidConnection(connection)) { + return; + } + + const source = connection.source; + const target = connection.target; + + const sourceNode = nodes.find((node) => node.id === source); + const targetNode = nodes.find((node) => node.id === target); + + if (!sourceNode || !targetNode) { + return; + } + + // Determine direction: place->transition or transition->place + if (sourceNode.type === "place" && targetNode.type === "transition") { + addArc({ + transitionId: target, + arcDirection: "input", + placeId: source, + weight: 1, + }); + } else if ( + sourceNode.type === "transition" && + targetNode.type === "place" + ) { + addArc({ + transitionId: source, + arcDirection: "output", + placeId: target, + weight: 1, + }); + } else if ( + sourceNode.type === "transition" && + targetNode.type === "componentInstance" && + connection.targetHandle?.startsWith("port-in-") + ) { + addArc({ + transitionId: source, + arcDirection: "output", + endpoint: { + kind: "componentPort", + componentInstanceId: target, + portPlaceId: connection.targetHandle.slice("port-in-".length), + }, + weight: 1, + }); + } else if ( + sourceNode.type === "componentInstance" && + targetNode.type === "transition" && + connection.sourceHandle?.startsWith("port-out-") + ) { + addArc({ + transitionId: target, + arcDirection: "input", + endpoint: { + kind: "componentPort", + componentInstanceId: source, + portPlaceId: connection.sourceHandle.slice("port-out-".length), + }, + weight: 1, + }); + } + } + + // Shared function to create a node at a given position + function createNodeAtPosition( + nodeType: "place" | "transition", + rawPosition: { x: number; y: number }, + ) { + if (isReadonly) { + return; + } + const id = `${nodeType}__${generateUuid()}`; + const itemNumber = nodes.length + 1; + const position = snapToGrid ? snapPositionToGrid(rawPosition) : rawPosition; + + if (nodeType === "place") { + addPlace({ + id, + name: `Place${itemNumber}`, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: position.x, + y: position.y, + }); + } else { + addTransition({ + id, + name: `Transition${itemNumber}`, + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: generateDefaultLambdaCode("predicate"), + transitionKernelCode: DEFAULT_TRANSITION_KERNEL_CODE, + x: position.x, + y: position.y, + }); + } + selectItem({ type: nodeType, id }); + setEditionMode("cursor"); + } + + // Node click selection is handled by ReactFlow's internal handleNodeClick + // which fires select changes through onNodesChange → useApplyNodeChanges. + // We don't need an onNodeClick handler for selection — doing so would + // conflict with ReactFlow's internal selection management. + + // Edge selection is handled here instead of in applyNodeChanges, + // because we want edges selectable only by click, not by drag-to-select. + function onEdgeClick(_event: React.MouseEvent, edge: { id: string }) { + selectItem({ + type: "arc", + id: edge.id, + }); + } + + function onNodeMouseEnter( + _event: React.MouseEvent, + node: { id: string; type?: string }, + ) { + const type = node.type as + | "place" + | "transition" + | "componentInstance" + | undefined; + if (type) setHoveredItem({ type, id: node.id }); + } + + function onNodeMouseLeave() { + clearHoveredItem(); + } + + function onEdgeMouseEnter(_event: React.MouseEvent, edge: { id: string }) { + setHoveredItem({ + type: "arc", + id: edge.id, + }); + } + + function onEdgeMouseLeave() { + clearHoveredItem(); + } + + function onPaneClick(event: React.MouseEvent) { + // Clear selection when clicking empty canvas in select mode + if (editionMode === "cursor") { + clearSelection(); + return; + } + + if (editionMode === "add-component" && componentSubnetId) { + const subnet = (petriNetDefinition.subnets ?? []).find( + ({ id }) => id === componentSubnetId, + ); + const rawPosition = reactFlow.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + const position = snapToGrid + ? snapPositionToGrid(rawPosition) + : rawPosition; + const id = `componentInstance__${generateUuid()}`; + + addComponentInstance({ + id, + name: subnet ? toInstanceName(subnet.name) : "Instance", + subnetId: componentSubnetId, + parameterValues: {}, + x: position.x, + y: position.y, + }); + selectItem({ type: "componentInstance", id }); + setEditionMode("cursor"); + return; + } + + if (editionMode !== "add-place" && editionMode !== "add-transition") { + return; + } + + const nodeType = editionMode === "add-place" ? "place" : "transition"; + + const position = reactFlow.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + createNodeAtPosition(nodeType, position); + } + + function onDragOver(event: React.DragEvent) { + event.preventDefault(); + // eslint-disable-next-line no-param-reassign + event.dataTransfer.dropEffect = "move"; + } + + function onDrop(event: React.DragEvent) { + event.preventDefault(); + + const nodeType = event.dataTransfer.getData("application/reactflow"); + + // Validate that we have a valid node type + if (nodeType !== "place" && nodeType !== "transition") { + return; + } + + const position = reactFlow.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + createNodeAtPosition(nodeType, position); + } + + // Prevent ReactFlow from capturing keyboard events when in Monaco editor + // TODO: This is messy and we should find a better way to handle keyboard shortcuts and collisions. + useEffect(() => { + function preventReactFlowKeyboard(event: KeyboardEvent) { + const target = event.target as HTMLElement; + const isInMonaco = target.closest(".monaco-editor") !== null; + + if (isInMonaco) { + // Only stop propagation for keys that ReactFlow captures + // ReactFlow uses: Space (pan), Shift (selection), but we want to allow: + // - Cmd/Ctrl+Z (undo) + // - Cmd/Ctrl+Shift+Z (redo) + // - Cmd/Ctrl+C/V/X (copy/paste/cut) + // - and other editor shortcuts + + // Don't stop propagation if modifier keys are pressed (for editor shortcuts) + if (event.metaKey || event.ctrlKey) { + return; + } + + // Stop propagation for keys that would interfere with Monaco + // Primarily Space, which ReactFlow uses for panning + if (event.key === " " || event.key === "Spacebar") { + event.stopPropagation(); + } + } + } + + // Use capture phase to intercept before ReactFlow + document.addEventListener("keydown", preventReactFlowKeyboard, true); + return () => { + document.removeEventListener("keydown", preventReactFlowKeyboard, true); + }; + }, []); + + // Determine ReactFlow props based on edition mode + const isAddMode = + editionMode === "add-place" || + editionMode === "add-transition" || + editionMode === "add-component"; + const isPanMode = editionMode === "cursor" && cursorMode === "pan"; + const isSelectMode = editionMode === "cursor" && cursorMode === "select"; + + return ( + + + {hasCanvasSelection &&
} + {showMinimap && } + {!isActualMode && } + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx index c618b18fcfc..83a634ecd9b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx @@ -4,98 +4,20 @@ */ import "@xyflow/react/dist/style.css"; -import { Background, ReactFlow, SelectionMode } from "@xyflow/react"; -import { - use, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, -} from "react"; -import { v4 as generateUuid } from "uuid"; +import { ReactFlowProvider } from "@xyflow/react"; +import { use, useRef } from "react"; import { css } from "@hashintel/ds-helpers/css"; -import { - DEFAULT_TRANSITION_KERNEL_CODE, - generateDefaultLambdaCode, -} from "@hashintel/petrinaut-core"; -import { usePetrinautMutations } from "../../../react"; 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 { SNAP_GRID_SIZE } from "../../constants/ui"; -import { snapPositionToGrid } from "../../lib/snap-position-to-grid"; -import { Arc } from "./components/arc"; -import { ClassicPlaceNode } from "./components/classic-place-node"; -import { ClassicTransitionNode } from "./components/classic-transition-node"; -import { ComponentInstanceNode } from "./components/component-instance-node"; import { CursorTooltip } from "./components/cursor-tooltip"; -import { MiniMap } from "./components/mini-map"; -import { PlaceNode } from "./components/place-node"; -import { TransitionNode } from "./components/transition-node"; -import { ViewportControls } from "./components/viewport-controls"; -import { useApplyNodeChanges } from "./hooks/use-apply-node-changes"; -import { useRecenterOnPanelOpen } from "./hooks/use-recenter-on-panel-open"; -import { useSdcpnToReactFlow } from "./hooks/use-sdcpn-to-react-flow"; -import { useDebounceCallback } from "./hooks/util/use-debounce-callback"; -import { useResizeObserver } from "./hooks/util/use-resize-observer"; +import { useContainerSize } from "./hooks/util/use-container-size"; +import { SDCPNCanvas } from "./sdcpn-canvas"; import type { ViewportAction } from "../../types/viewport-action"; -import type { PetrinautReactFlowInstance } from "./reactflow-types"; -import type { Connection } from "@xyflow/react"; -/** - * Converts a free-form subnet display name to a valid PascalCase instance name. - * Splits on non-alphanumeric boundaries, capitalises each letter-starting word, - * and appends a trailing numeric suffix if present. - * "Hospital Ward" → "HospitalWard", "Subnet 1" → "Subnet1", "Coal Plant" → "CoalPlant" - * Falls back to "Instance" when the result would not satisfy PascalCase. - */ -const toInstanceName = (subnetName: string): string => { - const words = subnetName - .trim() - .split(/[^a-zA-Z0-9]+/) - .filter(Boolean); - - const letterParts: string[] = []; - let trailingNumber = ""; - - for (const word of words) { - if (/^\d+$/.test(word)) { - trailingNumber = word; - } else { - const letters = word.replace(/[^a-zA-Z]/g, ""); - if (letters) { - trailingNumber = ""; - letterParts.push(letters[0]!.toUpperCase() + letters.slice(1)); - } - } - } - - const result = letterParts.join("") + trailingNumber; - return /^[A-Z][a-zA-Z]*\d*$/.test(result) ? result : "Instance"; -}; - -const COMPACT_NODE_TYPES = { - place: PlaceNode, - transition: TransitionNode, - componentInstance: ComponentInstanceNode, -}; - -const CLASSIC_NODE_TYPES = { - place: ClassicPlaceNode, - transition: ClassicTransitionNode, - componentInstance: ComponentInstanceNode, -}; - -const REACTFLOW_EDGE_TYPES = { - default: Arc, -}; - -const ZOOM_PADDING = 0.4; +const CONTAINER_SIZE_SETTLE_MS = 100; const canvasContainerStyle = css({ width: "[100%]", @@ -106,429 +28,30 @@ const canvasContainerStyle = css({ }, }); -const fadeBgStyle = css({ - position: "absolute", - inset: "[0]", - background: "[rgba(255, 255, 255, 0.3)]", - pointerEvents: "none", -}); - /** * SDCPNView is responsible for rendering the SDCPN using ReactFlow. - * It reads from SDCPNContext and EditorContext, and handles all ReactFlow interactions. + * It measures the canvas container and only mounts the canvas once the size + * is known, so the net renders centered from its very first frame. Switching + * to another net remounts the canvas, centering it on the new net. */ export const SDCPNView: React.FC<{ viewportActions?: ViewportAction[]; }> = ({ viewportActions }) => { const canvasContainer = useRef(null); - const [reactFlowInstance, setReactFlowInstance] = - useState(null); - - const { compactNodes, showMinimap, snapToGrid, partialSelection } = - use(UserSettingsContext); - const nodeTypes = useMemo( - () => (compactNodes ? COMPACT_NODE_TYPES : CLASSIC_NODE_TYPES), - [compactNodes], + const containerSize = useContainerSize( + canvasContainer, + CONTAINER_SIZE_SETTLE_MS, ); - // 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, petriNetDefinition } = use(SDCPNContext); - const { addPlace, addTransition, addArc, addComponentInstance } = - usePetrinautMutations(); - - const { - editionMode, - setEditionMode, - componentSubnetId, - cursorMode, - selectItem, - clearSelection, - hasCanvasSelection, - setHoveredItem, - clearHoveredItem, - globalMode, - } = use(EditorContext); - const isActualMode = globalMode === "actual"; - - // Hook for applying node changes - const applyNodeChanges = useApplyNodeChanges(); - - // Convert SDCPN to ReactFlow format with dragging state - const { nodes, edges } = useSdcpnToReactFlow(); - - // When a panel opens, recenter the viewport to keep selected nodes visible - useRecenterOnPanelOpen(canvasContainer, reactFlowInstance, nodes); - - // Center viewport on SDCPN load - const fitLoadedNetIntoView = useEffectEvent( - (instance: PetrinautReactFlowInstance) => { - void instance.fitView({ - padding: ZOOM_PADDING, - minZoom, - maxZoom: 1.1, - }); - }, - ); - - useEffect(() => { - if (!reactFlowInstance) return; - fitLoadedNetIntoView(reactFlowInstance); - }, [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; - - // even if theres only a single place, always allow the user to zoom out at least a minimum reasonable amount - setMinZoom(Math.min(safeZoom, 0.75)); - } - }, - 100, - ); - - useResizeObserver(canvasContainer, () => { - fitZoomToNodes(reactFlowInstance, canvasContainer); - }); - - const isReadonly = useIsReadOnly(); - - function isValidConnection(connection: Connection) { - const sourceNode = nodes.find((node) => node.id === connection.source); - const targetNode = nodes.find((node) => node.id === connection.target); - - if (!sourceNode || !targetNode) { - return false; - } - - if (sourceNode.type === "place" && targetNode.type === "transition") { - return true; - } - if (sourceNode.type === "transition" && targetNode.type === "place") { - return true; - } - if ( - sourceNode.type === "transition" && - targetNode.type === "componentInstance" - ) { - return connection.targetHandle?.startsWith("port-in-") ?? false; - } - if ( - sourceNode.type === "componentInstance" && - targetNode.type === "transition" - ) { - return connection.sourceHandle?.startsWith("port-out-") ?? false; - } - - return false; - } - function onConnect(connection: Connection) { - if (!isValidConnection(connection)) { - return; - } + const { petriNetId } = use(SDCPNContext); + const { editionMode, cursorMode } = use(EditorContext); - const source = connection.source; - const target = connection.target; - - const sourceNode = nodes.find((node) => node.id === source); - const targetNode = nodes.find((node) => node.id === target); - - if (!sourceNode || !targetNode) { - return; - } - - // Determine direction: place->transition or transition->place - if (sourceNode.type === "place" && targetNode.type === "transition") { - addArc({ - transitionId: target, - arcDirection: "input", - placeId: source, - weight: 1, - }); - } else if ( - sourceNode.type === "transition" && - targetNode.type === "place" - ) { - addArc({ - transitionId: source, - arcDirection: "output", - placeId: target, - weight: 1, - }); - } else if ( - sourceNode.type === "transition" && - targetNode.type === "componentInstance" && - connection.targetHandle?.startsWith("port-in-") - ) { - addArc({ - transitionId: source, - arcDirection: "output", - endpoint: { - kind: "componentPort", - componentInstanceId: target, - portPlaceId: connection.targetHandle.slice("port-in-".length), - }, - weight: 1, - }); - } else if ( - sourceNode.type === "componentInstance" && - targetNode.type === "transition" && - connection.sourceHandle?.startsWith("port-out-") - ) { - addArc({ - transitionId: target, - arcDirection: "input", - endpoint: { - kind: "componentPort", - componentInstanceId: source, - portPlaceId: connection.sourceHandle.slice("port-out-".length), - }, - weight: 1, - }); - } - } - - function onInit(instance: PetrinautReactFlowInstance) { - setReactFlowInstance(instance); - fitZoomToNodes(instance, canvasContainer); - } - - // Shared function to create a node at a given position - function createNodeAtPosition( - nodeType: "place" | "transition", - rawPosition: { x: number; y: number }, - ) { - if (isReadonly) { - return; - } - const id = `${nodeType}__${generateUuid()}`; - const itemNumber = nodes.length + 1; - const position = snapToGrid ? snapPositionToGrid(rawPosition) : rawPosition; - - if (nodeType === "place") { - addPlace({ - id, - name: `Place${itemNumber}`, - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: position.x, - y: position.y, - }); - } else { - addTransition({ - id, - name: `Transition${itemNumber}`, - inputArcs: [], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: generateDefaultLambdaCode("predicate"), - transitionKernelCode: DEFAULT_TRANSITION_KERNEL_CODE, - x: position.x, - y: position.y, - }); - } - selectItem({ type: nodeType, id }); - setEditionMode("cursor"); - } - - // Node click selection is handled by ReactFlow's internal handleNodeClick - // which fires select changes through onNodesChange → useApplyNodeChanges. - // We don't need an onNodeClick handler for selection — doing so would - // conflict with ReactFlow's internal selection management. - - // Edge selection is handled here instead of in applyNodeChanges, - // because we want edges selectable only by click, not by drag-to-select. - function onEdgeClick(_event: React.MouseEvent, edge: { id: string }) { - selectItem({ - type: "arc", - id: edge.id, - }); - } - - function onNodeMouseEnter( - _event: React.MouseEvent, - node: { id: string; type?: string }, - ) { - const type = node.type as - | "place" - | "transition" - | "componentInstance" - | undefined; - if (type) setHoveredItem({ type, id: node.id }); - } - - function onNodeMouseLeave() { - clearHoveredItem(); - } - - function onEdgeMouseEnter(_event: React.MouseEvent, edge: { id: string }) { - setHoveredItem({ - type: "arc", - id: edge.id, - }); - } - - function onEdgeMouseLeave() { - clearHoveredItem(); - } - - function onPaneClick(event: React.MouseEvent) { - if (!reactFlowInstance || !canvasContainer.current) { - return; - } - - // Clear selection when clicking empty canvas in select mode - if (editionMode === "cursor") { - clearSelection(); - return; - } - - if (editionMode === "add-component" && componentSubnetId) { - const subnet = (petriNetDefinition.subnets ?? []).find( - ({ id }) => id === componentSubnetId, - ); - const rawPosition = reactFlowInstance.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }); - const position = snapToGrid - ? snapPositionToGrid(rawPosition) - : rawPosition; - const id = `componentInstance__${generateUuid()}`; - - addComponentInstance({ - id, - name: subnet ? toInstanceName(subnet.name) : "Instance", - subnetId: componentSubnetId, - parameterValues: {}, - x: position.x, - y: position.y, - }); - selectItem({ type: "componentInstance", id }); - setEditionMode("cursor"); - return; - } - - if (editionMode !== "add-place" && editionMode !== "add-transition") { - return; - } - - const nodeType = editionMode === "add-place" ? "place" : "transition"; - - const position = reactFlowInstance.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }); - - createNodeAtPosition(nodeType, position); - } - - function onDragOver(event: React.DragEvent) { - event.preventDefault(); - // eslint-disable-next-line no-param-reassign - event.dataTransfer.dropEffect = "move"; - } - - function onDrop(event: React.DragEvent) { - event.preventDefault(); - - if (!reactFlowInstance || !canvasContainer.current) { - return; - } - - const nodeType = event.dataTransfer.getData("application/reactflow"); - - // Validate that we have a valid node type - if (nodeType !== "place" && nodeType !== "transition") { - return; - } - - const position = reactFlowInstance.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }); - - createNodeAtPosition(nodeType, position); - } - - // Prevent ReactFlow from capturing keyboard events when in Monaco editor - // TODO: This is messy and we should find a better way to handle keyboard shortcuts and collisions. - useEffect(() => { - function preventReactFlowKeyboard(event: KeyboardEvent) { - const target = event.target as HTMLElement; - const isInMonaco = target.closest(".monaco-editor") !== null; - - if (isInMonaco) { - // Only stop propagation for keys that ReactFlow captures - // ReactFlow uses: Space (pan), Shift (selection), but we want to allow: - // - Cmd/Ctrl+Z (undo) - // - Cmd/Ctrl+Shift+Z (redo) - // - Cmd/Ctrl+C/V/X (copy/paste/cut) - // - and other editor shortcuts - - // Don't stop propagation if modifier keys are pressed (for editor shortcuts) - if (event.metaKey || event.ctrlKey) { - return; - } - - // Stop propagation for keys that would interfere with Monaco - // Primarily Space, which ReactFlow uses for panning - if (event.key === " " || event.key === "Spacebar") { - event.stopPropagation(); - } - } - } - - // Use capture phase to intercept before ReactFlow - document.addEventListener("keydown", preventReactFlowKeyboard, true); - return () => { - document.removeEventListener("keydown", preventReactFlowKeyboard, true); - }; - }, []); - - // Determine ReactFlow props based on edition mode const isAddMode = editionMode === "add-place" || editionMode === "add-transition" || editionMode === "add-component"; const isPanMode = editionMode === "cursor" && cursorMode === "pan"; - const isSelectMode = editionMode === "cursor" && cursorMode === "select"; - - // Set cursor style based on mode - const getCursorStyle = () => { - if (isAddMode) { - return "copy"; - } - if (isPanMode) { - return "grab"; - } - return "default"; - }; + const paneCursor = isAddMode ? "copy" : isPanMode ? "grab" : "default"; return (
- { - applyNodeChanges(n); - fitZoomToNodes(reactFlowInstance, canvasContainer); - }} - onEdgesChange={applyNodeChanges} - onConnect={isReadonly ? undefined : onConnect} - onInit={onInit} - onEdgeClick={onEdgeClick} - onNodeMouseEnter={onNodeMouseEnter} - onNodeMouseLeave={onNodeMouseLeave} - onEdgeMouseEnter={onEdgeMouseEnter} - onEdgeMouseLeave={onEdgeMouseLeave} - 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]} - selectionOnDrag={isSelectMode} - nodesDraggable={!isReadonly} - nodesConnectable={!isReadonly} - elementsSelectable={!isAddMode} - selectionMode={ - partialSelection ? SelectionMode.Partial : SelectionMode.Full - } - selectNodesOnDrag={false} - nodeOrigin={[0.5, 0.5]} - deleteKeyCode={null} - panOnScroll={false} - zoomOnScroll - minZoom={minZoom} - > - - {hasCanvasSelection &&
} - {showMinimap && } - {!isActualMode && ( - - )} - + {containerSize && ( + + + + )}
);