From 1eed484a0c71d53993abf60583791720e3c393e7 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 17 Feb 2026 19:30:56 +0100 Subject: [PATCH 01/25] Temporary patch --- yarn.lock | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9976cf4a713..5c957a01e56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19982,6 +19982,20 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:4.0.17": + version: 4.0.17 + resolution: "@vitest/expect@npm:4.0.17" + dependencies: + "@standard-schema/spec": "npm:^1.0.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.0.17" + "@vitest/utils": "npm:4.0.17" + chai: "npm:^6.2.1" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/cdaa6827aa3a9473d51fd0944bcd698a94507929fa3c98b00bbdb74342319ec04279f01108d7d2dd7cbcd0d8062f65a3f21bb3615c0d5223e61adcc036c8b370 + languageName: node + linkType: hard + "@vitest/expect@npm:4.0.18": version: 4.0.18 resolution: "@vitest/expect@npm:4.0.18" @@ -20015,6 +20029,25 @@ __metadata: languageName: node linkType: hard +"@vitest/mocker@npm:4.0.17": + version: 4.0.17 + resolution: "@vitest/mocker@npm:4.0.17" + dependencies: + "@vitest/spy": "npm:4.0.17" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/54e657fa5b79764926b15aac993528bfe7083f6731209253617b1f27d328aa3297fcbf96b67e84d1a5632553231f795585f2396f563837cf117a574c87f5cef7 + languageName: node + linkType: hard + "@vitest/mocker@npm:4.0.18": version: 4.0.18 resolution: "@vitest/mocker@npm:4.0.18" @@ -20043,6 +20076,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:4.0.17": + version: 4.0.17 + resolution: "@vitest/pretty-format@npm:4.0.17" + dependencies: + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/10a2dd7e2daf7ee006107d380bbd28b66b09a7014d31087daab0dea7dee0d12868cfcf6b3372729268502fd9065162345b68b9b9c5d225f5c6c2fd2c664a2a86 + languageName: node + linkType: hard + "@vitest/pretty-format@npm:4.0.18": version: 4.0.18 resolution: "@vitest/pretty-format@npm:4.0.18" @@ -20063,6 +20105,16 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:4.0.17": + version: 4.0.17 + resolution: "@vitest/runner@npm:4.0.17" + dependencies: + "@vitest/utils": "npm:4.0.17" + pathe: "npm:^2.0.3" + checksum: 10c0/f4ccc236d1ed5ba2186d5f36ff0306d4ac7b711a40d7316ad6fd71c0f7229482b19969a8737e87670f3d4efb08f2138ff5b47a744fd7ae8db6c03cf991293a04 + languageName: node + linkType: hard + "@vitest/runner@npm:4.0.18": version: 4.0.18 resolution: "@vitest/runner@npm:4.0.18" @@ -20084,6 +20136,17 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:4.0.17": + version: 4.0.17 + resolution: "@vitest/snapshot@npm:4.0.17" + dependencies: + "@vitest/pretty-format": "npm:4.0.17" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/31a047a097b13eff6c0f5393ea3e7203771ae9a22afe6465cd9023fd2ed516ddccd84523d48504a032c9d04a86a12e3f1235e08bb2ffc7d7a125e372c41ef53d + languageName: node + linkType: hard + "@vitest/snapshot@npm:4.0.18": version: 4.0.18 resolution: "@vitest/snapshot@npm:4.0.18" @@ -20104,6 +20167,13 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:4.0.17": + version: 4.0.17 + resolution: "@vitest/spy@npm:4.0.17" + checksum: 10c0/c290731ba3392f11eaba8fc7fa08063a3a4d14af6baeec210b260ccd5a46613196fb4a8ff3ac8bf91a9606aef90eee9b6364bda130ce71abff368e35dfe2b265 + languageName: node + linkType: hard + "@vitest/spy@npm:4.0.18": version: 4.0.18 resolution: "@vitest/spy@npm:4.0.18" @@ -20122,6 +20192,16 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.0.17": + version: 4.0.17 + resolution: "@vitest/utils@npm:4.0.17" + dependencies: + "@vitest/pretty-format": "npm:4.0.17" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/1e2e4d7d7709ec022f603a1e12015523a2290f326c0bbe0c6bd5481ec396d4efc6bf8c738d601915d88e74267e9841df1e05157edced10f5048865204aeb86ff + languageName: node + linkType: hard + "@vitest/utils@npm:4.0.18": version: 4.0.18 resolution: "@vitest/utils@npm:4.0.18" @@ -29469,7 +29549,16 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.13.0, get-tsconfig@npm:^4.13.6, get-tsconfig@npm:^4.7.5": +"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.13.0, get-tsconfig@npm:^4.7.5": + version: 4.13.0 + resolution: "get-tsconfig@npm:4.13.0" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/2c49ef8d3907047a107f229fd610386fe3b7fe9e42dfd6b42e7406499493cdda8c62e83e57e8d7a98125610774b9f604d3a0ff308d7f9de5c7ac6d1b07cb6036 + languageName: node + linkType: hard + +"get-tsconfig@npm:^4.13.6": version: 4.13.6 resolution: "get-tsconfig@npm:4.13.6" dependencies: @@ -45518,7 +45607,7 @@ __metadata: languageName: node linkType: hard -"vitest@npm:4.0.18, vitest@npm:^4.0.16": +"vitest@npm:4.0.18": version: 4.0.18 resolution: "vitest@npm:4.0.18" dependencies: @@ -45577,6 +45666,65 @@ __metadata: languageName: node linkType: hard +"vitest@npm:^4.0.16": + version: 4.0.17 + resolution: "vitest@npm:4.0.17" + dependencies: + "@vitest/expect": "npm:4.0.17" + "@vitest/mocker": "npm:4.0.17" + "@vitest/pretty-format": "npm:4.0.17" + "@vitest/runner": "npm:4.0.17" + "@vitest/snapshot": "npm:4.0.17" + "@vitest/spy": "npm:4.0.17" + "@vitest/utils": "npm:4.0.17" + es-module-lexer: "npm:^1.7.0" + expect-type: "npm:^1.2.2" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^3.10.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.0.3" + vite: "npm:^6.0.0 || ^7.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.0.17 + "@vitest/browser-preview": 4.0.17 + "@vitest/browser-webdriverio": 4.0.17 + "@vitest/ui": 4.0.17 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/e1648bbfe2d01e23ceb6856863344035d2a1c139f39e8b15859e6ea8dc510ac3ba425df7c45486492d85ca516472aa892540dfd11ab6ad0613be98fd56d40716 + languageName: node + linkType: hard + "vscode-languageserver-types@npm:3.17.5": version: 3.17.5 resolution: "vscode-languageserver-types@npm:3.17.5" From ae3085ad19ca31ebc9fd1643e15df205d531423b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 7 Mar 2026 14:24:19 +0100 Subject: [PATCH 02/25] H-5655: Refactor selection logic and migrate to @xyflow/react v12 - Extract selection state into dedicated module with SelectionMap type - Add arc selection and multi-selection support in properties panel - Migrate from reactflow v11 to @xyflow/react v12 - Fix node centering by offsetting positions in mapper instead of CSS transforms - Add measured dimensions to nodes to fix arc invisibility after large moves - Add browser tests for SDCPNView with vitest-browser-playwright - Update node dimensions to match actual visual sizes for classic/compact modes Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/eslint.config.js | 1 + libs/@hashintel/petrinaut/package.json | 10 +- libs/@hashintel/petrinaut/src/petrinaut.tsx | 2 +- .../petrinaut/src/state/editor-context.ts | 29 +- .../petrinaut/src/state/editor-provider.tsx | 90 ++- .../petrinaut/src/state/selection.ts | 39 ++ .../src/state/use-selection-cleanup.ts | 70 +++ .../petrinaut/src/state/use-selection.ts | 19 + .../src/views/Editor/editor-view.tsx | 4 + .../BottomPanel/subviews/diagnostics.tsx | 20 +- .../subviews/simulation-settings.tsx | 15 +- .../subviews/differential-equations-list.tsx | 21 +- .../LeftSideBar/subviews/nodes-list.tsx | 35 +- .../LeftSideBar/subviews/parameters-list.tsx | 19 +- .../LeftSideBar/subviews/types-list.tsx | 19 +- .../PropertiesPanel/arc-properties/main.tsx | 222 +++++++ .../PropertiesPanel/multi-selection-panel.tsx | 119 ++++ .../Editor/panels/PropertiesPanel/panel.tsx | 49 +- .../place-properties/subviews/main.tsx | 17 +- .../src/views/SDCPN/components/arc.tsx | 12 +- .../SDCPN/components/classic-place-node.tsx | 15 +- .../components/classic-transition-node.tsx | 15 +- .../src/views/SDCPN/components/mini-map.tsx | 10 +- .../src/views/SDCPN/components/node-card.tsx | 3 +- .../src/views/SDCPN/components/place-node.tsx | 14 +- .../SDCPN/components/transition-node.tsx | 14 +- .../SDCPN/components/viewport-controls.tsx | 6 +- .../SDCPN/hooks/use-apply-node-changes.ts | 60 +- .../SDCPN/hooks/use-sdcpn-to-react-flow.ts | 44 +- .../src/views/SDCPN/reactflow-types.ts | 11 +- .../views/SDCPN/sdcpn-view.browser.test.tsx | 548 ++++++++++++++++++ .../petrinaut/src/views/SDCPN/sdcpn-view.tsx | 109 ++-- .../src/views/SDCPN/styles/styling.ts | 14 +- libs/@hashintel/petrinaut/vite.config.ts | 2 +- .../petrinaut/vitest.browser.config.ts | 28 + yarn.lock | 70 ++- 36 files changed, 1556 insertions(+), 219 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/state/selection.ts create mode 100644 libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts create mode 100644 libs/@hashintel/petrinaut/src/state/use-selection.ts create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx create mode 100644 libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.browser.test.tsx create mode 100644 libs/@hashintel/petrinaut/vitest.browser.config.ts diff --git a/libs/@hashintel/petrinaut/eslint.config.js b/libs/@hashintel/petrinaut/eslint.config.js index d9e2693ad7c..1f108b8f9ef 100644 --- a/libs/@hashintel/petrinaut/eslint.config.js +++ b/libs/@hashintel/petrinaut/eslint.config.js @@ -11,6 +11,7 @@ export default [ "postcss.config.cjs", "vite.config.ts", "vite.site.config.ts", + "vitest.browser.config.ts", ".storybook/main.ts", ".storybook/manager.tsx", ".storybook/preview.tsx", diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index bf3e8cb97e5..6a2b4f0ab2d 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -34,7 +34,8 @@ "lint:eslint": "eslint --report-unused-disable-directives .", "lint:tsc": "tsgo --noEmit", "prepublishOnly": "turbo run build", - "test:unit": "vitest" + "test:unit": "vitest", + "test:browser": "vitest --config vitest.browser.config.ts" }, "dependencies": { "@ark-ui/react": "5.26.2", @@ -50,13 +51,13 @@ "@hashintel/refractive": "workspace:^", "@mantine/hooks": "8.3.5", "@monaco-editor/react": "4.8.0-rc.3", + "@xyflow/react": "12.10.1", "d3-array": "3.2.4", "d3-scale": "4.0.2", "elkjs": "0.11.0", "monaco-editor": "0.55.1", "react-icons": "5.5.0", "react-resizable-panels": "4.6.5", - "reactflow": "11.11.4", "typescript": "5.9.3", "uuid": "13.0.0", "vscode-languageserver-types": "3.17.5", @@ -77,6 +78,7 @@ "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260309.1", "@vitejs/plugin-react": "5.1.4", + "@vitest/browser-playwright": "^4.0.16", "babel-plugin-react-compiler": "1.0.0", "eslint": "9.39.3", "immer": "10.1.3", @@ -87,7 +89,9 @@ "rolldown-plugin-dts": "0.22.4", "storybook": "10.2.13", "vite": "8.0.0-beta.18", - "vitest": "4.0.18" + "vite-plugin-dts": "4.5.4", + "vitest": "4.0.18", + "vitest-browser-react": "^2.0.2" }, "peerDependencies": { "@hashintel/ds-components": "workspace:^", diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index 3fba8cbf5e5..d3ea8d69b4b 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -1,4 +1,4 @@ -import "reactflow/dist/style.css"; +import "@xyflow/react/dist/style.css"; import "./index.css"; import { type FunctionComponent } from "react"; diff --git a/libs/@hashintel/petrinaut/src/state/editor-context.ts b/libs/@hashintel/petrinaut/src/state/editor-context.ts index 99df4199e54..95ee28f53e5 100644 --- a/libs/@hashintel/petrinaut/src/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/state/editor-context.ts @@ -5,6 +5,7 @@ import { DEFAULT_LEFT_SIDEBAR_WIDTH, DEFAULT_PROPERTIES_PANEL_WIDTH, } from "../constants/ui"; +import type { SelectionItem, SelectionMap } from "./selection"; export type DraggingStateByNodeId = Record< string, @@ -34,8 +35,7 @@ export type EditorState = { isBottomPanelOpen: boolean; bottomPanelHeight: number; activeBottomPanelTab: BottomPanelTab; - selectedResourceId: string | null; - selectedItemIds: Set; + selection: SelectionMap; draggingStateByNodeId: DraggingStateByNodeId; timelineChartType: TimelineChartType; isPanelAnimating: boolean; @@ -55,11 +55,14 @@ export type EditorActions = { toggleBottomPanel: () => void; setBottomPanelHeight: (height: number) => void; setActiveBottomPanelTab: (tab: BottomPanelTab) => void; - setSelectedResourceId: (id: string | null) => void; - setSelectedItemIds: (ids: Set) => void; - addSelectedItemId: (id: string) => void; - removeSelectedItemId: (id: string) => void; + setSelection: (selection: SelectionMap) => void; + selectItem: (item: SelectionItem) => void; + toggleItem: (item: SelectionItem) => void; + addToSelection: (items: SelectionItem[]) => void; + removeFromSelection: (ids: string[]) => void; clearSelection: () => void; + focusNode: (nodeId: string) => void; + registerFocusNode: (fn: (nodeId: string) => void) => void; setDraggingStateByNodeId: (state: DraggingStateByNodeId) => void; updateDraggingStateByNodeId: ( updater: (state: DraggingStateByNodeId) => DraggingStateByNodeId, @@ -83,8 +86,7 @@ export const initialEditorState: EditorState = { isBottomPanelOpen: false, bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, activeBottomPanelTab: "diagnostics", - selectedResourceId: null, - selectedItemIds: new Set(), + selection: new Map(), draggingStateByNodeId: {}, timelineChartType: "run", isPanelAnimating: false, @@ -102,11 +104,14 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, setActiveBottomPanelTab: () => {}, - setSelectedResourceId: () => {}, - setSelectedItemIds: () => {}, - addSelectedItemId: () => {}, - removeSelectedItemId: () => {}, + setSelection: () => {}, + selectItem: () => {}, + toggleItem: () => {}, + addToSelection: () => {}, + removeFromSelection: () => {}, clearSelection: () => {}, + focusNode: () => {}, + registerFocusNode: () => {}, setDraggingStateByNodeId: () => {}, updateDraggingStateByNodeId: () => {}, resetDraggingState: () => {}, diff --git a/libs/@hashintel/petrinaut/src/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/state/editor-provider.tsx index 910a0d7b268..49ac9ad2867 100644 --- a/libs/@hashintel/petrinaut/src/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/editor-provider.tsx @@ -8,6 +8,7 @@ import { type EditorState, initialEditorState, } from "./editor-context"; +import type { SelectionItem, SelectionMap } from "./selection"; import { useSyncEditorToSettings } from "./use-sync-editor-to-settings"; import { UserSettingsContext } from "./user-settings-context"; @@ -32,6 +33,8 @@ export const EditorProvider: React.FC = ({ children }) => { undefined, ); + const focusNodeRef = useRef<(nodeId: string) => void>(() => {}); + const triggerPanelAnimation = () => { if (!userSettings.showAnimations) { return; @@ -74,26 +77,77 @@ export const EditorProvider: React.FC = ({ children }) => { setState((prev) => ({ ...prev, bottomPanelHeight: height })), setActiveBottomPanelTab: (tab) => setState((prev) => ({ ...prev, activeBottomPanelTab: tab })), - setSelectedResourceId: (id) => { - triggerPanelAnimation(); - setState((prev) => ({ ...prev, selectedResourceId: id })); + setSelection: (selection: SelectionMap) => + setState((prev) => ({ ...prev, selection })), + selectItem: (item: SelectionItem) => { + setState((prev) => { + const wasEmpty = prev.selection.size === 0; + const newSelection: SelectionMap = new Map([[item.id, item]]); + const willBeEmpty = false; + if (wasEmpty !== willBeEmpty) { + triggerPanelAnimation(); + } + return { ...prev, selection: newSelection }; + }); }, - setSelectedItemIds: (ids) => - setState((prev) => ({ ...prev, selectedItemIds: ids })), - addSelectedItemId: (id) => + toggleItem: (item: SelectionItem) => { setState((prev) => { - const newSet = new Set(prev.selectedItemIds); - newSet.add(id); - return { ...prev, selectedItemIds: newSet }; - }), - removeSelectedItemId: (id) => + const newSelection = new Map(prev.selection); + if (newSelection.has(item.id)) { + newSelection.delete(item.id); + } else { + newSelection.set(item.id, item); + } + const visibilityChanged = + (prev.selection.size === 0) !== (newSelection.size === 0); + if (visibilityChanged) { + triggerPanelAnimation(); + } + return { ...prev, selection: newSelection }; + }); + }, + addToSelection: (items: SelectionItem[]) => { + setState((prev) => { + const newSelection = new Map(prev.selection); + for (const item of items) { + newSelection.set(item.id, item); + } + const visibilityChanged = + (prev.selection.size === 0) !== (newSelection.size === 0); + if (visibilityChanged) { + triggerPanelAnimation(); + } + return { ...prev, selection: newSelection }; + }); + }, + removeFromSelection: (ids: string[]) => { setState((prev) => { - const newSet = new Set(prev.selectedItemIds); - newSet.delete(id); - return { ...prev, selectedItemIds: newSet }; - }), - clearSelection: () => - setState((prev) => ({ ...prev, selectedItemIds: new Set() })), + const newSelection = new Map(prev.selection); + for (const id of ids) { + newSelection.delete(id); + } + const visibilityChanged = + (prev.selection.size === 0) !== (newSelection.size === 0); + if (visibilityChanged) { + triggerPanelAnimation(); + } + return { ...prev, selection: newSelection }; + }); + }, + clearSelection: () => { + setState((prev) => { + if (prev.selection.size > 0) { + triggerPanelAnimation(); + } + return { ...prev, selection: new Map() }; + }); + }, + focusNode: (nodeId: string) => { + focusNodeRef.current(nodeId); + }, + registerFocusNode: (fn: (nodeId: string) => void) => { + focusNodeRef.current = fn; + }, setDraggingStateByNodeId: (draggingState: DraggingStateByNodeId) => setState((prev) => ({ ...prev, draggingStateByNodeId: draggingState })), updateDraggingStateByNodeId: (updater) => @@ -109,7 +163,7 @@ export const EditorProvider: React.FC = ({ children }) => { ...prev, isLeftSidebarOpen: false, isBottomPanelOpen: false, - selectedResourceId: null, + selection: new Map(), })); }, setTimelineChartType: (chartType) => diff --git a/libs/@hashintel/petrinaut/src/state/selection.ts b/libs/@hashintel/petrinaut/src/state/selection.ts new file mode 100644 index 00000000000..fe78cf38183 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/state/selection.ts @@ -0,0 +1,39 @@ +import { ARC_ID_PREFIX, ARC_ID_SEPARATOR } from "./sdcpn-context"; + +export type SelectionItemType = + | "place" + | "transition" + | "arc" + | "type" + | "differentialEquation" + | "parameter"; + +export type SelectionItem = + | { type: "place"; id: string } + | { type: "transition"; id: string } + | { type: "arc"; id: string } + | { type: "type"; id: string } + | { type: "differentialEquation"; id: string } + | { type: "parameter"; id: string }; + +/** Map from item ID -> typed SelectionItem. O(1) lookup for ReactFlow bridge. */ +export type SelectionMap = Map; + +export type PanelTarget = + | { kind: "none" } + | { kind: "single"; item: SelectionItem } + | { kind: "multi"; items: SelectionItem[] }; + +export function parseArcId( + arcId: string, +): { sourceId: string; targetId: string } | null { + if (!arcId.startsWith(ARC_ID_PREFIX)) { + return null; + } + const rest = arcId.slice(ARC_ID_PREFIX.length); + const [sourceId, targetId] = rest.split(ARC_ID_SEPARATOR); + if (!sourceId || !targetId) { + return null; + } + return { sourceId, targetId }; +} diff --git a/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts b/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts new file mode 100644 index 00000000000..bc61fc0962c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts @@ -0,0 +1,70 @@ +import { use, useEffect } from "react"; + +import { EditorContext } from "./editor-context"; +import { generateArcId, SDCPNContext } from "./sdcpn-context"; +import type { SelectionMap } from "./selection"; + +/** + * Reactively removes stale IDs from the selection when items are deleted from the SDCPN. + */ +export function useSelectionCleanup() { + const { petriNetDefinition } = use(SDCPNContext); + const { selection, setSelection } = use(EditorContext); + + useEffect(() => { + if (selection.size === 0) { + return; + } + + // Build the set of all valid IDs + const validIds = new Set(); + + for (const place of petriNetDefinition.places) { + validIds.add(place.id); + } + for (const transition of petriNetDefinition.transitions) { + validIds.add(transition.id); + for (const inputArc of transition.inputArcs) { + validIds.add( + generateArcId({ inputId: inputArc.placeId, outputId: transition.id }), + ); + } + for (const outputArc of transition.outputArcs) { + validIds.add( + generateArcId({ + inputId: transition.id, + outputId: outputArc.placeId, + }), + ); + } + } + for (const type of petriNetDefinition.types) { + validIds.add(type.id); + } + for (const eq of petriNetDefinition.differentialEquations) { + validIds.add(eq.id); + } + for (const param of petriNetDefinition.parameters) { + validIds.add(param.id); + } + + // Check if any selected ID is stale + let hasStale = false; + for (const id of selection.keys()) { + if (!validIds.has(id)) { + hasStale = true; + break; + } + } + + if (hasStale) { + const cleaned: SelectionMap = new Map(); + for (const [id, item] of selection) { + if (validIds.has(id)) { + cleaned.set(id, item); + } + } + setSelection(cleaned); + } + }, [petriNetDefinition, selection, setSelection]); +} diff --git a/libs/@hashintel/petrinaut/src/state/use-selection.ts b/libs/@hashintel/petrinaut/src/state/use-selection.ts new file mode 100644 index 00000000000..99a040b8c7a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/state/use-selection.ts @@ -0,0 +1,19 @@ +import { use, useMemo } from "react"; + +import { EditorContext } from "./editor-context"; +import type { PanelTarget } from "./selection"; + +export function usePanelTarget(): PanelTarget { + const { selection } = use(EditorContext); + + return useMemo(() => { + const items = Array.from(selection.values()); + if (items.length === 0) { + return { kind: "none" }; + } + if (items.length === 1) { + return { kind: "single", item: items[0]! }; + } + return { kind: "multi", items }; + }, [selection]); +} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index c6c635b6794..0fe4dc934c0 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -13,6 +13,7 @@ import { convertOldFormatToSDCPN } from "../../old-formats/convert-old-format"; import { EditorContext } from "../../state/editor-context"; import { PortalContainerContext } from "../../state/portal-container-context"; import { SDCPNContext } from "../../state/sdcpn-context"; +import { useSelectionCleanup } from "../../state/use-selection-cleanup"; import { SDCPNView } from "../SDCPN/sdcpn-view"; import { BottomBar } from "./components/BottomBar/bottom-bar"; import { TopBar } from "./components/TopBar/top-bar"; @@ -80,6 +81,9 @@ export const EditorView = ({ clearSelection, } = use(EditorContext); + // Clean up stale selections when items are deleted + useSelectionCleanup(); + function handleNew() { createNewNet({ title: "Untitled", diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx index 6a1a6864742..ad8877576d6 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx @@ -8,6 +8,7 @@ import { LanguageClientContext } from "../../../../../lsp/context"; import { parseDocumentUri } from "../../../../../monaco/editor-paths"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; +import type { SelectionItemType } from "../../../../../state/selection"; const emptyMessageStyle = css({ color: "neutral.s100", @@ -116,7 +117,7 @@ const DiagnosticsContent: React.FC = () => { LanguageClientContext, ); const { petriNetDefinition } = use(SDCPNContext); - const { setSelectedResourceId } = use(EditorContext); + const { selectItem } = use(EditorContext); // Track collapsed entities (all expanded by default) const [collapsedEntities, setCollapsedEntities] = useState>( new Set(), @@ -124,10 +125,14 @@ const DiagnosticsContent: React.FC = () => { // Handler to select an entity when clicking on a diagnostic const handleSelectEntity = useCallback( - (entityId: string) => { - setSelectedResourceId(entityId); + (entityId: string, entityType: EntityType) => { + const selectionType: SelectionItemType = + entityType === "differential-equation" + ? "differentialEquation" + : "transition"; + selectItem({ type: selectionType, id: entityId }); }, - [setSelectedResourceId], + [selectItem], ); // Group diagnostics by entity (transition or differential equation) @@ -255,7 +260,12 @@ const DiagnosticsContent: React.FC = () => { >