feat: add resizable actor grouping with full undo/redo support

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 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-18 20:06:59 +02:00
parent 59e30cca8a
commit f5adbc8ead
20 changed files with 2281 additions and 128 deletions

View file

@ -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<string, unknown>;
}
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<GroupData>) => 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<GroupData>) => {
// 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
<ReactFlow
nodes={[...nodes, ...groups]} // Groups are rendered as nodes
edges={edges}
nodeTypes={{ custom: CustomNode, group: GroupNode }}
// ... other props
/>
```
### 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

View file

@ -15,7 +15,7 @@ import { useDocumentHistory } from "./hooks/useDocumentHistory";
import { useWorkspaceStore } from "./stores/workspaceStore"; import { useWorkspaceStore } from "./stores/workspaceStore";
import { usePanelStore } from "./stores/panelStore"; import { usePanelStore } from "./stores/panelStore";
import { useCreateDocument } from "./hooks/useCreateDocument"; import { useCreateDocument } from "./hooks/useCreateDocument";
import type { Actor, Relation } from "./types"; import type { Actor, Relation, Group } from "./types";
import type { ExportOptions } from "./utils/graphExport"; import type { ExportOptions } from "./utils/graphExport";
/** /**
@ -49,6 +49,7 @@ function AppContent() {
const leftPanelRef = useRef<LeftPanelRef>(null); const leftPanelRef = useRef<LeftPanelRef>(null);
const [selectedNode, setSelectedNode] = useState<Actor | null>(null); const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null); const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
// Use refs for callbacks to avoid triggering re-renders // Use refs for callbacks to avoid triggering re-renders
const addNodeCallbackRef = useRef< const addNodeCallbackRef = useRef<
((nodeTypeId: string, position?: { x: number; y: number }) => void) | null ((nodeTypeId: string, position?: { x: number; y: number }) => void) | null
@ -91,17 +92,18 @@ function AppContent() {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Escape: Close property panels // Escape: Close property panels
if (e.key === "Escape") { if (e.key === "Escape") {
if (selectedNode || selectedEdge) { if (selectedNode || selectedEdge || selectedGroup) {
e.preventDefault(); e.preventDefault();
setSelectedNode(null); setSelectedNode(null);
setSelectedEdge(null); setSelectedEdge(null);
setSelectedGroup(null);
} }
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNode, selectedEdge]); }, [selectedNode, selectedEdge, selectedGroup]);
return ( return (
<div className="flex flex-col h-screen bg-gray-100"> <div className="flex flex-col h-screen bg-gray-100">
@ -143,6 +145,7 @@ function AppContent() {
onDeselectAll={() => { onDeselectAll={() => {
setSelectedNode(null); setSelectedNode(null);
setSelectedEdge(null); setSelectedEdge(null);
setSelectedGroup(null);
}} }}
onAddNode={addNodeCallbackRef.current || undefined} onAddNode={addNodeCallbackRef.current || undefined}
/> />
@ -153,18 +156,29 @@ function AppContent() {
<GraphEditor <GraphEditor
selectedNode={selectedNode} selectedNode={selectedNode}
selectedEdge={selectedEdge} selectedEdge={selectedEdge}
selectedGroup={selectedGroup}
onNodeSelect={(node) => { onNodeSelect={(node) => {
setSelectedNode(node); 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) { if (node) {
setSelectedEdge(null); setSelectedEdge(null);
setSelectedGroup(null);
} }
}} }}
onEdgeSelect={(edge) => { onEdgeSelect={(edge) => {
setSelectedEdge(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) { if (edge) {
setSelectedNode(null); 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={( onAddNodeRequest={(
@ -191,9 +205,11 @@ function AppContent() {
<RightPanel <RightPanel
selectedNode={selectedNode} selectedNode={selectedNode}
selectedEdge={selectedEdge} selectedEdge={selectedEdge}
selectedGroup={selectedGroup}
onClose={() => { onClose={() => {
setSelectedNode(null); setSelectedNode(null);
setSelectedEdge(null); setSelectedEdge(null);
setSelectedGroup(null);
}} }}
/> />
)} )}

View file

@ -30,22 +30,27 @@ import { useActiveDocument } from "../../stores/workspace/useActiveDocument";
import { useWorkspaceStore } from "../../stores/workspaceStore"; import { useWorkspaceStore } from "../../stores/workspaceStore";
import { useCreateDocument } from "../../hooks/useCreateDocument"; import { useCreateDocument } from "../../hooks/useCreateDocument";
import CustomNode from "../Nodes/CustomNode"; import CustomNode from "../Nodes/CustomNode";
import GroupNode from "../Nodes/GroupNode";
import CustomEdge from "../Edges/CustomEdge"; import CustomEdge from "../Edges/CustomEdge";
import ContextMenu from "./ContextMenu"; import ContextMenu from "./ContextMenu";
import EmptyState from "../Common/EmptyState"; import EmptyState from "../Common/EmptyState";
import { createNode } from "../../utils/nodeUtils"; import { createNode } from "../../utils/nodeUtils";
import DeleteIcon from "@mui/icons-material/Delete"; 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 { useConfirm } from "../../hooks/useConfirm";
import { useGraphExport } from "../../hooks/useGraphExport"; import { useGraphExport } from "../../hooks/useGraphExport";
import type { ExportOptions } from "../../utils/graphExport"; import type { ExportOptions } from "../../utils/graphExport";
import type { Actor, Relation } from "../../types"; import type { Actor, Relation, Group, GroupData } from "../../types";
interface GraphEditorProps { interface GraphEditorProps {
selectedNode: Actor | null; selectedNode: Actor | null;
selectedEdge: Relation | null; selectedEdge: Relation | null;
selectedGroup: Group | null;
onNodeSelect: (node: Actor | null) => void; onNodeSelect: (node: Actor | null) => void;
onEdgeSelect: (edge: Relation | null) => void; onEdgeSelect: (edge: Relation | null) => void;
onGroupSelect: (group: Group | null) => void;
onAddNodeRequest?: (callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => void; onAddNodeRequest?: (callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => void;
onExportRequest?: (callback: (format: 'png' | 'svg', options?: ExportOptions) => Promise<void>) => void; onExportRequest?: (callback: (format: 'png' | 'svg', options?: ExportOptions) => Promise<void>) => void;
} }
@ -63,7 +68,7 @@ interface GraphEditorProps {
* *
* Usage: Core component that wraps React Flow with custom nodes and edges * 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 // Sync with workspace active document
const { activeDocumentId } = useActiveDocument(); const { activeDocumentId } = useActiveDocument();
const { saveViewport, getViewport } = useWorkspaceStore(); const { saveViewport, getViewport } = useWorkspaceStore();
@ -75,14 +80,18 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
const { const {
nodes: storeNodes, nodes: storeNodes,
edges: storeEdges, edges: storeEdges,
groups: storeGroups,
nodeTypes: nodeTypeConfigs, nodeTypes: nodeTypeConfigs,
edgeTypes: edgeTypeConfigs, edgeTypes: edgeTypeConfigs,
setNodes, setNodes,
setEdges, setEdges,
setGroups,
addEdge: addEdgeWithHistory, addEdge: addEdgeWithHistory,
addNode: addNodeWithHistory, addNode: addNodeWithHistory,
createGroupWithActors,
deleteNode, deleteNode,
deleteEdge, deleteEdge,
deleteGroup,
} = useGraphWithHistory(); } = useGraphWithHistory();
const { pushToHistory } = useDocumentHistory(); const { pushToHistory } = useDocumentHistory();
@ -122,9 +131,13 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
const { confirm, ConfirmDialogComponent } = useConfirm(); const { confirm, ConfirmDialogComponent } = useConfirm();
// React Flow state (synchronized with store) // React Flow state (synchronized with store)
const [nodes, setNodesState, onNodesChange] = useNodesState( // Combine regular nodes and group nodes for ReactFlow
storeNodes as Node[], // 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( const [edges, setEdgesState, onEdgesChange] = useEdgesState(
storeEdges as Edge[], 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 // Track if a drag is in progress to capture state before drag
const dragInProgressRef = useRef(false); 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) // 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 // Context menu state
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
@ -150,27 +166,28 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
const pendingType = pendingSelectionRef.current?.type; const pendingType = pendingSelectionRef.current?.type;
const pendingId = pendingSelectionRef.current?.id; const pendingId = pendingSelectionRef.current?.id;
setNodesState((currentNodes) => { // IMPORTANT: Directly set the nodes array to avoid React Flow processing intermediate states
// If we have a pending selection, deselect all nodes (or select the new node) // Using setNodesState with a callback can cause React Flow to process stale state
if (hasPendingSelection) { if (hasPendingSelection) {
const pendingNodeId = pendingType === 'node' ? pendingId : null; const pendingNodeId = pendingType === 'node' || pendingType === 'group' ? pendingId : null;
return (storeNodes as Node[]).map((node) => ({ setNodesState(allNodes.map((node) => ({
...node, ...node,
selected: node.id === pendingNodeId, selected: node.id === pendingNodeId,
})); })));
} } else {
// Preserve existing selection state
// Otherwise, preserve existing selection state setNodesState((currentNodes) => {
const selectionMap = new Map( const selectionMap = new Map(
currentNodes.map((node) => [node.id, node.selected]) currentNodes.map((node) => [node.id, node.selected])
); );
return (storeNodes as Node[]).map((node) => ({ return allNodes.map((node) => ({
...node, ...node,
selected: selectionMap.get(node.id) || false, selected: selectionMap.get(node.id) || false,
})); }));
}); });
}
setEdgesState((currentEdges) => { setEdgesState((currentEdges) => {
// If we have a pending selection, deselect all edges (or select the new edge) // 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, 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 // Save viewport when switching documents and restore viewport for new document
useEffect(() => { useEffect(() => {
@ -364,25 +381,36 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
nodes: Node[]; nodes: Node[];
edges: Edge[]; edges: Edge[];
}) => { }) => {
// If a node is selected, notify parent // If a single node is selected
if (selectedNodes.length == 1) { if (selectedNodes.length == 1) {
const selectedNode = selectedNodes[0] as Actor; 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); onNodeSelect(selectedNode);
// Don't call onEdgeSelect - parent will handle clearing edge selection // Don't call others - parent will handle clearing
}
} }
// If an edge is selected, notify parent // If an edge is selected, notify parent
else if (selectedEdges.length == 1) { else if (selectedEdges.length == 1) {
const selectedEdge = selectedEdges[0] as Relation; const selectedEdge = selectedEdges[0] as Relation;
onEdgeSelect(selectedEdge); onEdgeSelect(selectedEdge);
// Don't call onNodeSelect - parent will handle clearing node selection // Don't call others - parent will handle clearing
} }
// Nothing selected // Nothing selected
else { else {
onNodeSelect(null); onNodeSelect(null);
onEdgeSelect(null); onEdgeSelect(null);
onGroupSelect(null);
} }
}, },
[onNodeSelect, onEdgeSelect], [onNodeSelect, onEdgeSelect, onGroupSelect],
); );
// Register the selection change handler with ReactFlow // Register the selection change handler with ReactFlow
@ -410,6 +438,20 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
pushToHistory("Move Actor"); 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 // Apply the changes
onNodesChange(changes); onNodesChange(changes);
@ -421,6 +463,14 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
change.dragging === false, 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 a drag just ended, sync to store
if (dragEndChanges.length > 0) { if (dragEndChanges.length > 0) {
dragInProgressRef.current = false; dragInProgressRef.current = false;
@ -428,29 +478,54 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
setTimeout(() => { setTimeout(() => {
// Sync to store - use callback to get fresh state // Sync to store - use callback to get fresh state
setNodesState((currentNodes) => { 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; return currentNodes;
}); });
}, 0); }, 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( const hasNonSelectionChanges = changes.some(
(change) => (change) =>
change.type !== "select" && change.type !== "select" &&
change.type !== "remove" && change.type !== "remove" &&
change.type !== "position", change.type !== "position" &&
change.type !== "dimensions",
); );
if (hasNonSelectionChanges) { if (hasNonSelectionChanges) {
setTimeout(() => { setTimeout(() => {
setNodesState((currentNodes) => { 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; return currentNodes;
}); });
}, 0); }, 0);
} }
} }
}, },
[onNodesChange, setNodesState, setNodes, pushToHistory], [onNodesChange, setNodesState, setNodes, setGroups, pushToHistory],
); );
const handleEdgesChange = useCallback( const handleEdgesChange = useCallback(
@ -542,6 +617,7 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
const nodeTypes: NodeTypes = useMemo( const nodeTypes: NodeTypes = useMemo(
() => ({ () => ({
custom: CustomNode, custom: CustomNode,
group: GroupNode,
}), }),
[], [],
); );
@ -693,6 +769,73 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
[contextMenu, screenToFlowPosition, handleAddNode], [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<string, { position: { x: number; y: number }; parentId: string; extent: 'parent' }> = {};
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 // Show empty state when no document is active
if (!activeDocumentId) { if (!activeDocumentId) {
return ( return (
@ -786,17 +929,74 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
)} )}
{/* Context Menu - Node */} {/* Context Menu - Node */}
{contextMenu && contextMenu.type === "node" && contextMenu.target && ( {contextMenu && contextMenu.type === "node" && contextMenu.target && (() => {
<ContextMenu const targetNode = contextMenu.target as Node;
x={contextMenu.x} const isGroup = targetNode.type === 'group';
y={contextMenu.y}
sections={[ // 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: [ actions: [
{ {
label: "Delete", label: "Ungroup",
icon: <UngroupIcon fontSize="small" />,
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: `Create Group (${selectedActorNodes.length} actors)`,
icon: <GroupWorkIcon fontSize="small" />,
onClick: handleCreateGroupFromSelection,
},
],
});
}
}
// Add "Delete" option (for both groups and actors)
sections.push({
actions: [
{
label: isGroup ? "Delete Group & Actors" : "Delete",
icon: <DeleteIcon fontSize="small" />, icon: <DeleteIcon fontSize="small" />,
onClick: async () => { 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({ const confirmed = await confirm({
title: "Delete Actor", title: "Delete Actor",
message: message:
@ -808,14 +1008,21 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
deleteNode(contextMenu.target!.id); deleteNode(contextMenu.target!.id);
setContextMenu(null); setContextMenu(null);
} }
}
}, },
}, },
], ],
}, });
]}
return (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
sections={sections}
onClose={() => setContextMenu(null)} onClose={() => setContextMenu(null)}
/> />
)} );
})()}
{/* Context Menu - Edge */} {/* Context Menu - Edge */}
{contextMenu && contextMenu.type === "edge" && contextMenu.target && ( {contextMenu && contextMenu.type === "edge" && contextMenu.target && (

View file

@ -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<GroupData>) => {
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 (
<div
style={{
width: '100%',
height: '100%',
position: 'relative',
}}
>
{/* Resize handles - only visible when selected */}
<NodeResizer
isVisible={selected}
minWidth={minWidth}
minHeight={minHeight}
handleStyle={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: '#3b82f6',
border: '2px solid white',
}}
lineStyle={{
borderWidth: 2,
borderColor: '#3b82f6',
}}
/>
{/* Label overlay - positioned at top-left corner */}
<div
style={{
position: 'absolute',
top: 8,
left: 8,
padding: '4px 8px',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: '4px',
border: '1px solid rgba(0, 0, 0, 0.1)',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
zIndex: 10,
}}
>
{isEditing ? (
<input
type="text"
value={editLabel}
onChange={(e) => 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' }}
/>
) : (
<span
className="text-sm font-medium text-gray-700 cursor-text select-none"
onDoubleClick={handleStartEdit}
title="Double-click to edit"
>
{data.label}
</span>
)}
</div>
{/* Content area - React Flow renders child nodes here automatically */}
</div>
);
};
export default memo(GroupNode);

View file

@ -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<number | null>(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<typeof selectedGroup.data>) => {
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 (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Name */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Group Name
</label>
<input
type="text"
value={label}
onChange={(e) => 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"
/>
</div>
{/* Description */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Description (optional)
</label>
<textarea
value={description}
onChange={(e) => handleDescriptionChange(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 resize-none"
rows={3}
placeholder="Enter description"
/>
</div>
{/* Background Color */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">
Background Color
</label>
<div className="grid grid-cols-6 gap-2">
{DEFAULT_GROUP_COLORS.map((c) => (
<button
key={c}
onClick={() => handleColorChange(c)}
className={`w-10 h-10 rounded border-2 transition-all ${
color === c ? 'border-blue-500 scale-110' : 'border-gray-300 hover:border-gray-400'
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
</div>
{/* Members */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">
Members ({groupActors.length} actor{groupActors.length !== 1 ? 's' : ''})
</label>
{groupActors.length === 0 ? (
<p className="text-xs text-gray-500 italic">No actors in this group</p>
) : (
<div className="space-y-1 max-h-48 overflow-y-auto border border-gray-200 rounded p-2">
{groupActors.map((actor) => {
const actorType = nodeTypes.find((nt) => nt.id === actor.data.type);
return (
<div
key={actor.id}
className="flex items-center justify-between py-1.5 px-2 hover:bg-gray-50 rounded text-sm"
>
<div className="flex items-center space-x-2 flex-1 min-w-0">
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: actorType?.color || '#6b7280' }}
/>
<span className="text-gray-700 truncate">{actor.data.label}</span>
<span className="text-xs text-gray-500">({actorType?.label || 'Unknown'})</span>
</div>
<Tooltip title="Remove from group">
<IconButton
size="small"
onClick={() => handleRemoveActor(actor.id)}
sx={{ padding: '2px' }}
>
<CloseIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</div>
);
})}
</div>
)}
</div>
{/* Actions */}
<div className="pt-4 border-t border-gray-200 space-y-2">
<button
onClick={handleUngroup}
className="w-full px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded hover:bg-blue-100 transition-colors"
>
Ungroup (Keep Actors)
</button>
<button
onClick={handleDeleteGroup}
className="w-full px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded hover:bg-red-100 transition-colors flex items-center justify-center space-x-2"
>
<DeleteIcon fontSize="small" />
<span>Delete Group & Actors</span>
</button>
</div>
{ConfirmDialogComponent}
</div>
);
};
export default GroupEditorPanel;

