Skip to content

Commit 47518e4

Browse files
kubeclaude
andcommitted
H-5655: Add isSelected/hasSelection to EditorContext and type deleteItemsByIds
Expose `isSelected(id)` and `hasSelection` from EditorContext so consumers no longer need direct access to the SelectionMap for simple lookups. Change `deleteItemsByIds` to accept typed SelectionMap, enabling it to partition by item type and skip irrelevant collections. Migrate all 12 UI consumers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aa307ef commit 47518e4

18 files changed

Lines changed: 171 additions & 135 deletions

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export type EditorState = {
3636
bottomPanelHeight: number;
3737
activeBottomPanelTab: BottomPanelTab;
3838
selection: SelectionMap;
39+
/** Whether any items are currently selected. */
40+
hasSelection: boolean;
3941
draggingStateByNodeId: DraggingStateByNodeId;
4042
timelineChartType: TimelineChartType;
4143
isPanelAnimating: boolean;
@@ -55,6 +57,8 @@ export type EditorActions = {
5557
toggleBottomPanel: () => void;
5658
setBottomPanelHeight: (height: number) => void;
5759
setActiveBottomPanelTab: (tab: BottomPanelTab) => void;
60+
/** Check whether a given ID is in the current selection. */
61+
isSelected: (id: string) => boolean;
5862
setSelection: (
5963
selection: SelectionMap | ((prev: SelectionMap) => SelectionMap),
6064
) => void;
@@ -87,6 +91,7 @@ export const initialEditorState: EditorState = {
8791
bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT,
8892
activeBottomPanelTab: "diagnostics",
8993
selection: new Map(),
94+
hasSelection: false,
9095
draggingStateByNodeId: {},
9196
timelineChartType: "run",
9297
isPanelAnimating: false,
@@ -104,6 +109,7 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = {
104109
toggleBottomPanel: () => {},
105110
setBottomPanelHeight: () => {},
106111
setActiveBottomPanelTab: () => {},
112+
isSelected: () => false,
107113
setSelection: () => {},
108114
selectItem: () => {},
109115
toggleItem: () => {},

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

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { use, useRef, useState } from "react";
1+
import { use, useCallback, useRef, useState } from "react";
22

33
import {
44
type DraggingStateByNodeId,
@@ -45,7 +45,7 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
4545
}, 500);
4646
};
4747

48-
const actions: EditorActions = {
48+
const actions: Omit<EditorActions, "isSelected"> = {
4949
setGlobalMode: (mode) =>
5050
setState((prev) => ({ ...prev, globalMode: mode })),
5151
setEditionMode: (mode) =>
@@ -83,22 +83,19 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
8383
typeof selectionOrUpdater === "function"
8484
? selectionOrUpdater(prev.selection)
8585
: selectionOrUpdater;
86-
const visibilityChanged =
87-
(prev.selection.size === 0) !== (selection.size === 0);
88-
if (visibilityChanged) {
86+
const hasSelection = selection.size > 0;
87+
if (prev.hasSelection !== hasSelection) {
8988
triggerPanelAnimation();
9089
}
91-
return { ...prev, selection };
90+
return { ...prev, selection, hasSelection };
9291
}),
9392
selectItem: (item: SelectionItem) => {
9493
setState((prev) => {
95-
const wasEmpty = prev.selection.size === 0;
9694
const newSelection: SelectionMap = new Map([[item.id, item]]);
97-
const willBeEmpty = false;
98-
if (wasEmpty !== willBeEmpty) {
95+
if (!prev.hasSelection) {
9996
triggerPanelAnimation();
10097
}
101-
return { ...prev, selection: newSelection };
98+
return { ...prev, selection: newSelection, hasSelection: true };
10299
});
103100
},
104101
toggleItem: (item: SelectionItem) => {
@@ -109,12 +106,11 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
109106
} else {
110107
newSelection.set(item.id, item);
111108
}
112-
const visibilityChanged =
113-
(prev.selection.size === 0) !== (newSelection.size === 0);
114-
if (visibilityChanged) {
109+
const hasSelection = newSelection.size > 0;
110+
if (prev.hasSelection !== hasSelection) {
115111
triggerPanelAnimation();
116112
}
117-
return { ...prev, selection: newSelection };
113+
return { ...prev, selection: newSelection, hasSelection };
118114
});
119115
},
120116
addToSelection: (items: SelectionItem[]) => {
@@ -123,12 +119,11 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
123119
for (const item of items) {
124120
newSelection.set(item.id, item);
125121
}
126-
const visibilityChanged =
127-
(prev.selection.size === 0) !== (newSelection.size === 0);
128-
if (visibilityChanged) {
122+
const hasSelection = newSelection.size > 0;
123+
if (prev.hasSelection !== hasSelection) {
129124
triggerPanelAnimation();
130125
}
131-
return { ...prev, selection: newSelection };
126+
return { ...prev, selection: newSelection, hasSelection };
132127
});
133128
},
134129
removeFromSelection: (ids: string[]) => {
@@ -137,20 +132,19 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
137132
for (const id of ids) {
138133
newSelection.delete(id);
139134
}
140-
const visibilityChanged =
141-
(prev.selection.size === 0) !== (newSelection.size === 0);
142-
if (visibilityChanged) {
135+
const hasSelection = newSelection.size > 0;
136+
if (prev.hasSelection !== hasSelection) {
143137
triggerPanelAnimation();
144138
}
145-
return { ...prev, selection: newSelection };
139+
return { ...prev, selection: newSelection, hasSelection };
146140
});
147141
},
148142
clearSelection: () => {
149143
setState((prev) => {
150-
if (prev.selection.size > 0) {
144+
if (prev.hasSelection) {
151145
triggerPanelAnimation();
152146
}
153-
return { ...prev, selection: new Map() };
147+
return { ...prev, selection: new Map(), hasSelection: false };
154148
});
155149
},
156150
setDraggingStateByNodeId: (draggingState: DraggingStateByNodeId) =>
@@ -169,6 +163,7 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
169163
isLeftSidebarOpen: false,
170164
isBottomPanelOpen: false,
171165
selection: new Map(),
166+
hasSelection: false,
172167
}));
173168
},
174169
setTimelineChartType: (chartType) =>
@@ -188,9 +183,16 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
188183
timelineChartType: state.timelineChartType,
189184
});
190185

