mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
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:
parent
59e30cca8a
commit
f5adbc8ead
20 changed files with 2281 additions and 128 deletions
912
UX_CONCEPT_ACTOR_GROUPING.md
Normal file
912
UX_CONCEPT_ACTOR_GROUPING.md
Normal 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
|
||||||
26
src/App.tsx
26
src/App.tsx
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
154
src/components/Nodes/GroupNode.tsx
Normal file
154
src/components/Nodes/GroupNode.tsx
Normal 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);
|
||||||
261
src/components/Panels/GroupEditorPanel.tsx
Normal file
261
src/components/Panels/GroupEditorPanel.tsx
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 || [],
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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...');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
98
src/utils/cleanupStorage.ts
Normal file
98
src/utils/cleanupStorage.ts
Normal 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;
|
||||||
|
}
|
||||||
80
src/utils/safeStringify.ts
Normal file
80
src/utils/safeStringify.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue