Files
homelable/frontend/src/stores/canvasStore.ts
T
Pouzor c67b1775a5 feat(canvas): copy/paste nodes across designs
Clipboard now holds nodes + internal edges and survives design switches
(loadCanvas no longer clears it), so a selection copied in one design can
be pasted into another. Copy pulls in children of selected groups/
containers; paste remaps node/edge/parent IDs and lands the bounding-box
center under the cursor (or viewport center). Shortcut handling moved into
CanvasContainer for flow-coordinate projection.

ha-relevant: yes
2026-06-05 10:52:44 +02:00

673 lines
23 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, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types'
import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
/** Resolve a node's effective parent id from either the RF field or domain data. */
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
interface CanvasState {
nodes: Node<NodeData>[]
edges: Edge<EdgeData>[]
hasUnsavedChanges: boolean
selectedNodeId: string | null
selectedNodeIds: string[]
scanEventTs: number
// History
past: HistoryEntry[]
future: HistoryEntry[]
snapshotHistory: () => void
undo: () => void
redo: () => void
// Clipboard — survives design switches so nodes can be pasted into another design
clipboard: Clipboard
copySelectedNodes: () => void
/** Paste clipboard into the current canvas. `center` (flow coords) lands the
* pasted bounding-box center under the cursor / viewport center. */
pasteNodes: (center?: { x: number; y: number }) => void
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
reconnectEdge: (id: string, connection: Connection) => 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
editingTextId: string | null
setEditingTextId: (id: string | null) => void
toggleNodeCollapsed: (id: string) => void
createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void
markSaved: () => void
markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
fitViewPending: boolean
clearFitViewPending: () => void
notifyScanDeviceFound: () => void
hideIp: boolean
toggleHideIp: () => void
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
applyAllCustomStyles: (def: CustomStyleDef) => void
}
export const useCanvasStore = create<CanvasState>((set) => ({
nodes: [],
edges: [],
hasUnsavedChanges: false,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
editingTextId: null,
hideIp: false,
scanEventTs: 0,
fitViewPending: false,
past: [],
future: [],
clipboard: { nodes: [], edges: [] },
snapshotHistory: () =>
set((state) => ({
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
})),
undo: () =>
set((state) => {
if (state.past.length === 0) return state
const previous = state.past[state.past.length - 1]
return {
nodes: previous.nodes,
edges: previous.edges,
past: state.past.slice(0, -1),
future: [{ nodes: state.nodes, edges: state.edges }, ...state.future.slice(0, 49)],
hasUnsavedChanges: true,
}
}),
redo: () =>
set((state) => {
if (state.future.length === 0) return state
const next = state.future[0]
return {
nodes: next.nodes,
edges: next.edges,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: state.future.slice(1),
hasUnsavedChanges: true,
}
}),
copySelectedNodes: () =>
set((state) => {
// Start from explicitly selected nodes, then pull in all descendants so a
// copied group / container brings its children along.
const ids = new Set(state.nodes.filter((n) => n.selected).map((n) => n.id))
if (ids.size === 0) return { clipboard: { nodes: [], edges: [] } }
let grew = true
while (grew) {
grew = false
for (const n of state.nodes) {
const pid = parentIdOf(n)
if (pid && ids.has(pid) && !ids.has(n.id)) {
ids.add(n.id)
grew = true
}
}
}
const nodes = state.nodes.filter((n) => ids.has(n.id))
// Keep only edges whose both endpoints are inside the copied set.
const edges = state.edges.filter((e) => ids.has(e.source) && ids.has(e.target))
return { clipboard: { nodes, edges } }
}),
pasteNodes: (center) =>
set((state) => {
const clip = state.clipboard
if (clip.nodes.length === 0) return state
// Fresh ids for every copied node; edges/parent links are remapped through it.
const idMap = new Map<string, string>()
clip.nodes.forEach((n) => idMap.set(n.id, generateUUID()))
// A "root" is a copied node whose parent was not also copied — these carry
// absolute positions and receive the paste offset; children move with them.
const isRoot = (n: Node<NodeData>) => {
const pid = parentIdOf(n)
return !pid || !idMap.has(pid)
}
const roots = clip.nodes.filter(isRoot)
// Default cascade offset; when a target center is given, shift the root
// bounding-box center onto it instead.
let offsetX = 50
let offsetY = 50
if (center && roots.length > 0) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const n of roots) {
const w = n.width ?? n.measured?.width ?? 200
const h = n.height ?? n.measured?.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)
}
offsetX = center.x - (minX + maxX) / 2
offsetY = center.y - (minY + maxY) / 2
}
const pasted = clip.nodes.map((n) => {
const root = isRoot(n)
const newParentId = root ? undefined : idMap.get(parentIdOf(n)!)
return {
...n,
id: idMap.get(n.id)!,
position: root
? { x: n.position.x + offsetX, y: n.position.y + offsetY }
: { ...n.position },
selected: true,
parentId: newParentId,
extent: newParentId ? ('parent' as const) : undefined,
data: { ...n.data, parent_id: newParentId },
}
})
const pastedEdges = clip.edges.map((e) => ({
...e,
id: generateUUID(),
source: idMap.get(e.source)!,
target: idMap.get(e.target)!,
selected: false,
}))
// React Flow requires parents before children within the appended block.
const parents = pasted.filter((n) => !n.parentId)
const children = pasted.filter((n) => !!n.parentId)
const pastedNodes = [...parents, ...children]
// Deselect everything already on the canvas so only the paste is selected.
const existing = state.nodes.map((n) => (n.selected ? { ...n, selected: false } : n))
return {
nodes: [...existing, ...pastedNodes],
edges: [...state.edges, ...pastedEdges],
selectedNodeId: null,
selectedNodeIds: pastedNodes.map((n) => n.id),
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
hasUnsavedChanges: true,
}
}),
onNodesChange: (changes) =>
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) => ({
edges: applyEdgeChanges(changes, state.edges),
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
})),
onConnect: (connection) =>
set((state) => {
const extra = connection as Connection & Partial<EdgeData>
const edgeType = extra.type ?? 'ethernet'
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, animated: extra.animated },
}, state.edges),
hasUnsavedChanges: true,
}
}),
setSelectedNode: (id) => set({
selectedNodeId: id,
selectedNodeIds: id ? [id] : [],
}),
addNode: (node) =>
set((state) => {
const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
const shouldNestInParent = !!(parent?.data.container_mode)
const enriched = node.data.parent_id && shouldNestInParent
? {
...node,
parentId: node.data.parent_id,
extent: 'parent' as const,
position: {
x: Math.max(10, node.position.x - parent.position.x),
y: Math.max(10, node.position.y - parent.position.y),
},
}
: node
// Parents must come before children in the array (React Flow requirement)
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
if (enriched.parentId) {
const parentIdx = withoutNew.findIndex((n) => n.id === enriched.parentId)
const insertAt = parentIdx >= 0 ? parentIdx + 1 : withoutNew.length
const nodes = [...withoutNew.slice(0, insertAt), enriched, ...withoutNew.slice(insertAt)]
return { nodes, hasUnsavedChanges: true }
}
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
}),
updateNode: (id, data) =>
set((state) => {
let nodes = state.nodes.map((n) => {
if (n.id !== id) return n
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
// When properties change, clear stored height so the node auto-sizes to fit new content
if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect' && n.data.type !== 'group') {
updated.height = undefined
}
if ('parent_id' in data) {
const newParentId = data.parent_id ?? undefined
if (!newParentId && n.parentId) {
// Detaching from a container: convert position back to absolute canvas coords
const parent = state.nodes.find((p) => p.id === n.parentId)
if (parent) {
updated.position = {
x: parent.position.x + n.position.x,
y: parent.position.y + n.position.y,
}
}
updated.parentId = undefined
updated.extent = undefined
} else if (newParentId && newParentId !== n.parentId) {
const parent = state.nodes.find((p) => p.id === newParentId)
if (parent?.data.container_mode) {
// Attaching to a container-mode Proxmox: nest visually
updated.parentId = newParentId
updated.extent = 'parent' as const
// Convert absolute position to parent-relative (keep node visible inside)
updated.position = {
x: Math.max(10, n.position.x - parent.position.x),
y: Math.max(10, n.position.y - parent.position.y),
}
}
}
}
return updated
})
// React Flow requires parent nodes to precede their children in the array
if ('parent_id' in data) {
const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId)
nodes = [...parents, ...children]
}
// Remap edges when bottom_handles is reduced so no edge disappears
let edges = state.edges
if ('bottom_handles' in data && data.bottom_handles != null) {
const currentNode = state.nodes.find((n) => n.id === id)
const oldCount = currentNode?.data.bottom_handles ?? 1
const newCount = data.bottom_handles
if (newCount < oldCount) {
const removed = removedBottomHandleIds(oldCount, newCount)
edges = state.edges.map((e) => {
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
return { ...e, sourceHandle: 'bottom' }
if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
return { ...e, targetHandle: 'bottom' }
return e
})
}
}
return { nodes, edges, hasUnsavedChanges: true }
}),
deleteNode: (id) =>
set((state) => {
const idsToRemove = new Set<string>()
const collect = (nodeId: string) => {
idsToRemove.add(nodeId)
state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id))
}
collect(id)
return {
nodes: state.nodes.filter((n) => !idsToRemove.has(n.id)),
edges: state.edges.filter((e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target)),
selectedNodeId: idsToRemove.has(state.selectedNodeId ?? '') ? 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,
})),
reconnectEdge: (id, connection) =>
set((state) => ({
edges: state.edges.map((e) =>
e.id === id
? {
...e,
source: connection.source ?? e.source,
target: connection.target ?? e.target,
sourceHandle: normalizeHandle(connection.sourceHandle),
targetHandle: normalizeHandle(connection.targetHandle),
}
: e
),
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
hasUnsavedChanges: true,
})),
deleteEdge: (id) =>
set((state) => ({
edges: state.edges.filter((e) => e.id !== id),
hasUnsavedChanges: true,
})),
setProxmoxContainerMode: (proxmoxId, enabled) =>
set((state) => {
const parentNode = state.nodes.find((n) => n.id === proxmoxId)
let nodes = state.nodes.map((n) => {
if (n.id === proxmoxId) {
const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
return enabled
? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 }
: { ...withMode, width: undefined, height: undefined }
}
if (n.data.parent_id === proxmoxId) {
if (enabled && parentNode) {
return {
...n,
parentId: proxmoxId,
extent: 'parent' as const,
position: {
x: Math.max(10, n.position.x - parentNode.position.x),
y: Math.max(10, n.position.y - parentNode.position.y),
},
}
}
if (!enabled && parentNode) {
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: parentNode.position.x + n.position.x,
y: parentNode.position.y + n.position.y,
},
}
}
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 }),
setEditingTextId: (id) => set({ editingTextId: id }),
toggleNodeCollapsed: (id) =>
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === id
? { ...n, data: { ...n.data, collapsed: !n.data.collapsed } }
: n
),
hasUnsavedChanges: true,
})),
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 }),
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })),
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)
// NOTE: clipboard is intentionally preserved here so nodes copied in one
// design can be pasted after switching to another design.
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true })
},
clearFitViewPending: () => set({ fitViewPending: false }),
applyTypeNodeStyle: (nodeType, style) =>
set((state) => ({
nodes: state.nodes.map((n) => {
if (n.data.type !== nodeType) return n
return {
...n,
width: style.width > 0 ? style.width : n.width,
height: style.height > 0 ? style.height : n.height,
data: {
...n.data,
custom_colors: {
...n.data.custom_colors,
border: applyOpacity(style.borderColor, style.borderOpacity),
background: applyOpacity(style.bgColor, style.bgOpacity),
icon: applyOpacity(style.iconColor, style.iconOpacity),
},
},
}
}),
hasUnsavedChanges: true,
})),
applyTypeEdgeStyle: (edgeType, style) =>
set((state) => ({
edges: state.edges.map((e) => {
if ((e.data?.type ?? 'ethernet') !== edgeType) return e
return {
...e,
data: {
...e.data,
type: edgeType,
custom_color: applyOpacity(style.color, style.opacity),
path_style: style.pathStyle,
animated: style.animated,
} as EdgeData,
}
}),
hasUnsavedChanges: true,
})),
applyAllCustomStyles: (def) =>
set((state) => {
const nodes = state.nodes.map((n) => {
const style = def.nodes[n.data.type]
if (!style) return n
return {
...n,
width: style.width > 0 ? style.width : n.width,
height: style.height > 0 ? style.height : n.height,
data: {
...n.data,
custom_colors: {
...n.data.custom_colors,
border: applyOpacity(style.borderColor, style.borderOpacity),
background: applyOpacity(style.bgColor, style.bgOpacity),
icon: applyOpacity(style.iconColor, style.iconOpacity),
},
},
}
})
const edges = state.edges.map((e) => {
const edgeType = (e.data?.type ?? 'ethernet') as EdgeType
const style = def.edges[edgeType]
if (!style) return e
return {
...e,
data: {
...e.data,
type: edgeType,
custom_color: applyOpacity(style.color, style.opacity),
path_style: style.pathStyle,
animated: style.animated,
} as EdgeData,
}
})
return { nodes, edges, hasUnsavedChanges: true }
}),
}))