feat(network): add GroupNode component with resize, inline label, and group type colors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-13 20:13:03 +00:00
parent 764db79060
commit a4512dcf90
3 changed files with 93 additions and 1 deletions

View File

@@ -0,0 +1,86 @@
import { memo, useState, useRef, useEffect } from 'react'
import { NodeProps, NodeResizer, useReactFlow } from '@xyflow/react'
import type { GroupNodeData } from '@/types/network-diagram'
const GROUP_COLORS: Record<string, string> = {
subnet: '#60a5fa',
vlan: '#a78bfa',
site: '#34d399',
dmz: '#f87171',
custom: '#94a3b8',
}
const GroupNodeComponent = ({ data, selected, id }: NodeProps) => {
const groupData = data as GroupNodeData
const color = GROUP_COLORS[groupData.groupType] ?? GROUP_COLORS.custom
const [editing, setEditing] = useState(false)
const [labelValue, setLabelValue] = useState(groupData.label ?? '')
const inputRef = useRef<HTMLInputElement>(null)
const { updateNodeData } = useReactFlow()
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
// Sync if external data.label changes
useEffect(() => {
if (!editing) setLabelValue(groupData.label ?? '')
}, [groupData.label, editing])
const handleLabelCommit = () => {
setEditing(false)
if (labelValue !== groupData.label) {
updateNodeData(id, { ...groupData, label: labelValue })
}
}
return (
<>
<NodeResizer
isVisible={selected}
minWidth={120}
minHeight={80}
lineStyle={{ border: `1px solid ${color}` }}
handleStyle={{ width: 8, height: 8, borderRadius: 2, background: color }}
/>
<div
className="w-full h-full rounded-lg relative"
style={{
border: `1.5px dashed ${color}`,
background: `${color}0d`,
boxSizing: 'border-box',
}}
>
<div className="absolute top-0 left-0 -translate-y-full pb-0.5 pl-1">
{editing ? (
<input
ref={inputRef}
value={labelValue}
onChange={e => setLabelValue(e.target.value)}
onBlur={handleLabelCommit}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === 'Escape') handleLabelCommit()
e.stopPropagation()
}}
className="text-[11px] font-medium bg-transparent border-none outline-none text-primary min-w-[40px] max-w-[200px]"
style={{ color }}
/>
) : (
<span
className="text-[11px] font-medium cursor-text select-none"
style={{ color }}
onDoubleClick={() => setEditing(true)}
>
{labelValue || groupData.groupType}
</span>
)}
</div>
</div>
</>
)
}
GroupNodeComponent.displayName = 'GroupNode'
export const GroupNode = memo(GroupNodeComponent)
export default GroupNode

View File

@@ -1,5 +1,5 @@
import { DeviceNode } from './DeviceNode'
import { GroupNode } from '../ui/labeled-group-node'
import { GroupNode } from './GroupNode'
export const nodeTypes = {
device: DeviceNode,

View File

@@ -123,6 +123,12 @@ export interface DiagramImportResponse {
warnings: string[]
}
export interface GroupNodeData {
label: string
groupType: 'subnet' | 'vlan' | 'site' | 'dmz' | 'custom'
[key: string]: unknown
}
export interface DiagramExportResponse {
schemaVersion: number
name: string