Files
homelable/frontend/src/stores/canvasStore.ts
T
Pouzor 1e72366d03 feat: add Group Rectangle — resizable decorative zones on canvas
- New GroupRectNode: rounded rect with NodeResizer (8 handles), no
  connection handles, always behind network nodes via negative zIndex
- GroupRectModal: label, font preset (Inter/Mono/Serif), 3×3 text
  position grid, text/border/background color pickers, z-order 1–9
- Sidebar: "Add Rectangle" button (below Add Node), group rects
  excluded from node count stats
- Save/load: size persisted in custom_colors.width/height, no backend
  schema changes required
- elevateNodesOnSelect=false on ReactFlow so selected rects never
  pop above network nodes
- Tests: 3 new store tests + 9 GroupRectModal tests (66 total, all pass)
2026-03-10 16:59:40 +01:00

167 lines
5.4 KiB
TypeScript

import { create } from 'zustand'
import {
type Node,
type Edge,
type NodeChange,
type EdgeChange,
type Connection,
applyNodeChanges,
applyEdgeChanges,
addEdge,
} from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
interface CanvasState {
nodes: Node<NodeData>[]
edges: Edge<EdgeData>[]
hasUnsavedChanges: boolean
selectedNodeId: string | null
scanEventTs: number
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
onConnect: (connection: Connection) => void
setSelectedNode: (id: string | null) => void
addNode: (node: Node<NodeData>) => void
updateNode: (id: string, data: Partial<NodeData>) => void
deleteNode: (id: string) => void
updateEdge: (id: string, data: Partial<EdgeData>) => void
deleteEdge: (id: string) => void
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
setNodeZIndex: (id: string, zIndex: number) => void
editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void
markSaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
notifyScanDeviceFound: () => void
}
export const useCanvasStore = create<CanvasState>((set) => ({
nodes: [],
edges: [],
hasUnsavedChanges: false,
selectedNodeId: null,
editingGroupRectId: null,
scanEventTs: 0,
onNodesChange: (changes) =>
set((state) => ({
nodes: applyNodeChanges(changes, state.nodes),
hasUnsavedChanges: true,
})),
onEdgesChange: (changes) =>
set((state) => ({
edges: applyEdgeChanges(changes, state.edges),
hasUnsavedChanges: true,
})),
onConnect: (connection) =>
set((state) => {
const extra = connection as Connection & Partial<EdgeData>
const edgeType = extra.type ?? 'ethernet'
// Normalize invisible stub handle IDs so React Flow can locate the handle
// and render the edge immediately (top-t / bottom-t are opacity:0 helpers).
const normalizeHandle = (h: string | null | undefined) =>
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
return {
edges: addEdge({
...connection,
sourceHandle: normalizeHandle(extra.sourceHandle),
targetHandle: normalizeHandle(extra.targetHandle),
type: edgeType,
data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style },
}, state.edges),
hasUnsavedChanges: true,
}
}),
setSelectedNode: (id) => set({ selectedNodeId: id }),
addNode: (node) =>
set((state) => {
const enriched = node.data.parent_id
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
: node
// Parents must come before children in the array
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
if (enriched.parentId) {
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
}
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
}),
updateNode: (id, data) =>
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === id ? { ...n, data: { ...n.data, ...data } } : n
),
hasUnsavedChanges: true,
})),
deleteNode: (id) =>
set((state) => ({
nodes: state.nodes.filter((n) => n.id !== id),
edges: state.edges.filter((e) => e.source !== id && e.target !== id),
selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId,
hasUnsavedChanges: true,
})),
updateEdge: (id, data) =>
set((state) => ({
edges: state.edges.map((e) =>
e.id === id ? { ...e, type: data.type ?? e.type, data: { ...e.data, ...data } as EdgeData } : e
),
hasUnsavedChanges: true,
})),
deleteEdge: (id) =>
set((state) => ({
edges: state.edges.filter((e) => e.id !== id),
hasUnsavedChanges: true,
})),
setProxmoxContainerMode: (proxmoxId, enabled) =>
set((state) => {
let nodes = state.nodes.map((n) => {
if (n.id === proxmoxId) {
const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
return enabled
? { ...withMode, width: 300, height: 200 }
: { ...withMode, width: undefined, height: undefined }
}
if (n.data.parent_id === proxmoxId) {
return enabled
? { ...n, parentId: proxmoxId, extent: 'parent' as const }
: { ...n, parentId: undefined, extent: undefined }
}
return n
})
if (enabled) {
const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId)
nodes = [...parents, ...children]
}
return { nodes, hasUnsavedChanges: true }
}),
setNodeZIndex: (id, zIndex) =>
set((state) => ({
nodes: state.nodes.map((n) => n.id === id ? { ...n, zIndex } : n),
hasUnsavedChanges: true,
})),
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
markSaved: () => set({ hasUnsavedChanges: false }),
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
loadCanvas: (nodes, edges) => {
// React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId)
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null })
},
}))