View file

@ -5,8 +5,9 @@ import { usePanelStore } from '../../stores/panelStore';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import NodeEditorPanel from './NodeEditorPanel'; import NodeEditorPanel from './NodeEditorPanel';
import EdgeEditorPanel from './EdgeEditorPanel'; import EdgeEditorPanel from './EdgeEditorPanel';
import GroupEditorPanel from './GroupEditorPanel';
import GraphAnalysisPanel from './GraphAnalysisPanel'; import GraphAnalysisPanel from './GraphAnalysisPanel';
import type { Actor, Relation } from '../../types'; import type { Actor, Relation, Group } from '../../types';
/** /**
* RightPanel - Context-aware properties panel on the right side * RightPanel - Context-aware properties panel on the right side
@ -23,6 +24,7 @@ import type { Actor, Relation } from '../../types';
interface Props { interface Props {
selectedNode: Actor | null; selectedNode: Actor | null;
selectedEdge: Relation | null; selectedEdge: Relation | null;
selectedGroup: Group | null;
onClose: () => void; onClose: () => void;
} }
@ -45,7 +47,7 @@ const PanelHeader = ({ title, onCollapse }: PanelHeaderProps) => (
</div> </div>
); );
const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { const RightPanel = ({ selectedNode, selectedEdge, selectedGroup, onClose }: Props) => {
const { const {
rightPanelCollapsed, rightPanelCollapsed,
rightPanelWidth, rightPanelWidth,
@ -68,15 +70,15 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
); );
} }
// No selection state - show graph metrics // Group properties view (priority over node/edge if group selected)
if (!selectedNode && !selectedEdge) { if (selectedGroup) {
return ( return (
<div <div
className="h-full bg-white border-l border-gray-200 flex flex-col" className="h-full bg-white border-l border-gray-200 flex flex-col"
style={{ width: `${rightPanelWidth}px` }} style={{ width: `${rightPanelWidth}px` }}
> >
<PanelHeader title="Graph Analysis" onCollapse={collapseRightPanel} /> <PanelHeader title="Group Properties" onCollapse={collapseRightPanel} />
<GraphAnalysisPanel nodes={nodes} edges={edges} /> <GroupEditorPanel selectedGroup={selectedGroup} onClose={onClose} />
</div> </div>
); );
} }
@ -107,7 +109,16 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
); );
} }
return null; // No selection state - show graph metrics
return (
<div
className="h-full bg-white border-l border-gray-200 flex flex-col"
style={{ width: `${rightPanelWidth}px` }}
>
<PanelHeader title="Graph Analysis" onCollapse={collapseRightPanel} />
<GraphAnalysisPanel nodes={nodes} edges={edges} />
</div>
);
}; };
export default RightPanel; export default RightPanel;

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { flushSync } from 'react-dom';
import { useWorkspaceStore } from '../stores/workspaceStore'; import { useWorkspaceStore } from '../stores/workspaceStore';
import { useHistoryStore } from '../stores/historyStore'; import { useHistoryStore } from '../stores/historyStore';
import { useGraphStore } from '../stores/graphStore'; import { useGraphStore } from '../stores/graphStore';
@ -21,9 +22,7 @@ export function useDocumentHistory() {
const activeDocumentId = useWorkspaceStore((state) => state.activeDocumentId); const activeDocumentId = useWorkspaceStore((state) => state.activeDocumentId);
const markDocumentDirty = useWorkspaceStore((state) => state.markDocumentDirty); const markDocumentDirty = useWorkspaceStore((state) => state.markDocumentDirty);
const setNodeTypes = useGraphStore((state) => state.setNodeTypes); const loadGraphState = useGraphStore((state) => state.loadGraphState);
const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes);
const setLabels = useGraphStore((state) => state.setLabels);
const historyStore = useHistoryStore(); const historyStore = useHistoryStore();
@ -63,6 +62,18 @@ export function useDocumentHistory() {
return; return;
} }
// IMPORTANT: Update timeline's current state with graphStore BEFORE capturing snapshot
// This ensures the snapshot includes the current groups, nodes, and edges
const currentState = timeline.states.get(timeline.currentStateId);
if (currentState) {
const graphStore = useGraphStore.getState();
currentState.graph = {
nodes: graphStore.nodes as any,
edges: graphStore.edges as any,
groups: graphStore.groups as any,
};
}
// Create a snapshot of the complete document state // Create a snapshot of the complete document state
// NOTE: Read types and labels from the document, not from graphStore // NOTE: Read types and labels from the document, not from graphStore
const snapshot: DocumentSnapshot = { const snapshot: DocumentSnapshot = {
@ -111,6 +122,18 @@ export function useDocumentHistory() {
return; return;
} }
// IMPORTANT: Update timeline's current state with graphStore BEFORE capturing snapshot
// This ensures the snapshot includes the current groups
const currentState = timeline.states.get(timeline.currentStateId);
if (currentState) {
const graphStore = useGraphStore.getState();
currentState.graph = {
nodes: graphStore.nodes as any,
edges: graphStore.edges as any,
groups: graphStore.groups as any,
};
}
// NOTE: Read types and labels from the document, not from graphStore // NOTE: Read types and labels from the document, not from graphStore
const currentSnapshot: DocumentSnapshot = { const currentSnapshot: DocumentSnapshot = {
timeline: { timeline: {
@ -134,17 +157,23 @@ export function useDocumentHistory() {
activeDoc.edgeTypes = restoredState.edgeTypes; activeDoc.edgeTypes = restoredState.edgeTypes;
activeDoc.labels = restoredState.labels || []; activeDoc.labels = restoredState.labels || [];
// Sync to graph store
setNodeTypes(restoredState.nodeTypes);
setEdgeTypes(restoredState.edgeTypes);
setLabels(restoredState.labels || []);
// Load the current state's graph from the restored timeline // Load the current state's graph from the restored timeline
const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId); const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId);
if (currentState) { if (currentState) {
useGraphStore.setState({ // IMPORTANT: Use flushSync to force React to process the Zustand update immediately
// This prevents React Flow from processing stale state before the new state arrives
flushSync(() => {
// Use loadGraphState to update ALL graph state atomically in a single Zustand transaction
// This prevents React Flow from receiving intermediate state where nodes have
// parentId references but groups don't exist yet (which causes "Parent node not found")
loadGraphState({
nodes: currentState.graph.nodes, nodes: currentState.graph.nodes,
edges: currentState.graph.edges, edges: currentState.graph.edges,
groups: currentState.graph.groups || [],
nodeTypes: restoredState.nodeTypes,
edgeTypes: restoredState.edgeTypes,
labels: restoredState.labels || [],
});
}); });
} }
@ -157,7 +186,7 @@ export function useDocumentHistory() {
saveDocument(activeDocumentId); saveDocument(activeDocumentId);
}, 1000); }, 1000);
} }
}, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, setLabels, markDocumentDirty]); }, [activeDocumentId, historyStore, loadGraphState, markDocumentDirty]);
/** /**
* Redo the last undone action for the active document * Redo the last undone action for the active document
@ -184,6 +213,18 @@ export function useDocumentHistory() {
return; return;
} }
// IMPORTANT: Update timeline's current state with graphStore BEFORE capturing snapshot
// This ensures the snapshot includes the current groups
const currentState = timeline.states.get(timeline.currentStateId);
if (currentState) {
const graphStore = useGraphStore.getState();
currentState.graph = {
nodes: graphStore.nodes as any,
edges: graphStore.edges as any,
groups: graphStore.groups as any,
};
}
// NOTE: Read types and labels from the document, not from graphStore // NOTE: Read types and labels from the document, not from graphStore
const currentSnapshot: DocumentSnapshot = { const currentSnapshot: DocumentSnapshot = {
timeline: { timeline: {
@ -207,17 +248,23 @@ export function useDocumentHistory() {
activeDoc.edgeTypes = restoredState.edgeTypes; activeDoc.edgeTypes = restoredState.edgeTypes;
activeDoc.labels = restoredState.labels || []; activeDoc.labels = restoredState.labels || [];
// Sync to graph store
setNodeTypes(restoredState.nodeTypes);
setEdgeTypes(restoredState.edgeTypes);
setLabels(restoredState.labels || []);
// Load the current state's graph from the restored timeline // Load the current state's graph from the restored timeline
const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId); const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId);
if (currentState) { if (currentState) {
useGraphStore.setState({ // IMPORTANT: Use flushSync to force React to process the Zustand update immediately
// This prevents React Flow from processing stale state before the new state arrives
flushSync(() => {
// Use loadGraphState to update ALL graph state atomically in a single Zustand transaction
// This prevents React Flow from receiving intermediate state where nodes have
// parentId references but groups don't exist yet (which causes "Parent node not found")
loadGraphState({
nodes: currentState.graph.nodes, nodes: currentState.graph.nodes,
edges: currentState.graph.edges, edges: currentState.graph.edges,
groups: currentState.graph.groups || [],
nodeTypes: restoredState.nodeTypes,
edgeTypes: restoredState.edgeTypes,
labels: restoredState.labels || [],
});
}); });
} }
@ -230,7 +277,7 @@ export function useDocumentHistory() {
saveDocument(activeDocumentId); saveDocument(activeDocumentId);
}, 1000); }, 1000);
} }
}, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, setLabels, markDocumentDirty]); }, [activeDocumentId, historyStore, loadGraphState, markDocumentDirty]);
/** /**
* Check if undo is available for the active document * Check if undo is available for the active document

View file

@ -2,7 +2,7 @@ import { useCallback, useRef, useEffect } from 'react';
import { useGraphStore } from '../stores/graphStore'; import { useGraphStore } from '../stores/graphStore';
import { useWorkspaceStore } from '../stores/workspaceStore'; import { useWorkspaceStore } from '../stores/workspaceStore';
import { useDocumentHistory } from './useDocumentHistory'; import { useDocumentHistory } from './useDocumentHistory';
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig, RelationData } from '../types'; import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig, RelationData, GroupData } from '../types';
/** /**
* useGraphWithHistory Hook * useGraphWithHistory Hook
@ -19,13 +19,14 @@ import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig, Rela
* History-tracked operations (saved to document-level history): * History-tracked operations (saved to document-level history):
* - Node operations: addNode, updateNode, deleteNode * - Node operations: addNode, updateNode, deleteNode
* - Edge operations: addEdge, updateEdge, deleteEdge * - Edge operations: addEdge, updateEdge, deleteEdge
* - Group operations: addGroup, updateGroup, deleteGroup, addActorToGroup, removeActorFromGroup
* - Type operations: addNodeType, updateNodeType, deleteNodeType, addEdgeType, updateEdgeType, deleteEdgeType * - Type operations: addNodeType, updateNodeType, deleteNodeType, addEdgeType, updateEdgeType, deleteEdgeType
* - Label operations: addLabel, updateLabel, deleteLabel * - Label operations: addLabel, updateLabel, deleteLabel
* - Utility: clearGraph * - Utility: clearGraph
* *
* Read-only pass-through operations (no history): * Read-only pass-through operations (no history):
* - setNodes, setEdges, setLabels (used for bulk updates during undo/redo/document loading) * - setNodes, setEdges, setGroups, setLabels (used for bulk updates during undo/redo/document loading)
* - nodes, edges, nodeTypes, edgeTypes, labels (state access) * - nodes, edges, groups, nodeTypes, edgeTypes, labels (state access)
* - loadGraphState * - loadGraphState
* *
* Usage: * Usage:
@ -338,6 +339,124 @@ export function useGraphWithHistory() {
[activeDocumentId, graphStore, pushToHistory, deleteLabelFromDocument] [activeDocumentId, graphStore, pushToHistory, deleteLabelFromDocument]
); );
// Group operations
const addGroup = useCallback(
(group: Group) => {
if (isRestoringRef.current) {
graphStore.addGroup(group);
return;
}
pushToHistory(`Create Group: ${group.data.label}`); // Synchronous push BEFORE mutation
graphStore.addGroup(group);
},
[graphStore, pushToHistory]
);
const updateGroup = useCallback(
(id: string, updates: Partial<GroupData>) => {
if (isRestoringRef.current) {
graphStore.updateGroup(id, updates);
return;
}
// Check if this is a position update (group move)
if ('collapsed' in updates) {
const group = graphStore.groups.find((g) => g.id === id);
pushToHistory(updates.collapsed ? `Collapse Group: ${group?.data.label}` : `Expand Group: ${group?.data.label}`);
} else if ('label' in updates) {
pushToHistory('Rename Group');
} else {
pushToHistory('Update Group');
}
graphStore.updateGroup(id, updates);
},
[graphStore, pushToHistory]
);
const deleteGroup = useCallback(
(id: string, ungroupActors = true) => {
if (isRestoringRef.current) {
graphStore.deleteGroup(id, ungroupActors);
return;
}
const group = graphStore.groups.find((g) => g.id === id);
pushToHistory(ungroupActors ? `Ungroup: ${group?.data.label}` : `Delete Group: ${group?.data.label}`);
graphStore.deleteGroup(id, ungroupActors);
},
[graphStore, pushToHistory]
);
const addActorToGroup = useCallback(
(actorId: string, groupId: string) => {
if (isRestoringRef.current) {
graphStore.addActorToGroup(actorId, groupId);
return;
}
const group = graphStore.groups.find((g) => g.id === groupId);
pushToHistory(`Add Actor to Group: ${group?.data.label}`);
graphStore.addActorToGroup(actorId, groupId);
},
[graphStore, pushToHistory]
);
const removeActorFromGroup = useCallback(
(actorId: string, groupId: string) => {
if (isRestoringRef.current) {
graphStore.removeActorFromGroup(actorId, groupId);
return;
}
const group = graphStore.groups.find((g) => g.id === groupId);
pushToHistory(`Remove Actor from Group: ${group?.data.label}`);
graphStore.removeActorFromGroup(actorId, groupId);
},
[graphStore, pushToHistory]
);
/**
* createGroupWithActors - Atomic operation to create a group and add actors to it
*
* This operation ensures that both the group creation and the actor parent relationship
* updates are captured in a single history snapshot. This prevents the "Parent node not found"
* error that occurs when these are tracked as separate operations.
*
* @param group - The group node to create
* @param actorIds - Array of actor IDs to add to the group
* @param actorUpdates - Map of actorId -> position/parentId updates for each actor
*/
const createGroupWithActors = useCallback(
(
group: Group,
_actorIds: string[],
actorUpdates: Record<string, { position: { x: number; y: number }; parentId: string; extent: 'parent' }>
) => {
if (isRestoringRef.current) {
graphStore.addGroup(group);
const updatedNodes = graphStore.nodes.map((node) => {
const update = actorUpdates[node.id];
return update ? { ...node, ...update } : node;
});
graphStore.setNodes(updatedNodes as Actor[]);
return;
}
// Add the group first
graphStore.addGroup(group);
// Update actors to be children of the group
const updatedNodes = graphStore.nodes.map((node) => {
const update = actorUpdates[node.id];
return update ? { ...node, ...update } : node;
});
// Update nodes in store
graphStore.setNodes(updatedNodes as Actor[]);
// Push history AFTER all changes are complete
// This ensures the timeline state snapshot includes the new group
pushToHistory(`Create Group: ${group.data.label}`);
},
[graphStore, pushToHistory]
);
return { return {
// Wrapped operations with history // Wrapped operations with history
addNode, addNode,
@ -346,6 +465,12 @@ export function useGraphWithHistory() {
addEdge, addEdge,
updateEdge, updateEdge,
deleteEdge, deleteEdge,
addGroup,
updateGroup,
deleteGroup,
addActorToGroup,
removeActorFromGroup,
createGroupWithActors,
addNodeType, addNodeType,
updateNodeType, updateNodeType,
deleteNodeType, deleteNodeType,
@ -360,11 +485,13 @@ export function useGraphWithHistory() {
// Pass through read-only operations // Pass through read-only operations
nodes: graphStore.nodes, nodes: graphStore.nodes,
edges: graphStore.edges, edges: graphStore.edges,
groups: graphStore.groups,
nodeTypes: graphStore.nodeTypes, nodeTypes: graphStore.nodeTypes,
edgeTypes: graphStore.edgeTypes, edgeTypes: graphStore.edgeTypes,
labels: graphStore.labels, labels: graphStore.labels,
setNodes: graphStore.setNodes, setNodes: graphStore.setNodes,
setEdges: graphStore.setEdges, setEdges: graphStore.setEdges,
setGroups: graphStore.setGroups,
setNodeTypes: graphStore.setNodeTypes, setNodeTypes: graphStore.setNodeTypes,
setEdgeTypes: graphStore.setEdgeTypes, setEdgeTypes: graphStore.setEdgeTypes,
setLabels: graphStore.setLabels, setLabels: graphStore.setLabels,

View file

@ -3,10 +3,12 @@ import { addEdge as rfAddEdge } from 'reactflow';
import type { import type {
Actor, Actor,
Relation, Relation,
Group,
NodeTypeConfig, NodeTypeConfig,
EdgeTypeConfig, EdgeTypeConfig,
LabelConfig, LabelConfig,
RelationData, RelationData,
GroupData,
GraphActions GraphActions
} from '../types'; } from '../types';
import { loadGraphState } from './persistence/loader'; import { loadGraphState } from './persistence/loader';
@ -28,6 +30,7 @@ import { loadGraphState } from './persistence/loader';
interface GraphStore { interface GraphStore {
nodes: Actor[]; nodes: Actor[];
edges: Relation[]; edges: Relation[];
groups: Group[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[]; labels: LabelConfig[];
@ -57,6 +60,7 @@ const loadInitialState = (): GraphStore => {
return { return {
nodes: savedState.nodes, nodes: savedState.nodes,
edges: savedState.edges, edges: savedState.edges,
groups: savedState.groups || [],
nodeTypes: savedState.nodeTypes, nodeTypes: savedState.nodeTypes,
edgeTypes: savedState.edgeTypes, edgeTypes: savedState.edgeTypes,
labels: savedState.labels || [], labels: savedState.labels || [],
@ -66,6 +70,7 @@ const loadInitialState = (): GraphStore => {
return { return {
nodes: [], nodes: [],
edges: [], edges: [],
groups: [],
nodeTypes: defaultNodeTypes, nodeTypes: defaultNodeTypes,
edgeTypes: defaultEdgeTypes, edgeTypes: defaultEdgeTypes,
labels: [], labels: [],
@ -77,6 +82,7 @@ const initialState = loadInitialState();
export const useGraphStore = create<GraphStore & GraphActions>((set) => ({ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
nodes: initialState.nodes, nodes: initialState.nodes,
edges: initialState.edges, edges: initialState.edges,
groups: initialState.groups,
nodeTypes: initialState.nodeTypes, nodeTypes: initialState.nodeTypes,
edgeTypes: initialState.edgeTypes, edgeTypes: initialState.edgeTypes,
labels: initialState.labels, labels: initialState.labels,
@ -235,11 +241,134 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
}; };
}), }),
// Group operations
addGroup: (group: Group) =>
set((state) => ({
groups: [...state.groups, group],
})),
updateGroup: (id: string, updates: Partial<GroupData>) =>
set((state) => ({
groups: state.groups.map((group) =>
group.id === id
? { ...group, data: { ...group.data, ...updates } }
: group
),
})),
deleteGroup: (id: string, ungroupActors = true) =>
set((state) => {
if (ungroupActors) {
// Remove group and unparent actors (move them back to canvas)
// Note: parentId is a React Flow v11+ property for parent-child relationships
const updatedNodes = state.nodes.map((node) => {
const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' };
return nodeWithParent.parentId === id
? { ...node, parentId: undefined, extent: undefined }
: node;
});
return {
groups: state.groups.filter((group) => group.id !== id),
nodes: updatedNodes,
};
} else {
// Delete group AND all actors inside
const nodeWithParent = (node: Actor) => node as Actor & { parentId?: string };
const updatedNodes = state.nodes.filter((node) => nodeWithParent(node).parentId !== id);
// Delete all edges connected to deleted actors
const deletedNodeIds = new Set(
state.nodes.filter((node) => nodeWithParent(node).parentId === id).map((node) => node.id)
);
const updatedEdges = state.edges.filter(
(edge) => !deletedNodeIds.has(edge.source) && !deletedNodeIds.has(edge.target)
);
return {
groups: state.groups.filter((group) => group.id !== id),
nodes: updatedNodes,
edges: updatedEdges,
};
}
}),
addActorToGroup: (actorId: string, groupId: string) =>
set((state) => {
const group = state.groups.find((g) => g.id === groupId);
if (!group) return state;
// Update actor to be child of group
const updatedNodes = state.nodes.map((node) =>
node.id === actorId
? {
...node,
parentId: groupId,
extent: 'parent' as const,
// Convert to relative position (will be adjusted in component)
position: node.position,
}
: node
);
// Update group's actorIds
const updatedGroups = state.groups.map((g) =>
g.id === groupId
? {
...g,
data: {
...g.data,
actorIds: [...g.data.actorIds, actorId],
},
}
: g
);
return {
nodes: updatedNodes,
groups: updatedGroups,
};
}),
removeActorFromGroup: (actorId: string, groupId: string) =>
set((state) => {
// Update actor to remove parent
const updatedNodes = state.nodes.map((node) =>
node.id === actorId
? {
...node,
parentId: undefined,
extent: undefined,
// Keep current position (will be adjusted in component)
}
: node
);
// Update group's actorIds
const updatedGroups = state.groups.map((g) =>
g.id === groupId
? {
...g,
data: {
...g.data,
actorIds: g.data.actorIds.filter((id) => id !== actorId),
},
}
: g
);
return {
nodes: updatedNodes,
groups: updatedGroups,
};
}),
// Utility operations // Utility operations
clearGraph: () => clearGraph: () =>
set({ set({
nodes: [], nodes: [],
edges: [], edges: [],
groups: [],
}), }),
setNodes: (nodes: Actor[]) => setNodes: (nodes: Actor[]) =>
@ -252,6 +381,11 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
edges, edges,
}), }),
setGroups: (groups: Group[]) =>
set({
groups,
}),
setNodeTypes: (nodeTypes: NodeTypeConfig[]) => setNodeTypes: (nodeTypes: NodeTypeConfig[]) =>
set({ set({
nodeTypes, nodeTypes,
@ -271,12 +405,30 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
// Import/export is now handled by the workspace-level system // Import/export is now handled by the workspace-level system
// See: workspaceStore.importDocumentFromFile() and workspaceStore.exportDocument() // See: workspaceStore.importDocumentFromFile() and workspaceStore.exportDocument()
loadGraphState: (data) => loadGraphState: (data) => {
// Build set of valid group IDs to check for orphaned parentId references
const validGroupIds = new Set((data.groups || []).map((g) => g.id));
// Sanitize nodes - remove parentId if the referenced group doesn't exist
// This handles timeline states created before groups feature was implemented
const sanitizedNodes = data.nodes.map((node) => {
const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' };
if (nodeWithParent.parentId && !validGroupIds.has(nodeWithParent.parentId)) {
// Remove orphaned parent reference
const { parentId, extent, ...cleanNode } = nodeWithParent;
return cleanNode as Actor;
}
return node;
});
// Atomic update: all state changes happen in a single set() call
set({ set({
nodes: data.nodes, nodes: sanitizedNodes,
edges: data.edges, edges: data.edges,
groups: data.groups || [],
nodeTypes: data.nodeTypes, nodeTypes: data.nodeTypes,
edgeTypes: data.edgeTypes, edgeTypes: data.edgeTypes,
labels: data.labels || [], labels: data.labels || [],
}), });
},
})); }));

View file

@ -1,6 +1,7 @@
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types'; import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
import type { ConstellationDocument, SerializedActor, SerializedRelation } from './types'; import type { ConstellationDocument, SerializedActor, SerializedRelation, SerializedGroup } from './types';
import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants'; import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants';
import { safeParse } from '../../utils/safeStringify';
/** /**
* Loader - Handles loading and validating data from localStorage * Loader - Handles loading and validating data from localStorage
@ -80,6 +81,16 @@ function deserializeRelations(serializedRelations: SerializedRelation[]): Relati
})) as Relation[]; })) as Relation[];
} }
// Deserialize groups (add back React Flow properties and initialize transient UI state)
function deserializeGroups(serializedGroups: SerializedGroup[]): Group[] {
return serializedGroups.map(group => ({
...group,
// Initialize transient UI state (not persisted)
selected: false,
dragging: false,
})) as Group[];
}
// Load document from localStorage // Load document from localStorage
export function loadDocument(): ConstellationDocument | null { export function loadDocument(): ConstellationDocument | null {
try { try {
@ -90,7 +101,7 @@ export function loadDocument(): ConstellationDocument | null {
return null; return null;
} }
const parsed = JSON.parse(json); const parsed = safeParse(json);
if (!validateDocument(parsed)) { if (!validateDocument(parsed)) {
console.error('Invalid document structure'); console.error('Invalid document structure');
@ -115,6 +126,7 @@ export function loadDocument(): ConstellationDocument | null {
export function getCurrentGraphFromDocument(document: ConstellationDocument): { export function getCurrentGraphFromDocument(document: ConstellationDocument): {
nodes: SerializedActor[]; nodes: SerializedActor[];
edges: SerializedRelation[]; edges: SerializedRelation[];
groups: SerializedGroup[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[]; labels: LabelConfig[];
@ -132,6 +144,7 @@ export function getCurrentGraphFromDocument(document: ConstellationDocument): {
return { return {
nodes: currentState.graph.nodes, nodes: currentState.graph.nodes,
edges: currentState.graph.edges, edges: currentState.graph.edges,
groups: currentState.graph.groups || [], // Default to empty array for backward compatibility
nodeTypes, nodeTypes,
edgeTypes, edgeTypes,
labels: labels || [], // Default to empty array for backward compatibility labels: labels || [], // Default to empty array for backward compatibility
@ -163,6 +176,7 @@ function migrateNodeTypes(nodeTypes: NodeTypeConfig[]): NodeTypeConfig[] {
export function deserializeGraphState(document: ConstellationDocument): { export function deserializeGraphState(document: ConstellationDocument): {
nodes: Actor[]; nodes: Actor[];
edges: Relation[]; edges: Relation[];
groups: Group[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[]; labels: LabelConfig[];
@ -175,6 +189,7 @@ export function deserializeGraphState(document: ConstellationDocument): {
const nodes = deserializeActors(currentGraph.nodes); const nodes = deserializeActors(currentGraph.nodes);
const edges = deserializeRelations(currentGraph.edges); const edges = deserializeRelations(currentGraph.edges);
const groups = deserializeGroups(currentGraph.groups);
// Migrate node types to include shape property // Migrate node types to include shape property
const migratedNodeTypes = migrateNodeTypes(currentGraph.nodeTypes); const migratedNodeTypes = migrateNodeTypes(currentGraph.nodeTypes);
@ -182,6 +197,7 @@ export function deserializeGraphState(document: ConstellationDocument): {
return { return {
nodes, nodes,
edges, edges,
groups,
nodeTypes: migratedNodeTypes, nodeTypes: migratedNodeTypes,
edgeTypes: currentGraph.edgeTypes, edgeTypes: currentGraph.edgeTypes,
labels: currentGraph.labels || [], // Default to empty array for backward compatibility labels: currentGraph.labels || [], // Default to empty array for backward compatibility
@ -196,6 +212,7 @@ export function deserializeGraphState(document: ConstellationDocument): {
export function loadGraphState(): { export function loadGraphState(): {
nodes: Actor[]; nodes: Actor[];
edges: Relation[]; edges: Relation[];
groups: Group[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[]; labels: LabelConfig[];

View file

@ -1,6 +1,7 @@
import type { ConstellationDocument, SerializedActor, SerializedRelation } from './types'; import type { ConstellationDocument, SerializedActor, SerializedRelation } from './types';
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types'; import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants'; import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants';
import { safeStringify } from '../../utils/safeStringify';
/** /**
* Saver - Handles serialization and saving to localStorage * Saver - Handles serialization and saving to localStorage
@ -87,7 +88,7 @@ export function createDocument(
// via workspace/persistence.ts // via workspace/persistence.ts
export function saveDocument(document: ConstellationDocument): boolean { export function saveDocument(document: ConstellationDocument): boolean {
try { try {
const json = JSON.stringify(document); const json = safeStringify(document);
localStorage.setItem(STORAGE_KEYS.GRAPH_STATE, json); localStorage.setItem(STORAGE_KEYS.GRAPH_STATE, json);
localStorage.setItem(STORAGE_KEYS.LAST_SAVED, document.metadata.updatedAt); localStorage.setItem(STORAGE_KEYS.LAST_SAVED, document.metadata.updatedAt);
return true; return true;

View file

@ -1,4 +1,4 @@
import type { ActorData, RelationData, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types'; import type { ActorData, RelationData, GroupData, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
import type { ConstellationState } from '../../types/timeline'; import type { ConstellationState } from '../../types/timeline';
import type { Bibliography } from '../../types/bibliography'; import type { Bibliography } from '../../types/bibliography';
@ -14,6 +14,8 @@ export interface SerializedActor {
type: string; // React Flow node type (e.g., "custom") type: string; // React Flow node type (e.g., "custom")
position: { x: number; y: number }; position: { x: number; y: number };
data: ActorData; data: ActorData;
parentNode?: string; // Group ID if actor belongs to a group
extent?: 'parent';
} }
// Simplified edge structure for storage (without React Flow internals) // Simplified edge structure for storage (without React Flow internals)
@ -27,6 +29,16 @@ export interface SerializedRelation {
targetHandle?: string | null; targetHandle?: string | null;
} }
// Simplified group structure for storage (without React Flow internals)
export interface SerializedGroup {
id: string;
type: 'group'; // React Flow node type
position: { x: number; y: number };
data: GroupData;
width?: number;
height?: number;
}
// Complete document structure for storage // Complete document structure for storage
// Every document has a timeline with states. The current graph is always // Every document has a timeline with states. The current graph is always
// derived from the current state in the timeline. // derived from the current state in the timeline.

View file

@ -5,8 +5,8 @@ import type {
StateId, StateId,
TimelineActions, TimelineActions,
} from "../types/timeline"; } from "../types/timeline";
import type { Actor, Relation } from "../types"; import type { Actor, Relation, Group } from "../types";
import type { SerializedActor, SerializedRelation } from "./persistence/types"; import type { SerializedActor, SerializedRelation, SerializedGroup } from "./persistence/types";
import { useGraphStore } from "./graphStore"; import { useGraphStore } from "./graphStore";
import { useWorkspaceStore } from "./workspaceStore"; import { useWorkspaceStore } from "./workspaceStore";
import { useToastStore } from "./toastStore"; import { useToastStore } from "./toastStore";
@ -164,20 +164,22 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
const newStateId = generateStateId(); const newStateId = generateStateId();
const now = new Date().toISOString(); const now = new Date().toISOString();
// Get graph to clone (nodes and edges only, types are global) // Get graph to clone (nodes, edges, and groups - types are global)
let graphToClone: ConstellationState["graph"]; let graphToClone: ConstellationState["graph"];
if (cloneFromCurrent) { if (cloneFromCurrent) {
// Clone from current graph state (nodes and edges only) // Clone from current graph state (nodes, edges, and groups)
const graphStore = useGraphStore.getState(); const graphStore = useGraphStore.getState();
graphToClone = { graphToClone = {
nodes: graphStore.nodes as unknown as SerializedActor[], nodes: graphStore.nodes as unknown as SerializedActor[],
edges: graphStore.edges as unknown as SerializedRelation[], edges: graphStore.edges as unknown as SerializedRelation[],
groups: graphStore.groups as unknown as SerializedGroup[],
}; };
} else { } else {
// Empty graph // Empty graph
graphToClone = { graphToClone = {
nodes: [], nodes: [],
edges: [], edges: [],
groups: [],
}; };
} }
@ -209,10 +211,15 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
// Load new state's graph into graph store // Load new state's graph into graph store
// Types come from the document and are already in the graph store // Types come from the document and are already in the graph store
useGraphStore.setState({ // IMPORTANT: Use loadGraphState for atomic update to prevent React Flow errors
nodes: newState.graph.nodes, const graphStore = useGraphStore.getState();
edges: newState.graph.edges, graphStore.loadGraphState({
// nodeTypes and edgeTypes remain unchanged (they're global per document) nodes: newState.graph.nodes as unknown as Actor[],
edges: newState.graph.edges as unknown as Relation[],
groups: (newState.graph.groups || []) as unknown as Group[],
nodeTypes: graphStore.nodeTypes,
edgeTypes: graphStore.edgeTypes,
labels: graphStore.labels,
}); });
// Mark document as dirty // Mark document as dirty
@ -251,13 +258,14 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
pushDocumentHistory(activeDocumentId, `Switch to State: ${targetState.label}`); pushDocumentHistory(activeDocumentId, `Switch to State: ${targetState.label}`);
} }
// Save current graph state to current state before switching (nodes and edges only) // Save current graph state to current state before switching (nodes, edges, and groups)
const currentState = timeline.states.get(timeline.currentStateId); const currentState = timeline.states.get(timeline.currentStateId);
if (currentState) { if (currentState) {
const graphStore = useGraphStore.getState(); const graphStore = useGraphStore.getState();
currentState.graph = { currentState.graph = {
nodes: graphStore.nodes as unknown as SerializedActor[], nodes: graphStore.nodes as unknown as SerializedActor[],
edges: graphStore.edges as unknown as SerializedRelation[], edges: graphStore.edges as unknown as SerializedRelation[],
groups: graphStore.groups as unknown as SerializedGroup[],
}; };
currentState.updatedAt = new Date().toISOString(); currentState.updatedAt = new Date().toISOString();
} }
@ -275,11 +283,16 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
return { timelines: newTimelines }; return { timelines: newTimelines };
}); });
// Load target state's graph (nodes and edges only, types are global) // Load target state's graph (nodes, edges, and groups - types are global)
useGraphStore.setState({ // IMPORTANT: Use loadGraphState for atomic update to prevent React Flow errors
const graphStore = useGraphStore.getState();
graphStore.loadGraphState({
nodes: targetState.graph.nodes as unknown as Actor[], nodes: targetState.graph.nodes as unknown as Actor[],
edges: targetState.graph.edges as unknown as Relation[], edges: targetState.graph.edges as unknown as Relation[],
// nodeTypes and edgeTypes remain unchanged (they're global per document) groups: (targetState.graph.groups || []) as unknown as Group[],
nodeTypes: graphStore.nodeTypes,
edgeTypes: graphStore.edgeTypes,
labels: graphStore.labels,
}); });
}, },

View file

@ -1,6 +1,7 @@
import type { ConstellationDocument } from '../persistence/types'; import type { ConstellationDocument } from '../persistence/types';
import type { WorkspaceState, DocumentMetadata } from './types'; import type { WorkspaceState, DocumentMetadata } from './types';
import { validateDocument } from '../persistence/loader'; import { validateDocument } from '../persistence/loader';
import { safeStringify, safeParse } from '../../utils/safeStringify';
/** /**
* Workspace Persistence * Workspace Persistence
@ -34,7 +35,7 @@ export function saveWorkspaceState(state: WorkspaceState): boolean {
try { try {
localStorage.setItem( localStorage.setItem(
WORKSPACE_STORAGE_KEYS.WORKSPACE_STATE, WORKSPACE_STORAGE_KEYS.WORKSPACE_STATE,
JSON.stringify(state) safeStringify(state)
); );
return true; return true;
} catch (error) { } catch (error) {
@ -49,7 +50,7 @@ export function loadWorkspaceState(): WorkspaceState | null {
const json = localStorage.getItem(WORKSPACE_STORAGE_KEYS.WORKSPACE_STATE); const json = localStorage.getItem(WORKSPACE_STORAGE_KEYS.WORKSPACE_STATE);
if (!json) return null; if (!json) return null;
const state = JSON.parse(json) as WorkspaceState; const state = safeParse<WorkspaceState>(json);
return state; return state;
} catch (error) { } catch (error) {
console.error('Failed to load workspace state:', error); console.error('Failed to load workspace state:', error);
@ -64,7 +65,7 @@ export function saveDocumentToStorage(
): boolean { ): boolean {
try { try {
const key = `${WORKSPACE_STORAGE_KEYS.DOCUMENT_PREFIX}${documentId}`; const key = `${WORKSPACE_STORAGE_KEYS.DOCUMENT_PREFIX}${documentId}`;
localStorage.setItem(key, JSON.stringify(document)); localStorage.setItem(key, safeStringify(document));
return true; return true;
} catch (error) { } catch (error) {
console.error(`Failed to save document ${documentId}:`, error); console.error(`Failed to save document ${documentId}:`, error);
@ -79,7 +80,7 @@ export function loadDocumentFromStorage(documentId: string): ConstellationDocume
const json = localStorage.getItem(key); const json = localStorage.getItem(key);
if (!json) return null; if (!json) return null;
const doc = JSON.parse(json); const doc = safeParse(json);
// Validate document structure // Validate document structure
if (!validateDocument(doc)) { if (!validateDocument(doc)) {
@ -116,7 +117,7 @@ export function saveDocumentMetadata(
): boolean { ): boolean {
try { try {
const key = `${WORKSPACE_STORAGE_KEYS.DOCUMENT_METADATA_PREFIX}${documentId}`; const key = `${WORKSPACE_STORAGE_KEYS.DOCUMENT_METADATA_PREFIX}${documentId}`;
localStorage.setItem(key, JSON.stringify(metadata)); localStorage.setItem(key, safeStringify(metadata));
return true; return true;
} catch (error) { } catch (error) {
console.error(`Failed to save metadata for ${documentId}:`, error); console.error(`Failed to save metadata for ${documentId}:`, error);
@ -131,7 +132,7 @@ export function loadDocumentMetadata(documentId: string): DocumentMetadata | nul
const json = localStorage.getItem(key); const json = localStorage.getItem(key);
if (!json) return null; if (!json) return null;
return JSON.parse(json) as DocumentMetadata; return safeParse<DocumentMetadata>(json);
} catch (error) { } catch (error) {
console.error(`Failed to load metadata for ${documentId}:`, error); console.error(`Failed to load metadata for ${documentId}:`, error);
return null; return null;

View file

@ -32,6 +32,7 @@ import { getCurrentGraphFromDocument } from './persistence/loader';
// @ts-expect-error - citation.js doesn't have TypeScript definitions // @ts-expect-error - citation.js doesn't have TypeScript definitions
import { Cite } from '@citation-js/core'; import { Cite } from '@citation-js/core';
import type { CSLReference } from '../types/bibliography'; import type { CSLReference } from '../types/bibliography';
import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage';
/** /**
* Workspace Store * Workspace Store
@ -60,6 +61,18 @@ const defaultSettings: WorkspaceSettings = {
// Initialize workspace // Initialize workspace
function initializeWorkspace(): Workspace { function initializeWorkspace(): Workspace {
// Check if storage cleanup is needed (remove __proto__ attributes)
if (needsStorageCleanup()) {
console.log('[Security] Cleaning up localStorage to remove __proto__ attributes...');
const { cleaned, errors } = cleanupAllStorage();
if (cleaned > 0) {
console.log(`[Security] ✓ Cleaned ${cleaned} items in localStorage`);
}
if (errors > 0) {
console.error(`[Security] ✗ ${errors} errors during cleanup`);
}
}
// Check if migration is needed // Check if migration is needed
if (needsMigration()) { if (needsMigration()) {
console.log('Migration needed, migrating legacy data...'); console.log('Migration needed, migrating legacy data...');

View file

@ -44,6 +44,18 @@ code {
outline: none; outline: none;
} }
/* Group node styling - React Flow native */
.react-flow__node-group {
background-color: rgba(240, 242, 245, 0.5);
border: 2px dashed rgba(100, 116, 139, 0.4);
border-radius: 8px;
}
.react-flow__node-group.selected {
background-color: rgba(219, 234, 254, 0.5);
border: 2px solid rgba(59, 130, 246, 0.6);
}
/* Smooth transitions for interactive elements */ /* Smooth transitions for interactive elements */
button { button {
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;

View file

@ -66,10 +66,22 @@ export interface LabelConfig {
description?: string; description?: string;
} }
// Group Types
export interface GroupData {
label: string;
description?: string;
color: string;
actorIds: string[];
metadata?: Record<string, unknown>;
}
export type Group = Node<GroupData>;
// Graph State // Graph State
export interface GraphState { export interface GraphState {
nodes: Actor[]; nodes: Actor[];
edges: Relation[]; edges: Relation[];
groups: Group[];
nodeTypes: NodeTypeConfig[]; nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[]; labels: LabelConfig[];
@ -101,15 +113,21 @@ export interface GraphActions {
addLabel: (label: LabelConfig) => void; addLabel: (label: LabelConfig) => void;
updateLabel: (id: string, updates: Partial<Omit<LabelConfig, 'id'>>) => void; updateLabel: (id: string, updates: Partial<Omit<LabelConfig, 'id'>>) => void;
deleteLabel: (id: string) => void; deleteLabel: (id: string) => void;
addGroup: (group: Group) => void;
updateGroup: (id: string, updates: Partial<GroupData>) => void;
deleteGroup: (id: string, ungroupActors?: boolean) => void;
addActorToGroup: (actorId: string, groupId: string) => void;
removeActorFromGroup: (actorId: string, groupId: string) => void;
clearGraph: () => void; clearGraph: () => void;
setNodes: (nodes: Actor[]) => void; setNodes: (nodes: Actor[]) => void;
setEdges: (edges: Relation[]) => void; setEdges: (edges: Relation[]) => void;
setGroups: (groups: Group[]) => void;
setNodeTypes: (nodeTypes: NodeTypeConfig[]) => void; setNodeTypes: (nodeTypes: NodeTypeConfig[]) => void;
setEdgeTypes: (edgeTypes: EdgeTypeConfig[]) => void; setEdgeTypes: (edgeTypes: EdgeTypeConfig[]) => void;
setLabels: (labels: LabelConfig[]) => void; setLabels: (labels: LabelConfig[]) => void;
// NOTE: exportToFile and importFromFile have been removed // NOTE: exportToFile and importFromFile have been removed
// Import/export is now handled by the workspace-level system (workspaceStore) // Import/export is now handled by the workspace-level system (workspaceStore)
loadGraphState: (data: { nodes: Actor[]; edges: Relation[]; nodeTypes: NodeTypeConfig[]; edgeTypes: EdgeTypeConfig[]; labels?: LabelConfig[] }) => void; loadGraphState: (data: { nodes: Actor[]; edges: Relation[]; groups?: Group[]; nodeTypes: NodeTypeConfig[]; edgeTypes: EdgeTypeConfig[]; labels?: LabelConfig[] }) => void;
} }
export interface EditorActions { export interface EditorActions {

View file

@ -1,4 +1,4 @@
import type { SerializedActor, SerializedRelation } from '../stores/persistence/types'; import type { SerializedActor, SerializedRelation, SerializedGroup } from '../stores/persistence/types';
/** /**
* Timeline Types * Timeline Types
@ -18,10 +18,11 @@ export interface ConstellationState {
description?: string; // Optional detailed description description?: string; // Optional detailed description
parentStateId?: string; // Parent state (null/undefined = root state) parentStateId?: string; // Parent state (null/undefined = root state)
// Graph snapshot (nodes and edges only, types are global per document) // Graph snapshot (nodes, edges, and groups - types are global per document)
graph: { graph: {
nodes: SerializedActor[]; nodes: SerializedActor[];
edges: SerializedRelation[]; edges: SerializedRelation[];
groups?: SerializedGroup[]; // Optional for backward compatibility
}; };
// Optional metadata - users can use these or ignore them // Optional metadata - users can use these or ignore them

View file

@ -0,0 +1,98 @@
/**
* Storage Cleanup Utility
*
* Removes dangerous __proto__ properties from existing localStorage data
*/
import { safeParse, safeStringify } from './safeStringify';
import { WORKSPACE_STORAGE_KEYS } from '../stores/workspace/persistence';
/**
* Clean all workspace data in localStorage
* This will re-save all data without __proto__ properties
*/
export function cleanupAllStorage(): { cleaned: number; errors: number } {
let cleaned = 0;
let errors = 0;
console.log('[Storage Cleanup] Starting cleanup of localStorage...');
try {
// Get all keys that need cleaning
const keysToClean: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (
key === WORKSPACE_STORAGE_KEYS.WORKSPACE_STATE ||
key === WORKSPACE_STORAGE_KEYS.WORKSPACE_SETTINGS ||
key.startsWith(WORKSPACE_STORAGE_KEYS.DOCUMENT_PREFIX) ||
key.startsWith(WORKSPACE_STORAGE_KEYS.DOCUMENT_METADATA_PREFIX) ||
key === WORKSPACE_STORAGE_KEYS.LEGACY_GRAPH_STATE
)) {
keysToClean.push(key);
}
}
console.log(`[Storage Cleanup] Found ${keysToClean.length} items to check`);
// Clean each key
for (const key of keysToClean) {
try {
const json = localStorage.getItem(key);
if (!json) continue;
// Check if it contains __proto__
if (json.includes('"__proto__"')) {
console.log(`[Storage Cleanup] Cleaning ${key}...`);
// Parse and clean the data
const cleaned_data = safeParse(json);
// Re-save with clean data
localStorage.setItem(key, safeStringify(cleaned_data));
cleaned++;
console.log(`[Storage Cleanup] ✓ Cleaned ${key}`);
}
} catch (error) {
console.error(`[Storage Cleanup] ✗ Error cleaning ${key}:`, error);
errors++;
}
}
console.log(`[Storage Cleanup] Complete! Cleaned ${cleaned} items, ${errors} errors`);
} catch (error) {
console.error('[Storage Cleanup] Fatal error during cleanup:', error);
errors++;
}
return { cleaned, errors };
}
/**
* Check if storage needs cleanup (contains __proto__)
*/
export function needsStorageCleanup(): boolean {
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (
key === WORKSPACE_STORAGE_KEYS.WORKSPACE_STATE ||
key === WORKSPACE_STORAGE_KEYS.WORKSPACE_SETTINGS ||
key.startsWith(WORKSPACE_STORAGE_KEYS.DOCUMENT_PREFIX) ||
key.startsWith(WORKSPACE_STORAGE_KEYS.DOCUMENT_METADATA_PREFIX) ||
key === WORKSPACE_STORAGE_KEYS.LEGACY_GRAPH_STATE
)) {
const json = localStorage.getItem(key);
if (json && json.includes('"__proto__"')) {
return true;
}
}
}
} catch (error) {
console.error('[Storage Cleanup] Error checking for cleanup:', error);
}
return false;
}

View file

@ -0,0 +1,80 @@
/**
* Safe JSON stringification utilities
*
* Prevents serialization of dangerous properties like __proto__, constructor, and prototype
* to avoid prototype pollution attacks and unnecessary data in localStorage.
*/
// List of dangerous property names to exclude from serialization
const DANGEROUS_PROPS = new Set(['__proto__', 'constructor', 'prototype']);
/**
* Replacer function for JSON.stringify that filters out dangerous properties
* @param key - Property key
* @param value - Property value
* @returns The value to serialize, or undefined to omit the property
*/
export function safeReplacer(key: string, value: unknown): unknown {
// Skip dangerous properties
if (DANGEROUS_PROPS.has(key)) {
return undefined;
}
return value;
}
/**
* Safe JSON.stringify that excludes dangerous properties
* @param value - Value to stringify
* @param space - Optional formatting (spaces/tabs)
* @returns JSON string
*/
export function safeStringify(value: unknown, space?: string | number): string {
return JSON.stringify(value, safeReplacer, space);
}
/**
* Deep clean an object by removing dangerous properties
* This mutates the object in place and also returns it for chaining
* @param obj - Object to clean
* @returns The cleaned object
*/
export function deepCleanObject<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj;
}
// Handle arrays
if (Array.isArray(obj)) {
obj.forEach((item) => deepCleanObject(item));
return obj;
}
// Handle objects
const objRecord = obj as Record<string, unknown>;
// Remove dangerous properties
DANGEROUS_PROPS.forEach((prop) => {
if (prop in objRecord) {
delete objRecord[prop];
}
});
// Recursively clean nested objects
Object.values(objRecord).forEach((value) => {
if (value && typeof value === 'object') {
deepCleanObject(value);
}
});
return obj;
}
/**
* Parse JSON and clean the result
* @param json - JSON string to parse
* @returns Parsed and cleaned object
*/
export function safeParse<T = unknown>(json: string): T {
const parsed = JSON.parse(json) as T;
return deepCleanObject(parsed);
}