feat: lasso selection, multi-select panel, and named groups

- Add lasso/box selection via selectionOnDrag (Space to pan, lasso by default)
- Add lasso/pan toggle button in canvas controls (bottom-left)
- Multi-select panel in right panel when 2+ nodes selected (including zones)
- Create named Group node from selected nodes with bounding box math
- Group node: resizable, inline rename, show/hide border toggle, status summary
- GroupDetailPanel: lists members, online/offline count, ungroup action
- Fix group persistence after save/reload (extend proxmox container map to include group nodes)
- Fix groupRect serialization to preserve parent_id
- Remove background color from group and proxmox container node wrappers
- Add tests for all new store actions, GroupNode, MultiSelectPanel, GroupDetailPanel
This commit is contained in:
Pouzor
2026-04-01 23:15:52 +02:00
parent 45a17b0254
commit 0b89244317
15 changed files with 1113 additions and 187 deletions
+123 -5
View File
@@ -19,6 +19,7 @@ interface CanvasState {
edges: Edge<EdgeData>[]
hasUnsavedChanges: boolean
selectedNodeId: string | null
selectedNodeIds: string[]
scanEventTs: number
// History
@@ -46,6 +47,8 @@ interface CanvasState {
setNodeZIndex: (id: string, zIndex: number) => void
editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void
createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void
markSaved: () => void
markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
@@ -59,6 +62,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
edges: [],
hasUnsavedChanges: false,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
hideIp: false,
scanEventTs: 0,
@@ -125,10 +129,15 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}),
onNodesChange: (changes) =>
set((state) => ({
nodes: applyNodeChanges(changes, state.nodes),
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
})),
set((state) => {
const nodes = applyNodeChanges(changes, state.nodes)
const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id)
return {
nodes,
selectedNodeIds,
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
}
}),
onEdgesChange: (changes) =>
set((state) => ({
@@ -156,7 +165,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}
}),
setSelectedNode: (id) => set({ selectedNodeId: id }),
setSelectedNode: (id) => set((state) => ({
selectedNodeId: id,
selectedNodeIds: id ? state.selectedNodeIds : [],
})),
addNode: (node) =>
set((state) => {
@@ -244,6 +256,112 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
createGroup: (nodeIds, name) =>
set((state) => {
const PADDING_H = 24
const PADDING_TOP = 48
const PADDING_BOTTOM = 24
const targets = state.nodes.filter((n) => nodeIds.includes(n.id))
if (targets.length === 0) return state
// Bounding box in absolute coordinates
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const n of targets) {
const w = n.width ?? 200
const h = n.height ?? 80
minX = Math.min(minX, n.position.x)
minY = Math.min(minY, n.position.y)
maxX = Math.max(maxX, n.position.x + w)
maxY = Math.max(maxY, n.position.y + h)
}
const groupX = minX - PADDING_H
const groupY = minY - PADDING_TOP
const groupW = maxX - minX + PADDING_H * 2
const groupH = maxY - minY + PADDING_TOP + PADDING_BOTTOM
const groupId = generateUUID()
const groupNode: Node<NodeData> = {
id: groupId,
type: 'group',
position: { x: groupX, y: groupY },
width: groupW,
height: groupH,
data: {
label: name,
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: true },
},
selected: false,
}
// Convert children to relative positions and assign parentId
const updatedNodes = state.nodes.map((n) => {
if (!nodeIds.includes(n.id)) return n
return {
...n,
parentId: groupId,
extent: 'parent' as const,
position: {
x: n.position.x - groupX,
y: n.position.y - groupY,
},
selected: false,
data: { ...n.data, parent_id: groupId },
}
})
// Group node must come before its children
const withoutTargets = updatedNodes.filter((n) => !nodeIds.includes(n.id))
const children = updatedNodes.filter((n) => nodeIds.includes(n.id))
const nodes = [...withoutTargets, groupNode, ...children]
return {
nodes,
selectedNodeIds: [],
selectedNodeId: null,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
ungroup: (groupId) =>
set((state) => {
const group = state.nodes.find((n) => n.id === groupId)
if (!group) return state
const groupAbsX = group.position.x
const groupAbsY = group.position.y
const nodes = state.nodes
.filter((n) => n.id !== groupId)
.map((n) => {
if (n.parentId !== groupId) return n
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: n.position.x + groupAbsX,
y: n.position.y + groupAbsY,
},
data: { ...n.data, parent_id: undefined },
}
})
return {
nodes,
selectedNodeId: null,
selectedNodeIds: [],
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
markSaved: () => set({ hasUnsavedChanges: false }),
markUnsaved: () => set({ hasUnsavedChanges: true }),