c67b1775a5
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
673 lines
23 KiB
TypeScript
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 }
|
|
}),
|
|
}))
|