186+
const { selection } = state;
187+
const isSelected = useCallback(
188+
(id: string) => selection.has(id),
189+
[selection],
190+
);
191+
191192
const contextValue: EditorContextValue = {
192193
...state,
193194
...actions,
195+
isSelected,
194196
};
195197

196198
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

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

Lines changed: 87 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -261,93 +261,114 @@ export const SDCPNProvider: React.FC<SDCPNProviderProps> = ({
261261

262262
return null;
263263
},
264-
deleteItemsByIds(ids) {
264+
deleteItemsByIds(items) {
265265
rest.mutatePetriNetDefinition((sdcpn) => {
266-
const idsToProcess = new Set(ids);
266+
// Partition selection by type for targeted deletion
267+
const placeIds = new Set<string>();
268+
const transitionIds = new Set<string>();
269+
const arcIds = new Set<string>();
270+
const typeIds = new Set<string>();
271+
const equationIds = new Set<string>();
272+
const parameterIds = new Set<string>();
267273

268-
/**
269-
* Deal with the transitions first because we always need to check them,
270-
* in case they, an arc within them or a place referenced by an arc is being deleted.
271-
*/
272-
for (let i = sdcpn.transitions.length - 1; i >= 0; i--) {
273-
const transition = sdcpn.transitions[i]!;
274-
if (idsToProcess.has(transition.id)) {
275-
sdcpn.transitions.splice(i, 1);
276-
idsToProcess.delete(transition.id);
277-
continue;
274+
for (const [id, item] of items) {
275+
switch (item.type) {
276+
case "place":
277+
placeIds.add(id);
278+
break;
279+
case "transition":
280+
transitionIds.add(id);
281+
break;
282+
case "arc":
283+
arcIds.add(id);
284+
break;
285+
case "type":
286+
typeIds.add(id);
287+
break;
288+
case "differentialEquation":
289+
equationIds.add(id);
290+
break;
291+
case "parameter":
292+
parameterIds.add(id);
293+
break;
278294
}
295+
}
279296

280-
for (
281-
let inputArcIndex = transition.inputArcs.length - 1;
282-
inputArcIndex >= 0;
283-
inputArcIndex--
284-
) {
285-
const inputArc = transition.inputArcs[inputArcIndex]!;
286-
const arcId = generateArcId({
287-
inputId: inputArc.placeId,
288-
outputId: transition.id,
289-
});
297+
// Transitions need special handling: we always iterate them when places,
298+
// transitions, or arcs are being deleted, because arcs live inside transitions
299+
// and deleting a place must cascade to remove its connected arcs.
300+
const hasCanvasDeletes =
301+
placeIds.size > 0 || transitionIds.size > 0 || arcIds.size > 0;
290302

291-
if (idsToProcess.has(arcId) || idsToProcess.has(inputArc.placeId)) {
292-
transition.inputArcs.splice(inputArcIndex, 1);
293-
idsToProcess.delete(arcId);
303+
if (hasCanvasDeletes) {
304+
for (let i = sdcpn.transitions.length - 1; i >= 0; i--) {
305+
const transition = sdcpn.transitions[i]!;
306+
if (transitionIds.has(transition.id)) {
307+
sdcpn.transitions.splice(i, 1);
308+
continue;
294309
}
295-
}
296-
297-
for (
298-
let outputArcIndex = transition.outputArcs.length - 1;
299-
outputArcIndex >= 0;
300-
outputArcIndex--
301-
) {
302-
const outputArc = transition.outputArcs[outputArcIndex]!;
303-
const arcId = generateArcId({
304-
inputId: transition.id,
305-
outputId: outputArc.placeId,
306-
});
307310

308-
if (
309-
idsToProcess.has(arcId) ||
310-
idsToProcess.has(outputArc.placeId)
311+
for (
312+
let arcIdx = transition.inputArcs.length - 1;
313+
arcIdx >= 0;
314+
arcIdx--
311315
) {
312-
transition.outputArcs.splice(outputArcIndex, 1);
313-
idsToProcess.delete(arcId);
316+
const inputArc = transition.inputArcs[arcIdx]!;
317+
const arcId = generateArcId({
318+
inputId: inputArc.placeId,
319+
outputId: transition.id,
320+
});
321+
322+
if (arcIds.has(arcId) || placeIds.has(inputArc.placeId)) {
323+
transition.inputArcs.splice(arcIdx, 1);
324+
}
314325
}
315-
}
316326

317-
if (idsToProcess.size === 0) {
318-
return;
327+
for (
328+
let arcIdx = transition.outputArcs.length - 1;
329+
arcIdx >= 0;
330+
arcIdx--
331+
) {
332+
const outputArc = transition.outputArcs[arcIdx]!;
333+
const arcId = generateArcId({
334+
inputId: transition.id,
335+
outputId: outputArc.placeId,
336+
});
337+
338+
if (arcIds.has(arcId) || placeIds.has(outputArc.placeId)) {
339+
transition.outputArcs.splice(arcIdx, 1);
340+
}
341+
}
319342
}
320-
}
321343

322-
for (let i = sdcpn.places.length - 1; i >= 0; i--) {
323-
const place = sdcpn.places[i]!;
324-
if (idsToProcess.has(place.id)) {
325-
sdcpn.places.splice(i, 1);
326-
idsToProcess.delete(place.id);
344+
for (let i = sdcpn.places.length - 1; i >= 0; i--) {
345+
if (placeIds.has(sdcpn.places[i]!.id)) {
346+
sdcpn.places.splice(i, 1);
347+
}
327348
}
328349
}
329350

330-
for (let i = sdcpn.types.length - 1; i >= 0; i--) {
331-
const type = sdcpn.types[i]!;
332-
if (idsToProcess.has(type.id)) {
333-
sdcpn.types.splice(i, 1);
334-
idsToProcess.delete(type.id);
351+
if (typeIds.size > 0) {
352+
for (let i = sdcpn.types.length - 1; i >= 0; i--) {
353+
if (typeIds.has(sdcpn.types[i]!.id)) {
354+
sdcpn.types.splice(i, 1);
355+
}
335356
}
336357
}
337358

338-
for (let i = sdcpn.differentialEquations.length - 1; i >= 0; i--) {
339-
const equation = sdcpn.differentialEquations[i]!;
340-
if (idsToProcess.has(equation.id)) {
341-
sdcpn.differentialEquations.splice(i, 1);
342-
idsToProcess.delete(equation.id);
359+
if (equationIds.size > 0) {
360+
for (let i = sdcpn.differentialEquations.length - 1; i >= 0; i--) {
361+
if (equationIds.has(sdcpn.differentialEquations[i]!.id)) {
362+
sdcpn.differentialEquations.splice(i, 1);
363+
}
343364
}
344365
}
345366

346-
for (let i = sdcpn.parameters.length - 1; i >= 0; i--) {
347-
const parameter = sdcpn.parameters[i]!;
348-
if (idsToProcess.has(parameter.id)) {
349-
sdcpn.parameters.splice(i, 1);
350-
idsToProcess.delete(parameter.id);
367+
if (parameterIds.size > 0) {
368+
for (let i = sdcpn.parameters.length - 1; i >= 0; i--) {
369+
if (parameterIds.has(sdcpn.parameters[i]!.id)) {
370+
sdcpn.parameters.splice(i, 1);
371+
}
351372
}
352373
}
353374
});

libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function useKeyboardShortcuts(
1515
onCursorModeChange: (mode: CursorMode) => void,
1616
) {
1717
const undoRedo = use(UndoRedoContext);
18-
const { selection, clearSelection } = use(EditorContext);
18+
const { selection, hasSelection, clearSelection } = use(EditorContext);
1919
const { deleteItemsByIds, readonly } = use(SDCPNContext);
2020
const isSimulationReadOnly = useIsReadOnly();
2121
const isReadonly = isSimulationReadOnly || readonly;
@@ -54,10 +54,10 @@ export function useKeyboardShortcuts(
5454
if (
5555
(event.key === "Delete" || event.key === "Backspace") &&
5656
!isReadonly &&
57-
selection.size > 0
57+
hasSelection
5858
) {
5959
event.preventDefault();
60-
deleteItemsByIds(new Set(selection.keys()));
60+
deleteItemsByIds(selection);
6161
clearSelection();
6262
return;
6363
}

0 commit comments

Comments
 (0)