Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1eed484
Temporary patch
kube Feb 17, 2026
ae3085a
H-5655: Refactor selection logic and migrate to @xyflow/react v12
kube Mar 7, 2026
2f75439
Update yarn.lock
kube Mar 7, 2026
f8c5cbb
H-5655: Fix selection conflicts and multi-node drag, add integration …
kube Mar 7, 2026
b0543ef
H-5655: Batch multi-node drag position commits into single atomic mut…
kube Mar 9, 2026
d5dc664
H-5655: Fix stale selection race and restrict arc selection to click-…
kube Mar 9, 2026
1a1e724
H-5655: Add partial selection setting and refactor settings dialog
kube Mar 9, 2026
95e8d2b
H-5655: Remove focusNode viewport centering on sidebar selection
kube Mar 9, 2026
e185ca7
H-5655: Highlight selected nodes in MiniMap
kube Mar 9, 2026
1cdd1ed
H-5655: Remove browser tests and related dependencies
kube Mar 9, 2026
c9cdd5d
Update yarn.lock
kube Mar 9, 2026
6912c7b
H-5655: Make Delete/Backspace work globally and support all item types
kube Mar 9, 2026
cba394c
H-5655: Add isSelected/hasSelection to EditorContext and type deleteI…
kube Mar 9, 2026
25b5c87
H-5655: Add changeset for selection logic refactor
kube Mar 9, 2026
248a014
H-5655: Fix layout coordinate conversion, selection guard, and restor…
kube Mar 10, 2026
ba94bd8
Fix from review
kube Mar 10, 2026
fb41c5b
Remove vite.browser.config.ts from ESLint allowDefaultProject
kube Mar 10, 2026
2e13d92
yarn fix:yarn-deduplicate
kube Mar 10, 2026
94f502f
yarn fix:yarn-deduplicate
kube Mar 11, 2026
616e1ab
Update refactor-selection-logic.md
kube Mar 12, 2026
ac4803f
Address review nits: rename dims to dimensions, restore descriptive a…
kube Mar 12, 2026
e783a6a
Clean up dangling references when deleting types and differential equ…
kube Mar 12, 2026
3dfb714
Address AI review feedback: stabilize setSelection, remove dead code,…
kube Mar 12, 2026
c88f30b
Fix issue for React compiler
kube Mar 12, 2026
278aaa6
Use functional updater in selection cleanup to avoid stale closure
kube Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/refactor-selection-logic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Add multi-selection support with keyboard shortcuts, refactor selection logic, migrate to @xyflow/react v12
3 changes: 2 additions & 1 deletion libs/@hashintel/petrinaut/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,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",
Expand Down Expand Up @@ -87,6 +87,7 @@
"rolldown-plugin-dts": "0.22.4",
"storybook": "10.2.13",
"vite": "8.0.0-beta.18",
"vite-plugin-dts": "4.5.4",
"vitest": "4.0.18"
},
"peerDependencies": {
Expand Down
25 changes: 17 additions & 8 deletions libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { ElkNode } from "elkjs";
import ELK from "elkjs";

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

/**
* @see https://eclipse.dev/elk/documentation/tooldevelopers
Expand Down Expand Up @@ -38,6 +37,10 @@ export type NodePosition = {
*/
export const calculateGraphLayout = async (
sdcpn: SDCPN,
dimensions: {
place: { width: number; height: number };
transition: { width: number; height: number };
},
): Promise<Record<string, NodePosition>> => {
if (sdcpn.places.length === 0) {
return {};
Expand All @@ -47,13 +50,13 @@ export const calculateGraphLayout = async (
const elkNodes: ElkNode["children"] = [
...sdcpn.places.map((place) => ({
id: place.id,
width: nodeDimensions.place.width,
height: nodeDimensions.place.height,
width: dimensions.place.width,
height: dimensions.place.height,
})),
...sdcpn.transitions.map((transition) => ({
id: transition.id,
width: nodeDimensions.transition.width,
height: nodeDimensions.transition.height,
width: dimensions.transition.width,
height: dimensions.transition.height,
})),
];

Expand Down Expand Up @@ -87,15 +90,21 @@ export const calculateGraphLayout = async (

const updatedElements = await elk.layout(graph);

const placeIds = new Set(sdcpn.places.map((place) => place.id));

/**
* ELK inserts the calculated position as a root 'x' and 'y'.
* ELK returns top-left positions, but the SDCPN store uses center
* coordinates, so we offset by half the node dimensions.
*/
const positionsByNodeId: Record<string, NodePosition> = {};
for (const child of updatedElements.children ?? []) {
if (child.x !== undefined && child.y !== undefined) {
const nodeDimensions = placeIds.has(child.id)
? dimensions.place
: dimensions.transition;
positionsByNodeId[child.id] = {
x: child.x,
y: child.y,
x: child.x + nodeDimensions.width / 2,
y: child.y + nodeDimensions.height / 2,
};
}
}
Expand Down
2 changes: 1 addition & 1 deletion libs/@hashintel/petrinaut/src/petrinaut.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import "reactflow/dist/style.css";
import "@xyflow/react/dist/style.css";
import "./index.css";

import { type FunctionComponent } from "react";
Expand Down
29 changes: 17 additions & 12 deletions libs/@hashintel/petrinaut/src/state/editor-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,8 +35,9 @@ export type EditorState = {
isBottomPanelOpen: boolean;
bottomPanelHeight: number;
activeBottomPanelTab: BottomPanelTab;
selectedResourceId: string | null;
selectedItemIds: Set<string>;
selection: SelectionMap;
/** Whether any items are currently selected. */
hasSelection: boolean;
draggingStateByNodeId: DraggingStateByNodeId;
timelineChartType: TimelineChartType;
isPanelAnimating: boolean;
Expand All @@ -55,10 +57,13 @@ export type EditorActions = {
toggleBottomPanel: () => void;
setBottomPanelHeight: (height: number) => void;
setActiveBottomPanelTab: (tab: BottomPanelTab) => void;
setSelectedResourceId: (id: string | null) => void;
setSelectedItemIds: (ids: Set<string>) => void;
addSelectedItemId: (id: string) => void;
removeSelectedItemId: (id: string) => void;
/** Check whether a given ID is in the current selection. */
isSelected: (id: string) => boolean;
setSelection: (
selection: SelectionMap | ((prev: SelectionMap) => SelectionMap),
) => void;
selectItem: (item: SelectionItem) => void;
toggleItem: (item: SelectionItem) => void;
clearSelection: () => void;
setDraggingStateByNodeId: (state: DraggingStateByNodeId) => void;
updateDraggingStateByNodeId: (
Expand All @@ -83,8 +88,8 @@ export const initialEditorState: EditorState = {
isBottomPanelOpen: false,
bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT,
activeBottomPanelTab: "diagnostics",
selectedResourceId: null,
selectedItemIds: new Set(),
selection: new Map(),
hasSelection: false,
draggingStateByNodeId: {},
timelineChartType: "run",
isPanelAnimating: false,
Expand All @@ -102,10 +107,10 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = {
toggleBottomPanel: () => {},
setBottomPanelHeight: () => {},
setActiveBottomPanelTab: () => {},
setSelectedResourceId: () => {},
setSelectedItemIds: () => {},
addSelectedItemId: () => {},
removeSelectedItemId: () => {},
isSelected: () => false,
setSelection: () => {},
selectItem: () => {},
toggleItem: () => {},
clearSelection: () => {},
setDraggingStateByNodeId: () => {},
updateDraggingStateByNodeId: () => {},
Expand Down
72 changes: 53 additions & 19 deletions libs/@hashintel/petrinaut/src/state/editor-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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

const actions: EditorActions = {
const setSelection = (
selectionOrUpdater: SelectionMap | ((prev: SelectionMap) => SelectionMap),
) =>
setState((prev) => {
const selection =
typeof selectionOrUpdater === "function"
? selectionOrUpdater(prev.selection)
: selectionOrUpdater;
const hasSelection = selection.size > 0;
if (prev.hasSelection !== hasSelection) {
triggerPanelAnimation();
}
return { ...prev, selection, hasSelection };
});

const actions: Omit<EditorActions, "isSelected"> = {
setGlobalMode: (mode) =>
setState((prev) => ({ ...prev, globalMode: mode })),
setEditionMode: (mode) =>
Expand Down Expand Up @@ -74,26 +90,39 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
setState((prev) => ({ ...prev, bottomPanelHeight: height })),
setActiveBottomPanelTab: (tab) =>
setState((prev) => ({ ...prev, activeBottomPanelTab: tab })),
setSelectedResourceId: (id) => {
triggerPanelAnimation();
setState((prev) => ({ ...prev, selectedResourceId: id }));
setSelection,
selectItem: (item: SelectionItem) => {
setState((prev) => {
const newSelection: SelectionMap = new Map([[item.id, item]]);
if (!prev.hasSelection) {
triggerPanelAnimation();
}
return { ...prev, selection: newSelection, hasSelection: true };
});
},
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 hasSelection = newSelection.size > 0;
if (prev.hasSelection !== hasSelection) {
triggerPanelAnimation();
}
return { ...prev, selection: newSelection, hasSelection };
});
},
clearSelection: () => {
setState((prev) => {
const newSet = new Set(prev.selectedItemIds);
newSet.delete(id);
return { ...prev, selectedItemIds: newSet };
}),
clearSelection: () =>
setState((prev) => ({ ...prev, selectedItemIds: new Set() })),
if (prev.hasSelection) {
triggerPanelAnimation();
}
return { ...prev, selection: new Map(), hasSelection: false };
});
},
setDraggingStateByNodeId: (draggingState: DraggingStateByNodeId) =>
setState((prev) => ({ ...prev, draggingStateByNodeId: draggingState })),
updateDraggingStateByNodeId: (updater) =>
Expand All @@ -109,7 +138,8 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
...prev,
isLeftSidebarOpen: false,
isBottomPanelOpen: false,
selectedResourceId: null,
selection: new Map(),
hasSelection: false,
}));
},
setTimelineChartType: (chartType) =>
Expand All @@ -129,9 +159,13 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
timelineChartType: state.timelineChartType,
});

const { selection } = state;
const isSelected = (id: string) => selection.has(id);

const contextValue: EditorContextValue = {
...state,
...actions,
isSelected,
};

return (
Expand Down
3 changes: 2 additions & 1 deletion libs/@hashintel/petrinaut/src/state/sdcpn-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
SDCPN,
Transition,
} from "../core/types/sdcpn";
import type { SelectionMap } from "./selection";

export const ARC_ID_PREFIX = "$A_";
export type ArcIdPrefix = typeof ARC_ID_PREFIX;
Expand Down Expand Up @@ -101,7 +102,7 @@ export type MutationHelperFunctions = {
| "differentialEquation"
| "parameter"
| null;
deleteItemsByIds: (ids: Set<string>) => void;
deleteItemsByIds: (items: SelectionMap) => void;
layoutGraph: () => Promise<void>;
};

Expand Down
Loading
Loading