Skip to content

Commit 44fe283

Browse files
kubeclaude
andauthored
H-5655: Refactor selection logic and migrate to @xyflow/react v12 (#8523)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5284a95 commit 44fe283

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1307
-466
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hashintel/petrinaut": patch
3+
---
4+
5+
Add multi-selection support with keyboard shortcuts, refactor selection logic, migrate to @xyflow/react v12

libs/@hashintel/petrinaut/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,13 @@
5050
"@hashintel/refractive": "workspace:^",
5151
"@mantine/hooks": "8.3.5",
5252
"@monaco-editor/react": "4.8.0-rc.3",
53+
"@xyflow/react": "12.10.1",
5354
"d3-array": "3.2.4",
5455
"d3-scale": "4.0.2",
5556
"elkjs": "0.11.0",
5657
"monaco-editor": "0.55.1",
5758
"react-icons": "5.5.0",
5859
"react-resizable-panels": "4.6.5",
59-
"reactflow": "11.11.4",
6060
"typescript": "5.9.3",
6161
"uuid": "13.0.0",
6262
"vscode-languageserver-types": "3.17.5",
@@ -87,6 +87,7 @@
8787
"rolldown-plugin-dts": "0.22.4",
8888
"storybook": "10.2.13",
8989
"vite": "8.0.0-beta.18",
90+
"vite-plugin-dts": "4.5.4",
9091
"vitest": "4.0.18"
9192
},
9293
"peerDependencies": {

libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { ElkNode } from "elkjs";
22
import ELK from "elkjs";
33

44
import type { SDCPN } from "../core/types/sdcpn";
5-
import { nodeDimensions } from "../views/SDCPN/styles/styling";
65

76
/**
87
* @see https://eclipse.dev/elk/documentation/tooldevelopers
@@ -38,6 +37,10 @@ export type NodePosition = {
3837
*/
3938
export const calculateGraphLayout = async (
4039
sdcpn: SDCPN,
40+
dimensions: {
41+
place: { width: number; height: number };
42+
transition: { width: number; height: number };
43+
},
4144
): Promise<Record<string, NodePosition>> => {
4245
if (sdcpn.places.length === 0) {
4346
return {};
@@ -47,13 +50,13 @@ export const calculateGraphLayout = async (
4750
const elkNodes: ElkNode["children"] = [
4851
...sdcpn.places.map((place) => ({
4952
id: place.id,
50-
width: nodeDimensions.place.width,
51-
height: nodeDimensions.place.height,
53+
width: dimensions.place.width,
54+
height: dimensions.place.height,
5255
})),
5356
...sdcpn.transitions.map((transition) => ({
5457
id: transition.id,
55-
width: nodeDimensions.transition.width,
56-
height: nodeDimensions.transition.height,
58+
width: dimensions.transition.width,
59+
height: dimensions.transition.height,
5760
})),
5861
];
5962

@@ -87,15 +90,21 @@ export const calculateGraphLayout = async (
8790

8891
const updatedElements = await elk.layout(graph);
8992

93+
const placeIds = new Set(sdcpn.places.map((place) => place.id));
94+
9095
/**
91-
* ELK inserts the calculated position as a root 'x' and 'y'.
96+
* ELK returns top-left positions, but the SDCPN store uses center
97+
* coordinates, so we offset by half the node dimensions.
9298
*/
9399
const positionsByNodeId: Record<string, NodePosition> = {};
94100
for (const child of updatedElements.children ?? []) {
95101
if (child.x !== undefined && child.y !== undefined) {
102+
const nodeDimensions = placeIds.has(child.id)
103+
? dimensions.place
104+
: dimensions.transition;
96105
positionsByNodeId[child.id] = {
97-
x: child.x,
98-
y: child.y,
106+
x: child.x + nodeDimensions.width / 2,
107+
y: child.y + nodeDimensions.height / 2,
99108
};
100109
}
101110
}

libs/@hashintel/petrinaut/src/petrinaut.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import "reactflow/dist/style.css";
1+
import "@xyflow/react/dist/style.css";
22
import "./index.css";
33

44
import { type FunctionComponent } from "react";

libs/@hashintel/petrinaut/src/state/editor-context.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
DEFAULT_LEFT_SIDEBAR_WIDTH,
66
DEFAULT_PROPERTIES_PANEL_WIDTH,
77
} from "../constants/ui";
8+
import type { SelectionItem, SelectionMap } from "./selection";
89

910
export type DraggingStateByNodeId = Record<
1011
string,
@@ -34,8 +35,9 @@ export type EditorState = {
3435
isBottomPanelOpen: boolean;
3536
bottomPanelHeight: number;
3637
activeBottomPanelTab: BottomPanelTab;
37-
selectedResourceId: string | null;
38-
selectedItemIds: Set<string>;
38+
selection: SelectionMap;
39+
/** Whether any items are currently selected. */
40+
hasSelection: boolean;
3941
draggingStateByNodeId: DraggingStateByNodeId;
4042
timelineChartType: TimelineChartType;
4143
isPanelAnimating: boolean;
@@ -55,10 +57,13 @@ export type EditorActions = {
5557
toggleBottomPanel: () => void;
5658
setBottomPanelHeight: (height: number) => void;
5759
setActiveBottomPanelTab: (tab: BottomPanelTab) => void;
58-
setSelectedResourceId: (id: string | null) => void;
59-
setSelectedItemIds: (ids: Set<string>) => void;
60-
addSelectedItemId: (id: string) => void;
61-
removeSelectedItemId: (id: string) => void;
60+
/** Check whether a given ID is in the current selection. */
61+
isSelected: (id: string) => boolean;
62+
setSelection: (
63+
selection: SelectionMap | ((prev: SelectionMap) => SelectionMap),
64+
) => void;
65+
selectItem: (item: SelectionItem) => void;
66+
toggleItem: (item: SelectionItem) => void;
6267
clearSelection: () => void;
6368
setDraggingStateByNodeId: (state: DraggingStateByNodeId) => void;
6469
updateDraggingStateByNodeId: (
@@ -83,8 +88,8 @@ export const initialEditorState: EditorState = {
8388
isBottomPanelOpen: false,
8489
bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT,
8590
activeBottomPanelTab: "diagnostics",
86-
selectedResourceId: null,
87-
selectedItemIds: new Set(),
91+
selection: new Map(),
92+
hasSelection: false,
8893
draggingStateByNodeId: {},
8994
timelineChartType: "run",
9095
isPanelAnimating: false,
@@ -102,10 +107,10 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = {
102107
toggleBottomPanel: () => {},
103108
setBottomPanelHeight: () => {},
104109
setActiveBottomPanelTab: () => {},
105-
setSelectedResourceId: () => {},
106-
setSelectedItemIds: () => {},
107-
addSelectedItemId: () => {},
108-
removeSelectedItemId: () => {},
110+
isSelected: () => false,
111+
setSelection: () => {},
112+
selectItem: () => {},
113+
toggleItem: () => {},
109114
clearSelection: () => {},
110115
setDraggingStateByNodeId: () => {},
111116
updateDraggingStateByNodeId: () => {},

libs/@hashintel/petrinaut/src/state/editor-provider.tsx

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type EditorState,
99
initialEditorState,
1010
} from "./editor-context";
11+
import type { SelectionItem, SelectionMap } from "./selection";
1112
import { useSyncEditorToSettings } from "./use-sync-editor-to-settings";
1213
import { UserSettingsContext } from "./user-settings-context";
1314

@@ -44,7 +45,22 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
4445
}, 500);
4546
};
4647

47-
const actions: EditorActions = {
48+
const setSelection = (
49+
selectionOrUpdater: SelectionMap | ((prev: SelectionMap) => SelectionMap),
50+
) =>
51+
setState((prev) => {
52+
const selection =
53+
typeof selectionOrUpdater === "function"
54+
? selectionOrUpdater(prev.selection)
55+
: selectionOrUpdater;
56+
const hasSelection = selection.size > 0;
57+
if (prev.hasSelection !== hasSelection) {
58+
triggerPanelAnimation();
59+
}
60+
return { ...prev, selection, hasSelection };
61+
});
62+
63+
const actions: Omit<EditorActions, "isSelected"> = {
4864
setGlobalMode: (mode) =>
4965
setState((prev) => ({ ...prev, globalMode: mode })),
5066
setEditionMode: (mode) =>
@@ -74,26 +90,39 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
7490
setState((prev) => ({ ...prev, bottomPanelHeight: height })),
7591
setActiveBottomPanelTab: (tab) =>
7692
setState((prev) => ({ ...prev, activeBottomPanelTab: tab })),
77-
setSelectedResourceId: (id) => {
78-
triggerPanelAnimation();
79-
setState((prev) => ({ ...prev, selectedResourceId: id }));
93+
setSelection,
94+
selectItem: (item: SelectionItem) => {
95+
setState((prev) => {
96+
const newSelection: SelectionMap = new Map([[item.id, item]]);
97+
if (!prev.hasSelection) {
98+
triggerPanelAnimation();
99+
}
100+
return { ...prev, selection: newSelection, hasSelection: true };
101+
});
80102
},
81-
setSelectedItemIds: (ids) =>
82-
setState((prev) => ({ ...prev, selectedItemIds: ids })),
83-
addSelectedItemId: (id) =>
103+
toggleItem: (item: SelectionItem) => {
84104
setState((prev) => {
85-
const newSet = new Set(prev.selectedItemIds);
86-
newSet.add(id);
87-
return { ...prev, selectedItemIds: newSet };
88-
}),
89-
removeSelectedItemId: (id) =>
105+
const newSelection = new Map(prev.selection);
106+
if (newSelection.has(item.id)) {
107+
newSelection.delete(item.id);
108+
} else {
109+
newSelection.set(item.id, item);
110+
}
111+
const hasSelection = newSelection.size > 0;
112+
if (prev.hasSelection !== hasSelection) {
113+
triggerPanelAnimation();
114+
}
115+
return { ...prev, selection: newSelection, hasSelection };
116+
});
117+
},
118+
clearSelection: () => {
90119
setState((prev) => {
91-
const newSet = new Set(prev.selectedItemIds);
92-
newSet.delete(id);
93-
return { ...prev, selectedItemIds: newSet };
94-
}),
95-
clearSelection: () =>
96-
setState((prev) => ({ ...prev, selectedItemIds: new Set() })),
120+
if (prev.hasSelection) {
121+
triggerPanelAnimation();
122+
}
123+
return { ...prev, selection: new Map(), hasSelection: false };
124+
});
125+
},
97126
setDraggingStateByNodeId: (draggingState: DraggingStateByNodeId) =>
98127
setState((prev) => ({ ...prev, draggingStateByNodeId: draggingState })),
99128
updateDraggingStateByNodeId: (updater) =>
@@ -109,7 +138,8 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
109138
...prev,
110139
isLeftSidebarOpen: false,
111140
isBottomPanelOpen: false,
112-
selectedResourceId: null,
141+
selection: new Map(),
142+
hasSelection: false,
113143
}));
114144
},
115145
setTimelineChartType: (chartType) =>
@@ -129,9 +159,13 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
129159
timelineChartType: state.timelineChartType,
130160
});
131161

162+
const { selection } = state;
163+
const isSelected = (id: string) => selection.has(id);
164+
132165
const contextValue: EditorContextValue = {
133166
...state,
134167
...actions,
168+
isSelected,
135169
};
136170

137171
return (

libs/@hashintel/petrinaut/src/state/sdcpn-context.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
SDCPN,
1111
Transition,
1212
} from "../core/types/sdcpn";
13+
import type { SelectionMap } from "./selection";
1314

1415
export const ARC_ID_PREFIX = "$A_";
1516
export type ArcIdPrefix = typeof ARC_ID_PREFIX;
@@ -101,7 +102,7 @@ export type MutationHelperFunctions = {
101102
| "differentialEquation"
102103
| "parameter"
103104
| null;
104-
deleteItemsByIds: (ids: Set<string>) => void;
105+
deleteItemsByIds: (items: SelectionMap) => void;
105106
layoutGraph: () => Promise<void>;
106107
};
107108

0 commit comments

Comments
 (0)