From f5adbc8ead1eaf21b389f13b30564faa18e38da8 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 18 Oct 2025 20:06:59 +0200 Subject: [PATCH] feat: add resizable actor grouping with full undo/redo support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements visual grouping of actors with context menu operations, resizable containers, and complete history tracking integration. Features: - Create groups from multiple selected actors via context menu - Groups visualized as resizable containers with child nodes - Ungroup actors (non-destructive) or delete group with actors - Right-click context menu with group-specific operations - Dedicated GroupEditorPanel for group properties - Smart minimum size constraint based on child node positions - Full undo/redo support for group operations and resizes Technical Implementation: - GroupNode component with React Flow NodeResizer integration - Atomic createGroupWithActors operation for consistent history - Parent-child relationship using React Flow v11 parentId pattern - Groups stored separately from actors in graphStore - Fixed history tracking to sync graphStore before snapshots - Resize tracking to prevent state sync loops during interaction - Dynamic minimum dimensions to keep children inside bounds - Sanitization of orphaned parentId references on state load History Fixes: - pushToHistory now syncs timeline with graphStore before snapshot - Prevents missing groups/nodes in history states - Ensures undo/redo correctly restores all graph elements - Atomic state updates to avoid React Flow processing stale state Storage & Persistence: - Groups saved in timeline states and document structure - Safe JSON serialization to prevent prototype pollution - Cleanup utilities for removing __proto__ from localStorage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- UX_CONCEPT_ACTOR_GROUPING.md | 912 +++++++++++++++++++++ src/App.tsx | 26 +- src/components/Editor/GraphEditor.tsx | 333 ++++++-- src/components/Nodes/GroupNode.tsx | 154 ++++ src/components/Panels/GroupEditorPanel.tsx | 261 ++++++ src/components/Panels/RightPanel.tsx | 25 +- src/hooks/useDocumentHistory.ts | 89 +- src/hooks/useGraphWithHistory.ts | 133 ++- src/stores/graphStore.ts | 158 +++- src/stores/persistence/loader.ts | 23 +- src/stores/persistence/saver.ts | 3 +- src/stores/persistence/types.ts | 14 +- src/stores/timelineStore.ts | 37 +- src/stores/workspace/persistence.ts | 13 +- src/stores/workspaceStore.ts | 13 + src/styles/index.css | 12 + src/types/index.ts | 20 +- src/types/timeline.ts | 5 +- src/utils/cleanupStorage.ts | 98 +++ src/utils/safeStringify.ts | 80 ++ 20 files changed, 2281 insertions(+), 128 deletions(-) create mode 100644 UX_CONCEPT_ACTOR_GROUPING.md create mode 100644 src/components/Nodes/GroupNode.tsx create mode 100644 src/components/Panels/GroupEditorPanel.tsx create mode 100644 src/utils/cleanupStorage.ts create mode 100644 src/utils/safeStringify.ts diff --git a/UX_CONCEPT_ACTOR_GROUPING.md b/UX_CONCEPT_ACTOR_GROUPING.md new file mode 100644 index 0000000..9b9b056 --- /dev/null +++ b/UX_CONCEPT_ACTOR_GROUPING.md @@ -0,0 +1,912 @@ +# Actor Grouping Feature - UX Concept Document + +**Version:** 1.0 +**Date:** 2025-10-17 +**Application:** Constellation Analyzer + +--- + +## Executive Summary + +This document defines the UX concept for a new **Actor Grouping** feature in Constellation Analyzer. The feature allows users to group multiple actors into visual containers that can be collapsed/expanded to reduce UI clutter while maintaining graph relationships. + +The design follows the application's existing patterns: +- React Flow-based graph visualization +- Tailwind CSS + Material-UI design system +- Zustand state management with history tracking +- Collapsible panels and sections +- Context-sensitive interactions + +--- + +## Table of Contents + +1. [Feature Overview](#1-feature-overview) +2. [User Stories & Use Cases](#2-user-stories--use-cases) +3. [Information Architecture](#3-information-architecture) +4. [Visual Design](#4-visual-design) +5. [Interaction Design](#5-interaction-design) +6. [State Management](#6-state-management) +7. [Edge Cases & Constraints](#7-edge-cases--constraints) +8. [Implementation Considerations](#8-implementation-considerations) +9. [Future Enhancements](#9-future-enhancements) + +--- + +## 1. Feature Overview + +### 1.1 Purpose + +Allow users to organize related actors into logical groups with visual containment, improving: +- **Organization**: Cluster related actors semantically (e.g., "Engineering Team", "External Systems") +- **Clarity**: Reduce visual complexity in large graphs +- **Focus**: Collapse groups to hide details, expand to show full structure +- **Context**: Maintain visible relationships between groups and individual actors + +### 1.2 Core Capabilities + +1. **Create Group**: Select multiple actors and group them into a named container +2. **Visual Container**: Box with header, background color, and contained actors +3. **Expand/Collapse**: Toggle between full view and collapsed placeholder +4. **Group Editing**: Rename, recolor, add/remove actors +5. **Nested Relations**: Show relations within group and between group/external actors +6. **History Support**: Full undo/redo integration + +### 1.3 Key Principles + +- **Non-Destructive**: Grouping does not delete or modify actors/relations +- **Reversible**: Groups can be ungrouped, returning actors to independent state +- **Transparent**: Collapsed groups show summary info (actor count, relation count) +- **Consistent**: Follows existing design patterns (collapsible sections, context menus, panels) + +--- + +## 2. User Stories & Use Cases + +### User Story 1: Creating a Group +> **As a** constellation analyst +> **I want to** select multiple actors and group them +> **So that** I can organize related entities visually + +**Acceptance Criteria:** +- User can select 2+ actors using Shift+Click or drag selection box +- Context menu shows "Create Group" option for multi-selection +- Prompted to name the group (optional, defaults to "Group 1", "Group 2", etc.) +- Group container appears with all selected actors inside +- Actors maintain their relative positions within the group + +### User Story 2: Collapsing a Group +> **As a** constellation analyst +> **I want to** collapse a group to hide its contents +> **So that** I can reduce clutter when viewing the big picture + +**Acceptance Criteria:** +- Group header shows expand/collapse toggle button +- Collapsed state shows: + - Group name + - Actor count badge (e.g., "5 actors") + - Relation indicators (connections to external nodes) +- Relations from group actors to external nodes remain visible, attached to collapsed group boundary +- Double-click on collapsed group expands it + +### User Story 3: Editing Group Properties +> **As a** constellation analyst +> **I want to** edit a group's name and appearance +> **So that** I can customize organization to my needs + +**Acceptance Criteria:** +- Selecting a group shows properties in right panel +- Editable properties: name, color, description +- List of contained actors (with ability to remove from group) +- Delete group button (ungroups actors, doesn't delete them) + +### User Story 4: Ungrouping +> **As a** constellation analyst +> **I want to** ungroup a set of actors +> **So that** I can reorganize my graph structure + +**Acceptance Criteria:** +- Right-click group → "Ungroup" option +- Actors return to canvas at their last positions +- Relations remain intact +- Operation is undoable + +--- + +## 3. Information Architecture + +### 3.1 Data Model + +```typescript +// New type for groups +export interface GroupData { + label: string; // Group name + description?: string; // Optional description + color: string; // Background color (semi-transparent) + collapsed: boolean; // Expand/collapse state + actorIds: string[]; // IDs of actors in this group + metadata?: Record; +} + +export type Group = { + id: string; // Unique group ID + type: 'group'; // Node type identifier + position: { x: number; y: number }; // Top-left corner + data: GroupData; + // React Flow will calculate dimensions based on contained nodes +}; + +// Updated types +export interface GraphState { + nodes: Actor[]; + edges: Relation[]; + groups: Group[]; // NEW: Array of groups + nodeTypes: NodeTypeConfig[]; + edgeTypes: EdgeTypeConfig[]; + labels: LabelConfig[]; +} +``` + +### 3.2 Hierarchical Relationships + +``` +Graph +├── Groups (containers) +│ ├── Group 1 (expanded) +│ │ ├── Actor A +│ │ ├── Actor B +│ │ └── Actor C +│ └── Group 2 (collapsed) +│ └── [3 actors hidden] +├── Standalone Actors +│ ├── Actor D +│ └── Actor E +└── Relations + ├── A → B (internal to Group 1) + ├── A → D (crosses group boundary) + └── Group 2 → E (from collapsed group) +``` + +### 3.3 Information Display Hierarchy + +**Expanded Group:** +``` +┌─────────────────────────────────┐ +│ [−] Engineering Team [×] │ ← Header with collapse/delete +├─────────────────────────────────┤ +│ │ +│ ┌──────┐ ┌──────┐ │ ← Contained actors +│ │ Dev │ ───→ │ Lead │ │ with relations +│ └──────┘ └──────┘ │ +│ │ +│ ┌──────┐ │ +│ │ QA │ │ +│ └──────┘ │ +│ │ +└─────────────────────────────────┘ +``` + +**Collapsed Group:** +``` +┌─────────────────────────┐ +│ [+] Engineering Team │ ← Compact header +│ 3 actors │ ← Summary info +└─────────────────────────┘ + │ (Relations extend from edges) + └──────→ (External node) +``` + +--- + +## 4. Visual Design + +### 4.1 Design Tokens + +**Group Colors (Semi-transparent backgrounds):** +```typescript +const DEFAULT_GROUP_COLORS = [ + 'rgba(59, 130, 246, 0.08)', // Blue + 'rgba(16, 185, 129, 0.08)', // Green + 'rgba(245, 158, 11, 0.08)', // Orange + 'rgba(139, 92, 246, 0.08)', // Purple + 'rgba(236, 72, 153, 0.08)', // Pink + 'rgba(20, 184, 166, 0.08)', // Teal +]; +``` + +**Border Styles:** +```typescript +const GROUP_BORDER_STYLE = { + width: '2px', + style: 'dashed', // Dashed to differentiate from nodes + radius: '8px', // Rounded corners + opacity: 0.4, // Subtle +}; +``` + +**Header Styles:** +```typescript +const GROUP_HEADER_STYLE = { + background: 'rgba(0, 0, 0, 0.05)', // Subtle gray overlay + padding: '8px 12px', + fontSize: '14px', + fontWeight: 600, + color: '#374151', // Gray-700 + height: '40px', +}; +``` + +**Typography:** +- Group Name: 14px, font-semibold (600), gray-700 +- Actor Count: 12px, font-medium (500), gray-500 +- Description: 12px, font-normal (400), gray-600 + +**Spacing:** +- Padding inside group: 16px (around contained actors) +- Margin between actors in group: maintain existing spacing +- Collapsed group size: 240px × 80px (fixed dimensions) + +### 4.2 Component Mockups + +#### Expanded Group Structure + +``` +┌─────────────────────────────────────────────┐ +│ Header Area (bg-gray-50/50, h-40px) │ +│ ┌────────────────────────────────────────┐ │ +│ │ [−] Group Name [👁️] [✏️] [×] │ │ +│ └────────────────────────────────────────┘ │ +├─────────────────────────────────────────────┤ +│ Content Area (padding: 16px) │ +│ │ +│ [Actor nodes positioned freely] │ +│ [Relations between actors] │ +│ │ +│ │ +└─────────────────────────────────────────────┘ + ↑ Dashed border (2px, border-color) +``` + +**Header Components:** +- **Collapse Button** `[−]`: IconButton with ExpandLessIcon, left-aligned +- **Group Name**: Text label, editable on double-click or via right panel +- **Visibility Toggle** `[👁️]`: IconButton to show/hide group temporarily (fade out) +- **Edit Button** `[✏️]`: IconButton to open right panel with group properties +- **Delete Button** `[×]`: IconButton to ungroup (with confirmation) + +#### Collapsed Group Structure + +``` +┌───────────────────────┐ +│ [+] Group Name │ ← Header only +│ 5 actors │ ← Summary badge +│ 3 relations │ ← Connection count +└───────────────────────┘ +``` + +**Collapsed Dimensions:** +- Width: 240px (fixed) +- Height: Auto (based on content, min 80px) +- Border: Same dashed style +- Background: Darker tint of group color (0.12 opacity) + +**Collapsed Summary:** +- Actor count: Shows total actors inside +- Relation count: Shows external relations (connections to/from group) +- No internal structure visible + +### 4.3 Relation Rendering + +**Internal Relations (within group):** +- Rendered normally when group is expanded +- Hidden when group is collapsed + +**External Relations (group ↔ standalone actors):** +- **Expanded:** Edge connects from specific actor inside group to external node +- **Collapsed:** Edge connects from group boundary (closest edge point) to external node + - Edge label shows source actor name in parentheses: "collaborates (Alice)" + +**Group-to-Group Relations:** +- Treated as external relations +- Shows summary label when both groups collapsed + +### 4.4 Selection & Highlighting States + +**Group Selected:** +- Border becomes solid (not dashed) +- Border width: 3px +- Border color: Primary blue (#3b82f6) +- Right panel shows group properties + +**Group Hovered:** +- Border opacity: 0.7 (from 0.4) +- Header background darkens slightly +- Cursor: pointer + +**Actor in Group Hovered/Selected:** +- Same as current behavior +- Group border remains unchanged + +--- + +## 5. Interaction Design + +### 5.1 Creating Groups + +#### Method 1: Selection + Context Menu (Primary) + +1. **Multi-Select Actors:** + - Shift+Click: Add to selection + - Ctrl+Click (Windows) / Cmd+Click (Mac): Toggle selection + - Drag selection box: Select multiple actors in rectangle + +2. **Right-Click on Selected Actor:** + - Context menu shows new option: **"Group Selected Actors"** + - Positioned near "Delete" option + +3. **Name Group Dialog:** + - Modal dialog: `InputDialog` + - Title: "Create Actor Group" + - Message: "Enter a name for this group" + - Placeholder: "Group 1" (auto-increment) + - Validation: Max 50 characters + - Buttons: "Create" (primary blue) | "Cancel" + +4. **Group Created:** + - New group node added to graph + - Bounding box calculated from selected actors' positions + - Selected actors moved "into" group (z-index and parent relationship) + - Default color assigned (cycle through colors) + - Group starts in **expanded** state + +#### Method 2: Left Panel Button (Alternative) + +**New Section in Left Panel: "Organization"** +- Positioned between "Relations" and "Layout" +- Contains: + - **"Create Group"** button (disabled if < 2 actors selected) + - Explanation text: "Select 2+ actors to create a group" + +### 5.2 Expanding/Collapsing Groups + +#### Toggle via Header Button + +- Click `[−]` button → Collapses group +- Click `[+]` button → Expands group +- Animation: 300ms ease-in-out transition + - Actors fade out/in + - Group dimensions animate + - Relations re-route smoothly + +#### Keyboard Shortcut + +- Select group → Press `Space` to toggle collapse state + +#### Double-Click Collapsed Group + +- Double-clicking collapsed group expands it +- Fast interaction for exploration + +### 5.3 Editing Groups + +#### Select Group → Right Panel + +**Group Properties Panel** (similar to NodeEditorPanel): + +``` +┌─────────────────────────────────┐ +│ Group Properties [×] │ +├─────────────────────────────────┤ +│ Name │ +│ ┌─────────────────────────────┐ │ +│ │ Engineering Team │ │ +│ └─────────────────────────────┘ │ +│ │ +│ Description (optional) │ +│ ┌─────────────────────────────┐ │ +│ │ Core development team... │ │ +│ │ │ │ +│ └─────────────────────────────┘ │ +│ │ +│ Background Color │ +│ [Color Picker Component] │ +│ │ +│ Members (5 actors) │ +│ ┌─────────────────────────────┐ │ +│ │ • Alice (Developer) [×] │ │ +│ │ • Bob (Lead) [×] │ │ +│ │ • Charlie (QA) [×] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ State │ +│ ○ Expanded ● Collapsed │ +│ │ +│ [Ungroup] [Delete Group] │ +└─────────────────────────────────┘ +``` + +**Editable Properties:** +- **Name**: Text input, live update (500ms debounce) +- **Description**: Textarea, optional +- **Color**: Color picker (same as node type config) +- **Members**: List with remove buttons + - Click `[×]` → Remove actor from group (moves to canvas) + - Shows actor label and type +- **State**: Radio buttons to expand/collapse +- **Actions**: + - **Ungroup**: Button to dissolve group, actors return to canvas + - **Delete Group**: Button to delete group AND all actors inside (with confirmation) + +#### Inline Name Editing + +- Double-click group header → Enter edit mode +- Input field replaces name text +- Enter to save, Escape to cancel + +### 5.4 Managing Group Membership + +#### Add Actor to Existing Group (Drag & Drop) + +1. Drag actor node onto group +2. Group highlights with thicker border (visual feedback) +3. Drop → Actor becomes member of group +4. Position adjusted to be inside group boundary + +#### Remove Actor from Group + +**Method 1:** Right panel → Click `[×]` next to actor name +**Method 2:** Drag actor out of group boundary → Auto-removed + +#### Move Group + +- Drag group header → Move entire group with all actors +- Actors maintain relative positions within group +- Relations update in real-time + +### 5.5 Deleting & Ungrouping + +#### Ungroup (Non-Destructive) + +- **Action**: Right-click group → "Ungroup" +- **Result**: + - Group node removed + - All actors return to canvas at their absolute positions + - Relations unchanged + - Undo description: "Ungroup [Group Name]" + +#### Delete Group (Destructive) + +- **Action**: Right panel → "Delete Group" button +- **Confirmation**: Dialog warns: + - "Delete this group AND all 5 actors inside?" + - "This will also delete all connected relations." + - Severity: danger (red) +- **Result**: + - Group deleted + - All actors inside deleted + - All relations to/from those actors deleted + - Undo description: "Delete Group [Group Name]" + +### 5.6 Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Ctrl/Cmd + G` | Group selected actors | +| `Space` (group selected) | Toggle expand/collapse | +| `Ctrl/Cmd + Shift + G` | Ungroup selected group | +| `Delete` | Delete selected group (with confirmation) | + +--- + +## 6. State Management + +### 6.1 Zustand Store Updates + +**graphStore.ts:** + +```typescript +interface GraphState { + // ... existing properties + groups: Group[]; +} + +interface GraphActions { + // ... existing actions + addGroup: (group: Group) => void; + updateGroup: (id: string, updates: Partial) => void; + deleteGroup: (id: string) => void; + addActorToGroup: (actorId: string, groupId: string) => void; + removeActorFromGroup: (actorId: string, groupId: string) => void; + setGroups: (groups: Group[]) => void; +} +``` + +**Implementation Notes:** +- Groups stored separately from nodes/edges +- React Flow renders groups as custom "parent" nodes +- Actors in groups have `parentNode: groupId` property +- Use `useGraphWithHistory()` for all mutations (undo/redo support) + +### 6.2 History Integration + +**History Descriptions:** +- "Create Group [Name]" +- "Rename Group [Old] → [New]" +- "Collapse Group [Name]" +- "Expand Group [Name]" +- "Add Actor to Group [Name]" +- "Remove Actor from Group [Name]" +- "Ungroup [Name]" +- "Delete Group [Name]" + +**Undo/Redo Behavior:** +- Creating group: Undo removes group, actors return to original positions +- Collapsing: Undo expands group +- Ungrouping: Undo re-creates group with same members +- Deleting: Undo restores group and all actors + +### 6.3 React Flow Integration + +**Parent-Child Nodes:** +- React Flow supports parent nodes natively +- Actors in group have `parentNode` property set to group ID +- React Flow calculates group bounds automatically +- Use `extent: 'parent'` to constrain actors within group + +**Custom Node Type for Groups:** +```typescript +// src/components/Nodes/GroupNode.tsx +const GroupNode = ({ data, selected }: NodeProps) => { + // Render group container + // Header with collapse button, name, controls + // Background with padding + // Handle expand/collapse state +}; + +export default memo(GroupNode); +``` + +**Node Types Registration:** +```typescript +const nodeTypes: NodeTypes = useMemo( + () => ({ + custom: CustomNode, // Existing actor nodes + group: GroupNode, // NEW: Group container nodes + }), + [], +); +``` + +--- + +## 7. Edge Cases & Constraints + +### 7.1 Constraints + +1. **Minimum Group Size**: Must contain at least 2 actors +2. **No Nested Groups**: Groups cannot contain other groups (may be future enhancement) +3. **No Partial Overlap**: Actor can only belong to one group at a time +4. **Group Name Length**: Max 50 characters +5. **Selection Limit**: No limit on actors per group, but UI should warn if >20 actors + +### 7.2 Edge Cases + +#### Case 1: Creating Group with Single Actor +- **Behavior**: "Create Group" option disabled in context menu +- **Feedback**: Tooltip shows "Select 2 or more actors to create a group" + +#### Case 2: Collapsing Group with External Relations +- **Behavior**: Relations re-route to group boundary +- **Display**: Edge label shows source actor: "collaborates (Alice)" +- **Re-expanding**: Relations return to original actor + +#### Case 3: Deleting Actor Inside Group +- **Behavior**: Actor removed from group member list +- **If only 1 actor left**: Prompt user: "Only 1 actor remaining. Ungroup or add more actors?" + - Auto-ungroup if user deletes second-to-last actor + +#### Case 4: Moving Actor via Drag (Inside Group) +- **Behavior**: Actor moves within group bounds +- **Constraint**: Cannot drag outside group (use remove from group action instead) +- **Alternative**: Allow drag outside → Auto-removes from group (needs clear visual feedback) + +#### Case 5: Undo After Deleting Actor in Group +- **Behavior**: Undo restores actor to group +- **Challenge**: Maintain group membership in history + +#### Case 6: Group with No Name +- **Behavior**: Use default name "Group 1", "Group 2", etc. +- **Auto-increment**: Based on existing groups + +#### Case 7: Searching/Filtering with Collapsed Groups +- **Behavior**: If search matches actor inside collapsed group: + - Auto-expand group to show matching actor + - OR: Highlight collapsed group with badge "2 matches inside" +- **Recommendation**: Auto-expand (simpler UX) + +#### Case 8: Exporting Graph with Groups +- **JSON Export**: Include groups array in exported data +- **PNG/SVG Export**: Render groups visually (expanded or collapsed based on current state) + +--- + +## 8. Implementation Considerations + +### 8.1 Component Structure + +**New Components:** +``` +src/components/ +├── Nodes/ +│ ├── GroupNode.tsx # NEW: Group container component +│ └── CustomNode.tsx # Update: Support parentNode +├── Panels/ +│ ├── GroupEditorPanel.tsx # NEW: Group properties editor +│ ├── LeftPanel.tsx # Update: Add "Organization" section +│ └── RightPanel.tsx # Update: Route to GroupEditorPanel +├── Config/ +│ └── GroupConfig.tsx # NEW: (Optional) Manage group presets +└── Common/ + └── GroupBadge.tsx # NEW: Actor count badge component +``` + +### 8.2 React Flow Configuration + +**Parent Node Setup:** +```typescript +// When creating group +const groupNode: Group = { + id: generateId(), + type: 'group', + position: { x: minX - 20, y: minY - 50 }, // Offset for padding + data: { + label: groupName, + color: selectedColor, + collapsed: false, + actorIds: selectedActorIds, + }, +}; + +// Update actors to be children +const updatedActors = selectedActors.map(actor => ({ + ...actor, + parentNode: groupNode.id, + extent: 'parent' as const, + // Keep relative position + position: { + x: actor.position.x - groupNode.position.x, + y: actor.position.y - groupNode.position.y, + }, +})); +``` + +**ReactFlow Props:** +```typescript + +``` + +### 8.3 Collapse/Expand Logic + +**Collapse Transition:** +1. Save current actor positions (for undo) +2. Set `group.data.collapsed = true` +3. Set all actors in group to `hidden: true` +4. Calculate collapsed group dimensions (fixed 240×80) +5. Re-route external edges to group boundary +6. Animate transition (300ms) + +**Expand Transition:** +1. Set `group.data.collapsed = false` +2. Calculate expanded group dimensions (bounding box + padding) +3. Set all actors in group to `hidden: false` +4. Restore actor positions (relative to group) +5. Re-route edges back to specific actors +6. Animate transition (300ms) + +### 8.4 Edge Routing for Collapsed Groups + +**Challenge:** When group is collapsed, edges need to connect to group boundary instead of hidden actors. + +**Solution:** +- Create virtual handles on collapsed group node (top, right, bottom, left) +- Calculate closest handle to external node +- Update edge source/target to group handle when collapsed +- Restore original actor handle when expanded + +**Custom Edge Logic:** +```typescript +// In CustomEdge.tsx +const sourceNode = nodes.find(n => n.id === source); +const isSourceCollapsedGroup = + sourceNode?.type === 'group' && sourceNode.data.collapsed; + +if (isSourceCollapsedGroup) { + // Find original actor from edge metadata + const originalActorId = edge.data?.sourceActorId; + // Show label with actor name + edgeLabel = `${edge.data.label} (${originalActorName})`; +} +``` + +### 8.5 Performance Considerations + +**Optimization Strategies:** +1. **Memoization**: Memo GroupNode component +2. **Collapsed Rendering**: Don't render hidden actors in DOM (use `hidden: true`) +3. **Lazy Expansion**: Only calculate positions when expanding +4. **Debounced Updates**: Group property edits debounced to 500ms +5. **Batch Operations**: Group creation updates all actors in single transaction + +**Expected Performance:** +- 100+ actors: No noticeable lag +- 10+ groups: Smooth interactions +- Collapse/Expand: <300ms animation + +--- + +## 9. Future Enhancements + +### Phase 2 Features (Out of Scope for MVP) + +1. **Nested Groups**: Groups inside groups (tree hierarchy) +2. **Group Templates**: Save/load group configurations +3. **Auto-Grouping**: ML-based clustering suggestions +4. **Group Layouts**: Auto-arrange actors within group (grid, circle, tree) +5. **Group Styles**: Custom border styles, background patterns +6. **Minimap Integration**: Show groups as colored regions in minimap +7. **Swimlanes**: Horizontal/vertical lanes for process flows +8. **Group Permissions**: Lock/unlock groups to prevent edits +9. **Group Notes**: Rich text annotations for groups +10. **Import/Export Groups**: Reuse group structures across documents + +### Design System Evolution + +**Potential Additions:** +- Group color presets (beyond 6 default colors) +- Group icon library (similar to actor icons) +- Group shapes (rounded, rectangular, pill-shaped) +- Border style options (solid, dashed, dotted, double) + +### Accessibility Improvements + +- Screen reader announcements for group operations +- Keyboard navigation between groups (Tab key) +- High contrast mode support +- Reduced motion option for collapse/expand animations + +--- + +## 10. Appendix + +### 10.1 Design Alignment Checklist + +**Visual Consistency:** +- ✅ Uses Tailwind CSS utility classes +- ✅ Material-UI IconButton components +- ✅ Consistent spacing (padding-3, padding-16) +- ✅ Consistent typography (text-sm, font-semibold) +- ✅ Consistent colors (gray-50, gray-200, blue-500) + +**Interaction Patterns:** +- ✅ Context menus for quick actions +- ✅ Right panel for detailed editing +- ✅ Collapsible sections +- ✅ Confirmation dialogs for destructive actions +- ✅ Debounced live updates (500ms) + +**State Management:** +- ✅ Zustand for global state +- ✅ `useGraphWithHistory()` for mutations +- ✅ Per-document history tracking +- ✅ LocalStorage persistence via workspace + +### 10.2 Color Palette Reference + +**Group Background Colors (RGBA):** +```css +--group-blue: rgba(59, 130, 246, 0.08); +--group-green: rgba(16, 185, 129, 0.08); +--group-orange: rgba(245, 158, 11, 0.08); +--group-purple: rgba(139, 92, 246, 0.08); +--group-pink: rgba(236, 72, 153, 0.08); +--group-teal: rgba(20, 184, 166, 0.08); +``` + +**Border Colors (Same as background, but with 0.4 opacity):** +```css +--group-border-blue: rgba(59, 130, 246, 0.4); +--group-border-green: rgba(16, 185, 129, 0.4); +/* etc. */ +``` + +### 10.3 Component Hierarchy Diagram + +``` +GraphEditor +├── ReactFlow +│ ├── CustomNode (Actor) +│ │ └── NodeShapeRenderer +│ ├── GroupNode (NEW) ← NEW COMPONENT +│ │ ├── GroupHeader +│ │ │ ├── CollapseButton +│ │ │ ├── NameLabel +│ │ │ ├── EditButton +│ │ │ └── DeleteButton +│ │ └── GroupContent (when expanded) +│ │ └── [Contained CustomNode children] +│ └── CustomEdge (Relation) +│ └── [Updated to handle group edges] +└── RightPanel + ├── GraphAnalysisPanel + ├── NodeEditorPanel + ├── EdgeEditorPanel + └── GroupEditorPanel (NEW) ← NEW COMPONENT +``` + +### 10.4 User Flow Diagrams + +**Creating a Group Flow:** +``` +[Select Multiple Actors] + ↓ +[Right-Click Selected Actor] + ↓ +[Context Menu: "Group Selected Actors"] + ↓ +[Input Dialog: "Enter Group Name"] + ↓ +[Create Group with Default Color] + ↓ +[Group Appears (Expanded)] + ↓ +[Select Group → Edit Properties in Right Panel] +``` + +**Collapsing a Group Flow:** +``` +[Group Visible (Expanded)] + ↓ +[Click Collapse Button in Header] + ↓ +[Animation: Actors Fade Out (300ms)] + ↓ +[Group Shows Collapsed View] + ↓ +[External Relations Re-route to Group Boundary] + ↓ +[Summary Badges Show Actor/Relation Counts] +``` + +--- + +## Conclusion + +This UX concept provides a comprehensive design for Actor Grouping in Constellation Analyzer. The feature integrates seamlessly with the existing application architecture, following established patterns for visual design, interaction, and state management. + +**Key Design Decisions:** +1. **Parent-Child Nodes**: Leverage React Flow's parent node feature for natural containment +2. **Collapse as Primary View**: Groups can be collapsed to reduce clutter (primary use case) +3. **Non-Destructive Operations**: Grouping and ungrouping preserve all data +4. **Consistent UI Patterns**: Reuse existing components (InputDialog, ConfirmDialog, Right Panel) +5. **History Integration**: Full undo/redo support via `useGraphWithHistory()` + +**Next Steps:** +1. Review and approve UX concept +2. Create detailed technical implementation plan +3. Implement core grouping functionality (create, expand/collapse) +4. Implement group editing (right panel, properties) +5. Implement advanced features (drag-to-group, edge routing) +6. Testing and refinement + +--- + +**Document Metadata:** +- **Author**: Claude Code +- **Version**: 1.0 +- **Last Updated**: 2025-10-17 +- **Status**: Draft for Review diff --git a/src/App.tsx b/src/App.tsx index 84cb65d..074609e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,7 @@ import { useDocumentHistory } from "./hooks/useDocumentHistory"; import { useWorkspaceStore } from "./stores/workspaceStore"; import { usePanelStore } from "./stores/panelStore"; import { useCreateDocument } from "./hooks/useCreateDocument"; -import type { Actor, Relation } from "./types"; +import type { Actor, Relation, Group } from "./types"; import type { ExportOptions } from "./utils/graphExport"; /** @@ -49,6 +49,7 @@ function AppContent() { const leftPanelRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [selectedEdge, setSelectedEdge] = useState(null); + const [selectedGroup, setSelectedGroup] = useState(null); // Use refs for callbacks to avoid triggering re-renders const addNodeCallbackRef = useRef< ((nodeTypeId: string, position?: { x: number; y: number }) => void) | null @@ -91,17 +92,18 @@ function AppContent() { const handleKeyDown = (e: KeyboardEvent) => { // Escape: Close property panels if (e.key === "Escape") { - if (selectedNode || selectedEdge) { + if (selectedNode || selectedEdge || selectedGroup) { e.preventDefault(); setSelectedNode(null); setSelectedEdge(null); + setSelectedGroup(null); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedNode, selectedEdge]); + }, [selectedNode, selectedEdge, selectedGroup]); return (
@@ -143,6 +145,7 @@ function AppContent() { onDeselectAll={() => { setSelectedNode(null); setSelectedEdge(null); + setSelectedGroup(null); }} onAddNode={addNodeCallbackRef.current || undefined} /> @@ -153,18 +156,29 @@ function AppContent() { { setSelectedNode(node); - // Only clear edge if we're setting a node (not clearing) + // Only clear others if we're setting a node (not clearing) if (node) { setSelectedEdge(null); + setSelectedGroup(null); } }} onEdgeSelect={(edge) => { setSelectedEdge(edge); - // Only clear node if we're setting an edge (not clearing) + // Only clear others if we're setting an edge (not clearing) if (edge) { setSelectedNode(null); + setSelectedGroup(null); + } + }} + onGroupSelect={(group) => { + setSelectedGroup(group); + // Only clear others if we're setting a group (not clearing) + if (group) { + setSelectedNode(null); + setSelectedEdge(null); } }} onAddNodeRequest={( @@ -191,9 +205,11 @@ function AppContent() { { setSelectedNode(null); setSelectedEdge(null); + setSelectedGroup(null); }} /> )} diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 1f3aedf..d79f18e 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -30,22 +30,27 @@ import { useActiveDocument } from "../../stores/workspace/useActiveDocument"; import { useWorkspaceStore } from "../../stores/workspaceStore"; import { useCreateDocument } from "../../hooks/useCreateDocument"; import CustomNode from "../Nodes/CustomNode"; +import GroupNode from "../Nodes/GroupNode"; import CustomEdge from "../Edges/CustomEdge"; import ContextMenu from "./ContextMenu"; import EmptyState from "../Common/EmptyState"; import { createNode } from "../../utils/nodeUtils"; import DeleteIcon from "@mui/icons-material/Delete"; +import GroupWorkIcon from "@mui/icons-material/GroupWork"; +import UngroupIcon from "@mui/icons-material/CallSplit"; import { useConfirm } from "../../hooks/useConfirm"; import { useGraphExport } from "../../hooks/useGraphExport"; import type { ExportOptions } from "../../utils/graphExport"; -import type { Actor, Relation } from "../../types"; +import type { Actor, Relation, Group, GroupData } from "../../types"; interface GraphEditorProps { selectedNode: Actor | null; selectedEdge: Relation | null; + selectedGroup: Group | null; onNodeSelect: (node: Actor | null) => void; onEdgeSelect: (edge: Relation | null) => void; + onGroupSelect: (group: Group | null) => void; onAddNodeRequest?: (callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => void; onExportRequest?: (callback: (format: 'png' | 'svg', options?: ExportOptions) => Promise) => void; } @@ -63,7 +68,7 @@ interface GraphEditorProps { * * Usage: Core component that wraps React Flow with custom nodes and edges */ -const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportRequest }: GraphEditorProps) => { +const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeRequest, onExportRequest }: GraphEditorProps) => { // Sync with workspace active document const { activeDocumentId } = useActiveDocument(); const { saveViewport, getViewport } = useWorkspaceStore(); @@ -75,14 +80,18 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq const { nodes: storeNodes, edges: storeEdges, + groups: storeGroups, nodeTypes: nodeTypeConfigs, edgeTypes: edgeTypeConfigs, setNodes, setEdges, + setGroups, addEdge: addEdgeWithHistory, addNode: addNodeWithHistory, + createGroupWithActors, deleteNode, deleteEdge, + deleteGroup, } = useGraphWithHistory(); const { pushToHistory } = useDocumentHistory(); @@ -122,9 +131,13 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq const { confirm, ConfirmDialogComponent } = useConfirm(); // React Flow state (synchronized with store) - const [nodes, setNodesState, onNodesChange] = useNodesState( - storeNodes as Node[], - ); + // Combine regular nodes and group nodes for ReactFlow + // IMPORTANT: Parent nodes (groups) MUST appear BEFORE child nodes for React Flow to process correctly + const allNodes = useMemo(() => { + return [...(storeGroups as Node[]), ...(storeNodes as Node[])]; + }, [storeNodes, storeGroups]); + + const [nodes, setNodesState, onNodesChange] = useNodesState(allNodes); const [edges, setEdgesState, onEdgesChange] = useEdgesState( storeEdges as Edge[], ); @@ -132,8 +145,11 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq // Track if a drag is in progress to capture state before drag const dragInProgressRef = useRef(false); + // Track if a resize is in progress to avoid sync loops + const resizeInProgressRef = useRef(false); + // Track pending selection (ID of item to select after next sync) - const pendingSelectionRef = useRef<{ type: 'node' | 'edge', id: string } | null>(null); + const pendingSelectionRef = useRef<{ type: 'node' | 'edge' | 'group', id: string } | null>(null); // Context menu state const [contextMenu, setContextMenu] = useState<{ @@ -150,27 +166,28 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq const pendingType = pendingSelectionRef.current?.type; const pendingId = pendingSelectionRef.current?.id; - setNodesState((currentNodes) => { - // If we have a pending selection, deselect all nodes (or select the new node) - if (hasPendingSelection) { - const pendingNodeId = pendingType === 'node' ? pendingId : null; + // IMPORTANT: Directly set the nodes array to avoid React Flow processing intermediate states + // Using setNodesState with a callback can cause React Flow to process stale state + if (hasPendingSelection) { + const pendingNodeId = pendingType === 'node' || pendingType === 'group' ? pendingId : null; - return (storeNodes as Node[]).map((node) => ({ - ...node, - selected: node.id === pendingNodeId, - })); - } - - // Otherwise, preserve existing selection state - const selectionMap = new Map( - currentNodes.map((node) => [node.id, node.selected]) - ); - - return (storeNodes as Node[]).map((node) => ({ + setNodesState(allNodes.map((node) => ({ ...node, - selected: selectionMap.get(node.id) || false, - })); - }); + selected: node.id === pendingNodeId, + }))); + } else { + // Preserve existing selection state + setNodesState((currentNodes) => { + const selectionMap = new Map( + currentNodes.map((node) => [node.id, node.selected]) + ); + + return allNodes.map((node) => ({ + ...node, + selected: selectionMap.get(node.id) || false, + })); + }); + } setEdgesState((currentEdges) => { // If we have a pending selection, deselect all edges (or select the new edge) @@ -198,7 +215,7 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq selected: selectionMap.get(edge.id) || false, })); }); - }, [storeNodes, storeEdges, setNodesState, setEdgesState]); + }, [allNodes, storeEdges, setNodesState, setEdgesState]); // Save viewport when switching documents and restore viewport for new document useEffect(() => { @@ -364,25 +381,36 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq nodes: Node[]; edges: Edge[]; }) => { - // If a node is selected, notify parent + // If a single node is selected if (selectedNodes.length == 1) { - const selectedNode = selectedNodes[0] as Actor; - onNodeSelect(selectedNode); - // Don't call onEdgeSelect - parent will handle clearing edge selection + const selectedItem = selectedNodes[0]; + + // Check if it's a group (type === 'group') + if (selectedItem.type === 'group') { + const selectedGroup = selectedItem as Group; + onGroupSelect(selectedGroup); + // Don't call others - parent will handle clearing + } else { + // It's a regular actor node + const selectedNode = selectedItem as Actor; + onNodeSelect(selectedNode); + // Don't call others - parent will handle clearing + } } // If an edge is selected, notify parent else if (selectedEdges.length == 1) { const selectedEdge = selectedEdges[0] as Relation; onEdgeSelect(selectedEdge); - // Don't call onNodeSelect - parent will handle clearing node selection + // Don't call others - parent will handle clearing } // Nothing selected else { onNodeSelect(null); onEdgeSelect(null); + onGroupSelect(null); } }, - [onNodeSelect, onEdgeSelect], + [onNodeSelect, onEdgeSelect, onGroupSelect], ); // Register the selection change handler with ReactFlow @@ -410,6 +438,20 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq pushToHistory("Move Actor"); } + // Check if a resize operation just started (resizing: true) + const resizeStartChanges = changes.filter( + (change) => + change.type === "dimensions" && + "resizing" in change && + change.resizing === true, + ); + + // Capture state BEFORE the resize operation begins + if (resizeStartChanges.length > 0 && !resizeInProgressRef.current) { + resizeInProgressRef.current = true; + pushToHistory("Resize Group"); + } + // Apply the changes onNodesChange(changes); @@ -421,6 +463,14 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq change.dragging === false, ); + // Check if any resize operation just completed (resizing: false) + const resizeEndChanges = changes.filter( + (change) => + change.type === "dimensions" && + "resizing" in change && + change.resizing === false, + ); + // If a drag just ended, sync to store if (dragEndChanges.length > 0) { dragInProgressRef.current = false; @@ -428,29 +478,54 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq setTimeout(() => { // Sync to store - use callback to get fresh state setNodesState((currentNodes) => { - setNodes(currentNodes as Actor[]); + // Filter out groups - they're stored separately + const actorNodes = currentNodes.filter((node) => node.type !== 'group'); + setNodes(actorNodes as Actor[]); return currentNodes; }); }, 0); - } else { - // For non-drag changes (dimension, etc), just sync to store + } + + // If a resize just ended, sync to store + if (resizeEndChanges.length > 0) { + resizeInProgressRef.current = false; + setTimeout(() => { + setNodesState((currentNodes) => { + // Sync groups (which can be resized) to store + const groupNodes = currentNodes.filter((node) => node.type === 'group'); + const actorNodes = currentNodes.filter((node) => node.type !== 'group'); + + // Update groups in store with new dimensions + setGroups(groupNodes as Group[]); + setNodes(actorNodes as Actor[]); + + return currentNodes; + }); + }, 0); + } + + // For other non-drag, non-resize changes, DON'T sync during drag/resize + if (!dragInProgressRef.current && !resizeInProgressRef.current) { const hasNonSelectionChanges = changes.some( (change) => change.type !== "select" && change.type !== "remove" && - change.type !== "position", + change.type !== "position" && + change.type !== "dimensions", ); if (hasNonSelectionChanges) { setTimeout(() => { setNodesState((currentNodes) => { - setNodes(currentNodes as Actor[]); + // Filter out groups - they're stored separately + const actorNodes = currentNodes.filter((node) => node.type !== 'group'); + setNodes(actorNodes as Actor[]); return currentNodes; }); }, 0); } } }, - [onNodesChange, setNodesState, setNodes, pushToHistory], + [onNodesChange, setNodesState, setNodes, setGroups, pushToHistory], ); const handleEdgesChange = useCallback( @@ -542,6 +617,7 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq const nodeTypes: NodeTypes = useMemo( () => ({ custom: CustomNode, + group: GroupNode, }), [], ); @@ -693,6 +769,73 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq [contextMenu, screenToFlowPosition, handleAddNode], ); + // Create group from selected nodes + const handleCreateGroupFromSelection = useCallback(() => { + const selectedActorNodes = nodes.filter((node) => node.selected && node.type !== 'group') as Actor[]; + + if (selectedActorNodes.length < 2) { + return; // Need at least 2 nodes to create a group + } + + // Calculate bounding box of selected nodes + const minX = Math.min(...selectedActorNodes.map((n) => n.position.x)); + const minY = Math.min(...selectedActorNodes.map((n) => n.position.y)); + const maxX = Math.max(...selectedActorNodes.map((n) => n.position.x + (n.width || 150))); + const maxY = Math.max(...selectedActorNodes.map((n) => n.position.y + (n.height || 100))); + + // Add padding + const padding = 40; + const groupPosition = { + x: minX - padding, + y: minY - padding, + }; + const groupWidth = maxX - minX + padding * 2; + const groupHeight = maxY - minY + padding * 2; + + // Create group ID + const groupId = `group_${Date.now()}`; + + // Create group data + const groupData: GroupData = { + label: `Group ${storeGroups.length + 1}`, + color: 'rgba(240, 242, 245, 0.5)', // Default gray - matches CSS + actorIds: selectedActorNodes.map((n) => n.id), + }; + + // Create group node + const newGroup: Group = { + id: groupId, + type: 'group', + position: groupPosition, + data: groupData, + style: { + width: groupWidth, + height: groupHeight, + }, + }; + + // Build actor updates map (relative positions and parent relationship) + const actorUpdates: Record = {}; + selectedActorNodes.forEach((node) => { + actorUpdates[node.id] = { + position: { + x: node.position.x - groupPosition.x, + y: node.position.y - groupPosition.y, + }, + parentId: groupId, + extent: 'parent' as const, + }; + }); + + // Use atomic operation to create group and update actors in a single history snapshot + createGroupWithActors(newGroup, selectedActorNodes.map((n) => n.id), actorUpdates); + + // Select the new group + pendingSelectionRef.current = { type: 'group', id: groupId }; + + setContextMenu(null); + }, [nodes, storeGroups, createGroupWithActors]); + // Show empty state when no document is active if (!activeDocumentId) { return ( @@ -786,36 +929,100 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq )} {/* Context Menu - Node */} - {contextMenu && contextMenu.type === "node" && contextMenu.target && ( - { + const targetNode = contextMenu.target as Node; + const isGroup = targetNode.type === 'group'; + + // Calculate how many actor nodes are selected (exclude groups) + const selectedActorNodes = nodes.filter((node) => node.selected && node.type !== 'group'); + const canCreateGroup = selectedActorNodes.length >= 2; + + const sections = []; + + // If it's a group node, show "Ungroup" option + if (isGroup) { + const groupNode = targetNode as Group; + sections.push({ + actions: [ + { + label: "Ungroup", + icon: , + onClick: async () => { + const confirmed = await confirm({ + title: "Ungroup Actors", + message: `Ungroup "${groupNode.data.label}"? All ${groupNode.data.actorIds.length} actors will be moved back to the canvas.`, + confirmLabel: "Ungroup", + severity: "info", + }); + if (confirmed) { + deleteGroup(groupNode.id, true); // true = ungroup (non-destructive) + setContextMenu(null); + } + }, + }, + ], + }); + } else { + // For regular actor nodes, add "Create Group" option if multiple nodes are selected + if (canCreateGroup) { + sections.push({ actions: [ { - label: "Delete", - icon: , - onClick: async () => { - const confirmed = await confirm({ - title: "Delete Actor", - message: - "Are you sure you want to delete this actor? All connected relations will also be deleted.", - confirmLabel: "Delete", - severity: "danger", - }); - if (confirmed) { - deleteNode(contextMenu.target!.id); - setContextMenu(null); - } - }, + label: `Create Group (${selectedActorNodes.length} actors)`, + icon: , + onClick: handleCreateGroupFromSelection, }, ], + }); + } + } + + // Add "Delete" option (for both groups and actors) + sections.push({ + actions: [ + { + label: isGroup ? "Delete Group & Actors" : "Delete", + icon: , + onClick: async () => { + if (isGroup) { + const groupNode = targetNode as Group; + const confirmed = await confirm({ + title: "Delete Group and Actors", + message: `Delete "${groupNode.data.label}" AND all ${groupNode.data.actorIds.length} actors inside? This will also delete all connected relations. This action cannot be undone.`, + confirmLabel: "Delete", + severity: "danger", + }); + if (confirmed) { + deleteGroup(groupNode.id, false); // false = destructive delete + setContextMenu(null); + } + } else { + const confirmed = await confirm({ + title: "Delete Actor", + message: + "Are you sure you want to delete this actor? All connected relations will also be deleted.", + confirmLabel: "Delete", + severity: "danger", + }); + if (confirmed) { + deleteNode(contextMenu.target!.id); + setContextMenu(null); + } + } + }, }, - ]} - onClose={() => setContextMenu(null)} - /> - )} + ], + }); + + return ( + setContextMenu(null)} + /> + ); + })()} {/* Context Menu - Edge */} {contextMenu && contextMenu.type === "edge" && contextMenu.target && ( diff --git a/src/components/Nodes/GroupNode.tsx b/src/components/Nodes/GroupNode.tsx new file mode 100644 index 0000000..8f39d1d --- /dev/null +++ b/src/components/Nodes/GroupNode.tsx @@ -0,0 +1,154 @@ +import { memo, useState, useMemo } from 'react'; +import { NodeProps, NodeResizer, useStore } from 'reactflow'; +import type { GroupData } from '../../types'; +import type { Actor } from '../../types'; + +/** + * GroupNode - Simple label overlay for React Flow's native group nodes + * + * This component provides a minimal UI on top of React Flow's built-in group styling. + * The group itself (border, background) is styled via CSS in index.css. + * + * Features: + * - Editable label (double-click to edit, Enter to save, Escape to cancel) + * - Resizable via drag handles with smart minimum size based on children + * - Simple, unobtrusive design that doesn't interfere with React Flow's layout + * + * Usage: Automatically rendered by React Flow for nodes with type='group' + */ +const GroupNode = ({ id, data, selected }: NodeProps) => { + const [isEditing, setIsEditing] = useState(false); + const [editLabel, setEditLabel] = useState(data.label); + + // Get child nodes from React Flow store to calculate minimum dimensions + const childNodes = useStore((state) => { + return state.nodeInternals + ? Array.from(state.nodeInternals.values()).filter( + (node) => (node as Actor & { parentId?: string }).parentId === id + ) + : []; + }); + + // Calculate minimum dimensions based on child nodes + const { minWidth, minHeight } = useMemo(() => { + if (childNodes.length === 0) { + return { minWidth: 150, minHeight: 100 }; + } + + // Find the bounding box of all child nodes (in relative coordinates) + const padding = 20; // Minimum padding around children + + let maxX = 0; + let maxY = 0; + + childNodes.forEach((node) => { + const nodeWidth = node.width || 150; + const nodeHeight = node.height || 100; + const rightEdge = node.position.x + nodeWidth; + const bottomEdge = node.position.y + nodeHeight; + + maxX = Math.max(maxX, rightEdge); + maxY = Math.max(maxY, bottomEdge); + }); + + return { + minWidth: Math.max(150, maxX + padding), + minHeight: Math.max(100, maxY + padding), + }; + }, [childNodes]); + + const handleStartEdit = () => { + setEditLabel(data.label); + setIsEditing(true); + }; + + const handleSaveEdit = () => { + if (editLabel.trim() && editLabel !== data.label) { + // Update via the group store + // For now, we'll just close editing - actual update will be handled by GroupEditorPanel + // TODO: Implement direct label update + } + setIsEditing(false); + }; + + const handleCancelEdit = () => { + setEditLabel(data.label); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSaveEdit(); + } else if (e.key === 'Escape') { + handleCancelEdit(); + } + }; + + return ( +
+ {/* Resize handles - only visible when selected */} + + + {/* Label overlay - positioned at top-left corner */} +
+ {isEditing ? ( + setEditLabel(e.target.value)} + onBlur={handleSaveEdit} + onKeyDown={handleKeyDown} + className="text-sm font-medium text-gray-700 bg-transparent border-none focus:outline-none" + autoFocus + style={{ minWidth: '80px', maxWidth: '200px' }} + /> + ) : ( + + {data.label} + + )} +
+ + {/* Content area - React Flow renders child nodes here automatically */} +
+ ); +}; + +export default memo(GroupNode); diff --git a/src/components/Panels/GroupEditorPanel.tsx b/src/components/Panels/GroupEditorPanel.tsx new file mode 100644 index 0000000..06ea591 --- /dev/null +++ b/src/components/Panels/GroupEditorPanel.tsx @@ -0,0 +1,261 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import CloseIcon from '@mui/icons-material/Close'; +import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; +import { useConfirm } from '../../hooks/useConfirm'; +import type { Group } from '../../types'; + +/** + * GroupEditorPanel - Properties editor for selected group + * + * Features: + * - Edit group name and description + * - Change group background color + * - View and manage group members (actors) + * - Ungroup or delete group + * - Live updates with debouncing + */ + +interface Props { + selectedGroup: Group; + onClose: () => void; +} + +const DEFAULT_GROUP_COLORS = [ + 'rgba(59, 130, 246, 0.08)', // Blue + 'rgba(16, 185, 129, 0.08)', // Green + 'rgba(245, 158, 11, 0.08)', // Orange + 'rgba(139, 92, 246, 0.08)', // Purple + 'rgba(236, 72, 153, 0.08)', // Pink + 'rgba(20, 184, 166, 0.08)', // Teal +]; + +const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => { + const { updateGroup, deleteGroup, removeActorFromGroup, nodes, nodeTypes } = useGraphWithHistory(); + const { confirm, ConfirmDialogComponent } = useConfirm(); + + const [label, setLabel] = useState(selectedGroup.data.label); + const [description, setDescription] = useState(selectedGroup.data.description || ''); + const [color, setColor] = useState(selectedGroup.data.color); + + // Debounce timer + const [debounceTimer, setDebounceTimer] = useState(null); + + // Ref to track current group ID (avoids recreating callback) + const selectedGroupIdRef = useRef(selectedGroup.id); + + // Update ref when group changes + useEffect(() => { + selectedGroupIdRef.current = selectedGroup.id; + }, [selectedGroup.id]); + + // Sync local state when selected group changes + useEffect(() => { + setLabel(selectedGroup.data.label); + setDescription(selectedGroup.data.description || ''); + setColor(selectedGroup.data.color); + }, [selectedGroup.id, selectedGroup.data]); + + // Debounced update function + const debouncedUpdate = useCallback( + (updates: Partial) => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + const timer = window.setTimeout(() => { + updateGroup(selectedGroupIdRef.current, updates); + }, 500); + + setDebounceTimer(timer); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [updateGroup, debounceTimer] + ); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + }; + }, [debounceTimer]); + + const handleLabelChange = (newLabel: string) => { + setLabel(newLabel); + if (newLabel.trim()) { + debouncedUpdate({ label: newLabel.trim() }); + } + }; + + const handleDescriptionChange = (newDescription: string) => { + setDescription(newDescription); + debouncedUpdate({ description: newDescription.trim() || undefined }); + }; + + const handleColorChange = (newColor: string) => { + setColor(newColor); + updateGroup(selectedGroup.id, { color: newColor }); + }; + + const handleRemoveActor = async (actorId: string) => { + const actor = nodes.find((n) => n.id === actorId); + + const confirmed = await confirm({ + title: 'Remove Actor from Group', + message: `Remove "${actor?.data.label || 'actor'}" from "${selectedGroup.data.label}"?`, + confirmLabel: 'Remove', + severity: 'info', + }); + + if (confirmed) { + removeActorFromGroup(actorId, selectedGroup.id); + } + }; + + const handleUngroup = async () => { + const confirmed = await confirm({ + title: 'Ungroup Actors', + message: `Ungroup "${selectedGroup.data.label}"? All ${selectedGroup.data.actorIds.length} actors will be moved back to the canvas.`, + confirmLabel: 'Ungroup', + severity: 'info', + }); + + if (confirmed) { + deleteGroup(selectedGroup.id, true); // true = ungroup (non-destructive) + onClose(); + } + }; + + const handleDeleteGroup = async () => { + const confirmed = await confirm({ + title: 'Delete Group and Actors', + message: `Delete "${selectedGroup.data.label}" AND all ${selectedGroup.data.actorIds.length} actors inside? This will also delete all connected relations. This action cannot be undone.`, + confirmLabel: 'Delete', + severity: 'danger', + }); + + if (confirmed) { + deleteGroup(selectedGroup.id, false); // false = destructive delete + onClose(); + } + }; + + // Get actors in this group + const groupActors = nodes.filter((node) => selectedGroup.data.actorIds.includes(node.id)); + + return ( +
+ {/* Name */} +
+ + handleLabelChange(e.target.value)} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Enter group name" + /> +
+ + {/* Description */} +
+ +