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)
This commit is contained in:
@@ -57,6 +57,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
||||
snapGrid={[16, 16]}
|
||||
fitView
|
||||
colorMode="dark"
|
||||
elevateNodesOnSelect={false}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
isValidConnection={(connection) => connection.source !== connection.target}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { NodeData, TextPosition } from '@/types'
|
||||
|
||||
const FONT_FAMILIES: Record<string, string> = {
|
||||
inter: 'Inter, sans-serif',
|
||||
mono: '"JetBrains Mono", monospace',
|
||||
serif: 'Georgia, serif',
|
||||
}
|
||||
|
||||
interface AlignStyle {
|
||||
alignItems: string
|
||||
justifyContent: string
|
||||
textAlign: React.CSSProperties['textAlign']
|
||||
}
|
||||
|
||||
const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
|
||||
'top-left': { alignItems: 'flex-start', justifyContent: 'flex-start', textAlign: 'left' },
|
||||
'top-center': { alignItems: 'flex-start', justifyContent: 'center', textAlign: 'center' },
|
||||
'top-right': { alignItems: 'flex-start', justifyContent: 'flex-end', textAlign: 'right' },
|
||||
'middle-left': { alignItems: 'center', justifyContent: 'flex-start', textAlign: 'left' },
|
||||
'center': { alignItems: 'center', justifyContent: 'center', textAlign: 'center' },
|
||||
'middle-right': { alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right' },
|
||||
'bottom-left': { alignItems: 'flex-end', justifyContent: 'flex-start', textAlign: 'left' },
|
||||
'bottom-center': { alignItems: 'flex-end', justifyContent: 'center', textAlign: 'center' },
|
||||
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
|
||||
}
|
||||
|
||||
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||
|
||||
const rc = data.custom_colors ?? {}
|
||||
const borderColor = rc.border ?? '#00d4ff'
|
||||
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
||||
const textColor = rc.text_color ?? '#e6edf3'
|
||||
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||
const textPos = (rc.text_position ?? 'top-left') as TextPosition
|
||||
const posStyle = POSITION_STYLES[textPos]
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={80}
|
||||
minHeight={60}
|
||||
handleStyle={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: '#00d4ff',
|
||||
border: '1px solid #0d1117',
|
||||
}}
|
||||
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: posStyle.alignItems,
|
||||
justifyContent: posStyle.justifyContent,
|
||||
padding: 12,
|
||||
background: backgroundColor,
|
||||
border: `${selected ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`,
|
||||
borderRadius: 10,
|
||||
fontFamily,
|
||||
color: textColor,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingGroupRectId(id)
|
||||
}}
|
||||
>
|
||||
{data.label && (
|
||||
<span style={{ textAlign: posStyle.textAlign, userSelect: 'none', whiteSpace: 'pre-wrap' }}>
|
||||
{data.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index'
|
||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
|
||||
export const nodeTypes = {
|
||||
isp: IspNode,
|
||||
@@ -17,4 +18,5 @@ export const nodeTypes = {
|
||||
computer: ComputerNode,
|
||||
cpl: CplNode,
|
||||
generic: GenericNode,
|
||||
groupRect: GroupRectNode,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user