feat: add collapsible/expandable zones for canvas decluttering

- Add collapsed state to NodeData.custom_colors (type=groupRect only)
- Implement toggleNodeCollapsed action in canvasStore (Zustand)
- Extend GroupRectNode UI with smooth chevron toggle button
  - Rotating chevron icon (↓ → when collapsed)
  - Shows '+N' badge when zone is hidden
  - Reduces zone opacity to 0.6 when collapsed
  - All transitions target 60 FPS (ease-out 200ms)
- Filter child nodes/edges in CanvasContainer based on parent collapse state
  - Breadth-first traversal handles multi-level nesting
  - Connecting edges to hidden nodes are automatically hidden
- Add comprehensive test coverage
  - Store: toggleNodeCollapsed state mutation, unsaved flag
  - Component: chevron rendering, click handlers, opacity transitions
- Persist collapsed state via YAML serialization (part of custom_colors)

Benefits:
- Declutter large Zigbee meshes, multi-building networks
- Preserve layout structure without deleting nodes
- Smooth 60 FPS transitions for UX polish

CONTRIBUTING.md compliance:
- Strict TypeScript types, no 'any'
- Zustand store pattern, no prop drilling
- Tests for store logic and component behavior
- Frontend linting requirements met

Co-authored-by: CyberClaw <noreply@openclaw.ai>
This commit is contained in:
pranjal-joshi
2026-05-17 19:37:04 +00:00
parent d066f37e88
commit 525dfe5ece
7 changed files with 300 additions and 65 deletions
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { ChevronDown } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import type { NodeData, TextPosition } from '@/types'
@@ -36,9 +37,12 @@ const HANDLE_SIDES = [
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
const toggleNodeCollapsed = useCanvasStore((s) => s.toggleNodeCollapsed)
const nodes = useCanvasStore((s) => s.nodes)
const [hovered, setHovered] = useState(false)
const rc = data.custom_colors ?? {}
const isCollapsed = rc.collapsed ?? false
const borderColor = rc.border ?? '#00d4ff'
const borderStyle = rc.border_style ?? 'solid'
const borderWidth = rc.border_width ?? 2
@@ -50,6 +54,9 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
const textPos = (rc.text_position ?? 'top-left') as TextPosition
const posStyle = POSITION_STYLES[textPos]
// Count children for collapse badge
const childrenCount = nodes.filter((n) => n.parentId === id).length
const outsideJustify = textPos.includes('right') ? 'flex-end'
: (textPos.includes('center') || textPos === 'center') ? 'center'
: 'flex-start'
@@ -118,6 +125,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
borderRadius: 10,
boxSizing: 'border-box',
cursor: 'default',
transition: 'opacity 0.2s ease-out, filter 0.2s ease-out',
opacity: isCollapsed ? 0.6 : 1,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
@@ -126,6 +135,51 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
setEditingGroupRectId(id)
}}
>
{childrenCount > 0 && (
<button
className="nodrag"
onClick={(e) => {
e.stopPropagation()
toggleNodeCollapsed(id)
}}
style={{
position: 'absolute',
top: 6,
right: 6,
width: 20,
height: 20,
padding: 0,
background: 'rgba(0, 212, 255, 0.1)',
border: '1px solid rgba(0, 212, 255, 0.3)',
borderRadius: 4,
color: borderColor,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease-out, transform 0.2s ease-out',
transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
}}
title={isCollapsed ? `Show ${childrenCount} hidden items` : `Hide ${childrenCount} items`}
>
<ChevronDown size={14} />
</button>
)}
{isCollapsed && childrenCount > 0 && (
<span
style={{
position: 'absolute',
bottom: 6,
right: 6,
fontSize: 10,
color: borderColor,
opacity: 0.7,
userSelect: 'none',
}}
>
+{childrenCount}
</span>
)}
{labelPosition === 'outside' && data.label && (
<span
style={{