diff --git a/.changeset/refactor-selection-logic.md b/.changeset/refactor-selection-logic.md new file mode 100644 index 00000000000..07848f13027 --- /dev/null +++ b/.changeset/refactor-selection-logic.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add multi-selection support with keyboard shortcuts, refactor selection logic, migrate to @xyflow/react v12 diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index bf3e8cb97e5..7a633752ba0 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -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", @@ -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": { diff --git a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts index 7bce4e14b4f..94605dca1c8 100644 --- a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts +++ b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts @@ -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 @@ -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> => { if (sdcpn.places.length === 0) { return {}; @@ -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, })), ]; @@ -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 = {}; 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, }; } } 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..93aa8d9b94c 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,9 @@ export type EditorState = { isBottomPanelOpen: boolean; bottomPanelHeight: number; activeBottomPanelTab: BottomPanelTab; - selectedResourceId: string | null; - selectedItemIds: Set; + selection: SelectionMap; + /** Whether any items are currently selected. */ + hasSelection: boolean; draggingStateByNodeId: DraggingStateByNodeId; timelineChartType: TimelineChartType; isPanelAnimating: boolean; @@ -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) => 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: ( @@ -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, @@ -102,10 +107,10 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, setActiveBottomPanelTab: () => {}, - setSelectedResourceId: () => {}, - setSelectedItemIds: () => {}, - addSelectedItemId: () => {}, - removeSelectedItemId: () => {}, + isSelected: () => false, + setSelection: () => {}, + selectItem: () => {}, + toggleItem: () => {}, clearSelection: () => {}, setDraggingStateByNodeId: () => {}, updateDraggingStateByNodeId: () => {}, diff --git a/libs/@hashintel/petrinaut/src/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/state/editor-provider.tsx index 910a0d7b268..e847881ada6 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"; @@ -44,7 +45,22 @@ export const EditorProvider: React.FC = ({ 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 = { setGlobalMode: (mode) => setState((prev) => ({ ...prev, globalMode: mode })), setEditionMode: (mode) => @@ -74,26 +90,39 @@ 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, + 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) => @@ -109,7 +138,8 @@ export const EditorProvider: React.FC = ({ children }) => { ...prev, isLeftSidebarOpen: false, isBottomPanelOpen: false, - selectedResourceId: null, + selection: new Map(), + hasSelection: false, })); }, setTimelineChartType: (chartType) => @@ -129,9 +159,13 @@ export const EditorProvider: React.FC = ({ children }) => { timelineChartType: state.timelineChartType, }); + const { selection } = state; + const isSelected = (id: string) => selection.has(id); + const contextValue: EditorContextValue = { ...state, ...actions, + isSelected, }; return ( diff --git a/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts b/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts index fe8ed71b96e..008339c9799 100644 --- a/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts +++ b/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts @@ -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; @@ -101,7 +102,7 @@ export type MutationHelperFunctions = { | "differentialEquation" | "parameter" | null; - deleteItemsByIds: (ids: Set) => void; + deleteItemsByIds: (items: SelectionMap) => void; layoutGraph: () => Promise; }; diff --git a/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx b/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx index aa2efd6fd93..b307cf1acb6 100644 --- a/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx @@ -1,4 +1,10 @@ +import { use } from "react"; + import { calculateGraphLayout } from "../lib/calculate-graph-layout"; +import { + classicNodeDimensions, + compactNodeDimensions, +} from "../views/SDCPN/styles/styling"; import { ARC_ID_PREFIX, generateArcId, @@ -6,11 +12,16 @@ import { type SDCPNContextValue, type SDCPNProviderProps, } from "./sdcpn-context"; +import { UserSettingsContext } from "./user-settings-context"; export const SDCPNProvider: React.FC = ({ children, ...rest }: React.PropsWithChildren) => { + const { compactNodes } = use(UserSettingsContext); + const dimensions = compactNodes + ? compactNodeDimensions + : classicNodeDimensions; const value: SDCPNContextValue = { ...rest, addPlace(place) { @@ -179,6 +190,17 @@ export const SDCPNProvider: React.FC = ({ break; } } + // Clear dangling colorId references + for (const place of sdcpn.places) { + if (place.colorId === typeId) { + place.colorId = null; + } + } + for (const equation of sdcpn.differentialEquations) { + if (equation.colorId === typeId) { + equation.colorId = ""; + } + } }); }, addDifferentialEquation(equation) { @@ -204,6 +226,12 @@ export const SDCPNProvider: React.FC = ({ break; } } + // Clear dangling differentialEquationId references + for (const place of sdcpn.places) { + if (place.differentialEquationId === equationId) { + place.differentialEquationId = null; + } + } }); }, addParameter(parameter) { @@ -261,73 +289,134 @@ export const SDCPNProvider: React.FC = ({ return null; }, - deleteItemsByIds(ids) { + deleteItemsByIds(items) { rest.mutatePetriNetDefinition((sdcpn) => { - const idsToProcess = new Set(ids); + // Partition selection by type for targeted deletion + const placeIds = new Set(); + const transitionIds = new Set(); + const arcIds = new Set(); + const typeIds = new Set(); + const equationIds = new Set(); + const parameterIds = new Set(); - /** - * Deal with the transitions first because we always need to check them, - * in case they, an arc within them or a place referenced by an arc is being deleted. - */ - for (let i = sdcpn.transitions.length - 1; i >= 0; i--) { - const transition = sdcpn.transitions[i]!; - if (idsToProcess.has(transition.id)) { - sdcpn.transitions.splice(i, 1); - idsToProcess.delete(transition.id); - continue; + for (const [id, item] of items) { + switch (item.type) { + case "place": + placeIds.add(id); + break; + case "transition": + transitionIds.add(id); + break; + case "arc": + arcIds.add(id); + break; + case "type": + typeIds.add(id); + break; + case "differentialEquation": + equationIds.add(id); + break; + case "parameter": + parameterIds.add(id); + break; } + } - for ( - let inputArcIndex = transition.inputArcs.length - 1; - inputArcIndex >= 0; - inputArcIndex-- - ) { - const inputArc = transition.inputArcs[inputArcIndex]!; - const arcId = generateArcId({ - inputId: inputArc.placeId, - outputId: transition.id, - }); + // Transitions need special handling: we always iterate them when places, + // transitions, or arcs are being deleted, because arcs live inside transitions + // and deleting a place must cascade to remove its connected arcs. + const hasCanvasDeletes = + placeIds.size > 0 || transitionIds.size > 0 || arcIds.size > 0; - if (idsToProcess.has(arcId) || idsToProcess.has(inputArc.placeId)) { - transition.inputArcs.splice(inputArcIndex, 1); - idsToProcess.delete(arcId); + if (hasCanvasDeletes) { + for (let i = sdcpn.transitions.length - 1; i >= 0; i--) { + const transition = sdcpn.transitions[i]!; + if (transitionIds.has(transition.id)) { + sdcpn.transitions.splice(i, 1); + continue; } - } - for ( - let outputArcIndex = transition.outputArcs.length - 1; - outputArcIndex >= 0; - outputArcIndex-- - ) { - const outputArc = transition.outputArcs[outputArcIndex]!; - const arcId = generateArcId({ - inputId: transition.id, - outputId: outputArc.placeId, - }); + for ( + let inputArcIndex = transition.inputArcs.length - 1; + inputArcIndex >= 0; + inputArcIndex-- + ) { + const inputArc = transition.inputArcs[inputArcIndex]!; + const arcId = generateArcId({ + inputId: inputArc.placeId, + outputId: transition.id, + }); - if ( - idsToProcess.has(arcId) || - idsToProcess.has(outputArc.placeId) + if (arcIds.has(arcId) || placeIds.has(inputArc.placeId)) { + transition.inputArcs.splice(inputArcIndex, 1); + } + } + + for ( + let outputArcIndex = transition.outputArcs.length - 1; + outputArcIndex >= 0; + outputArcIndex-- ) { - transition.outputArcs.splice(outputArcIndex, 1); - idsToProcess.delete(arcId); + const outputArc = transition.outputArcs[outputArcIndex]!; + const arcId = generateArcId({ + inputId: transition.id, + outputId: outputArc.placeId, + }); + + if (arcIds.has(arcId) || placeIds.has(outputArc.placeId)) { + transition.outputArcs.splice(outputArcIndex, 1); + } + } + } + + for (let i = sdcpn.places.length - 1; i >= 0; i--) { + if (placeIds.has(sdcpn.places[i]!.id)) { + sdcpn.places.splice(i, 1); + } + } + } + + if (typeIds.size > 0) { + for (let i = sdcpn.types.length - 1; i >= 0; i--) { + if (typeIds.has(sdcpn.types[i]!.id)) { + sdcpn.types.splice(i, 1); + } + } + // Clear dangling colorId references on places and equations + for (const place of sdcpn.places) { + if (place.colorId && typeIds.has(place.colorId)) { + place.colorId = null; + } + } + for (const equation of sdcpn.differentialEquations) { + if (typeIds.has(equation.colorId)) { + equation.colorId = ""; } } + } - /** - * If we have no more ids we can return now. - * Places aren't referred to by anything else, so no more ids == no places to delete. - */ - if (idsToProcess.size === 0) { - return; + if (equationIds.size > 0) { + for (let i = sdcpn.differentialEquations.length - 1; i >= 0; i--) { + if (equationIds.has(sdcpn.differentialEquations[i]!.id)) { + sdcpn.differentialEquations.splice(i, 1); + } + } + // Clear dangling differentialEquationId references on places + for (const place of sdcpn.places) { + if ( + place.differentialEquationId && + equationIds.has(place.differentialEquationId) + ) { + place.differentialEquationId = null; + } } } - for (let i = sdcpn.places.length - 1; i >= 0; i--) { - const place = sdcpn.places[i]!; - if (idsToProcess.has(place.id)) { - sdcpn.places.splice(i, 1); - idsToProcess.delete(place.id); + if (parameterIds.size > 0) { + for (let i = sdcpn.parameters.length - 1; i >= 0; i--) { + if (parameterIds.has(sdcpn.parameters[i]!.id)) { + sdcpn.parameters.splice(i, 1); + } } } }); @@ -339,7 +428,7 @@ export const SDCPNProvider: React.FC = ({ return; } - const positions = await calculateGraphLayout(sdcpn); + const positions = await calculateGraphLayout(sdcpn, dimensions); rest.mutatePetriNetDefinition((sdcpnToMutate) => { for (const place of sdcpnToMutate.places) { 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..b7f770f9e94 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts @@ -0,0 +1,72 @@ +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) { + setSelection((prev) => { + const cleaned: SelectionMap = new Map(); + for (const [id, item] of prev) { + if (validIds.has(id)) { + cleaned.set(id, item); + } + } + return 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/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/state/user-settings-context.ts index 9c0484826f8..65f6c32f9a9 100644 --- a/libs/@hashintel/petrinaut/src/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/state/user-settings-context.ts @@ -38,6 +38,7 @@ export type UserSettings = { bottomPanelHeight: number; activeBottomPanelTab: BottomPanelTab; timelineChartType: TimelineChartType; + partialSelection: boolean; subViewPanels: SubViewPanelsSettings; }; @@ -54,6 +55,7 @@ export type UserSettingsActions = { setActiveBottomPanelTab: (value: BottomPanelTab) => void; setCursorMode: (value: CursorMode) => void; setTimelineChartType: (value: TimelineChartType) => void; + setPartialSelection: (value: boolean) => void; updateSubViewSection: ( containerName: string, sectionId: string, @@ -76,6 +78,7 @@ export const defaultUserSettings: UserSettings = { bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, activeBottomPanelTab: "diagnostics", timelineChartType: "run", + partialSelection: true, subViewPanels: {}, }; @@ -93,6 +96,7 @@ const DEFAULT_CONTEXT_VALUE: UserSettingsContextValue = { setActiveBottomPanelTab: () => {}, setCursorMode: () => {}, setTimelineChartType: () => {}, + setPartialSelection: () => {}, updateSubViewSection: () => {}, }; diff --git a/libs/@hashintel/petrinaut/src/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/state/user-settings-provider.tsx index 1441688bddb..a28aea5fb36 100644 --- a/libs/@hashintel/petrinaut/src/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/user-settings-provider.tsx @@ -69,6 +69,8 @@ export const UserSettingsProvider: React.FC = ({ setState((prev) => ({ ...prev, activeBottomPanelTab: value })), setTimelineChartType: (value: TimelineChartType) => setState((prev) => ({ ...prev, timelineChartType: value })), + setPartialSelection: (value: boolean) => + setState((prev) => ({ ...prev, partialSelection: value })), updateSubViewSection: ( containerName: string, sectionId: string, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index df4edfeb770..6a4e6478f1d 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -1,7 +1,10 @@ -import { use, useEffect } from "react"; +import { use, useEffect, useEffectEvent } from "react"; import type { CursorMode, EditorState } from "../../../../state/editor-context"; +import { EditorContext } from "../../../../state/editor-context"; +import { SDCPNContext } from "../../../../state/sdcpn-context"; import { UndoRedoContext } from "../../../../state/undo-redo-context"; +import { useIsReadOnly } from "../../../../state/use-is-read-only"; type EditorMode = EditorState["globalMode"]; type EditorEditionMode = EditorState["editionMode"]; @@ -12,78 +15,94 @@ export function useKeyboardShortcuts( onCursorModeChange: (mode: CursorMode) => void, ) { const undoRedo = use(UndoRedoContext); + const { selection, hasSelection, clearSelection } = use(EditorContext); + const { deleteItemsByIds, readonly } = use(SDCPNContext); + const isSimulationReadOnly = useIsReadOnly(); + const isReadonly = isSimulationReadOnly || readonly; - useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - const target = event.target as HTMLElement; + const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { + const target = event.target as HTMLElement; - const isInputFocused = - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable || - target.closest(".monaco-editor") !== null || - target.closest("#sentry-feedback") !== null; + const isInputFocused = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable || + target.closest(".monaco-editor") !== null || + target.closest("#sentry-feedback") !== null; - // Handle undo/redo shortcuts, but let inputs handle their own undo/redo. - if ( - undoRedo && - !isInputFocused && - (event.metaKey || event.ctrlKey) && - event.key.toLowerCase() === "z" - ) { - event.preventDefault(); - if (event.shiftKey) { - undoRedo.redo(); - } else { - undoRedo.undo(); - } - return; + // Handle undo/redo shortcuts, but let inputs handle their own undo/redo. + if ( + undoRedo && + !isInputFocused && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "z" + ) { + event.preventDefault(); + if (event.shiftKey) { + undoRedo.redo(); + } else { + undoRedo.undo(); } + return; + } - if (isInputFocused) { - return; - } + if (isInputFocused) { + return; + } - // Check that no modifier keys are pressed - if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { - return; - } + // Delete selected items with Backspace or Delete + if ( + (event.key === "Delete" || event.key === "Backspace") && + !isReadonly && + hasSelection + ) { + event.preventDefault(); + deleteItemsByIds(selection); + clearSelection(); + return; + } - // Switch modes based on key - switch (event.key.toLowerCase()) { - // If escape is pressed, switch to cursor mode (keep current cursor) - case "escape": - event.preventDefault(); - onEditionModeChange("cursor"); - break; - case "v": + // Check that no modifier keys are pressed + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + + // Switch modes based on key + switch (event.key.toLowerCase()) { + // If escape is pressed, switch to cursor mode (keep current cursor) + case "escape": + event.preventDefault(); + onEditionModeChange("cursor"); + break; + case "v": + event.preventDefault(); + onCursorModeChange("select"); + onEditionModeChange("cursor"); + break; + case "h": + event.preventDefault(); + onCursorModeChange("pan"); + onEditionModeChange("cursor"); + break; + case "n": + if (mode === "edit") { event.preventDefault(); - onCursorModeChange("select"); - onEditionModeChange("cursor"); - break; - case "h": + onEditionModeChange("add-place"); + } + break; + case "t": + if (mode === "edit") { event.preventDefault(); - onCursorModeChange("pan"); - onEditionModeChange("cursor"); - break; - case "n": - if (mode === "edit") { - event.preventDefault(); - onEditionModeChange("add-place"); - } - break; - case "t": - if (mode === "edit") { - event.preventDefault(); - onEditionModeChange("add-transition"); - } - break; - } + onEditionModeChange("add-transition"); + } + break; } + }); + useEffect(() => { window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [mode, onEditionModeChange, onCursorModeChange, undoRedo]); + }, [handleKeyDown]); } 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 = () => { >